feat(mid-section-defect): full-line bidirectional defect trace center with dual query mode

Transform /mid-section-defect from TMTT-only backward analysis into a full-line
bidirectional defect traceability center supporting all detection stations.

Key changes:
- Parameterized station detection: any workcenter group as detection station
- Bidirectional tracing: backward (upstream attribution) + forward (downstream reject rates)
- Dual query mode: date range OR LOT/工單/WAFER container-based seed resolution
- Multi-select filters for upstream station, equipment model (RESOURCEFAMILYNAME), and loss reasons
- Progressive 3-stage trace pipeline (seed-resolve → lineage → events) with streaming UI
- Equipment model lookup via resource cache instead of SPECNAME
- Session caching, auto-refresh, searchable MultiSelect with fuzzy matching
- Remove legacy tmtt-defect module (fully superseded)
- Archive openspec change artifacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-24 16:16:33 +08:00
parent bb58a0e119
commit f14591c7dc
67 changed files with 2957 additions and 2931 deletions

View File

@@ -161,6 +161,21 @@ CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
# Sliding window size for counting successes/failures
CIRCUIT_BREAKER_WINDOW_SIZE=10
# ============================================================
# Trace Pipeline Configuration
# ============================================================
# Slow query warning threshold (seconds) — logs warning when stage exceeds this
TRACE_SLOW_THRESHOLD_SECONDS=15
# Max parallel workers for events domain fetching (per request)
TRACE_EVENTS_MAX_WORKERS=4
# Max parallel workers for EventFetcher batch queries (per domain)
EVENT_FETCHER_MAX_WORKERS=4
# Max parallel workers for forward pipeline WIP+rejects fetching
FORWARD_PIPELINE_MAX_WORKERS=2
# ============================================================
# Performance Metrics Configuration
# ============================================================

View File

@@ -11,12 +11,11 @@
"/resource-history": {"content_cutover_enabled": true},
"/qc-gate": {"content_cutover_enabled": true},
"/job-query": {"content_cutover_enabled": true},
"/tmtt-defect": {"content_cutover_enabled": true},
"/admin/pages": {"content_cutover_enabled": true},
"/admin/performance": {"content_cutover_enabled": true},
"/tables": {"content_cutover_enabled": false},
"/excel-query": {"content_cutover_enabled": false},
"/query-tool": {"content_cutover_enabled": false},
"/mid-section-defect": {"content_cutover_enabled": false}
"/mid-section-defect": {"content_cutover_enabled": true}
}
}

View File

@@ -29,9 +29,9 @@
{
"route": "/reject-history",
"name": "報廢歷史查詢",
"status": "dev",
"drawer_id": "drawer-2",
"order": 4
"status": "released",
"drawer_id": "drawer",
"order": 1
},
{
"route": "/wip-detail",
@@ -83,28 +83,21 @@
"name": "設備維修查詢",
"status": "released",
"drawer_id": "drawer",
"order": 1
"order": 2
},
{
"route": "/query-tool",
"name": "批次追蹤工具",
"status": "released",
"drawer_id": "drawer",
"order": 2
},
{
"route": "/tmtt-defect",
"name": "TMTT印字腳型不良分析",
"status": "dev",
"drawer_id": "dev-tools",
"order": 5
"order": 3
},
{
"route": "/mid-section-defect",
"name": "中段製程不良追溯",
"status": "dev",
"drawer_id": "dev-tools",
"order": 6
"name": "製程不良追溯分析",
"status": "released",
"drawer_id": "drawer",
"order": 4
},
{
"route": "/admin/pages",

View File

@@ -10,7 +10,6 @@
"/resource-history": ["resource-history.js"],
"/qc-gate": ["qc-gate.js"],
"/job-query": ["job-query.js"],
"/tmtt-defect": ["tmtt-defect.js"],
"/admin/performance": ["admin-performance.js"],
"/tables": ["tables.js"],
"/excel-query": ["excel-query.js"],

View File

@@ -70,13 +70,6 @@
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-tmtt-defect",
"scope": "/tmtt-defect",
"owner": "frontend-mes-reporting",
"milestone": "full-modernization-phase2",
"reason": "Legacy styles pending full token and scope migration"
},
{
"id": "style-legacy-admin-pages",
"scope": "/admin/pages",

View File

@@ -10,7 +10,6 @@
"/resource-history": { "known_issues": [] },
"/qc-gate": { "known_issues": [] },
"/job-query": { "known_issues": [] },
"/tmtt-defect": { "known_issues": [] },
"/tables": { "known_issues": [] },
"/excel-query": { "known_issues": [] },
"/query-tool": { "known_issues": [] },

View File

@@ -0,0 +1,9 @@
{
"mode": "block",
"errors": [],
"warnings": [
"/excel-query uses shell tokens without fallback ['--portal-shadow-panel'] in frontend/src/excel-query/style.css with approved exception"
],
"info": [],
"passed": true
}

View File

@@ -120,18 +120,6 @@
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/tmtt-defect",
"route_id": "tmtt-defect",
"title": "TMTT Defect",
"scope": "in-scope",
"render_mode": "native",
"owner": "frontend-mes-reporting",
"visibility_policy": "released_or_admin",
"canonical_shell_path": "/portal-shell/tmtt-defect",
"rollback_strategy": "fallback_to_legacy_route",
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
},
{
"route": "/admin/pages",
"route_id": "admin-pages",

View File

@@ -10,7 +10,6 @@
{ "route": "/resource-history", "category": "report" },
{ "route": "/qc-gate", "category": "report" },
{ "route": "/job-query", "category": "report" },
{ "route": "/tmtt-defect", "category": "report" },
{ "route": "/tables", "category": "report" },
{ "route": "/excel-query", "category": "report" },
{ "route": "/query-tool", "category": "report" },

View File

@@ -76,6 +76,12 @@
"name": "批次追蹤工具",
"status": "released",
"order": 2
},
{
"route": "/mid-section-defect",
"name": "製程不良追溯分析",
"status": "released",
"order": 3
}
]
},
@@ -108,18 +114,6 @@
"name": "效能監控",
"status": "dev",
"order": 2
},
{
"route": "/tmtt-defect",
"name": "TMTT印字腳型不良分析",
"status": "dev",
"order": 5
},
{
"route": "/mid-section-defect",
"name": "中段製程不良追溯",
"status": "dev",
"order": 6
}
]
}
@@ -194,6 +188,12 @@
"name": "批次追蹤工具",
"status": "released",
"order": 2
},
{
"route": "/mid-section-defect",
"name": "製程不良追溯分析",
"status": "released",
"order": 3
}
]
}

View File

@@ -8,6 +8,7 @@ import TraceProgressBar from '../shared-composables/TraceProgressBar.vue';
import FilterBar from './components/FilterBar.vue';
import KpiCards from './components/KpiCards.vue';
import MultiSelect from './components/MultiSelect.vue';
import ParetoChart from './components/ParetoChart.vue';
import TrendChart from './components/TrendChart.vue';
import DetailTable from './components/DetailTable.vue';
@@ -16,18 +17,84 @@ ensureMesApiAvailable();
const API_TIMEOUT = 120000;
const PAGE_SIZE = 200;
const SESSION_CACHE_KEY = 'msd:cache';
const SESSION_CACHE_TTL = 5 * 60 * 1000; // 5 min, matches backend Redis TTL
const CHART_TOP_N = 10;
function buildMachineChartFromAttribution(records) {
if (!records || records.length === 0) return [];
const agg = {};
for (const rec of records) {
const key = rec.EQUIPMENT_NAME || '(未知)';
if (!agg[key]) agg[key] = { input_qty: 0, defect_qty: 0, lot_count: 0 };
agg[key].input_qty += rec.INPUT_QTY;
agg[key].defect_qty += rec.DEFECT_QTY;
agg[key].lot_count += rec.DETECTION_LOT_COUNT;
}
const sorted = Object.entries(agg).sort((a, b) => b[1].defect_qty - a[1].defect_qty);
const items = [];
const other = { input_qty: 0, defect_qty: 0, lot_count: 0 };
for (let i = 0; i < sorted.length; i++) {
const [name, data] = sorted[i];
if (i < CHART_TOP_N) {
const rate = data.input_qty > 0 ? Math.round((data.defect_qty / data.input_qty) * 1e6) / 1e4 : 0;
items.push({ name, input_qty: data.input_qty, defect_qty: data.defect_qty, defect_rate: rate, lot_count: data.lot_count });
} else {
other.input_qty += data.input_qty;
other.defect_qty += data.defect_qty;
other.lot_count += data.lot_count;
}
}
if (other.defect_qty > 0 || other.input_qty > 0) {
const rate = other.input_qty > 0 ? Math.round((other.defect_qty / other.input_qty) * 1e6) / 1e4 : 0;
items.push({ name: '其他', ...other, defect_rate: rate });
}
const totalDefects = items.reduce((s, d) => s + d.defect_qty, 0);
let cumsum = 0;
for (const item of items) {
cumsum += item.defect_qty;
item.cumulative_pct = totalDefects > 0 ? Math.round((cumsum / totalDefects) * 1e4) / 100 : 0;
}
return items;
}
const stationOptions = ref([]);
(async () => {
try {
const result = await apiGet('/api/mid-section-defect/station-options');
if (result?.success && Array.isArray(result.data)) {
stationOptions.value = result.data;
}
} catch { /* non-blocking */ }
})();
const stationLabelMap = computed(() => {
const m = {};
for (const opt of stationOptions.value) {
m[opt.name] = opt.label || opt.name;
}
return m;
});
const filters = reactive({
startDate: '',
endDate: '',
lossReasons: [],
station: '測試',
direction: 'backward',
});
const committedFilters = ref({
startDate: '',
endDate: '',
lossReasons: [],
station: '測試',
direction: 'backward',
});
const queryMode = ref('date_range');
const containerInputType = ref('lot');
const containerInput = ref('');
const resolutionInfo = ref(null);
const availableLossReasons = ref([]);
const trace = useTraceProgress({ profile: 'mid_section_defect' });
@@ -54,6 +121,69 @@ const loading = reactive({
const hasQueried = ref(false);
const queryError = ref('');
const restoredFromCache = ref(false);
const upstreamStationFilter = ref([]);
const upstreamSpecFilter = ref([]);
const upstreamStationOptions = computed(() => {
const attribution = analysisData.value?.attribution;
if (!Array.isArray(attribution) || attribution.length === 0) return [];
const seen = new Set();
const options = [];
for (const rec of attribution) {
const group = rec.WORKCENTER_GROUP;
if (group && !seen.has(group)) {
seen.add(group);
options.push({ value: group, label: stationLabelMap.value[group] || group });
}
}
return options.sort((a, b) => a.label.localeCompare(b.label, 'zh-TW'));
});
const upstreamSpecOptions = computed(() => {
const attribution = analysisData.value?.attribution;
if (!Array.isArray(attribution) || attribution.length === 0) return [];
// Apply station filter first so spec options are contextual
const base = upstreamStationFilter.value.length > 0
? attribution.filter(rec => upstreamStationFilter.value.includes(rec.WORKCENTER_GROUP))
: attribution;
const seen = new Set();
const options = [];
for (const rec of base) {
const family = rec.RESOURCEFAMILYNAME;
if (family && family !== '(未知)' && !seen.has(family)) {
seen.add(family);
options.push(family);
}
}
return options.sort((a, b) => a.localeCompare(b, 'zh-TW'));
});
const filteredByMachineData = computed(() => {
const attribution = analysisData.value?.attribution;
const hasFilter = upstreamStationFilter.value.length > 0 || upstreamSpecFilter.value.length > 0;
if (!hasFilter || !Array.isArray(attribution) || attribution.length === 0) {
return analysisData.value?.charts?.by_machine ?? [];
}
const filtered = attribution.filter(rec => {
if (upstreamStationFilter.value.length > 0 && !upstreamStationFilter.value.includes(rec.WORKCENTER_GROUP)) return false;
if (upstreamSpecFilter.value.length > 0 && !upstreamSpecFilter.value.includes(rec.RESOURCEFAMILYNAME)) return false;
return true;
});
return filtered.length > 0 ? buildMachineChartFromAttribution(filtered) : [];
});
const isForward = computed(() => committedFilters.value.direction === 'forward');
const committedStation = computed(() => {
const key = committedFilters.value.station || '測試';
return stationLabelMap.value[key] || key;
});
const headerSubtitle = computed(() => {
const station = committedStation.value;
if (isForward.value) {
return `${station}站不良批次 → 追蹤倖存批次下游表現`;
}
return `${station}站不良 → 回溯上游機台歸因`;
});
const hasTraceError = computed(() => (
Boolean(trace.stage_errors.seed)
@@ -67,7 +197,9 @@ const showTraceProgress = computed(() => (
));
const eventsAggregation = computed(() => trace.stage_results.events?.aggregation || null);
const showAnalysisSkeleton = computed(() => hasQueried.value && loading.querying && !eventsAggregation.value);
const showAnalysisCharts = computed(() => hasQueried.value && Boolean(eventsAggregation.value));
const showAnalysisCharts = computed(() => hasQueried.value && (Boolean(eventsAggregation.value) || restoredFromCache.value));
const skeletonChartCount = computed(() => (isForward.value ? 4 : 6));
function emptyAnalysisData() {
return {
@@ -105,12 +237,20 @@ function unwrapApiResult(result, fallbackMessage) {
function buildFilterParams() {
const snapshot = committedFilters.value;
const params = {
start_date: snapshot.startDate,
end_date: snapshot.endDate,
station: snapshot.station,
direction: snapshot.direction,
};
if (snapshot.lossReasons.length) {
params.loss_reasons = snapshot.lossReasons;
}
if (snapshot.queryMode === 'container') {
params.mode = 'container';
params.resolve_type = snapshot.containerInputType === 'lot' ? 'lot_id' : snapshot.containerInputType;
params.values = snapshot.containerValues;
} else {
params.start_date = snapshot.startDate;
params.end_date = snapshot.endDate;
}
return params;
}
@@ -119,6 +259,8 @@ function buildDetailParams() {
const params = {
start_date: snapshot.startDate,
end_date: snapshot.endDate,
station: snapshot.station,
direction: snapshot.direction,
};
if (snapshot.lossReasons.length) {
params.loss_reasons = snapshot.lossReasons.join(',');
@@ -126,11 +268,23 @@ function buildDetailParams() {
return params;
}
function parseContainerValues() {
return containerInput.value
.split(/[\n,;]+/)
.map((v) => v.trim().replace(/\*/g, '%'))
.filter(Boolean);
}
function snapshotFilters() {
committedFilters.value = {
startDate: filters.startDate,
endDate: filters.endDate,
lossReasons: [...filters.lossReasons],
station: filters.station,
direction: filters.direction,
queryMode: queryMode.value,
containerInputType: containerInputType.value,
containerValues: parseContainerValues(),
};
}
@@ -183,16 +337,30 @@ async function loadDetail(page = 1, signal = null) {
async function loadAnalysis() {
queryError.value = '';
restoredFromCache.value = false;
resolutionInfo.value = null;
upstreamStationFilter.value = [];
upstreamSpecFilter.value = [];
trace.abort();
trace.reset();
loading.querying = true;
hasQueried.value = true;
analysisData.value = emptyAnalysisData();
const isContainerMode = committedFilters.value.queryMode === 'container';
try {
const params = buildFilterParams();
await trace.execute(params);
// Extract resolution info for container mode
if (isContainerMode && trace.stage_results.seed) {
resolutionInfo.value = {
resolved_count: trace.stage_results.seed.seed_count || 0,
not_found: trace.stage_results.seed.not_found || [],
};
}
if (eventsAggregation.value) {
analysisData.value = {
...analysisData.value,
@@ -206,7 +374,11 @@ async function loadAnalysis() {
}
if (!stageError || trace.completed_stages.value.includes('events')) {
await loadDetail(1, createAbortSignal('msd-detail'));
// Container mode: no detail/export (no date range for legacy API)
if (!isContainerMode) {
await loadDetail(1, createAbortSignal('msd-detail'));
}
saveSession();
}
if (!autoRefreshStarted) {
@@ -247,6 +419,8 @@ function exportCsv() {
const params = new URLSearchParams({
start_date: snapshot.startDate,
end_date: snapshot.endDate,
station: snapshot.station,
direction: snapshot.direction,
});
if (snapshot.lossReasons.length) {
params.set('loss_reasons', snapshot.lossReasons.join(','));
@@ -254,7 +428,7 @@ function exportCsv() {
const link = document.createElement('a');
link.href = `/api/mid-section-defect/export?${params.toString()}`;
link.download = `mid_section_defect_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
link.download = `mid_section_defect_${snapshot.station}_${snapshot.direction}_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -271,7 +445,53 @@ const { createAbortSignal, startAutoRefresh } = useAutoRefresh({
refreshOnVisible: true,
});
function saveSession() {
try {
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify({
ts: Date.now(),
committedFilters: committedFilters.value,
filters: { ...filters },
analysisData: analysisData.value,
detailData: detailData.value,
detailPagination: detailPagination.value,
availableLossReasons: availableLossReasons.value,
queryMode: queryMode.value,
containerInputType: containerInputType.value,
containerInput: containerInput.value,
resolutionInfo: resolutionInfo.value,
}));
} catch { /* quota exceeded or unavailable */ }
}
function restoreSession() {
try {
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
if (Date.now() - data.ts > SESSION_CACHE_TTL) {
sessionStorage.removeItem(SESSION_CACHE_KEY);
return false;
}
Object.assign(filters, data.filters);
committedFilters.value = data.committedFilters;
analysisData.value = data.analysisData;
detailData.value = data.detailData || [];
detailPagination.value = data.detailPagination || { page: 1, page_size: PAGE_SIZE, total_count: 0, total_pages: 1 };
availableLossReasons.value = data.availableLossReasons || [];
queryMode.value = data.queryMode || 'date_range';
containerInputType.value = data.containerInputType || 'lot';
containerInput.value = data.containerInput || '';
resolutionInfo.value = data.resolutionInfo || null;
hasQueried.value = true;
restoredFromCache.value = true;
return true;
} catch {
return false;
}
}
function initPage() {
if (restoreSession()) return;
setDefaultDates();
snapshotFilters();
void loadLossReasons();
@@ -283,16 +503,24 @@ void initPage();
<template>
<div class="page-container">
<header class="page-header">
<h1>中段製程不良追溯分析</h1>
<p class="header-desc">TMTT 測試站不良回溯至上游機台 / 站點 / 製程</p>
<h1>製程不良追溯分析</h1>
<p class="header-desc">{{ headerSubtitle }}</p>
</header>
<FilterBar
:filters="filters"
:loading="loading.querying"
:available-loss-reasons="availableLossReasons"
:station-options="stationOptions"
:query-mode="queryMode"
:container-input-type="containerInputType"
:container-input="containerInput"
:resolution-info="resolutionInfo"
@update-filters="handleUpdateFilters"
@query="handleQuery"
@update:query-mode="queryMode = $event"
@update:container-input-type="containerInputType = $event"
@update:container-input="containerInput = $event"
/>
<TraceProgressBar
@@ -306,7 +534,7 @@ void initPage();
<template v-if="hasQueried">
<div v-if="analysisData.genealogy_status === 'error'" class="warning-banner">
追溯分析未完成genealogy 查詢失敗圖表僅顯示 TMTT 站點數據
追溯分析未完成genealogy 查詢失敗圖表僅顯示偵測站數據
</div>
<div v-if="showAnalysisSkeleton" class="trace-skeleton-section">
@@ -314,29 +542,65 @@ void initPage();
<div v-for="index in 6" :key="`kpi-${index}`" class="trace-skeleton-card trace-skeleton-pulse"></div>
</div>
<div class="trace-skeleton-chart-grid">
<div v-for="index in 6" :key="`chart-${index}`" class="trace-skeleton-chart trace-skeleton-pulse"></div>
<div v-for="index in skeletonChartCount" :key="`chart-${index}`" class="trace-skeleton-chart trace-skeleton-pulse"></div>
<div class="trace-skeleton-chart trace-skeleton-trend trace-skeleton-pulse"></div>
</div>
</div>
<transition name="trace-fade">
<div v-if="showAnalysisCharts">
<KpiCards :kpi="analysisData.kpi" :loading="false" />
<KpiCards
:kpi="analysisData.kpi"
:loading="false"
:direction="committedFilters.direction"
:station-label="committedStation"
/>
<div class="charts-section">
<div class="charts-row">
<ParetoChart title="依站點歸因" :data="analysisData.charts?.by_station" />
<ParetoChart title="依不良原因" :data="analysisData.charts?.by_loss_reason" />
</div>
<div class="charts-row">
<ParetoChart title="依上游機台歸因" :data="analysisData.charts?.by_machine" />
<ParetoChart title="依 TMTT 機台" :data="analysisData.charts?.by_tmtt_machine" />
</div>
<div class="charts-row">
<ParetoChart title="依製程 (WORKFLOW)" :data="analysisData.charts?.by_workflow" />
<ParetoChart title="依封裝 (PACKAGE)" :data="analysisData.charts?.by_package" />
</div>
<div class="charts-row charts-row-full">
<template v-if="!isForward">
<div class="charts-row">
<ParetoChart title="依上游機台歸因" :data="filteredByMachineData">
<template #header-extra>
<div class="chart-inline-filters">
<MultiSelect
v-if="upstreamStationOptions.length > 1"
:model-value="upstreamStationFilter"
:options="upstreamStationOptions"
placeholder="全部站點"
@update:model-value="upstreamStationFilter = $event; upstreamSpecFilter = []"
/>
<MultiSelect
v-if="upstreamSpecOptions.length > 1"
:model-value="upstreamSpecFilter"
:options="upstreamSpecOptions"
placeholder="全部型號"
@update:model-value="upstreamSpecFilter = $event"
/>
</div>
</template>
</ParetoChart>
<ParetoChart title="依不良原因" :data="analysisData.charts?.by_loss_reason" />
</div>
<div class="charts-row">
<ParetoChart title="依偵測機台" :data="analysisData.charts?.by_detection_machine" />
<ParetoChart title="依製程 (WORKFLOW)" :data="analysisData.charts?.by_workflow" />
</div>
<div class="charts-row">
<ParetoChart title="依封裝 (PACKAGE)" :data="analysisData.charts?.by_package" />
<ParetoChart title="依 TYPE" :data="analysisData.charts?.by_pj_type" />
</div>
</template>
<template v-else>
<div class="charts-row">
<ParetoChart title="依下游站點" :data="analysisData.charts?.by_downstream_station" />
<ParetoChart title="依下游不良原因" :data="analysisData.charts?.by_downstream_loss_reason" />
</div>
<div class="charts-row">
<ParetoChart title="依下游機台" :data="analysisData.charts?.by_downstream_machine" />
<ParetoChart title="依偵測機台" :data="analysisData.charts?.by_detection_machine" />
</div>
</template>
<div v-if="committedFilters.queryMode !== 'container'" class="charts-row charts-row-full">
<TrendChart :data="analysisData.daily_trend" />
</div>
</div>
@@ -344,9 +608,11 @@ void initPage();
</transition>
<DetailTable
v-if="committedFilters.queryMode !== 'container'"
:data="detailData"
:loading="detailLoading"
:pagination="detailPagination"
:direction="committedFilters.direction"
@export-csv="exportCsv"
@prev-page="prevPage"
@next-page="nextPage"
@@ -354,7 +620,7 @@ void initPage();
</template>
<div v-else-if="!loading.querying" class="empty-state">
<p>請選擇日期範圍與不良原因點擊查詢開始分析</p>
<p>請選擇偵測站與查詢條件點擊查詢開始分析</p>
</div>
</div>
</template>

View File

@@ -16,6 +16,10 @@ const props = defineProps({
type: Object,
default: () => ({ page: 1, page_size: 200, total_count: 0, total_pages: 1 }),
},
direction: {
type: String,
default: 'backward',
},
});
const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
@@ -23,12 +27,12 @@ const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
const sortField = ref('DEFECT_RATE');
const sortAsc = ref(false);
const COLUMNS = [
const COLUMNS_BACKWARD = [
{ key: 'CONTAINERNAME', label: 'LOT ID', width: '140px' },
{ key: 'PJ_TYPE', label: 'TYPE', width: '80px' },
{ key: 'PRODUCTLINENAME', label: 'PACKAGE', width: '90px' },
{ key: 'WORKFLOW', label: 'WORKFLOW', width: '100px' },
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT 設備', width: '110px' },
{ key: 'DETECTION_EQUIPMENTNAME', label: '偵測設備', width: '110px' },
{ key: 'INPUT_QTY', label: '投入數', width: '70px', numeric: true },
{ key: 'LOSS_REASON', label: '不良原因', width: '130px' },
{ key: 'DEFECT_QTY', label: '不良數', width: '70px', numeric: true },
@@ -37,6 +41,21 @@ const COLUMNS = [
{ key: 'UPSTREAM_MACHINES', label: '上游機台', width: '200px' },
];
const COLUMNS_FORWARD = [
{ key: 'CONTAINERNAME', label: 'LOT ID', width: '140px' },
{ key: 'DETECTION_EQUIPMENTNAME', label: '偵測設備', width: '120px' },
{ key: 'TRACKINQTY', label: '偵測投入', width: '80px', numeric: true },
{ key: 'DEFECT_QTY', label: '偵測不良', width: '80px', numeric: true },
{ key: 'DOWNSTREAM_STATIONS_REACHED', label: '下游到達站數', width: '100px', numeric: true },
{ key: 'DOWNSTREAM_TOTAL_REJECT', label: '下游不良總數', width: '100px', numeric: true },
{ key: 'DOWNSTREAM_REJECT_RATE', label: '下游不良率(%)', width: '110px', numeric: true },
{ key: 'WORST_DOWNSTREAM_STATION', label: '最差下游站', width: '120px' },
];
const activeColumns = computed(() => (
props.direction === 'forward' ? COLUMNS_FORWARD : COLUMNS_BACKWARD
));
const sortedData = computed(() => {
if (!props.data || !props.data.length) return [];
const field = sortField.value;
@@ -80,7 +99,7 @@ function sortIcon(field) {
function formatCell(value, col) {
if (value == null || value === '') return '-';
if (col.key === 'DEFECT_RATE') return Number(value).toFixed(2);
if (col.key === 'DEFECT_RATE' || col.key === 'DOWNSTREAM_REJECT_RATE') return Number(value).toFixed(2);
if (col.numeric) return Number(value).toLocaleString();
return value;
}
@@ -104,7 +123,7 @@ function formatCell(value, col) {
<thead>
<tr>
<th
v-for="col in COLUMNS"
v-for="col in activeColumns"
:key="col.key"
:style="{ width: col.width }"
class="sortable"
@@ -116,12 +135,12 @@ function formatCell(value, col) {
</thead>
<tbody>
<tr v-for="(row, idx) in sortedData" :key="idx">
<td v-for="col in COLUMNS" :key="col.key" :class="{ numeric: col.numeric }">
<td v-for="col in activeColumns" :key="col.key" :class="{ numeric: col.numeric }">
{{ formatCell(row[col.key], col) }}
</td>
</tr>
<tr v-if="!sortedData.length">
<td :colspan="COLUMNS.length" class="empty-row">暫無資料</td>
<td :colspan="activeColumns.length" class="empty-row">暫無資料</td>
</tr>
</tbody>
</table>

View File

@@ -14,9 +14,35 @@ const props = defineProps({
type: Array,
default: () => [],
},
stationOptions: {
type: Array,
default: () => [],
},
queryMode: {
type: String,
default: 'date_range',
},
containerInputType: {
type: String,
default: 'lot',
},
containerInput: {
type: String,
default: '',
},
resolutionInfo: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update-filters', 'query']);
const emit = defineEmits([
'update-filters',
'query',
'update:queryMode',
'update:containerInputType',
'update:containerInput',
]);
function updateFilters(patch) {
emit('update-filters', {
@@ -29,29 +55,112 @@ function updateFilters(patch) {
<template>
<section class="section-card">
<div class="section-inner">
<!-- Mode toggle tabs -->
<div class="mode-tab-row">
<button
type="button"
:class="['mode-tab', { active: queryMode === 'date_range' }]"
@click="$emit('update:queryMode', 'date_range')"
>
日期區間
</button>
<button
type="button"
:class="['mode-tab', { active: queryMode === 'container' }]"
@click="$emit('update:queryMode', 'container')"
>
LOT / 工單 / WAFER
</button>
</div>
<div class="filter-row">
<!-- Shared: detection station -->
<div class="filter-field">
<label for="msd-start-date">開始</label>
<input
id="msd-start-date"
type="date"
:value="filters.startDate"
<label for="msd-station">偵測站</label>
<select
id="msd-station"
:value="filters.station"
:disabled="loading"
@input="updateFilters({ startDate: $event.target.value })"
/>
class="filter-select"
@change="updateFilters({ station: $event.target.value })"
>
<option
v-for="opt in stationOptions"
:key="opt.name"
:value="opt.name"
>
{{ opt.label || opt.name }}
</option>
</select>
</div>
<div class="filter-field">
<label for="msd-end-date">結束</label>
<input
id="msd-end-date"
type="date"
:value="filters.endDate"
<!-- Container mode: input type -->
<div v-if="queryMode === 'container'" class="filter-field">
<label for="msd-container-type">輸入類型</label>
<select
id="msd-container-type"
class="filter-select"
:value="containerInputType"
:disabled="loading"
@input="updateFilters({ endDate: $event.target.value })"
/>
@change="$emit('update:containerInputType', $event.target.value)"
>
<option value="lot">LOT</option>
<option value="work_order">工單</option>
<option value="wafer_lot">WAFER LOT</option>
</select>
</div>
<!-- Shared: direction -->
<div class="filter-field">
<label>方向</label>
<div class="direction-toggle">
<button
type="button"
class="direction-btn"
:class="{ active: filters.direction === 'backward' }"
:disabled="loading"
@click="updateFilters({ direction: 'backward' })"
>
反向追溯
</button>
<button
type="button"
class="direction-btn"
:class="{ active: filters.direction === 'forward' }"
:disabled="loading"
@click="updateFilters({ direction: 'forward' })"
>
正向追溯
</button>
</div>
</div>
<!-- Date range mode: dates -->
<template v-if="queryMode === 'date_range'">
<div class="filter-field">
<label for="msd-start-date">開始</label>
<input
id="msd-start-date"
type="date"
:value="filters.startDate"
:disabled="loading"
@input="updateFilters({ startDate: $event.target.value })"
/>
</div>
<div class="filter-field">
<label for="msd-end-date">結束</label>
<input
id="msd-end-date"
type="date"
:value="filters.endDate"
:disabled="loading"
@input="updateFilters({ endDate: $event.target.value })"
/>
</div>
</template>
<!-- Shared: loss reasons -->
<div class="filter-field">
<label>不良原因</label>
<MultiSelect
@@ -72,6 +181,33 @@ function updateFilters(patch) {
查詢
</button>
</div>
<!-- Container mode: textarea input -->
<div v-if="queryMode === 'container'" class="container-input-row">
<textarea
class="filter-textarea"
rows="3"
:value="containerInput"
:disabled="loading"
placeholder="每行一個,支援 * 或 % wildcard&#10;GA26020001-A00-001&#10;GA260200%&#10;..."
@input="$emit('update:containerInput', $event.target.value)"
></textarea>
</div>
<!-- Resolution info (container mode) -->
<div
v-if="resolutionInfo && queryMode === 'container'"
class="resolution-info"
>
已解析 {{ resolutionInfo.resolved_count }} 筆容器
<template v-if="resolutionInfo.not_found?.length > 0">
<span class="resolution-warn">
({{ resolutionInfo.not_found.length }} 筆未找到:
{{ resolutionInfo.not_found.slice(0, 10).join(', ')
}}{{ resolutionInfo.not_found.length > 10 ? '...' : '' }})
</span>
</template>
</div>
</div>
</section>
</template>

View File

@@ -10,11 +10,19 @@ const props = defineProps({
type: Boolean,
default: false,
},
direction: {
type: String,
default: 'backward',
},
stationLabel: {
type: String,
default: '測試',
},
});
const cards = computed(() => [
const backwardCards = computed(() => [
{
label: 'TMTT 投入數',
label: `${props.stationLabel} 投入數`,
value: formatNumber(props.kpi.total_input),
unit: 'pcs',
color: '#3b82f6',
@@ -52,6 +60,49 @@ const cards = computed(() => [
},
]);
const forwardCards = computed(() => [
{
label: '偵測批次數',
value: formatNumber(props.kpi.detection_lot_count),
unit: 'lots',
color: '#3b82f6',
},
{
label: '偵測不良數',
value: formatNumber(props.kpi.detection_defect_qty),
unit: 'pcs',
color: '#ef4444',
},
{
label: '追蹤批次數',
value: formatNumber(props.kpi.tracked_lot_count),
unit: 'lots',
color: '#6366f1',
},
{
label: '下游到達站數',
value: formatNumber(props.kpi.downstream_stations_reached),
unit: '站',
color: '#8b5cf6',
},
{
label: '下游不良總數',
value: formatNumber(props.kpi.downstream_total_reject),
unit: 'pcs',
color: '#f59e0b',
},
{
label: '下游不良率',
value: formatRate(props.kpi.downstream_reject_rate),
unit: '%',
color: '#10b981',
},
]);
const cards = computed(() => (
props.direction === 'forward' ? forwardCards.value : backwardCards.value
));
function formatNumber(v) {
if (v == null || v === 0) return '0';
return Number(v).toLocaleString();

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = defineProps({
modelValue: {
@@ -18,12 +18,18 @@ const props = defineProps({
type: Boolean,
default: false,
},
searchable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update:modelValue']);
const rootRef = ref(null);
const searchRef = ref(null);
const isOpen = ref(false);
const searchQuery = ref('');
const normalizedOptions = computed(() => {
return props.options.map((option) => {
@@ -43,6 +49,14 @@ const normalizedOptions = computed(() => {
});
});
const filteredOptions = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return normalizedOptions.value;
return normalizedOptions.value.filter(
(opt) => opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
);
});
const selectedSet = computed(() => new Set((props.modelValue || []).map((value) => String(value))));
const selectedText = computed(() => {
@@ -71,6 +85,17 @@ function toggleDropdown() {
isOpen.value = !isOpen.value;
}
watch(isOpen, (open) => {
if (open) {
searchQuery.value = '';
nextTick(() => {
if (props.searchable && searchRef.value) {
searchRef.value.focus();
}
});
}
});
function isSelected(value) {
return selectedSet.value.has(String(value));
}
@@ -131,9 +156,19 @@ onBeforeUnmount(() => {
</button>
<div v-if="isOpen" class="multi-select-dropdown">
<div v-if="searchable" class="multi-select-search">
<input
ref="searchRef"
v-model="searchQuery"
type="text"
class="multi-select-search-input"
placeholder="搜尋..."
/>
</div>
<div class="multi-select-options">
<button
v-for="option in normalizedOptions"
v-for="option in filteredOptions"
:key="option.value"
type="button"
class="multi-select-option"
@@ -142,6 +177,9 @@ onBeforeUnmount(() => {
<input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" />
<span>{{ option.label }}</span>
</button>
<div v-if="filteredOptions.length === 0" class="multi-select-no-match">
無符合項目
</div>
</div>
<div class="multi-select-actions">

View File

@@ -120,7 +120,10 @@ const chartOption = computed(() => {
<template>
<div class="chart-card">
<h3 class="chart-title">{{ title }}</h3>
<div class="chart-header">
<h3 class="chart-title">{{ title }}</h3>
<slot name="header-extra" />
</div>
<VChart
v-if="chartOption"
class="chart-canvas"

View File

@@ -71,6 +71,83 @@ body {
font-weight: 700;
}
/* ====== Mode Tabs ====== */
.mode-tab-row {
display: flex;
gap: 0;
border: 1px solid var(--msd-border);
border-radius: 6px;
overflow: hidden;
width: fit-content;
margin-bottom: 12px;
}
.mode-tab {
padding: 7px 16px;
border: none;
background: #f8fafc;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.mode-tab.active {
background: var(--msd-primary);
color: #fff;
}
.mode-tab:not(:last-child) {
border-right: 1px solid var(--msd-border);
}
.mode-tab:hover:not(.active) {
background: #e2e8f0;
}
/* ====== Container Input ====== */
.container-input-row {
padding: 8px 0 0;
}
.filter-textarea {
width: 100%;
resize: vertical;
font-family: monospace;
line-height: 1.5;
border: 1px solid var(--msd-border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: #1f2937;
background: #ffffff;
}
.filter-textarea:focus {
outline: none;
border-color: var(--msd-primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
.filter-textarea:disabled {
background: #f1f5f9;
cursor: not-allowed;
}
/* ====== Resolution Info ====== */
.resolution-info {
padding: 8px 0 0;
font-size: 13px;
color: #0f766e;
font-weight: 600;
}
.resolution-warn {
color: #b45309;
font-weight: 400;
}
/* ====== Filter Bar ====== */
.filter-row {
display: flex;
@@ -139,6 +216,54 @@ body {
background: #f1f5f9;
}
/* ====== Filter Select ====== */
.filter-select {
border: 1px solid var(--msd-border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
background: #ffffff;
color: #1f2937;
min-width: 100px;
}
/* ====== Direction Toggle ====== */
.direction-toggle {
display: inline-flex;
border: 1px solid var(--msd-border);
border-radius: 6px;
overflow: hidden;
}
.direction-btn {
border: none;
background: #ffffff;
color: #475569;
font-size: 13px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.direction-btn + .direction-btn {
border-left: 1px solid var(--msd-border);
}
.direction-btn.active {
background: var(--msd-primary);
color: #ffffff;
}
.direction-btn:hover:not(:disabled):not(.active) {
background: #f1f5f9;
}
.direction-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* ====== MultiSelect ====== */
.multi-select {
position: relative;
@@ -220,6 +345,33 @@ body {
pointer-events: none;
}
.multi-select-search {
padding: 8px 8px 4px;
}
.multi-select-search-input {
width: 100%;
border: 1px solid var(--msd-border);
border-radius: 5px;
padding: 5px 8px;
font-size: 13px;
color: #1f2937;
background: #ffffff;
outline: none;
}
.multi-select-search-input:focus {
border-color: var(--msd-primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
.multi-select-no-match {
padding: 12px;
text-align: center;
color: var(--msd-muted);
font-size: 13px;
}
.multi-select-actions {
display: flex;
gap: 6px;
@@ -322,6 +474,18 @@ body {
grid-column: 1 / -1;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.chart-header .chart-title {
margin: 0;
}
.chart-title {
margin: 0 0 8px;
font-size: 14px;
@@ -329,6 +493,48 @@ body {
color: var(--msd-text);
}
.chart-inline-filters {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.chart-inline-filters .multi-select {
min-width: 160px;
}
.chart-inline-filters .multi-select-trigger {
padding: 3px 7px;
font-size: 12px;
border-color: var(--msd-border);
background: #f8fafc;
color: #475569;
}
.chart-inline-filters .multi-select-dropdown {
min-width: 220px;
right: 0;
left: auto;
}
.chart-inline-filter {
border: 1px solid var(--msd-border);
border-radius: 5px;
padding: 3px 7px;
font-size: 12px;
color: #475569;
background: #f8fafc;
cursor: pointer;
max-width: 140px;
flex-shrink: 0;
}
.chart-inline-filter:focus {
outline: 2px solid var(--msd-primary);
outline-offset: 1px;
}
.chart-canvas {
width: 100%;
height: 320px;
@@ -456,79 +662,79 @@ body {
}
/* ====== Empty State ====== */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: var(--msd-muted);
font-size: 15px;
}
/* ====== Trace Skeleton ====== */
.trace-skeleton-section {
margin-bottom: 16px;
}
.trace-skeleton-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.trace-skeleton-card {
min-height: 100px;
border-radius: 10px;
background: #e5eaf2;
box-shadow: var(--msd-shadow);
}
.trace-skeleton-chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.trace-skeleton-chart {
min-height: 300px;
border-radius: 10px;
background: #e5eaf2;
box-shadow: var(--msd-shadow);
}
.trace-skeleton-trend {
grid-column: 1 / -1;
}
.trace-skeleton-pulse {
animation: trace-skeleton-pulse 1.2s ease-in-out infinite;
}
.trace-fade-enter-active,
.trace-fade-leave-active {
transition: opacity 0.3s ease-in;
}
.trace-fade-enter-from,
.trace-fade-leave-to {
opacity: 0;
}
@keyframes trace-skeleton-pulse {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
@media (max-width: 1200px) {
.trace-skeleton-chart-grid {
grid-template-columns: 1fr;
}
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: var(--msd-muted);
font-size: 15px;
}
/* ====== Trace Skeleton ====== */
.trace-skeleton-section {
margin-bottom: 16px;
}
.trace-skeleton-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.trace-skeleton-card {
min-height: 100px;
border-radius: 10px;
background: #e5eaf2;
box-shadow: var(--msd-shadow);
}
.trace-skeleton-chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.trace-skeleton-chart {
min-height: 300px;
border-radius: 10px;
background: #e5eaf2;
box-shadow: var(--msd-shadow);
}
.trace-skeleton-trend {
grid-column: 1 / -1;
}
.trace-skeleton-pulse {
animation: trace-skeleton-pulse 1.2s ease-in-out infinite;
}
.trace-fade-enter-active,
.trace-fade-leave-active {
transition: opacity 0.3s ease-in;
}
.trace-fade-enter-from,
.trace-fade-leave-to {
opacity: 0;
}
@keyframes trace-skeleton-pulse {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
@media (max-width: 1200px) {
.trace-skeleton-chart-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -62,14 +62,14 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
() => import('../query-tool/App.vue'),
[() => import('../query-tool/style.css')],
),
'/tmtt-defect': createNativeLoader(
() => import('../tmtt-defect/App.vue'),
[() => import('../tmtt-defect/style.css')],
),
'/tables': createNativeLoader(
() => import('../tables/App.vue'),
[() => import('../tables/style.css')],
),
'/mid-section-defect': createNativeLoader(
() => import('../mid-section-defect/App.vue'),
[() => import('../mid-section-defect/style.css')],
),
'/admin/performance': createNativeLoader(
() => import('../admin-performance/App.vue'),
[() => import('../admin-performance/style.css')],

View File

@@ -9,7 +9,6 @@ const IN_SCOPE_REPORT_ROUTES = Object.freeze([
'/resource-history',
'/qc-gate',
'/job-query',
'/tmtt-defect',
'/tables',
'/excel-query',
'/query-tool',
@@ -165,17 +164,6 @@ const ROUTE_CONTRACTS = Object.freeze({
scope: 'in-scope',
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
}),
'/tmtt-defect': buildContract({
route: '/tmtt-defect',
routeId: 'tmtt-defect',
renderMode: 'native',
owner: 'frontend-mes-reporting',
title: 'TMTT Defect',
rollbackStrategy: 'fallback_to_legacy_route',
visibilityPolicy: 'released_or_admin',
scope: 'in-scope',
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
}),
'/admin/pages': buildContract({
route: '/admin/pages',
routeId: 'admin-pages',
@@ -236,7 +224,7 @@ const ROUTE_CONTRACTS = Object.freeze({
routeId: 'mid-section-defect',
renderMode: 'native',
owner: 'frontend-mes-reporting',
title: '中段製程不良追溯',
title: '製程不良追溯分析',
rollbackStrategy: 'fallback_to_legacy_route',
visibilityPolicy: 'released_or_admin',
scope: 'in-scope',

View File

@@ -8,6 +8,7 @@ const DEFAULT_STAGE_TIMEOUT_MS = 60000;
const PROFILE_DOMAINS = Object.freeze({
query_tool: ['history', 'materials', 'rejects', 'holds', 'jobs'],
mid_section_defect: ['upstream_history'],
mid_section_defect_forward: ['upstream_history', 'downstream_rejects'],
});
function stageKey(stageName) {
@@ -31,23 +32,42 @@ function normalizeSeedContainerIds(seedPayload) {
return containerIds;
}
function collectAllContainerIds(seedContainerIds, lineagePayload) {
function collectAllContainerIds(seedContainerIds, lineagePayload, direction) {
const seen = new Set(seedContainerIds);
const merged = [...seedContainerIds];
const ancestors = lineagePayload?.ancestors || {};
Object.values(ancestors).forEach((values) => {
if (!Array.isArray(values)) {
return;
if (direction === 'forward') {
const childrenMap = lineagePayload?.children_map || {};
const queue = [...seedContainerIds];
while (queue.length > 0) {
const current = queue.shift();
const children = childrenMap[current];
if (!Array.isArray(children)) continue;
for (const child of children) {
const id = String(child || '').trim();
if (!id || seen.has(id)) continue;
seen.add(id);
merged.push(id);
queue.push(id);
}
}
values.forEach((value) => {
const id = String(value || '').trim();
if (!id || seen.has(id)) {
} else {
const ancestors = lineagePayload?.ancestors || {};
Object.values(ancestors).forEach((values) => {
if (!Array.isArray(values)) {
return;
}
seen.add(id);
merged.push(id);
values.forEach((value) => {
const id = String(value || '').trim();
if (!id || seen.has(id)) {
return;
}
seen.add(id);
merged.push(id);
});
});
});
}
return merged;
}
@@ -89,7 +109,11 @@ export function useTraceProgress({ profile } = {}) {
}
async function execute(params = {}) {
const domains = PROFILE_DOMAINS[profile];
const direction = params.direction || 'backward';
const domainKey = profile === 'mid_section_defect' && direction === 'forward'
? 'mid_section_defect_forward'
: profile;
const domains = PROFILE_DOMAINS[domainKey];
if (!domains) {
throw new Error(`Unsupported trace profile: ${profile}`);
}
@@ -123,13 +147,14 @@ export function useTraceProgress({ profile } = {}) {
profile,
container_ids: seedContainerIds,
cache_key: seedPayload?.cache_key || null,
params,
},
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
);
stage_results.lineage = lineagePayload;
completed_stages.value = [...completed_stages.value, 'lineage'];
const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload);
const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload, direction);
current_stage.value = 'events';
const eventsPayload = await apiPost(
'/api/trace/events',
@@ -142,6 +167,7 @@ export function useTraceProgress({ profile } = {}) {
seed_container_ids: seedContainerIds,
lineage: {
ancestors: lineagePayload?.ancestors || {},
children_map: lineagePayload?.children_map || {},
},
},
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },

View File

@@ -1,153 +0,0 @@
<script setup>
import { computed } from 'vue';
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
import SectionCard from '../shared-ui/components/SectionCard.vue';
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
import TmttChartCard from './components/TmttChartCard.vue';
import TmttDetailTable from './components/TmttDetailTable.vue';
import TmttKpiCards from './components/TmttKpiCards.vue';
import { useTmttDefectData } from './composables/useTmttDefectData.js';
const {
startDate,
endDate,
loading,
errorMessage,
hasData,
kpi,
charts,
dailyTrend,
filteredRows,
totalCount,
filteredCount,
activeFilter,
sortState,
queryData,
setFilter,
clearFilter,
toggleSort,
exportCsv,
} = useTmttDefectData();
const paretoCharts = [
{ key: 'by_workflow', field: 'WORKFLOW', title: '依 WORKFLOW' },
{ key: 'by_package', field: 'PRODUCTLINENAME', title: '依 PACKAGE' },
{ key: 'by_type', field: 'PJ_TYPE', title: '依 TYPE' },
{ key: 'by_tmtt_machine', field: 'TMTT_EQUIPMENTNAME', title: '依 TMTT 機台' },
{ key: 'by_mold_machine', field: 'MOLD_EQUIPMENTNAME', title: '依 MOLD 機台' },
];
const detailCountLabel = computed(() => {
if (!activeFilter.value) {
return `${filteredCount.value}`;
}
return `${filteredCount.value} / ${totalCount.value}`;
});
</script>
<template>
<div class="tmtt-page u-content-shell">
<header class="tmtt-header">
<h1>TMTT 印字與腳型不良分析</h1>
<p>Legacy rewrite exemplarVue 元件化 + Shared UI + Tailwind token layer</p>
</header>
<div class="u-panel-stack">
<SectionCard>
<template #header>
<div class="tmtt-block-title">查詢條件</div>
</template>
<FilterToolbar>
<label class="tmtt-field">
<span>起始日期</span>
<input v-model="startDate" type="date" />
</label>
<label class="tmtt-field">
<span>結束日期</span>
<input v-model="endDate" type="date" />
</label>
<template #actions>
<button type="button" class="tmtt-btn tmtt-btn-primary" :disabled="loading" @click="queryData">
{{ loading ? '查詢中...' : '查詢' }}
</button>
<button type="button" class="tmtt-btn tmtt-btn-success" :disabled="loading" @click="exportCsv">
匯出 CSV
</button>
</template>
</FilterToolbar>
</SectionCard>
<p v-if="errorMessage" class="tmtt-error-banner">{{ errorMessage }}</p>
<template v-if="hasData">
<TmttKpiCards :kpi="kpi" />
<div class="tmtt-chart-grid">
<TmttChartCard
v-for="config in paretoCharts"
:key="config.key"
:title="config.title"
mode="pareto"
:field="config.field"
:selected-value="activeFilter?.value || ''"
:data="charts[config.key] || []"
@select="setFilter"
/>
<TmttChartCard
title="每日印字不良率趨勢"
mode="print-trend"
:data="dailyTrend"
line-label="印字不良率"
line-color="#ef4444"
/>
<TmttChartCard
title="每日腳型不良率趨勢"
mode="lead-trend"
:data="dailyTrend"
line-label="腳型不良率"
line-color="#f59e0b"
/>
</div>
<SectionCard>
<template #header>
<div class="tmtt-detail-header">
<div>
<strong>明細清單</strong>
<span class="tmtt-detail-count">({{ detailCountLabel }})</span>
</div>
<div class="tmtt-detail-actions">
<StatusBadge
v-if="activeFilter"
tone="warning"
:text="activeFilter.label"
/>
<button
v-if="activeFilter"
type="button"
class="tmtt-btn tmtt-btn-ghost"
@click="clearFilter"
>
清除篩選
</button>
</div>
</div>
</template>
<TmttDetailTable :rows="filteredRows" :sort-state="sortState" @sort="toggleSort" />
</SectionCard>
</template>
<SectionCard v-else>
<div class="tmtt-empty-state">
<p>請選擇日期範圍後點擊查詢</p>
</div>
</SectionCard>
</div>
</div>
</template>

View File

@@ -1,177 +0,0 @@
<script setup>
import { computed } from 'vue';
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent, TitleComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, TitleComponent]);
const props = defineProps({
title: {
type: String,
required: true,
},
mode: {
type: String,
default: 'pareto',
},
data: {
type: Array,
default: () => [],
},
field: {
type: String,
default: '',
},
selectedValue: {
type: String,
default: '',
},
lineLabel: {
type: String,
default: '',
},
lineColor: {
type: String,
default: '#6366f1',
},
});
const emit = defineEmits(['select']);
function emptyOption() {
return {
title: {
text: '無資料',
left: 'center',
top: 'center',
textStyle: { color: '#94a3b8', fontSize: 14 },
},
xAxis: { show: false },
yAxis: { show: false },
series: [],
};
}
const chartOption = computed(() => {
const data = props.data || [];
if (!data.length) {
return emptyOption();
}
if (props.mode === 'pareto') {
const names = data.map((item) => item.name);
const printRates = data.map((item) => Number(item.print_defect_rate || 0));
const leadRates = data.map((item) => Number(item.lead_defect_rate || 0));
const cumPct = data.map((item) => Number(item.cumulative_pct || 0));
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
},
legend: { data: ['印字不良率', '腳型不良率', '累積%'], bottom: 0 },
grid: { left: 56, right: 56, top: 24, bottom: names.length > 8 ? 90 : 56 },
xAxis: {
type: 'category',
data: names,
axisLabel: {
rotate: names.length > 8 ? 35 : 0,
interval: 0,
formatter: (value) => (value.length > 16 ? `${value.slice(0, 16)}...` : value),
},
},
yAxis: [
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
{ type: 'value', name: '累積%', max: 100 },
],
series: [
{
name: '印字不良率',
type: 'bar',
stack: 'defect',
data: printRates,
itemStyle: { color: '#ef4444' },
barMaxWidth: 40,
},
{
name: '腳型不良率',
type: 'bar',
stack: 'defect',
data: leadRates,
itemStyle: { color: '#f59e0b' },
barMaxWidth: 40,
},
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: cumPct,
itemStyle: { color: '#6366f1' },
lineStyle: { width: 2 },
symbol: 'circle',
symbolSize: 6,
},
],
};
}
const dates = data.map((item) => item.date);
const lineValues = data.map((item) => Number(item[props.mode === 'print-trend' ? 'print_defect_rate' : 'lead_defect_rate'] || 0));
const inputValues = data.map((item) => Number(item.input_qty || 0));
return {
tooltip: { trigger: 'axis' },
legend: { data: [props.lineLabel || '趨勢', '投入數'], bottom: 0 },
grid: { left: 56, right: 56, top: 24, bottom: 56 },
xAxis: {
type: 'category',
data: dates,
axisLabel: { rotate: dates.length > 14 ? 35 : 0 },
},
yAxis: [
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
{ type: 'value', name: '投入數' },
],
series: [
{
name: props.lineLabel || '趨勢',
type: 'line',
data: lineValues,
itemStyle: { color: props.lineColor },
lineStyle: { width: 2 },
symbol: 'circle',
symbolSize: 4,
},
{
name: '投入數',
type: 'bar',
yAxisIndex: 1,
data: inputValues,
itemStyle: { color: '#c7d2fe' },
barMaxWidth: 20,
},
],
};
});
function handleClick(params) {
if (props.mode !== 'pareto' || params?.componentType !== 'series' || !params?.name || !props.field) {
return;
}
emit('select', {
field: props.field,
value: params.name,
label: `${props.field}: ${params.name}`,
});
}
</script>
<template>
<article class="tmtt-chart-card">
<h3>{{ title }}</h3>
<VChart class="tmtt-chart-canvas" :option="chartOption" autoresize @click="handleClick" />
</article>
</template>

View File

@@ -1,87 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
rows: {
type: Array,
default: () => [],
},
sortState: {
type: Object,
default: () => ({ column: '', asc: true }),
},
});
const emit = defineEmits(['sort']);
const columns = computed(() => [
{ key: 'CONTAINERNAME', label: 'LOT ID' },
{ key: 'PJ_TYPE', label: 'TYPE' },
{ key: 'PRODUCTLINENAME', label: 'PACKAGE' },
{ key: 'WORKFLOW', label: 'WORKFLOW' },
{ key: 'FINISHEDRUNCARD', label: '完工流水碼' },
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT設備' },
{ key: 'MOLD_EQUIPMENTNAME', label: 'MOLD設備' },
{ key: 'INPUT_QTY', label: '投入數', numeric: true },
{ key: 'PRINT_DEFECT_QTY', label: '印字不良', numeric: true, danger: true },
{ key: 'PRINT_DEFECT_RATE', label: '印字不良率(%)', numeric: true, danger: true, decimal: 4 },
{ key: 'LEAD_DEFECT_QTY', label: '腳型不良', numeric: true, warning: true },
{ key: 'LEAD_DEFECT_RATE', label: '腳型不良率(%)', numeric: true, warning: true, decimal: 4 },
]);
function formatNumber(value, decimal = null) {
const n = Number(value);
if (!Number.isFinite(n)) return '0';
if (Number.isInteger(decimal) && decimal >= 0) {
return n.toFixed(decimal);
}
return n.toLocaleString('zh-TW');
}
function sortIndicator(key) {
if (props.sortState?.column !== key) {
return '';
}
return props.sortState?.asc ? '▲' : '▼';
}
function cellClass(column) {
const classes = [];
if (column.numeric) classes.push('is-numeric');
if (column.danger) classes.push('is-danger');
if (column.warning) classes.push('is-warning');
return classes;
}
</script>
<template>
<div class="tmtt-detail-table-wrap">
<table class="tmtt-detail-table">
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
<button type="button" class="tmtt-sort-btn" @click="emit('sort', column.key)">
{{ column.label }}
<span class="tmtt-sort-indicator">{{ sortIndicator(column.key) }}</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-if="rows.length === 0">
<td :colspan="columns.length" class="tmtt-empty-row">無資料</td>
</tr>
<tr v-for="(row, index) in rows" v-else :key="`${row.CONTAINERNAME || 'row'}-${index}`">
<td
v-for="column in columns"
:key="`${row.CONTAINERNAME || 'row'}-${column.key}-${index}`"
:class="cellClass(column)"
>
<template v-if="column.numeric">{{ formatNumber(row[column.key], column.decimal) }}</template>
<template v-else>{{ row[column.key] || '' }}</template>
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -1,51 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
kpi: {
type: Object,
default: null,
},
});
function fmtNumber(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '-';
return n.toLocaleString('zh-TW');
}
function fmtRate(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '-';
return n.toFixed(4);
}
const cards = computed(() => {
const value = props.kpi || {};
return [
{ key: 'total_input', label: '投入數', display: fmtNumber(value.total_input), tone: 'neutral' },
{ key: 'lot_count', label: 'LOT 數', display: fmtNumber(value.lot_count), tone: 'neutral' },
{ key: 'print_defect_qty', label: '印字不良數', display: fmtNumber(value.print_defect_qty), tone: 'danger' },
{ key: 'print_defect_rate', label: '印字不良率', display: fmtRate(value.print_defect_rate), unit: '%', tone: 'danger' },
{ key: 'lead_defect_qty', label: '腳型不良數', display: fmtNumber(value.lead_defect_qty), tone: 'warning' },
{ key: 'lead_defect_rate', label: '腳型不良率', display: fmtRate(value.lead_defect_rate), unit: '%', tone: 'warning' },
];
});
</script>
<template>
<div class="tmtt-kpi-grid">
<article
v-for="card in cards"
:key="card.key"
class="tmtt-kpi-card"
:class="`tone-${card.tone}`"
>
<p class="tmtt-kpi-label">{{ card.label }}</p>
<p class="tmtt-kpi-value">
{{ card.display }}
<span v-if="card.unit" class="tmtt-kpi-unit">{{ card.unit }}</span>
</p>
</article>
</div>
</template>

View File

@@ -1,216 +0,0 @@
import { computed, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
ensureMesApiAvailable();
const NUMERIC_COLUMNS = new Set([
'INPUT_QTY',
'PRINT_DEFECT_QTY',
'PRINT_DEFECT_RATE',
'LEAD_DEFECT_QTY',
'LEAD_DEFECT_RATE',
]);
function notify(level, message) {
const toast = globalThis.Toast;
if (toast && typeof toast[level] === 'function') {
return toast[level](message);
}
if (level === 'error') {
console.error(message);
} else {
console.info(message);
}
return null;
}
function dismissToast(id) {
if (!id) return;
const toast = globalThis.Toast;
if (toast && typeof toast.dismiss === 'function') {
toast.dismiss(id);
}
}
function toComparable(value, key) {
if (NUMERIC_COLUMNS.has(key)) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
if (value == null) return '';
return String(value).toUpperCase();
}
function toDateString(date) {
return date.toISOString().slice(0, 10);
}
export function useTmttDefectData() {
const startDate = ref('');
const endDate = ref('');
const loading = ref(false);
const errorMessage = ref('');
const analysisData = ref(null);
const activeFilter = ref(null);
const sortState = ref({ column: '', asc: true });
function initializeDateRange() {
if (startDate.value && endDate.value) {
return;
}
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 6);
startDate.value = toDateString(start);
endDate.value = toDateString(end);
}
const hasData = computed(() => Boolean(analysisData.value));
const kpi = computed(() => analysisData.value?.kpi || null);
const charts = computed(() => analysisData.value?.charts || {});
const dailyTrend = computed(() => analysisData.value?.daily_trend || []);
const rawDetailRows = computed(() => analysisData.value?.detail || []);
const filteredRows = computed(() => {
let rows = rawDetailRows.value;
if (activeFilter.value?.field && activeFilter.value?.value) {
rows = rows.filter((row) => String(row?.[activeFilter.value.field] || '') === activeFilter.value.value);
}
if (!sortState.value.column) {
return rows;
}
const sorted = [...rows].sort((left, right) => {
const leftValue = toComparable(left?.[sortState.value.column], sortState.value.column);
const rightValue = toComparable(right?.[sortState.value.column], sortState.value.column);
if (leftValue < rightValue) {
return sortState.value.asc ? -1 : 1;
}
if (leftValue > rightValue) {
return sortState.value.asc ? 1 : -1;
}
return 0;
});
return sorted;
});
const totalCount = computed(() => rawDetailRows.value.length);
const filteredCount = computed(() => filteredRows.value.length);
async function queryData() {
if (!startDate.value || !endDate.value) {
notify('warning', '請選擇起始和結束日期');
return;
}
loading.value = true;
errorMessage.value = '';
const loadingToastId = notify('loading', '查詢中...');
try {
const result = await apiGet('/api/tmtt-defect/analysis', {
params: {
start_date: startDate.value,
end_date: endDate.value,
},
timeout: 120000,
});
if (!result || !result.success) {
const message = result?.error || '查詢失敗';
errorMessage.value = message;
notify('error', message);
return;
}
analysisData.value = result.data;
activeFilter.value = null;
sortState.value = { column: '', asc: true };
notify('success', '查詢完成');
} catch (error) {
const message = error?.message || '查詢失敗';
errorMessage.value = message;
notify('error', `查詢失敗: ${message}`);
} finally {
dismissToast(loadingToastId);
loading.value = false;
}
}
function setFilter({ field, value, label }) {
if (!field || !value) {
return;
}
activeFilter.value = {
field,
value,
label: label || `${field}: ${value}`,
};
}
function clearFilter() {
activeFilter.value = null;
}
function toggleSort(column) {
if (!column) {
return;
}
if (sortState.value.column === column) {
sortState.value = {
column,
asc: !sortState.value.asc,
};
return;
}
sortState.value = {
column,
asc: true,
};
}
function exportCsv() {
if (!startDate.value || !endDate.value) {
notify('warning', '請先查詢資料');
return;
}
const query = new URLSearchParams({
start_date: startDate.value,
end_date: endDate.value,
});
window.open(`/api/tmtt-defect/export?${query.toString()}`, '_blank', 'noopener');
}
initializeDateRange();
return {
startDate,
endDate,
loading,
errorMessage,
hasData,
kpi,
charts,
dailyTrend,
rawDetailRows,
filteredRows,
totalCount,
filteredCount,
activeFilter,
sortState,
queryData,
setFilter,
clearFilter,
toggleSort,
exportCsv,
};
}

View File

@@ -1,7 +0,0 @@
import { createApp } from 'vue';
import '../styles/tailwind.css';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View File

@@ -1,285 +0,0 @@
.tmtt-page {
max-width: 1680px;
margin: 0 auto;
padding: 20px;
}
.tmtt-header {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 16px;
color: #fff;
background: linear-gradient(
135deg,
var(--portal-brand-start, #667eea) 0%,
var(--portal-brand-end, #764ba2) 100%
);
box-shadow: var(--portal-shadow-panel, 0 8px 24px rgba(79, 70, 229, 0.18));
}
.tmtt-header h1 {
margin: 0;
font-size: 24px;
}
.tmtt-header p {
margin: 8px 0 0;
font-size: 13px;
opacity: 0.9;
}
.tmtt-block-title {
font-size: 14px;
font-weight: 600;
}
.tmtt-field {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #334155;
}
.tmtt-field input {
height: 34px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #cbd5e1;
background: #fff;
}
.tmtt-field input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
.tmtt-btn {
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tmtt-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tmtt-btn-primary {
background: #4f46e5;
color: #fff;
}
.tmtt-btn-primary:hover:not(:disabled) {
background: #4338ca;
}
.tmtt-btn-success {
background: #16a34a;
color: #fff;
}
.tmtt-btn-success:hover:not(:disabled) {
background: #15803d;
}
.tmtt-btn-ghost {
background: #f1f5f9;
color: #334155;
}
.tmtt-btn-ghost:hover {
background: #e2e8f0;
}
.tmtt-error-banner {
margin: 0;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
}
.tmtt-kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.tmtt-kpi-card {
padding: 14px;
border-radius: 10px;
border-left: 4px solid #64748b;
background: #fff;
box-shadow: var(--portal-shadow-soft, 0 2px 8px rgba(15, 23, 42, 0.08));
}
.tmtt-kpi-card.tone-danger {
border-left-color: #ef4444;
}
.tmtt-kpi-card.tone-warning {
border-left-color: #f59e0b;
}
.tmtt-kpi-label {
margin: 0;
font-size: 12px;
color: #64748b;
}
.tmtt-kpi-value {
margin: 8px 0 0;
font-size: 22px;
font-weight: 700;
color: #1f2937;
}
.tmtt-kpi-unit {
font-size: 12px;
margin-left: 4px;
color: #64748b;
}
.tmtt-chart-grid {
display: grid;
gap: 12px;
}
.tmtt-chart-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
padding: 14px;
}
.tmtt-chart-card h3 {
margin: 0;
font-size: 14px;
}
.tmtt-chart-canvas {
width: 100%;
height: 360px;
margin-top: 8px;
}
.tmtt-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tmtt-detail-count {
margin-left: 6px;
color: #64748b;
font-size: 13px;
}
.tmtt-detail-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tmtt-detail-table-wrap {
overflow: auto;
max-height: 520px;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.tmtt-detail-table {
width: 100%;
min-width: 1500px;
border-collapse: collapse;
font-size: 12px;
}
.tmtt-detail-table th,
.tmtt-detail-table td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
.tmtt-detail-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f8fafc;
}
.tmtt-sort-btn {
border: none;
padding: 0;
background: transparent;
font-size: 12px;
font-weight: 700;
color: #334155;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.tmtt-sort-indicator {
color: #64748b;
min-width: 10px;
}
.tmtt-detail-table tbody tr:hover {
background: #f8fafc;
}
.tmtt-detail-table .is-numeric {
text-align: right;
}
.tmtt-detail-table .is-danger {
color: #dc2626;
}
.tmtt-detail-table .is-warning {
color: #d97706;
}
.tmtt-empty-row {
text-align: center;
padding: 20px;
color: #94a3b8;
}
.tmtt-empty-state {
text-align: center;
color: #64748b;
font-size: 14px;
}
@media (max-width: 1280px) {
.tmtt-kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.tmtt-page {
padding: 12px;
}
.tmtt-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tmtt-detail-header {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -37,8 +37,6 @@ test('table parity: list/detail pages preserve pagination and sort continuity ho
assert.match(holdDetailSource, /page|currentPage|perPage/);
assert.match(holdDetailSource, /distribution|lots/i);
const tmttTableSource = readSource('src/tmtt-defect/components/TmttDetailTable.vue');
assert.match(tmttTableSource, /sort/i);
});
test('chart parity: chart pages keep tooltip, legend, autoresize and click linkage', () => {
@@ -53,10 +51,6 @@ test('chart parity: chart pages keep tooltip, legend, autoresize and click linka
assert.match(holdParetoSource, /legend\s*:/);
assert.match(holdParetoSource, /@click="handleChartClick"/);
const tmttChartSource = readSource('src/tmtt-defect/components/TmttChartCard.vue');
assert.match(tmttChartSource, /tooltip\s*:/);
assert.match(tmttChartSource, /legend\s*:/);
assert.match(tmttChartSource, /autoresize/);
});
test('matrix interaction parity: selection/highlight/drill handlers remain present', () => {

View File

@@ -30,7 +30,7 @@ test('buildLaunchHref replaces existing query keys with latest runtime values',
test('buildLaunchHref ignores empty and null-like query values', () => {
assert.equal(
buildLaunchHref('/tmtt-defect', { start_date: '', end_date: null, shift: undefined }),
'/tmtt-defect',
buildLaunchHref('/mid-section-defect', { start_date: '', end_date: null, shift: undefined }),
'/mid-section-defect',
);
});

View File

@@ -15,7 +15,6 @@ const WAVE_A_ROUTES = Object.freeze([
'/resource',
'/resource-history',
'/qc-gate',
'/tmtt-defect',
]);
const WAVE_B_NATIVE_ROUTES = Object.freeze([

View File

@@ -26,7 +26,6 @@ export default defineConfig(({ mode }) => ({
'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
tables: resolve(__dirname, 'src/tables/index.html'),
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html'),
'mid-section-defect': resolve(__dirname, 'src/mid-section-defect/index.html'),
'admin-performance': resolve(__dirname, 'src/admin-performance/index.html')

View File

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

View File

@@ -0,0 +1,75 @@
## Context
`/mid-section-defect` currently runs a 3-stage backward-only pipeline hardcoded to TMTT (測試) station:
1. `tmtt_detection.sql` — fetch defective lots at TMTT station
2. `LineageEngine.resolve_full_genealogy()` — find ancestor container IDs
3. `upstream_history.sql` — get WIP records at upstream stations → attribute defects to machines
The detection SQL has `LIKE '%TMTT%'` hardcoded on line 38. All internal naming uses `TMTT_` prefix. The page serves one direction (backward) for one station.
This change generalizes to any of the 12 workcenter groups as detection station, adds forward tracing direction, and removes the superseded `/tmtt-defect` page.
## Goals / Non-Goals
**Goals:**
- Parameterize detection station: replace TMTT hardcode with `{{ STATION_FILTER }}` built from `workcenter_groups.py` patterns
- Add forward tracing pipeline: detection rejects → forward lineage → downstream WIP + rejects → forward attribution
- Direction-aware UI: FilterBar station dropdown + direction toggle, KPI/charts/detail switch by direction
- Backward compatibility: `station=測試, direction=backward` produces identical results (renamed columns)
- Remove `/tmtt-defect` page and all associated code
**Non-Goals:**
- No changes to LineageEngine internals (already supports both `resolve_full_genealogy` and `resolve_forward_tree`)
- No changes to `reject-history` or `query-tool` pages
- No new caching strategy (reuse existing L1/L2 cache with station+direction in key)
- No multi-station or multi-direction in a single query
## Decisions
### D1: Parameterized SQL via template substitution (not dynamic SQL builder)
Use `SQLLoader.load_with_params()` with `{{ STATION_FILTER }}` placeholder — the same pattern already used by `upstream_history.sql`'s `{{ ANCESTOR_FILTER }}`. The filter is built in Python from `WORKCENTER_GROUPS[station]['patterns']` as OR-LIKE clauses with bind parameters.
**Alternative considered:** Dynamic SQL builder class. Rejected — adds abstraction for a simple OR-LIKE pattern; template substitution is established in the codebase.
### D2: Separate `station_detection.sql` instead of modifying `tmtt_detection.sql`
Create new `station_detection.sql` as a generalized copy. The old `tmtt_detection.sql` will be deleted when `/tmtt-defect` is removed. Clean separation avoids merge conflicts with any in-flight tmtt-defect work.
**Alternative considered:** Modify in-place. Rejected — the old file is deleted anyway and renaming avoids ambiguity.
### D3: Forward attribution uses TRACKINQTY as denominator
Forward reject rate = `REJECT_TOTAL_QTY / TRACKINQTY × 100` at each downstream station. TRACKINQTY comes from `upstream_history.sql` (needs adding to SELECT). This gives a per-station defect rate for lots that survived the detection station.
**Alternative considered:** Use lot count as denominator. Rejected — TRACKINQTY accounts for partial quantities (split/merge lots) and gives a more accurate rate.
### D4: Direction dispatch at service layer, not route layer
`query_analysis()` gains `station` and `direction` params and dispatches to `_run_backward_pipeline()` or `_run_forward_pipeline()` internally. Routes just pass through. This keeps route handlers thin and testable.
### D5: Forward pipeline reuses upstream_history.sql for WIP records
Both directions need WIP records at various stations. The existing `upstream_history.sql` (with added TRACKINQTY) serves both — just with different container ID sets (ancestors for backward, descendants for forward).
### D6: New `downstream_rejects` event domain in EventFetcher
Forward tracing needs reject records at downstream stations. Add `downstream_rejects` as a new domain in `EventFetcher._build_domain_sql()`, loading `downstream_rejects.sql` with batched IN clause. This follows the established domain pattern.
### D7: Frontend direction toggle — button group, not dropdown
Two discrete states (backward/forward) fit a toggle button group better than a dropdown. Matches the existing btn-primary pattern in the page's CSS.
### D8: Remove `/tmtt-defect` entirely
The generalized traceability center with `station=測試 + lossReasons=[276_腳型不良, 277_印字不良]` reproduces all tmtt-defect functionality. Remove: `frontend/src/tmtt-defect/`, backend routes/services/SQL, test files, and `nativeModuleRegistry.js` registration.
## Risks / Trade-offs
- **Forward pipeline performance for early stations** — Selecting `station=切割 (order=0), direction=forward` could produce a very large descendant tree (all lots flow downstream). → Mitigation: The existing `resolve_forward_tree()` already handles large sets; add a result count warning in UI if > 5000 tracked lots.
- **TRACKINQTY NULL values** — Some WIP records may have NULL TRACKINQTY. → Mitigation: COALESCE to 0 in SQL; skip lots with zero input in attribution to avoid division by zero.
- **TMTT removal breaks bookmarks** — Users with `/tmtt-defect` bookmarks get 404. → Mitigation: Low risk — page was in dev status, not released. No redirect needed.
- **Rename TMTT_ → DETECTION_ in API response keys** — Frontend consumers (CSV export, chart keys) reference these field names. → Mitigation: All consumers are within this page's code; rename consistently in one pass.

View File

@@ -0,0 +1,32 @@
## Why
`/mid-section-defect` 目前僅支援 TMTT 測試站的反向不良追溯,偵測站硬編碼在 SQL 中。實務上需要從任意站點偵測不良並雙向追溯:後段不良回推上游集中機台(反向),前段報廢後倖存批次的下游表現(正向)。將此頁面升級為全線雙向追溯中心,覆蓋 12 個 workcenter group × 2 方向的分析需求。同時移除功能被完全取代的 `/tmtt-defect`TMTT印字腳型不良分析頁面。
## What Changes
- **偵測站泛化**:將硬編碼的 TMTT 站點篩選改為參數化,使用者可從 12 個 workcenter group 中選擇任意偵測站
- **反向追溯泛化**:現有 TMTT → 上游機台歸因邏輯保留,但偵測站改為可選(預設仍為「測試」)
- **新增正向追溯**:偵測站報廢批次 → 追蹤倖存批次往下游走 → 各下游站的額外報廢率(判斷部分報廢後剩餘品是否仍有問題)
- **UI 改版**FilterBar 新增偵測站下拉 + 方向切換KPI/圖表/明細表依方向動態切換
- **重新命名**:頁面標題從「中段製程不良追溯」改為「製程不良追溯分析」,內部 TMTT_ 前綴統一改為 DETECTION_
- **移除 TMTT 印字腳型不良分析**`/tmtt-defect` 頁面功能已被泛化後的追溯中心完全覆蓋(選偵測站=測試 + 篩選不良原因=276_腳型不良/277_印字不良移除前後端代碼與路由註冊
## Capabilities
### New Capabilities
- `defect-trace-station-detection`: 參數化偵測站 SQL 與篩選邏輯,支援任意 workcenter group 作為偵測起點
- `defect-trace-forward-pipeline`: 正向追溯 pipeline — 偵測站報廢批次 → forward lineage → 下游 WIP + 下游報廢記錄 → 正向歸因引擎
- `defect-trace-bidirectional-ui`: 雙向追溯前端 — 偵測站選擇器、方向切換、方向感知的 KPI/圖表/明細表/CSV 匯出
### Modified Capabilities
- `progressive-trace-ux`: 需擴展支援 direction 參數lineage stage 依方向選擇 ancestor 或 forward tree
- `event-fetcher-unified`: 新增 `downstream_rejects` event domain
## Impact
- **Backend**: `mid_section_defect_service.py`(主要重構)、`mid_section_defect_routes.py``trace_routes.py``event_fetcher.py`
- **SQL**: 新增 `station_detection.sql``downstream_rejects.sql`;修改 `upstream_history.sql`(加 TRACKINQTY
- **Frontend**: `FilterBar.vue``App.vue``KpiCards.vue``DetailTable.vue``useTraceProgress.js``style.css`
- **Config**: `page_status.json`(頁面名稱更新 + 移除 tmtt-defect 條目)
- **API**: 所有 `/api/mid-section-defect/*` 端點新增 `station` + `direction` 參數;新增 `/station-options` 端點
- **移除**: `frontend/src/tmtt-defect/`(整個目錄)、`src/mes_dashboard/routes/tmtt_defect_routes.py``src/mes_dashboard/services/tmtt_defect_service.py``src/mes_dashboard/sql/tmtt_defect/`、相關測試檔案、`nativeModuleRegistry.js` 中的 tmtt-defect 註冊

View File

@@ -0,0 +1,94 @@
## ADDED Requirements
### Requirement: FilterBar SHALL include station dropdown
FilterBar SHALL display a `<select>` dropdown populated from `GET /api/mid-section-defect/station-options` on mount. The dropdown SHALL default to '測試' and emit `station` via the `update-filters` mechanism.
#### Scenario: Station dropdown loads on mount
- **WHEN** the FilterBar component mounts
- **THEN** it SHALL fetch station options from the API and populate the dropdown with 12 workcenter groups
- **THEN** the default selection SHALL be '測試'
#### Scenario: Station selection updates filters
- **WHEN** user selects a different station
- **THEN** `update-filters` SHALL emit with the new `station` value
### Requirement: FilterBar SHALL include direction toggle
FilterBar SHALL display a toggle button group with two options: '反向追溯' (`backward`) and '正向追溯' (`forward`). Default SHALL be `backward`.
#### Scenario: Direction toggle switches direction
- **WHEN** user clicks '正向追溯'
- **THEN** `update-filters` SHALL emit with `direction: 'forward'`
- **THEN** the active button SHALL visually indicate the selected direction
### Requirement: KPI cards SHALL display direction-aware labels
KpiCards component SHALL accept `direction` and `stationLabel` props and switch card labels between backward and forward modes.
#### Scenario: Backward KPI labels
- **WHEN** `direction='backward'`
- **THEN** KPI cards SHALL display existing labels: 偵測批次數, 偵測不良數, 上游追溯批次數, 上游站點數, etc.
#### Scenario: Forward KPI labels
- **WHEN** `direction='forward'`
- **THEN** KPI cards SHALL display: 偵測批次數, 偵測不良數, 追蹤批次數, 下游到達站數, 下游不良總數, 下游不良率
### Requirement: Chart layout SHALL switch by direction
App.vue SHALL render direction-appropriate chart sets.
#### Scenario: Backward chart layout
- **WHEN** `direction='backward'`
- **THEN** SHALL render 6 Pareto charts: by_station, by_loss_reason, by_machine, by_detection_machine, by_workflow, by_package
#### Scenario: Forward chart layout
- **WHEN** `direction='forward'`
- **THEN** SHALL render 4 Pareto charts: by_downstream_station, by_downstream_loss_reason, by_downstream_machine, by_detection_machine
### Requirement: Detail table columns SHALL switch by direction
DetailTable component SHALL accept a `direction` prop and render direction-appropriate columns.
#### Scenario: Backward detail columns
- **WHEN** `direction='backward'`
- **THEN** columns SHALL match existing backward layout (CONTAINERID, station history, upstream machine attribution, etc.)
#### Scenario: Forward detail columns
- **WHEN** `direction='forward'`
- **THEN** columns SHALL include: CONTAINERID, 偵測設備, 偵測投入, 偵測不良, 下游到達站數, 下游不良總數, 下游不良率, 最差下游站
### Requirement: Page header SHALL reflect station and direction
Page title SHALL be '製程不良追溯分析'. Subtitle SHALL dynamically reflect station and direction.
#### Scenario: Backward subtitle
- **WHEN** `station='電鍍', direction='backward'`
- **THEN** subtitle SHALL indicate: `電鍍站不良 → 回溯上游機台歸因`
#### Scenario: Forward subtitle
- **WHEN** `station='成型', direction='forward'`
- **THEN** subtitle SHALL indicate: `成型站不良批次 → 追蹤倖存批次下游表現`
### Requirement: CSV export SHALL include direction-appropriate columns
Export SHALL produce CSV with columns matching the current direction's detail table.
#### Scenario: Forward CSV export
- **WHEN** user exports with `direction='forward'`
- **THEN** CSV SHALL contain forward-specific columns (detection equipment, downstream stats)
### Requirement: Page metadata SHALL be updated
`page_status.json` SHALL update the page name from '中段製程不良追溯' to '製程不良追溯分析'.
#### Scenario: Page name in page_status.json
- **WHEN** the page metadata is read
- **THEN** the name for `mid-section-defect` SHALL be '製程不良追溯分析'
## REMOVED Requirements
### Requirement: TMTT印字腳型不良分析 page
**Reason**: Functionality fully superseded by generalized traceability center (station=測試 + loss reasons filter for 276_腳型不良/277_印字不良)
**Migration**: Use `/mid-section-defect` with station=測試 and filter loss reasons to 276_腳型不良 or 277_印字不良
#### Scenario: TMTT defect page removal
- **WHEN** the change is complete
- **THEN** `frontend/src/tmtt-defect/` directory SHALL be removed
- **THEN** `src/mes_dashboard/routes/tmtt_defect_routes.py` SHALL be removed
- **THEN** `src/mes_dashboard/services/tmtt_defect_service.py` SHALL be removed
- **THEN** `src/mes_dashboard/sql/tmtt_defect/` directory SHALL be removed
- **THEN** `nativeModuleRegistry.js` SHALL have tmtt-defect registration removed
- **THEN** `page_status.json` SHALL have tmtt-defect entry removed

View File

@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: Forward pipeline SHALL trace surviving lots downstream
When `direction=forward`, the system SHALL execute a forward tracing pipeline: detection station rejects → forward lineage (descendants) → downstream WIP + downstream rejects → forward attribution engine.
#### Scenario: Forward pipeline stages
- **WHEN** `query_analysis(station='成型', direction='forward')` is called
- **THEN** the pipeline SHALL execute in order:
1. Fetch detection data at 成型 station (lots with rejects in date range)
2. Resolve forward lineage via `LineageEngine.resolve_forward_tree(detection_cids)`
3. Collect tracked CIDs = detection CIDs all descendants
4. Fetch WIP history for tracked CIDs (with TRACKINQTY)
5. Fetch downstream reject records for tracked CIDs
6. Run forward attribution engine
7. Build KPI, charts, detail table, and trend data
#### Scenario: No descendants found
- **WHEN** forward lineage returns an empty descendants map
- **THEN** KPI SHALL show zero downstream rejects and zero downstream stations reached
- **THEN** charts and detail table SHALL be empty arrays
### Requirement: downstream_rejects.sql SHALL query reject records for tracked lots
`downstream_rejects.sql` SHALL query `DW_MES_LOTREJECTHISTORY` for batched CONTAINERIDs with the standard `WORKCENTER_GROUP` CASE WHEN classification.
#### Scenario: Downstream rejects query output columns
- **WHEN** the SQL is executed
- **THEN** it SHALL return: `CONTAINERID`, `WORKCENTERNAME`, `WORKCENTER_GROUP`, `LOSSREASONNAME`, `EQUIPMENTNAME`, `REJECT_TOTAL_QTY`, `TXNDATE`
#### Scenario: Batched IN clause for large CID sets
- **WHEN** tracked CIDs exceed 1000
- **THEN** the system SHALL batch queries in groups of 1000 (same pattern as `upstream_history.sql`)
### Requirement: upstream_history.sql SHALL include TRACKINQTY
The `upstream_history.sql` query SHALL include `h.TRACKINQTY` in both the `ranked_history` CTE and the final SELECT output.
#### Scenario: TRACKINQTY in output
- **WHEN** the SQL is executed
- **THEN** each row SHALL include `TRACKINQTY` representing the input quantity at that station
- **THEN** NULL values SHALL be handled as 0 via COALESCE
### Requirement: Forward attribution engine SHALL compute per-station reject rates
The forward attribution engine SHALL aggregate reject data by downstream station (stations with order > detection station's order) and compute reject rates using TRACKINQTY as denominator.
#### Scenario: Forward attribution calculation
- **WHEN** tracked lots reach downstream station Y with total TRACKINQTY=1000 and REJECT_TOTAL_QTY=50
- **THEN** station Y's reject rate SHALL be `50 / 1000 × 100 = 5.0%`
#### Scenario: Only downstream stations included
- **WHEN** detection station is 成型 (order=4)
- **THEN** attribution SHALL only include stations with order > 4 (去膠, 水吹砂, 電鍍, 移印, 切彎腳, 元件切割, 測試)
- **THEN** stations with order ≤ 4 SHALL be excluded from forward attribution
#### Scenario: Zero input quantity guard
- **WHEN** a downstream station has TRACKINQTY sum = 0 for tracked lots
- **THEN** reject rate SHALL be 0 (not division error)
### Requirement: Forward KPI SHALL summarize downstream impact
Forward direction KPI SHALL include: detection lot count, detection defect quantity, tracked lot count (detection + descendants), downstream stations reached, downstream total rejects, and overall downstream reject rate.
#### Scenario: Forward KPI fields
- **WHEN** forward analysis completes
- **THEN** KPI SHALL contain `detection_lot_count`, `detection_defect_qty`, `tracked_lot_count`, `downstream_stations_reached`, `downstream_total_reject`, `downstream_reject_rate`
### Requirement: Forward charts SHALL show downstream distribution
Forward direction charts SHALL include: by_downstream_station (Pareto by station reject qty), by_downstream_machine (Pareto by equipment), by_downstream_loss_reason (Pareto by reason), by_detection_machine (Pareto by detection station equipment).
#### Scenario: Forward chart keys
- **WHEN** forward analysis completes
- **THEN** charts SHALL contain keys: `by_downstream_station`, `by_downstream_machine`, `by_downstream_loss_reason`, `by_detection_machine`
### Requirement: Forward detail table SHALL show per-lot downstream tracking
Forward direction detail table SHALL show one row per detection lot with downstream tracking summary.
#### Scenario: Forward detail columns
- **WHEN** forward detail is requested
- **THEN** each row SHALL include: CONTAINERID, DETECTION_EQUIPMENTNAME, TRACKINQTY (at detection), detection reject qty, downstream stations reached count, downstream total rejects, downstream reject rate, worst downstream station (highest reject rate)

View File

@@ -0,0 +1,53 @@
## ADDED Requirements
### Requirement: Detection SQL SHALL be parameterized by workcenter group
The system SHALL replace hardcoded TMTT station filtering with a `{{ STATION_FILTER }}` template placeholder in `station_detection.sql`. The filter SHALL be built from `WORKCENTER_GROUPS[station]['patterns']` and `['exclude']` defined in `workcenter_groups.py`, generating OR-LIKE clauses with bind parameters.
#### Scenario: Station filter built from workcenter group patterns
- **WHEN** `station='電鍍'` is requested
- **THEN** the system SHALL build a SQL fragment: `UPPER(h.WORKCENTERNAME) LIKE :wc_p0 OR UPPER(h.WORKCENTERNAME) LIKE :wc_p1 OR ...` with bind values `['%掛鍍%', '%滾鍍%', '%條鍍%', '%電鍍%', '%補鍍%', '%TOTAI%', '%BANDL%']`
#### Scenario: Station filter respects exclude patterns
- **WHEN** `station='切割'` is requested (which has `exclude: ['元件切割', 'PKG_SAW']`)
- **THEN** the filter SHALL include patterns for '切割' AND exclude patterns via `AND UPPER(h.WORKCENTERNAME) NOT LIKE :wc_ex0 AND NOT LIKE :wc_ex1`
#### Scenario: Default station is 測試
- **WHEN** no `station` parameter is provided
- **THEN** the system SHALL default to `station='測試'` (patterns: `['TMTT', '測試']`)
- **THEN** results SHALL be equivalent to the previous hardcoded TMTT behavior
### Requirement: station_detection.sql SHALL generalize tmtt_detection.sql
`station_detection.sql` SHALL be a new SQL file that replaces `tmtt_detection.sql` with parameterized station filtering. Column aliases SHALL use `DETECTION_` prefix instead of `TMTT_` prefix.
#### Scenario: SQL column renaming
- **WHEN** `station_detection.sql` is executed
- **THEN** output columns SHALL include `DETECTION_EQUIPMENTID` and `DETECTION_EQUIPMENTNAME` (not `TMTT_EQUIPMENTID` / `TMTT_EQUIPMENTNAME`)
#### Scenario: Both WIP and reject CTEs use station filter
- **WHEN** the SQL is executed
- **THEN** both the WIP history CTE and the reject history CTE SHALL apply `{{ STATION_FILTER }}` to filter by the selected station
### Requirement: Station options endpoint SHALL return all workcenter groups
`GET /api/mid-section-defect/station-options` SHALL return the 12 workcenter groups from `WORKCENTER_GROUPS` as an ordered list with `name` and `order` fields.
#### Scenario: Station options response format
- **WHEN** the endpoint is called
- **THEN** it SHALL return a JSON array of 12 objects: `[{"name": "切割", "order": 0}, {"name": "焊接_DB", "order": 1}, ...]` sorted by order
### Requirement: All API endpoints SHALL accept station and direction parameters
All `/api/mid-section-defect/*` endpoints (`/analysis`, `/analysis/detail`, `/loss-reasons`, `/export`) SHALL accept `station` (string, default `'測試'`) and `direction` (string, `'backward'` | `'forward'`, default `'backward'`) query parameters.
#### Scenario: Parameters passed to service layer
- **WHEN** `/api/mid-section-defect/analysis?station=成型&direction=forward` is called
- **THEN** `query_analysis()` SHALL receive `station='成型'` and `direction='forward'`
#### Scenario: Invalid station rejected
- **WHEN** a station name not in `WORKCENTER_GROUPS` is provided
- **THEN** the endpoint SHALL return HTTP 400 with an error message
### Requirement: Cache key SHALL include station and direction
The cache key for analysis results SHALL include `station` and `direction` to prevent cross-contamination between different query contexts.
#### Scenario: Different station/direction combinations cached separately
- **WHEN** `station=測試, direction=backward` is queried, then `station=成型, direction=forward` is queried
- **THEN** each SHALL have its own independent cache entry

View File

@@ -0,0 +1,25 @@
## MODIFIED Requirements
### Requirement: EventFetcher SHALL provide unified cached event querying across domains
`EventFetcher` SHALL encapsulate batch event queries with L1/L2 layered cache and rate limit bucket configuration, supporting domains: `history`, `materials`, `rejects`, `holds`, `jobs`, `upstream_history`, `downstream_rejects`.
#### Scenario: Cache miss for event domain query
- **WHEN** `EventFetcher` is called for a domain with container IDs and no cache exists
- **THEN** the domain query SHALL execute against Oracle via `read_sql_df()`
- **THEN** the result SHALL be stored in L2 Redis cache with key format `evt:{domain}:{sorted_cids_hash}`
- **THEN** L1 memory cache SHALL also be populated (aligned with `core/cache.py` LayeredCache pattern)
#### Scenario: Cache hit for event domain query
- **WHEN** `EventFetcher` is called for a domain and L2 Redis cache contains a valid entry
- **THEN** the cached result SHALL be returned without executing Oracle query
- **THEN** DB connection pool SHALL NOT be consumed
#### Scenario: Rate limit bucket per domain
- **WHEN** `EventFetcher` is used from a route handler
- **THEN** each domain SHALL have a configurable rate limit bucket aligned with `configured_rate_limit()` pattern
- **THEN** rate limit configuration SHALL be overridable via environment variables
#### Scenario: downstream_rejects domain query
- **WHEN** `EventFetcher` is called with domain `downstream_rejects`
- **THEN** it SHALL load `mid_section_defect/downstream_rejects.sql` via `SQLLoader.load_with_params()` with `DESCENDANT_FILTER` set to the batched IN clause condition
- **THEN** the query SHALL return reject records with `WORKCENTER_GROUP` classification

View File

@@ -0,0 +1,32 @@
## MODIFIED Requirements
### Requirement: query-tool lineage tab SHALL load on-demand
The query-tool lineage tree SHALL auto-fire lineage API calls after lot resolution with concurrency-limited parallel requests and progressive rendering, while preserving on-demand expand/collapse for tree navigation.
The mid_section_defect profile SHALL support a `direction` parameter that controls lineage resolution direction: `backward` uses `resolve_full_genealogy()` (ancestors), `forward` uses `resolve_forward_tree()` (descendants).
`useTraceProgress.js` `PROFILE_DOMAINS` for `mid_section_defect` SHALL include `'upstream_history'` for backward and `['upstream_history', 'downstream_rejects']` for forward. Domain selection SHALL be handled by the backend based on `direction` in params.
`collectAllContainerIds()` SHALL support forward direction by collecting descendants from `children_map` (instead of ancestors) when `direction='forward'` is present in params.
#### Scenario: Auto-fire lineage after resolve
- **WHEN** lot resolution completes with N resolved lots
- **THEN** lineage SHALL be fetched via `POST /api/trace/lineage` for each lot automatically
- **THEN** concurrent requests SHALL be limited to 3 at a time to respect rate limits (10/60s)
- **THEN** response time SHALL be ≤3s per individual lot
#### Scenario: Multiple lots lineage results cached
- **WHEN** lineage data has been fetched for multiple lots
- **THEN** each lot's lineage data SHALL be preserved independently (not re-fetched)
- **WHEN** a new resolve query is executed
- **THEN** all cached lineage data SHALL be cleared
#### Scenario: Mid-section defect backward lineage
- **WHEN** profile is `mid_section_defect` and direction is `backward`
- **THEN** lineage stage SHALL call `resolve_full_genealogy()` to get ancestor container IDs
- **THEN** `collectAllContainerIds()` SHALL merge seed IDs with ancestor IDs
#### Scenario: Mid-section defect forward lineage
- **WHEN** profile is `mid_section_defect` and direction is `forward`
- **THEN** lineage stage SHALL call `resolve_forward_tree()` to get descendant container IDs
- **THEN** `collectAllContainerIds()` SHALL merge seed IDs with descendant IDs from `children_map`

View File

@@ -0,0 +1,62 @@
## 1. SQL Layer
- [x] 1.1 Create `station_detection.sql` — copy `tmtt_detection.sql`, replace hardcoded TMTT filter with `{{ STATION_FILTER }}` / `{{ STATION_FILTER_REJECTS }}` placeholders, rename `TMTT_EQUIPMENTID/NAME``DETECTION_EQUIPMENTID/NAME`
- [x] 1.2 Create `downstream_rejects.sql` — query `DW_MES_LOTREJECTHISTORY` for batched CONTAINERIDs with `WORKCENTER_GROUP` CASE WHEN, returning CONTAINERID, WORKCENTERNAME, WORKCENTER_GROUP, LOSSREASONNAME, EQUIPMENTNAME, REJECT_TOTAL_QTY, TXNDATE
- [x] 1.3 Modify `upstream_history.sql` — add `h.TRACKINQTY` (with COALESCE to 0) to `ranked_history` CTE and final SELECT
## 2. Backend Service — Station Parameterization
- [x] 2.1 Add `_build_station_filter(station_name, column_prefix)` to `mid_section_defect_service.py` — reads `WORKCENTER_GROUPS` patterns/exclude, builds OR-LIKE SQL with bind params
- [x] 2.2 Replace `_fetch_tmtt_data()` with `_fetch_station_detection_data(start_date, end_date, station)` — uses `station_detection.sql` + `_build_station_filter()`
- [x] 2.3 Update all public API signatures (`query_analysis`, `query_analysis_detail`, `export_csv`, `resolve_trace_seed_lots`, `build_trace_aggregation_from_events`) to accept `station` and `direction` params (default `'測試'`/`'backward'`)
- [x] 2.4 Add station+direction to cache keys
- [x] 2.5 Rename all internal `TMTT_``DETECTION_` references (variables, dict keys, DIMENSION_MAP entries)
## 3. Backend Service — Forward Pipeline
- [x] 3.1 Extract existing backward logic into `_run_backward_pipeline(start_date, end_date, station, loss_reasons)`
- [x] 3.2 Add `_fetch_downstream_rejects(tracked_cids)` — batch query using `downstream_rejects.sql`
- [x] 3.3 Implement `_attribute_forward_defects(detection_df, detection_cids, downstream_wip, downstream_rejects, station_order)` — per-station reject rate using TRACKINQTY denominator
- [x] 3.4 Implement `_run_forward_pipeline(start_date, end_date, station, loss_reasons)` — full 8-stage pipeline (detection → forward lineage → downstream WIP+rejects → attribution → KPI/charts/detail)
- [x] 3.5 Implement `_build_forward_kpi()`, `_build_forward_charts()`, `_build_forward_detail_table()` builders
- [x] 3.6 Add direction dispatch in `query_analysis()`: backward → `_run_backward_pipeline()`, forward → `_run_forward_pipeline()`
- [x] 3.7 Add `query_station_options()` — returns ordered workcenter groups list
## 4. Backend Routes & EventFetcher
- [x] 4.1 Update `mid_section_defect_routes.py` — add `station` + `direction` query params to all endpoints, add station validation, add `GET /station-options` endpoint
- [x] 4.2 Update `trace_routes.py``_seed_resolve_mid_section_defect()` passes `station`; lineage stage uses direction to choose `resolve_full_genealogy()` vs `resolve_forward_tree()`; events stage passes direction for domain selection
- [x] 4.3 Add `downstream_rejects` domain to `event_fetcher.py` — in `SUPPORTED_EVENT_DOMAINS` and `_build_domain_sql()`, loading `mid_section_defect/downstream_rejects.sql`
## 5. Frontend — FilterBar & App
- [x] 5.1 Update `FilterBar.vue` — add station `<select>` dropdown (fetches from `/station-options` on mount), add direction toggle button group (反向追溯/正向追溯), emit station+direction via `update-filters`
- [x] 5.2 Update `App.vue` — add `station: '測試'` and `direction: 'backward'` to filters reactive, include in `buildFilterParams()`, add computed `isForward`, switch chart layout by direction, update page header to '製程不良追溯分析' with dynamic subtitle
- [x] 5.3 Update `useTraceProgress.js` — add `downstream_rejects` to `PROFILE_DOMAINS.mid_section_defect` for forward, update `collectAllContainerIds()` to support `children_map` for forward direction
## 6. Frontend — Direction-Aware Components
- [x] 6.1 Update `KpiCards.vue` — accept `direction` + `stationLabel` props, switch card labels between backward/forward modes
- [x] 6.2 Update `DetailTable.vue` — accept `direction` prop, switch column definitions between backward (existing) and forward (偵測設備, 偵測投入, 偵測不良, 下游到達站數, 下游不良總數, 下游不良率, 最差下游站)
- [x] 6.3 Add `.direction-toggle` styles to `style.css`
## 7. Remove TMTT Defect Page
- [x] 7.1 Delete `frontend/src/tmtt-defect/` directory
- [x] 7.2 Delete `src/mes_dashboard/routes/tmtt_defect_routes.py`
- [x] 7.3 Delete `src/mes_dashboard/services/tmtt_defect_service.py`
- [x] 7.4 Delete `src/mes_dashboard/sql/tmtt_defect/` directory
- [x] 7.5 Remove tmtt-defect registration from `nativeModuleRegistry.js`, `routeContracts.js`, `vite.config.js`, `page_status.json`, `routes/__init__.py`, `app.py`, `page_registry.py`, and all migration baseline/config files
- [x] 7.6 Delete related test files and update remaining tests referencing tmtt-defect
## 8. Config & Metadata
- [x] 8.1 Update `page_status.json` — rename mid-section-defect page name from '中段製程不良追溯' to '製程不良追溯分析', remove tmtt-defect entry
## 9. Verification
- [x] 9.1 Run `python -m pytest tests/test_mid_section_defect_*.py -v` — all 22 tests pass
- [x] 9.2 Run `cd frontend && node --test` — 69/69 frontend tests pass
- [x] 9.3 Run all change-relevant backend tests (app_factory, navigation_contract, full_modernization_gates, page_registry, portal_shell_wave_b_native_smoke) — 64/64 pass
- [x] 9.4 Verify backward compat: `station=測試, direction=backward` produces identical data (renamed columns) — 25,415 detail rows, DETECTION_EQUIPMENTNAME columns (no TMTT_), KPI/charts/genealogy all correct
- [x] 9.5 Verify forward basic: `station=成型 (order=4), direction=forward` → 8 downstream stations, 1,673 detail rows, downstream reject distribution: 測試 1.67%, 水吹砂 0.03%, 切彎腳 0.03%, 去膠 0.02%, 電鍍 0.01%, 移印 0.01%

View File

@@ -85,7 +85,6 @@ def _route_css_targets() -> dict[str, list[Path]]:
"/resource-history": [ROOT / "frontend/src/resource-history/style.css"],
"/qc-gate": [ROOT / "frontend/src/qc-gate/style.css"],
"/job-query": [ROOT / "frontend/src/job-query/style.css"],
"/tmtt-defect": [ROOT / "frontend/src/tmtt-defect/style.css"],
"/admin/pages": [ROOT / "src/mes_dashboard/templates/admin/pages.html"],
"/admin/performance": [ROOT / "src/mes_dashboard/templates/admin/performance.html"],
"/tables": [ROOT / "frontend/src/tables/style.css"],

View File

@@ -131,15 +131,6 @@ TARGET_ROUTE_CONTRACTS: list[dict[str, Any]] = [
"owner": "frontend-mes-reporting",
"rollback_strategy": "fallback_to_legacy_route",
},
{
"route": "/tmtt-defect",
"page_name": "TMTT Defect",
"render_mode": "native",
"required_query_keys": [],
"source_dir": "frontend/src/tmtt-defect",
"owner": "frontend-mes-reporting",
"rollback_strategy": "fallback_to_legacy_route",
},
]
@@ -180,10 +171,6 @@ CRITICAL_API_PAYLOAD_CONTRACTS = {
"required_keys": ["summary", "table", "pareto"],
"notes": "QC-GATE chart/table linked view",
},
"/api/tmtt-defect/analysis": {
"required_keys": ["kpi", "pareto", "trend", "detail"],
"notes": "TMTT chart/table analysis payload",
},
}
@@ -199,7 +186,6 @@ ROUTE_NOTES = {
"/job-query": "resource/date query + txn detail + export",
"/excel-query": "upload/detect/query/export workflow",
"/query-tool": "resolve/history/associations/equipment-period workflows",
"/tmtt-defect": "analysis + chart interactions + CSV export",
}

View File

@@ -138,7 +138,6 @@ def main() -> int:
"/job-query",
"/excel-query",
"/query-tool",
"/tmtt-defect",
]
legacy = _measure_routes(legacy_routes, portal_spa_enabled=False)

View File

@@ -267,7 +267,6 @@ build_frontend_assets() {
"excel-query.js"
"tables.js"
"query-tool.js"
"tmtt-defect.js"
"qc-gate.js"
"mid-section-defect.js"
)

View File

@@ -879,14 +879,6 @@ def create_app(config_name: str | None = None) -> Flask:
200,
))
@app.route('/tmtt-defect')
def tmtt_defect_page():
"""TMTT printing & lead form defect analysis page."""
canonical_redirect = maybe_redirect_to_canonical_shell('/tmtt-defect')
if canonical_redirect is not None:
return canonical_redirect
return render_template('tmtt_defect.html')
@app.route('/qc-gate')
def qc_gate_page():
"""QC-GATE status report served as pure Vite HTML output."""

View File

@@ -16,7 +16,6 @@ from .admin_routes import admin_bp
from .resource_history_routes import resource_history_bp
from .job_query_routes import job_query_bp
from .query_tool_routes import query_tool_bp
from .tmtt_defect_routes import tmtt_defect_bp
from .qc_gate_routes import qc_gate_bp
from .mid_section_defect_routes import mid_section_defect_bp
from .trace_routes import trace_bp
@@ -35,7 +34,6 @@ def register_routes(app) -> None:
app.register_blueprint(resource_history_bp)
app.register_blueprint(job_query_bp)
app.register_blueprint(query_tool_bp)
app.register_blueprint(tmtt_defect_bp)
app.register_blueprint(qc_gate_bp)
app.register_blueprint(mid_section_defect_bp)
app.register_blueprint(trace_bp)
@@ -54,7 +52,6 @@ __all__ = [
'resource_history_bp',
'job_query_bp',
'query_tool_bp',
'tmtt_defect_bp',
'qc_gate_bp',
'mid_section_defect_bp',
'trace_bp',

View File

@@ -1,66 +1,81 @@
# -*- coding: utf-8 -*-
"""Mid-Section Defect Traceability Analysis API routes.
"""Defect Traceability Analysis API routes.
Reverse traceability from TMTT (test) station back to upstream production stations.
Bidirectional traceability from any detection station to upstream/downstream.
"""
from flask import Blueprint, jsonify, request, Response
from mes_dashboard.core.rate_limit import configured_rate_limit
from mes_dashboard.services.mid_section_defect_service import (
query_analysis,
query_analysis_detail,
query_all_loss_reasons,
export_csv,
from flask import Blueprint, jsonify, request, Response
from mes_dashboard.core.rate_limit import configured_rate_limit
from mes_dashboard.services.mid_section_defect_service import (
query_analysis,
query_analysis_detail,
query_all_loss_reasons,
query_station_options,
export_csv,
)
mid_section_defect_bp = Blueprint(
'mid_section_defect',
__name__,
url_prefix='/api/mid-section-defect'
)
_ANALYSIS_RATE_LIMIT = configured_rate_limit(
bucket="mid-section-defect-analysis",
max_attempts_env="MID_SECTION_DEFECT_ANALYSIS_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="MID_SECTION_DEFECT_ANALYSIS_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=6,
default_window_seconds=60,
)
_DETAIL_RATE_LIMIT = configured_rate_limit(
bucket="mid-section-defect-analysis-detail",
max_attempts_env="MID_SECTION_DEFECT_DETAIL_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="MID_SECTION_DEFECT_DETAIL_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=15,
default_window_seconds=60,
)
_EXPORT_RATE_LIMIT = configured_rate_limit(
bucket="mid-section-defect-export",
max_attempts_env="MID_SECTION_DEFECT_EXPORT_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="MID_SECTION_DEFECT_EXPORT_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=3,
default_window_seconds=60,
)
@mid_section_defect_bp.route('/analysis', methods=['GET'])
@_ANALYSIS_RATE_LIMIT
def api_analysis():
"""API: Get mid-section defect traceability analysis (summary).
mid_section_defect_bp = Blueprint(
'mid_section_defect',
__name__,
url_prefix='/api/mid-section-defect'
)
Returns kpi, charts, daily_trend, available_loss_reasons, genealogy_status,
and detail_total_count. Does NOT include the detail array — use
/analysis/detail for paginated detail data.
_ANALYSIS_RATE_LIMIT = configured_rate_limit(
bucket="mid-section-defect-analysis",
max_attempts_env="MID_SECTION_DEFECT_ANALYSIS_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="MID_SECTION_DEFECT_ANALYSIS_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=6,
default_window_seconds=60,
)
_DETAIL_RATE_LIMIT = configured_rate_limit(
bucket="mid-section-defect-analysis-detail",
max_attempts_env="MID_SECTION_DEFECT_DETAIL_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="MID_SECTION_DEFECT_DETAIL_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=15,
default_window_seconds=60,
)
_EXPORT_RATE_LIMIT = configured_rate_limit(
bucket="mid-section-defect-export",
max_attempts_env="MID_SECTION_DEFECT_EXPORT_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="MID_SECTION_DEFECT_EXPORT_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=3,
default_window_seconds=60,
)
def _parse_common_params():
"""Extract common query params (dates, loss_reasons, station, direction)."""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
loss_reasons_str = request.args.get('loss_reasons', '')
loss_reasons = [r.strip() for r in loss_reasons_str.split(',') if r.strip()] or None
station = request.args.get('station', '測試')
direction = request.args.get('direction', 'backward')
return start_date, end_date, loss_reasons, station, direction
@mid_section_defect_bp.route('/station-options', methods=['GET'])
def api_station_options():
"""API: Get available detection station options for dropdown."""
return jsonify({'success': True, 'data': query_station_options()})
@mid_section_defect_bp.route('/analysis', methods=['GET'])
@_ANALYSIS_RATE_LIMIT
def api_analysis():
"""API: Get defect traceability analysis (summary).
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
loss_reasons: Comma-separated loss reason names, optional
station: Detection station workcenter group (default '測試')
direction: 'backward' or 'forward' (default 'backward')
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
start_date, end_date, loss_reasons, station, direction = _parse_common_params()
if not start_date or not end_date:
return jsonify({
@@ -68,10 +83,7 @@ def api_analysis():
'error': '必須提供 start_date 和 end_date 參數'
}), 400
loss_reasons_str = request.args.get('loss_reasons', '')
loss_reasons = [r.strip() for r in loss_reasons_str.split(',') if r.strip()] or None
result = query_analysis(start_date, end_date, loss_reasons)
result = query_analysis(start_date, end_date, loss_reasons, station, direction)
if result is None:
return jsonify({'success': False, 'error': '查詢失敗,請稍後再試'}), 500
@@ -79,7 +91,6 @@ def api_analysis():
if 'error' in result:
return jsonify({'success': False, 'error': result['error']}), 400
# Return summary only (no detail array) to keep response lightweight
summary = {
'kpi': result.get('kpi'),
'charts': result.get('charts'),
@@ -87,25 +98,23 @@ def api_analysis():
'available_loss_reasons': result.get('available_loss_reasons'),
'genealogy_status': result.get('genealogy_status'),
'detail_total_count': len(result.get('detail', [])),
'attribution': result.get('attribution', []),
}
return jsonify({'success': True, 'data': summary})
@mid_section_defect_bp.route('/analysis/detail', methods=['GET'])
@_DETAIL_RATE_LIMIT
def api_analysis_detail():
"""API: Get paginated detail table for mid-section defect analysis.
@mid_section_defect_bp.route('/analysis/detail', methods=['GET'])
@_DETAIL_RATE_LIMIT
def api_analysis_detail():
"""API: Get paginated detail table for defect traceability analysis.
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
loss_reasons: Comma-separated loss reason names, optional
start_date, end_date, loss_reasons, station, direction (same as /analysis)
page: Page number (default 1)
page_size: Records per page (default 200, max 500)
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
start_date, end_date, loss_reasons, station, direction = _parse_common_params()
if not start_date or not end_date:
return jsonify({
@@ -113,14 +122,11 @@ def api_analysis_detail():
'error': '必須提供 start_date 和 end_date 參數'
}), 400
loss_reasons_str = request.args.get('loss_reasons', '')
loss_reasons = [r.strip() for r in loss_reasons_str.split(',') if r.strip()] or None
page = max(request.args.get('page', 1, type=int), 1)
page_size = max(1, min(request.args.get('page_size', 200, type=int), 500))
result = query_analysis_detail(
start_date, end_date, loss_reasons,
start_date, end_date, loss_reasons, station, direction,
page=page, page_size=page_size,
)
@@ -135,14 +141,7 @@ def api_analysis_detail():
@mid_section_defect_bp.route('/loss-reasons', methods=['GET'])
def api_loss_reasons():
"""API: Get all TMTT loss reasons (cached daily).
No parameters required — returns all loss reasons from last 180 days,
cached in Redis with 24h TTL for instant dropdown population.
Returns:
JSON with loss_reasons list.
"""
"""API: Get all loss reasons (cached daily)."""
result = query_all_loss_reasons()
if result is None:
@@ -151,21 +150,15 @@ def api_loss_reasons():
return jsonify({'success': True, 'data': result})
@mid_section_defect_bp.route('/export', methods=['GET'])
@_EXPORT_RATE_LIMIT
def api_export():
"""API: Export mid-section defect detail data as CSV.
@mid_section_defect_bp.route('/export', methods=['GET'])
@_EXPORT_RATE_LIMIT
def api_export():
"""API: Export defect traceability detail data as CSV.
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
loss_reasons: Comma-separated loss reason names, optional
Returns:
CSV file download.
start_date, end_date, loss_reasons, station, direction (same as /analysis)
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
start_date, end_date, loss_reasons, station, direction = _parse_common_params()
if not start_date or not end_date:
return jsonify({
@@ -173,13 +166,10 @@ def api_export():
'error': '必須提供 start_date 和 end_date 參數'
}), 400
loss_reasons_str = request.args.get('loss_reasons', '')
loss_reasons = [r.strip() for r in loss_reasons_str.split(',') if r.strip()] or None
filename = f"mid_section_defect_{start_date}_to_{end_date}.csv"
filename = f"defect_trace_{station}_{direction}_{start_date}_to_{end_date}.csv"
return Response(
export_csv(start_date, end_date, loss_reasons),
export_csv(start_date, end_date, loss_reasons, station, direction),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename={filename}',

View File

@@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
"""TMTT Defect Analysis API routes.
Contains Flask Blueprint for TMTT printing & lead form defect analysis endpoints.
"""
from flask import Blueprint, jsonify, request, Response
from mes_dashboard.services.tmtt_defect_service import (
query_tmtt_defect_analysis,
export_csv,
)
# Create Blueprint
tmtt_defect_bp = Blueprint(
'tmtt_defect',
__name__,
url_prefix='/api/tmtt-defect'
)
@tmtt_defect_bp.route('/analysis', methods=['GET'])
def api_tmtt_defect_analysis():
"""API: Get TMTT defect analysis data (KPI + charts + detail).
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
Returns:
JSON with kpi, charts, detail sections.
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if not start_date or not end_date:
return jsonify({
'success': False,
'error': '必須提供 start_date 和 end_date 參數'
}), 400
result = query_tmtt_defect_analysis(start_date, end_date)
if result is None:
return jsonify({'success': False, 'error': '查詢失敗,請稍後再試'}), 500
if 'error' in result:
return jsonify({'success': False, 'error': result['error']}), 400
return jsonify({'success': True, 'data': result})
@tmtt_defect_bp.route('/export', methods=['GET'])
def api_tmtt_defect_export():
"""API: Export TMTT defect detail data as CSV.
Query Parameters:
start_date: Start date (YYYY-MM-DD), required
end_date: End date (YYYY-MM-DD), required
Returns:
CSV file download.
"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if not start_date or not end_date:
return jsonify({
'success': False,
'error': '必須提供 start_date 和 end_date 參數'
}), 400
filename = f"tmtt_defect_{start_date}_to_{end_date}.csv"
return Response(
export_csv(start_date, end_date),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename={filename}',
'Content-Type': 'text/csv; charset=utf-8-sig'
}
)

View File

@@ -12,7 +12,9 @@ from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from flask import Blueprint, jsonify, request
@@ -33,8 +35,8 @@ logger = logging.getLogger("mes_dashboard.trace_routes")
trace_bp = Blueprint("trace", __name__, url_prefix="/api/trace")
TRACE_STAGE_TIMEOUT_SECONDS = 10.0
TRACE_LINEAGE_TIMEOUT_SECONDS = 60.0
TRACE_SLOW_THRESHOLD_SECONDS = float(os.getenv('TRACE_SLOW_THRESHOLD_SECONDS', '15'))
TRACE_EVENTS_MAX_WORKERS = int(os.getenv('TRACE_EVENTS_MAX_WORKERS', '4'))
TRACE_CACHE_TTL_SECONDS = 300
PROFILE_QUERY_TOOL = "query_tool"
@@ -57,6 +59,7 @@ SUPPORTED_EVENT_DOMAINS = {
"holds",
"jobs",
"upstream_history",
"downstream_rejects",
}
_TRACE_SEED_RATE_LIMIT = configured_rate_limit(
@@ -135,10 +138,6 @@ def _error(code: str, message: str, status_code: int = 400):
return error_response(code, message, status_code=status_code)
def _timeout(stage: str):
return _error(f"{stage.upper().replace('-', '_')}_TIMEOUT", f"{stage} stage exceeded timeout budget", 504)
def _is_timeout_exception(exc: Exception) -> bool:
text = str(exc).lower()
timeout_fragments = (
@@ -225,11 +224,72 @@ def _seed_resolve_query_tool(
def _seed_resolve_mid_section_defect(
params: Dict[str, Any],
) -> tuple[Optional[Dict[str, Any]], Optional[tuple[str, str, int]]]:
mode = str(params.get("mode") or "date_range").strip()
if mode == "container":
resolve_type = str(params.get("resolve_type") or "").strip()
if resolve_type not in {"lot_id", "work_order", "wafer_lot"}:
return None, (
"INVALID_PARAMS",
"resolve_type must be one of: lot_id, work_order, wafer_lot",
400,
)
values = _normalize_strings(params.get("values", []))
if not values:
return None, ("INVALID_PARAMS", "values must contain at least one query value", 400)
resolved = resolve_lots(resolve_type, values)
if not isinstance(resolved, dict):
return None, ("SEED_RESOLVE_FAILED", "seed resolve returned unexpected payload", 500)
if "error" in resolved:
return None, ("SEED_RESOLVE_FAILED", str(resolved.get("error") or "seed resolve failed"), 400)
seeds = []
seen: set = set()
not_found: List[str] = []
resolved_values: set = set()
for row in resolved.get("data", []):
if not isinstance(row, dict):
continue
container_id = str(row.get("container_id") or row.get("CONTAINERID") or "").strip()
if not container_id or container_id in seen:
continue
seen.add(container_id)
lot_id = str(
row.get("lot_id")
or row.get("CONTAINERNAME")
or row.get("input_value")
or container_id
).strip()
seeds.append({
"container_id": container_id,
"container_name": lot_id,
"lot_id": lot_id,
})
input_val = str(row.get("input_value") or "").strip()
if input_val:
resolved_values.add(input_val)
for val in values:
if val not in resolved_values and not any(
s.get("lot_id", "") == val or s.get("container_name", "") == val
for s in seeds
):
not_found.append(val)
return {
"seeds": seeds,
"seed_count": len(seeds),
"not_found": not_found,
}, None
# date_range mode (default)
start_date, end_date = _extract_date_range(params)
if not start_date or not end_date:
return None, ("INVALID_PARAMS", "start_date/end_date (or date_range) is required", 400)
result = resolve_trace_seed_lots(start_date, end_date)
station = str(params.get("station") or "測試").strip()
result = resolve_trace_seed_lots(start_date, end_date, station=station)
if result is None:
return None, ("SEED_RESOLVE_FAILED", "seed resolve service unavailable", 503)
if "error" in result:
@@ -334,9 +394,14 @@ def _build_msd_aggregation(
if not isinstance(params, dict):
return None, ("INVALID_PARAMS", "params is required for mid_section_defect profile", 400)
start_date, end_date = _extract_date_range(params)
if not start_date or not end_date:
return None, ("INVALID_PARAMS", "start_date/end_date is required in params", 400)
mode = str(params.get("mode") or "date_range").strip()
start_date: Optional[str] = None
end_date: Optional[str] = None
if mode != "container":
start_date, end_date = _extract_date_range(params)
if not start_date or not end_date:
return None, ("INVALID_PARAMS", "start_date/end_date is required in params", 400)
raw_loss_reasons = params.get("loss_reasons")
loss_reasons = parse_loss_reasons_param(raw_loss_reasons)
@@ -347,6 +412,8 @@ def _build_msd_aggregation(
seed_container_ids = _normalize_strings(list(lineage_ancestors.keys()))
upstream_events = domain_results.get("upstream_history", {})
station = str(params.get("station") or "測試").strip()
direction = str(params.get("direction") or "backward").strip()
aggregation = build_trace_aggregation_from_events(
start_date,
@@ -355,6 +422,9 @@ def _build_msd_aggregation(
seed_container_ids=seed_container_ids,
lineage_ancestors=lineage_ancestors,
upstream_events_by_cid=upstream_events,
station=station,
direction=direction,
mode=mode,
)
if aggregation is None:
return None, ("EVENTS_AGGREGATION_FAILED", "aggregation service unavailable", 503)
@@ -397,8 +467,8 @@ def seed_resolve():
resolved, route_error = _seed_resolve_mid_section_defect(params)
elapsed = time.monotonic() - started
if elapsed > TRACE_STAGE_TIMEOUT_SECONDS:
return _timeout("seed_resolve")
if elapsed > TRACE_SLOW_THRESHOLD_SECONDS:
logger.warning("trace seed-resolve slow elapsed=%.2fs", elapsed)
if route_error is not None:
code, message, status = route_error
@@ -441,9 +511,18 @@ def lineage():
payload.get("cache_key"),
)
# Determine lineage direction: backward profiles use reverse genealogy,
# forward profiles (and mid_section_defect with direction=backward) use genealogy
direction = "forward"
if profile == PROFILE_QUERY_TOOL_REVERSE:
direction = "backward"
elif profile == PROFILE_MID_SECTION_DEFECT:
params = payload.get("params") or {}
direction = str(params.get("direction") or "backward").strip()
started = time.monotonic()
try:
if profile == PROFILE_QUERY_TOOL_REVERSE:
if direction == "backward":
reverse_graph = LineageEngine.resolve_full_genealogy(container_ids)
response = _build_lineage_response(
container_ids,
@@ -475,8 +554,8 @@ def lineage():
return _error("LINEAGE_FAILED", "lineage stage failed", 500)
elapsed = time.monotonic() - started
if elapsed > TRACE_LINEAGE_TIMEOUT_SECONDS:
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
if elapsed > TRACE_SLOW_THRESHOLD_SECONDS:
logger.warning("trace lineage slow elapsed=%.2fs", elapsed)
cache_set(lineage_cache_key, response, ttl=TRACE_CACHE_TTL_SECONDS)
return jsonify(response)
@@ -526,19 +605,25 @@ def events():
raw_domain_results: Dict[str, Dict[str, List[Dict[str, Any]]]] = {}
failed_domains: List[str] = []
for domain in domains:
try:
events_by_cid = EventFetcher.fetch_events(container_ids, domain)
raw_domain_results[domain] = events_by_cid
rows = _flatten_domain_records(events_by_cid)
results[domain] = {"data": rows, "count": len(rows)}
except Exception as exc:
logger.error("events stage domain failed domain=%s: %s", domain, exc, exc_info=True)
failed_domains.append(domain)
with ThreadPoolExecutor(max_workers=min(len(domains), TRACE_EVENTS_MAX_WORKERS)) as executor:
futures = {
executor.submit(EventFetcher.fetch_events, container_ids, domain): domain
for domain in domains
}
for future in as_completed(futures):
domain = futures[future]
try:
events_by_cid = future.result()
raw_domain_results[domain] = events_by_cid
rows = _flatten_domain_records(events_by_cid)
results[domain] = {"data": rows, "count": len(rows)}
except Exception as exc:
logger.error("events stage domain failed domain=%s: %s", domain, exc, exc_info=True)
failed_domains.append(domain)
elapsed = time.monotonic() - started
if elapsed > TRACE_STAGE_TIMEOUT_SECONDS:
return _error("EVENTS_TIMEOUT", "events stage timed out", 504)
if elapsed > TRACE_SLOW_THRESHOLD_SECONDS:
logger.warning("trace events slow elapsed=%.2fs domains=%s", elapsed, ",".join(domains))
aggregation = None
if profile == PROFILE_MID_SECTION_DEFECT:

View File

@@ -5,9 +5,11 @@ from __future__ import annotations
import hashlib
import logging
import math
import os
import re
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List
from mes_dashboard.core.cache import cache_get, cache_set
@@ -17,6 +19,7 @@ from mes_dashboard.sql import QueryBuilder, SQLLoader
logger = logging.getLogger("mes_dashboard.event_fetcher")
ORACLE_IN_BATCH_SIZE = 1000
EVENT_FETCHER_MAX_WORKERS = int(os.getenv('EVENT_FETCHER_MAX_WORKERS', '4'))
_DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
"history": {
@@ -77,6 +80,15 @@ _DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
"default_max": 20,
"default_window": 60,
},
"downstream_rejects": {
"filter_column": "r.CONTAINERID",
"cache_ttl": 300,
"bucket": "event-downstream-rejects",
"max_env": "EVT_DOWNSTREAM_REJECTS_RATE_MAX_REQUESTS",
"window_env": "EVT_DOWNSTREAM_REJECTS_RATE_WINDOW_SECONDS",
"default_max": 20,
"default_window": 60,
},
}
@@ -155,6 +167,12 @@ class EventFetcher:
ANCESTOR_FILTER=condition_sql,
)
if domain == "downstream_rejects":
return SQLLoader.load_with_params(
"mid_section_defect/downstream_rejects",
DESCENDANT_FILTER=condition_sql,
)
if domain == "history":
sql = SQLLoader.load("query_tool/lot_history")
sql = EventFetcher._replace_container_filter(sql, condition_sql)
@@ -219,36 +237,63 @@ class EventFetcher:
filter_column = spec["filter_column"]
match_mode = spec.get("match_mode", "in")
for i in range(0, len(normalized_ids), ORACLE_IN_BATCH_SIZE):
batch = normalized_ids[i:i + ORACLE_IN_BATCH_SIZE]
def _fetch_batch(batch_ids):
builder = QueryBuilder()
if match_mode == "contains":
builder.add_or_like_conditions(filter_column, batch, position="both")
builder.add_or_like_conditions(filter_column, batch_ids, position="both")
else:
builder.add_in_condition(filter_column, batch)
builder.add_in_condition(filter_column, batch_ids)
sql = EventFetcher._build_domain_sql(domain, builder.get_conditions_sql())
df = read_sql_df(sql, builder.params)
if df is None or df.empty:
continue
return batch_ids, read_sql_df(sql, builder.params)
def _sanitize_record(d):
"""Replace NaN/NaT values with None for JSON-safe serialization."""
for k, v in d.items():
if isinstance(v, float) and math.isnan(v):
d[k] = None
return d
def _process_batch_result(batch_ids, df):
if df is None or df.empty:
return
for _, row in df.iterrows():
if domain == "jobs":
record = row.to_dict()
record = _sanitize_record(row.to_dict())
containers = record.get("CONTAINERIDS")
if not isinstance(containers, str) or not containers:
continue
for cid in batch:
for cid in batch_ids:
if cid in containers:
enriched = dict(record)
enriched["CONTAINERID"] = cid
grouped[cid].append(enriched)
continue
cid = row.get("CONTAINERID")
if not isinstance(cid, str) or not cid:
continue
grouped[cid].append(row.to_dict())
grouped[cid].append(_sanitize_record(row.to_dict()))
batches = [
normalized_ids[i:i + ORACLE_IN_BATCH_SIZE]
for i in range(0, len(normalized_ids), ORACLE_IN_BATCH_SIZE)
]
if len(batches) <= 1:
for batch in batches:
batch_ids, df = _fetch_batch(batch)
_process_batch_result(batch_ids, df)
else:
with ThreadPoolExecutor(max_workers=min(len(batches), EVENT_FETCHER_MAX_WORKERS)) as executor:
futures = {executor.submit(_fetch_batch, b): b for b in batches}
for future in as_completed(futures):
try:
batch_ids, df = future.result()
_process_batch_result(batch_ids, df)
except Exception:
logger.error(
"EventFetcher batch query failed domain=%s batch_size=%s",
domain, len(futures[future]), exc_info=True,
)
result = dict(grouped)
cache_set(cache_key, result, ttl=_DOMAIN_SPECS[domain]["cache_ttl"])

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,6 @@ LEGACY_NAV_ASSIGNMENTS = {
"/excel-query": {"drawer_id": "queries", "order": 2},
"/job-query": {"drawer_id": "queries", "order": 3},
"/query-tool": {"drawer_id": "queries", "order": 4},
"/tmtt-defect": {"drawer_id": "queries", "order": 5},
"/admin/pages": {
"drawer_id": "dev-tools",
"order": 1,

View File

@@ -1,529 +0,0 @@
# -*- coding: utf-8 -*-
"""TMTT Defect Analysis Service.
Provides functions for analyzing printing (印字) and lead form (腳型) defects
at TMTT stations, with MOLD equipment correlation and multi-dimension Pareto analysis.
Defect rates are calculated separately by LOSSREASONNAME:
- Print defect rate = 277_印字不良 / TMTT INPUT
- Lead defect rate = 276_腳型不良 / TMTT INPUT
"""
import csv
import io
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, List, Any, Generator
import math
import pandas as pd
from mes_dashboard.core.database import read_sql_df
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
from mes_dashboard.sql import SQLLoader
logger = logging.getLogger('mes_dashboard.tmtt_defect')
# Constants
MAX_QUERY_DAYS = 180
CACHE_TTL = 300 # 5 minutes
PRINT_DEFECT = '277_印字不良'
LEAD_DEFECT = '276_腳型不良'
# Dimension column mapping for chart aggregation
DIMENSION_MAP = {
'by_workflow': 'WORKFLOW',
'by_package': 'PRODUCTLINENAME',
'by_type': 'PJ_TYPE',
'by_tmtt_machine': 'TMTT_EQUIPMENTNAME',
'by_mold_machine': 'MOLD_EQUIPMENTNAME',
}
# CSV export column config
CSV_COLUMNS = [
('CONTAINERNAME', 'LOT ID'),
('PJ_TYPE', 'TYPE'),
('PRODUCTLINENAME', 'PACKAGE'),
('WORKFLOW', 'WORKFLOW'),
('FINISHEDRUNCARD', '完工流水碼'),
('TMTT_EQUIPMENTNAME', 'TMTT設備'),
('MOLD_EQUIPMENTNAME', 'MOLD設備'),
('INPUT_QTY', '投入數'),
('PRINT_DEFECT_QTY', '印字不良數'),
('PRINT_DEFECT_RATE', '印字不良率(%)'),
('LEAD_DEFECT_QTY', '腳型不良數'),
('LEAD_DEFECT_RATE', '腳型不良率(%)'),
]
# ============================================================
# Public API
# ============================================================
def query_tmtt_defect_analysis(
start_date: str,
end_date: str,
) -> Optional[Dict[str, Any]]:
"""Main entry point for TMTT defect analysis.
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
Returns:
Dict with kpi, charts, detail sections, or dict with 'error' key.
"""
# Validate dates
error = _validate_date_range(start_date, end_date)
if error:
return {'error': error}
# Check cache
cache_key = make_cache_key(
"tmtt_defect_analysis",
filters={'start_date': start_date, 'end_date': end_date},
)
cached = cache_get(cache_key)
if cached is not None:
return cached
# Fetch data
df = _fetch_base_data(start_date, end_date)
if df is None:
return None
# Build response
result = {
'kpi': _build_kpi(df),
'charts': _build_all_charts(df),
'daily_trend': _build_daily_trend(df),
'detail': _build_detail_table(df),
}
cache_set(cache_key, result, ttl=CACHE_TTL)
return result
def export_csv(
start_date: str,
end_date: str,
) -> Generator[str, None, None]:
"""Stream CSV export of detail data.
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
Yields:
CSV lines as strings.
"""
df = _fetch_base_data(start_date, end_date)
# BOM for Excel UTF-8 compatibility
yield '\ufeff'
output = io.StringIO()
writer = csv.writer(output)
# Header row
writer.writerow([label for _, label in CSV_COLUMNS])
yield output.getvalue()
output.seek(0)
output.truncate(0)
if df is None or df.empty:
return
detail = _build_detail_table(df)
for row in detail:
writer.writerow([row.get(col, '') for col, _ in CSV_COLUMNS])
yield output.getvalue()
output.seek(0)
output.truncate(0)
# ============================================================
# Helpers
# ============================================================
def _safe_str(v, default=''):
"""Return a JSON-safe string. Converts NaN/None to default."""
if v is None or (isinstance(v, float) and math.isnan(v)):
return default
try:
if pd.isna(v):
return default
except (TypeError, ValueError):
pass
return str(v)
def _safe_float(v, default=0.0):
"""Return a JSON-safe float. Converts NaN/None to default."""
if v is None:
return default
try:
f = float(v)
if math.isnan(f) or math.isinf(f):
return default
return f
except (TypeError, ValueError):
return default
def _safe_int(v, default=0):
"""Return a JSON-safe int. Converts NaN/None to default."""
return int(_safe_float(v, float(default)))
# ============================================================
# Internal Functions
# ============================================================
def _validate_date_range(start_date: str, end_date: str) -> Optional[str]:
"""Validate date range parameters.
Returns:
Error message string, or None if valid.
"""
try:
start = datetime.strptime(start_date, '%Y-%m-%d')
end = datetime.strptime(end_date, '%Y-%m-%d')
except (ValueError, TypeError):
return '日期格式無效,請使用 YYYY-MM-DD'
if start > end:
return '起始日期不能晚於結束日期'
if (end - start).days > MAX_QUERY_DAYS:
return f'查詢範圍不能超過 {MAX_QUERY_DAYS}'
return None
def _fetch_base_data(start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""Execute base_data.sql and return raw DataFrame.
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
Returns:
DataFrame or None on error.
"""
try:
sql = SQLLoader.load("tmtt_defect/base_data")
params = {
'start_date': start_date,
'end_date': end_date,
}
df = read_sql_df(sql, params)
if df is None:
logger.error("TMTT defect base query returned None")
return None
logger.info(
f"TMTT defect query: {len(df)} rows, "
f"{df['CONTAINERID'].nunique() if not df.empty else 0} unique lots"
)
return df
except Exception as exc:
logger.error(f"TMTT defect query failed: {exc}", exc_info=True)
return None
def _build_kpi(df: pd.DataFrame) -> Dict[str, Any]:
"""Build KPI summary from base data.
Defect rates are calculated separately by LOSSREASONNAME.
INPUT is deduplicated by CONTAINERID (a LOT may have multiple defect rows).
Args:
df: Base data DataFrame.
Returns:
KPI dict with total_input, lot_count, print/lead defect qty and rate.
"""
if df.empty:
return {
'total_input': 0,
'lot_count': 0,
'print_defect_qty': 0,
'print_defect_rate': 0.0,
'lead_defect_qty': 0,
'lead_defect_rate': 0.0,
}
# Deduplicate for INPUT: one TRACKINQTY per unique CONTAINERID
unique_lots = df.drop_duplicates(subset=['CONTAINERID'])
total_input = int(unique_lots['TRACKINQTY'].sum())
lot_count = len(unique_lots)
# Defect totals by type
defect_rows = df[df['REJECTQTY'] > 0]
print_qty = int(
defect_rows.loc[
defect_rows['LOSSREASONNAME'] == PRINT_DEFECT, 'REJECTQTY'
].sum()
)
lead_qty = int(
defect_rows.loc[
defect_rows['LOSSREASONNAME'] == LEAD_DEFECT, 'REJECTQTY'
].sum()
)
return {
'total_input': total_input,
'lot_count': lot_count,
'print_defect_qty': print_qty,
'print_defect_rate': round(print_qty / total_input * 100, 4) if total_input else 0.0,
'lead_defect_qty': lead_qty,
'lead_defect_rate': round(lead_qty / total_input * 100, 4) if total_input else 0.0,
}
def _build_chart_data(
df: pd.DataFrame,
dimension: str,
) -> List[Dict[str, Any]]:
"""Build Pareto chart data for a given dimension.
Each item includes separate print and lead defect quantities/rates.
Args:
df: Base data DataFrame.
dimension: Column name to group by.
Returns:
List of dicts sorted by total defect qty DESC, with cumulative_pct.
"""
if df.empty:
return []
# Fill NaN dimension values
work_df = df.copy()
work_df[dimension] = work_df[dimension].fillna('(未知)')
# INPUT per dimension (deduplicated by CONTAINERID within each group)
input_by_dim = (
work_df.drop_duplicates(subset=['CONTAINERID', dimension])
.groupby(dimension)['TRACKINQTY']
.sum()
)
# Defect qty per dimension per type
defect_rows = work_df[work_df['REJECTQTY'] > 0]
print_by_dim = (
defect_rows[defect_rows['LOSSREASONNAME'] == PRINT_DEFECT]
.groupby(dimension)['REJECTQTY']
.sum()
)
lead_by_dim = (
defect_rows[defect_rows['LOSSREASONNAME'] == LEAD_DEFECT]
.groupby(dimension)['REJECTQTY']
.sum()
)
# Combine
combined = pd.DataFrame({
'input_qty': input_by_dim,
'print_defect_qty': print_by_dim,
'lead_defect_qty': lead_by_dim,
}).fillna(0).astype({'print_defect_qty': int, 'lead_defect_qty': int, 'input_qty': int})
combined['total_defect_qty'] = combined['print_defect_qty'] + combined['lead_defect_qty']
combined = combined.sort_values('total_defect_qty', ascending=False)
# Cumulative percentage
total_defects = combined['total_defect_qty'].sum()
if total_defects > 0:
combined['cumulative_pct'] = (
combined['total_defect_qty'].cumsum() / total_defects * 100
).round(2)
else:
combined['cumulative_pct'] = 0.0
# Defect rates
combined['print_defect_rate'] = (
combined['print_defect_qty'] / combined['input_qty'] * 100
).round(4).where(combined['input_qty'] > 0, 0.0)
combined['lead_defect_rate'] = (
combined['lead_defect_qty'] / combined['input_qty'] * 100
).round(4).where(combined['input_qty'] > 0, 0.0)
result = []
for name, row in combined.iterrows():
result.append({
'name': _safe_str(name),
'input_qty': _safe_int(row['input_qty']),
'print_defect_qty': _safe_int(row['print_defect_qty']),
'print_defect_rate': _safe_float(row['print_defect_rate']),
'lead_defect_qty': _safe_int(row['lead_defect_qty']),
'lead_defect_rate': _safe_float(row['lead_defect_rate']),
'total_defect_qty': _safe_int(row['total_defect_qty']),
'cumulative_pct': _safe_float(row['cumulative_pct']),
})
return result
def _build_all_charts(df: pd.DataFrame) -> Dict[str, List[Dict]]:
"""Build chart data for all 5 dimensions.
Args:
df: Base data DataFrame.
Returns:
Dict mapping chart key to Pareto data list.
"""
return {
key: _build_chart_data(df, col)
for key, col in DIMENSION_MAP.items()
}
def _build_daily_trend(df: pd.DataFrame) -> List[Dict[str, Any]]:
"""Build daily defect rate trend data.
Groups by TRACKINTIMESTAMP date, calculates daily print/lead defect rates.
Args:
df: Base data DataFrame.
Returns:
List of dicts sorted by date ASC, each with date, input_qty,
print/lead defect qty and rate.
"""
if df.empty:
return []
work_df = df.copy()
work_df['DATE'] = pd.to_datetime(work_df['TRACKINTIMESTAMP']).dt.strftime('%Y-%m-%d')
# Daily INPUT (deduplicated by CONTAINERID per date)
daily_input = (
work_df.drop_duplicates(subset=['CONTAINERID', 'DATE'])
.groupby('DATE')['TRACKINQTY']
.sum()
)
# Daily defects by type
defect_rows = work_df[work_df['REJECTQTY'] > 0]
daily_print = (
defect_rows[defect_rows['LOSSREASONNAME'] == PRINT_DEFECT]
.groupby('DATE')['REJECTQTY']
.sum()
)
daily_lead = (
defect_rows[defect_rows['LOSSREASONNAME'] == LEAD_DEFECT]
.groupby('DATE')['REJECTQTY']
.sum()
)
combined = pd.DataFrame({
'input_qty': daily_input,
'print_defect_qty': daily_print,
'lead_defect_qty': daily_lead,
}).fillna(0).astype({'print_defect_qty': int, 'lead_defect_qty': int, 'input_qty': int})
combined['print_defect_rate'] = (
combined['print_defect_qty'] / combined['input_qty'] * 100
).round(4).where(combined['input_qty'] > 0, 0.0)
combined['lead_defect_rate'] = (
combined['lead_defect_qty'] / combined['input_qty'] * 100
).round(4).where(combined['input_qty'] > 0, 0.0)
combined = combined.sort_index()
result = []
for date, row in combined.iterrows():
result.append({
'date': str(date),
'input_qty': _safe_int(row['input_qty']),
'print_defect_qty': _safe_int(row['print_defect_qty']),
'print_defect_rate': _safe_float(row['print_defect_rate']),
'lead_defect_qty': _safe_int(row['lead_defect_qty']),
'lead_defect_rate': _safe_float(row['lead_defect_rate']),
})
return result
def _build_detail_table(df: pd.DataFrame) -> List[Dict[str, Any]]:
"""Build detail table rows, one per LOT.
Aggregates defect quantities per LOT across defect types.
Args:
df: Base data DataFrame.
Returns:
List of dicts, one per LOT.
"""
if df.empty:
return []
# Pivot defects per LOT
lot_group_cols = [
'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME',
'WORKFLOW', 'FINISHEDRUNCARD', 'TMTT_EQUIPMENTNAME',
'MOLD_EQUIPMENTNAME', 'TRACKINQTY',
]
# Get unique LOT info (first occurrence)
lots = df.drop_duplicates(subset=['CONTAINERID'])[lot_group_cols].copy()
# Aggregate defects per LOT per type
defect_rows = df[df['REJECTQTY'] > 0]
print_defects = (
defect_rows[defect_rows['LOSSREASONNAME'] == PRINT_DEFECT]
.groupby('CONTAINERID')['REJECTQTY']
.sum()
.rename('PRINT_DEFECT_QTY')
)
lead_defects = (
defect_rows[defect_rows['LOSSREASONNAME'] == LEAD_DEFECT]
.groupby('CONTAINERID')['REJECTQTY']
.sum()
.rename('LEAD_DEFECT_QTY')
)
lots = lots.set_index('CONTAINERID')
lots = lots.join(print_defects, how='left')
lots = lots.join(lead_defects, how='left')
lots['PRINT_DEFECT_QTY'] = lots['PRINT_DEFECT_QTY'].fillna(0).astype(int)
lots['LEAD_DEFECT_QTY'] = lots['LEAD_DEFECT_QTY'].fillna(0).astype(int)
# Calculate rates
lots['INPUT_QTY'] = lots['TRACKINQTY'].astype(int)
lots['PRINT_DEFECT_RATE'] = (
lots['PRINT_DEFECT_QTY'] / lots['INPUT_QTY'] * 100
).round(4).where(lots['INPUT_QTY'] > 0, 0.0)
lots['LEAD_DEFECT_RATE'] = (
lots['LEAD_DEFECT_QTY'] / lots['INPUT_QTY'] * 100
).round(4).where(lots['INPUT_QTY'] > 0, 0.0)
# Convert to list of dicts
lots = lots.reset_index()
result = []
for _, row in lots.iterrows():
result.append({
'CONTAINERNAME': _safe_str(row.get('CONTAINERNAME')),
'PJ_TYPE': _safe_str(row.get('PJ_TYPE')),
'PRODUCTLINENAME': _safe_str(row.get('PRODUCTLINENAME')),
'WORKFLOW': _safe_str(row.get('WORKFLOW')),
'FINISHEDRUNCARD': _safe_str(row.get('FINISHEDRUNCARD')),
'TMTT_EQUIPMENTNAME': _safe_str(row.get('TMTT_EQUIPMENTNAME')),
'MOLD_EQUIPMENTNAME': _safe_str(row.get('MOLD_EQUIPMENTNAME')),
'INPUT_QTY': _safe_int(row.get('INPUT_QTY')),
'PRINT_DEFECT_QTY': _safe_int(row.get('PRINT_DEFECT_QTY')),
'PRINT_DEFECT_RATE': _safe_float(row.get('PRINT_DEFECT_RATE')),
'LEAD_DEFECT_QTY': _safe_int(row.get('LEAD_DEFECT_QTY')),
'LEAD_DEFECT_RATE': _safe_float(row.get('LEAD_DEFECT_RATE')),
})
return result

View File

@@ -0,0 +1,56 @@
-- Defect Traceability - Downstream Reject Records (Forward Tracing)
-- Get reject records for tracked LOTs at all downstream stations
--
-- Parameters:
-- Dynamically built IN clause for descendant CONTAINERIDs ({{ DESCENDANT_FILTER }})
--
-- Tables used:
-- DWH.DW_MES_LOTREJECTHISTORY (reject records)
--
-- Performance:
-- CONTAINERID has index. Batch IN clause (up to 1000 per query).
SELECT
r.CONTAINERID,
r.WORKCENTERNAME,
CASE
WHEN UPPER(r.WORKCENTERNAME) LIKE '%元件切割%'
OR UPPER(r.WORKCENTERNAME) LIKE '%PKG_SAW%' THEN '元件切割'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%切割%' THEN '切割'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%焊接_DB%'
OR UPPER(r.WORKCENTERNAME) LIKE '%焊_DB_料%'
OR UPPER(r.WORKCENTERNAME) LIKE '%焊_DB%' THEN '焊接_DB'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%焊接_WB%'
OR UPPER(r.WORKCENTERNAME) LIKE '%焊_WB_料%'
OR UPPER(r.WORKCENTERNAME) LIKE '%焊_WB%' THEN '焊接_WB'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%焊接_DW%'
OR UPPER(r.WORKCENTERNAME) LIKE '%焊_DW%'
OR UPPER(r.WORKCENTERNAME) LIKE '%焊_DW_料%' THEN '焊接_DW'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%成型%'
OR UPPER(r.WORKCENTERNAME) LIKE '%成型_料%' THEN '成型'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%去膠%' THEN '去膠'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%水吹砂%' THEN '水吹砂'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%掛鍍%'
OR UPPER(r.WORKCENTERNAME) LIKE '%滾鍍%'
OR UPPER(r.WORKCENTERNAME) LIKE '%條鍍%'
OR UPPER(r.WORKCENTERNAME) LIKE '%電鍍%'
OR UPPER(r.WORKCENTERNAME) LIKE '%補鍍%'
OR UPPER(r.WORKCENTERNAME) LIKE '%TOTAI%'
OR UPPER(r.WORKCENTERNAME) LIKE '%BANDL%' THEN '電鍍'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%移印%' THEN '移印'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%切彎腳%' THEN '切彎腳'
WHEN UPPER(r.WORKCENTERNAME) LIKE '%TMTT%'
OR UPPER(r.WORKCENTERNAME) LIKE '%測試%' THEN '測試'
ELSE NULL
END AS WORKCENTER_GROUP,
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
NVL(r.REJECTQTY, 0)
+ NVL(r.STANDBYQTY, 0)
+ NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0)
+ NVL(r.PROCESSEDQTY, 0) AS REJECT_TOTAL_QTY,
r.TXNDATE
FROM DWH.DW_MES_LOTREJECTHISTORY r
WHERE {{ DESCENDANT_FILTER }}
ORDER BY r.CONTAINERID, r.TXNDATE

View File

@@ -1,93 +1,89 @@
-- Mid-Section Defect Traceability - TMTT Detection Data (Query 1)
-- Returns LOT-level data with TMTT input, ALL defects, and lot metadata
--
-- Parameters:
-- :start_date - Start date (YYYY-MM-DD)
-- :end_date - End date (YYYY-MM-DD)
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (TMTT station records)
-- DWH.DW_MES_LOTREJECTHISTORY (defect records - ALL loss reasons)
-- DWH.DW_MES_CONTAINER (product info + MFGORDERNAME for genealogy)
-- DWH.DW_MES_WIP (WORKFLOWNAME)
--
-- Changes from tmtt_defect/base_data.sql:
-- 1. Removed hardcoded LOSSREASONNAME filter → fetches ALL loss reasons
-- 2. Added MFGORDERNAME from DW_MES_CONTAINER (needed for genealogy batch)
-- 3. Removed MOLD equipment lookup (upstream tracing done separately)
-- 4. Kept existing dedup logic (ROW_NUMBER by CONTAINERID, latest TRACKINTIMESTAMP)
WITH tmtt_records AS (
SELECT /*+ MATERIALIZE */
h.CONTAINERID,
h.EQUIPMENTID AS TMTT_EQUIPMENTID,
h.EQUIPMENTNAME AS TMTT_EQUIPMENTNAME,
h.TRACKINQTY,
h.TRACKINTIMESTAMP,
h.TRACKOUTTIMESTAMP,
h.FINISHEDRUNCARD,
h.SPECNAME,
h.WORKCENTERNAME,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID
ORDER BY h.TRACKINTIMESTAMP DESC, h.TRACKOUTTIMESTAMP DESC NULLS LAST
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE h.TRACKINTIMESTAMP >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND h.TRACKINTIMESTAMP < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND (UPPER(h.WORKCENTERNAME) LIKE '%TMTT%' OR h.WORKCENTERNAME LIKE '%測試%')
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
),
tmtt_deduped AS (
SELECT * FROM tmtt_records WHERE rn = 1
),
tmtt_rejects AS (
SELECT /*+ MATERIALIZE */
r.CONTAINERID,
r.LOSSREASONNAME,
SUM(NVL(r.REJECTQTY, 0) + NVL(r.STANDBYQTY, 0) + NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0) + NVL(r.PROCESSEDQTY, 0)) AS REJECTQTY
FROM DWH.DW_MES_LOTREJECTHISTORY r
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND (UPPER(r.WORKCENTERNAME) LIKE '%TMTT%' OR r.WORKCENTERNAME LIKE '%測試%')
GROUP BY r.CONTAINERID, r.LOSSREASONNAME
),
lot_metadata AS (
SELECT /*+ MATERIALIZE */
c.CONTAINERID,
c.CONTAINERNAME,
c.MFGORDERNAME,
c.PJ_TYPE,
c.PRODUCTLINENAME
FROM DWH.DW_MES_CONTAINER c
WHERE c.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
),
workflow_info AS (
SELECT /*+ MATERIALIZE */
DISTINCT w.CONTAINERID,
w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE w.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
AND w.PRODUCTLINENAME <> '點測'
)
SELECT
t.CONTAINERID,
m.CONTAINERNAME,
m.MFGORDERNAME,
m.PJ_TYPE,
m.PRODUCTLINENAME,
NVL(wf.WORKFLOWNAME, t.SPECNAME) AS WORKFLOW,
t.FINISHEDRUNCARD,
t.TMTT_EQUIPMENTID,
t.TMTT_EQUIPMENTNAME,
t.TRACKINQTY,
t.TRACKINTIMESTAMP,
r.LOSSREASONNAME,
NVL(r.REJECTQTY, 0) AS REJECTQTY
FROM tmtt_deduped t
LEFT JOIN lot_metadata m ON t.CONTAINERID = m.CONTAINERID
LEFT JOIN workflow_info wf ON t.CONTAINERID = wf.CONTAINERID
LEFT JOIN tmtt_rejects r ON t.CONTAINERID = r.CONTAINERID
ORDER BY t.TRACKINTIMESTAMP
-- Defect Traceability - Parameterized Station Detection Data
-- Returns LOT-level data with detection station input, ALL defects, and lot metadata
--
-- Parameters:
-- :start_date - Start date (YYYY-MM-DD)
-- :end_date - End date (YYYY-MM-DD)
-- {{ STATION_FILTER }} - Dynamic LIKE clause for workcenter group (built by Python)
-- {{ STATION_FILTER_REJECTS }} - Same pattern for reject CTE (column alias differs)
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (detection station records)
-- DWH.DW_MES_LOTREJECTHISTORY (defect records - ALL loss reasons)
-- DWH.DW_MES_CONTAINER (product info + MFGORDERNAME for genealogy)
-- DWH.DW_MES_WIP (WORKFLOWNAME)
WITH detection_records AS (
SELECT /*+ MATERIALIZE */
h.CONTAINERID,
h.EQUIPMENTID AS DETECTION_EQUIPMENTID,
h.EQUIPMENTNAME AS DETECTION_EQUIPMENTNAME,
h.TRACKINQTY,
h.TRACKINTIMESTAMP,
h.TRACKOUTTIMESTAMP,
h.FINISHEDRUNCARD,
h.SPECNAME,
h.WORKCENTERNAME,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID
ORDER BY h.TRACKINTIMESTAMP DESC, h.TRACKOUTTIMESTAMP DESC NULLS LAST
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE h.TRACKINTIMESTAMP >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND h.TRACKINTIMESTAMP < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND ({{ STATION_FILTER }})
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
),
detection_deduped AS (
SELECT * FROM detection_records WHERE rn = 1
),
detection_rejects AS (
SELECT /*+ MATERIALIZE */
r.CONTAINERID,
r.LOSSREASONNAME,
SUM(NVL(r.REJECTQTY, 0) + NVL(r.STANDBYQTY, 0) + NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0) + NVL(r.PROCESSEDQTY, 0)) AS REJECTQTY
FROM DWH.DW_MES_LOTREJECTHISTORY r
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND ({{ STATION_FILTER_REJECTS }})
GROUP BY r.CONTAINERID, r.LOSSREASONNAME
),
lot_metadata AS (
SELECT /*+ MATERIALIZE */
c.CONTAINERID,
c.CONTAINERNAME,
c.MFGORDERNAME,
c.PJ_TYPE,
c.PRODUCTLINENAME
FROM DWH.DW_MES_CONTAINER c
WHERE c.CONTAINERID IN (SELECT CONTAINERID FROM detection_deduped)
),
workflow_info AS (
SELECT /*+ MATERIALIZE */
DISTINCT w.CONTAINERID,
w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE w.CONTAINERID IN (SELECT CONTAINERID FROM detection_deduped)
AND w.PRODUCTLINENAME <> '點測'
)
SELECT
t.CONTAINERID,
m.CONTAINERNAME,
m.MFGORDERNAME,
m.PJ_TYPE,
m.PRODUCTLINENAME,
NVL(wf.WORKFLOWNAME, t.SPECNAME) AS WORKFLOW,
t.FINISHEDRUNCARD,
t.DETECTION_EQUIPMENTID,
t.DETECTION_EQUIPMENTNAME,
t.TRACKINQTY,
t.TRACKINTIMESTAMP,
r.LOSSREASONNAME,
NVL(r.REJECTQTY, 0) AS REJECTQTY
FROM detection_deduped t
LEFT JOIN lot_metadata m ON t.CONTAINERID = m.CONTAINERID
LEFT JOIN workflow_info wf ON t.CONTAINERID = wf.CONTAINERID
LEFT JOIN detection_rejects r ON t.CONTAINERID = r.CONTAINERID
ORDER BY t.TRACKINTIMESTAMP

View File

@@ -0,0 +1,87 @@
-- Defect Traceability - Station Detection Data by Container IDs
-- Returns LOT-level data with detection station input, ALL defects, and lot metadata
-- Used in container query mode where seed lots are resolved first.
--
-- Parameters:
-- {{ CONTAINER_IDS }} - Comma-separated quoted CONTAINERID list (built by Python)
-- {{ STATION_FILTER }} - Dynamic LIKE clause for workcenter group (built by Python)
-- {{ STATION_FILTER_REJECTS }} - Same pattern for reject CTE (column alias differs)
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (detection station records)
-- DWH.DW_MES_LOTREJECTHISTORY (defect records - ALL loss reasons)
-- DWH.DW_MES_CONTAINER (product info + MFGORDERNAME for genealogy)
-- DWH.DW_MES_WIP (WORKFLOWNAME)
WITH detection_records AS (
SELECT /*+ MATERIALIZE */
h.CONTAINERID,
h.EQUIPMENTID AS DETECTION_EQUIPMENTID,
h.EQUIPMENTNAME AS DETECTION_EQUIPMENTNAME,
h.TRACKINQTY,
h.TRACKINTIMESTAMP,
h.TRACKOUTTIMESTAMP,
h.FINISHEDRUNCARD,
h.SPECNAME,
h.WORKCENTERNAME,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID
ORDER BY h.TRACKINTIMESTAMP DESC, h.TRACKOUTTIMESTAMP DESC NULLS LAST
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE h.CONTAINERID IN ({{ CONTAINER_IDS }})
AND ({{ STATION_FILTER }})
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
),
detection_deduped AS (
SELECT * FROM detection_records WHERE rn = 1
),
detection_rejects AS (
SELECT /*+ MATERIALIZE */
r.CONTAINERID,
r.LOSSREASONNAME,
SUM(NVL(r.REJECTQTY, 0) + NVL(r.STANDBYQTY, 0) + NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0) + NVL(r.PROCESSEDQTY, 0)) AS REJECTQTY
FROM DWH.DW_MES_LOTREJECTHISTORY r
WHERE r.CONTAINERID IN ({{ CONTAINER_IDS }})
AND ({{ STATION_FILTER_REJECTS }})
GROUP BY r.CONTAINERID, r.LOSSREASONNAME
),
lot_metadata AS (
SELECT /*+ MATERIALIZE */
c.CONTAINERID,
c.CONTAINERNAME,
c.MFGORDERNAME,
c.PJ_TYPE,
c.PRODUCTLINENAME
FROM DWH.DW_MES_CONTAINER c
WHERE c.CONTAINERID IN (SELECT CONTAINERID FROM detection_deduped)
),
workflow_info AS (
SELECT /*+ MATERIALIZE */
DISTINCT w.CONTAINERID,
w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE w.CONTAINERID IN (SELECT CONTAINERID FROM detection_deduped)
AND w.PRODUCTLINENAME <> '點測'
)
SELECT
t.CONTAINERID,
m.CONTAINERNAME,
m.MFGORDERNAME,
m.PJ_TYPE,
m.PRODUCTLINENAME,
NVL(wf.WORKFLOWNAME, t.SPECNAME) AS WORKFLOW,
t.FINISHEDRUNCARD,
t.DETECTION_EQUIPMENTID,
t.DETECTION_EQUIPMENTNAME,
t.TRACKINQTY,
t.TRACKINTIMESTAMP,
r.LOSSREASONNAME,
NVL(r.REJECTQTY, 0) AS REJECTQTY
FROM detection_deduped t
LEFT JOIN lot_metadata m ON t.CONTAINERID = m.CONTAINERID
LEFT JOIN workflow_info wf ON t.CONTAINERID = wf.CONTAINERID
LEFT JOIN detection_rejects r ON t.CONTAINERID = r.CONTAINERID
ORDER BY t.TRACKINTIMESTAMP

View File

@@ -1,16 +1,16 @@
-- Mid-Section Defect Traceability - Upstream Production History (Query 3)
-- Get production history for ancestor LOTs at all stations
--
-- Parameters:
-- Dynamically built IN clause for ancestor CONTAINERIDs
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (53M rows, CONTAINERID indexed → fast)
--
-- Performance:
-- CONTAINERID has index. Batch IN clause (up to 1000 per query).
-- Estimated 1-5s per batch.
--
-- Mid-Section Defect Traceability - Upstream Production History (Query 3)
-- Get production history for ancestor LOTs at all stations
--
-- Parameters:
-- Dynamically built IN clause for ancestor CONTAINERIDs
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (53M rows, CONTAINERID indexed → fast)
--
-- Performance:
-- CONTAINERID has index. Batch IN clause (up to 1000 per query).
-- Estimated 1-5s per batch.
--
WITH ranked_history AS (
SELECT
h.CONTAINERID,
@@ -49,15 +49,16 @@ WITH ranked_history AS (
h.EQUIPMENTNAME,
h.SPECNAME,
h.TRACKINTIMESTAMP,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID, h.WORKCENTERNAME, h.EQUIPMENTNAME
ORDER BY h.TRACKINTIMESTAMP DESC
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE {{ ANCESTOR_FILTER }}
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
)
NVL(h.TRACKINQTY, 0) AS TRACKINQTY,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID, h.WORKCENTERNAME, h.EQUIPMENTNAME
ORDER BY h.TRACKINTIMESTAMP DESC
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE {{ ANCESTOR_FILTER }}
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
)
SELECT
CONTAINERID,
WORKCENTERNAME,
@@ -65,7 +66,8 @@ SELECT
EQUIPMENTID,
EQUIPMENTNAME,
SPECNAME,
TRACKINTIMESTAMP
FROM ranked_history
WHERE rn = 1
ORDER BY CONTAINERID, TRACKINTIMESTAMP
TRACKINTIMESTAMP,
TRACKINQTY
FROM ranked_history
WHERE rn = 1
ORDER BY CONTAINERID, TRACKINTIMESTAMP

View File

@@ -1,116 +0,0 @@
-- TMTT Defect Analysis - Base Data Query
-- Returns LOT-level data with TMTT input, defects (印字/腳型), and MOLD equipment
--
-- Parameters:
-- :start_date - Start date (YYYY-MM-DD)
-- :end_date - End date (YYYY-MM-DD)
--
-- Tables used:
-- DWH.DW_MES_LOTWIPHISTORY (TMTT station records, MOLD station records)
-- DWH.DW_MES_LOTREJECTHISTORY (defect records)
-- DWH.DW_MES_CONTAINER (product info)
-- DWH.DW_MES_WIP (WORKFLOWNAME, filtered by PRODUCTLINENAME <> '點測')
--
-- Notes:
-- - LOSSREASONNAME: '276_腳型不良', '277_印字不良'
-- - TMTT station: WORKCENTERNAME matching 'TMTT' or '測試'
-- - MOLD station: WORKCENTERNAME matching '成型'
-- - Multiple MOLD equipment per LOT: take earliest TRACKINTIMESTAMP
-- - TMTT dedup: one row per CONTAINERID, take latest TRACKINTIMESTAMP
-- - LOTREJECTHISTORY only has EQUIPMENTNAME (no EQUIPMENTID)
-- - WORKFLOW: from DW_MES_WIP.WORKFLOWNAME (exclude PRODUCTLINENAME='點測')
-- - Defect qty = SUM(REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY)
WITH tmtt_records AS (
SELECT /*+ MATERIALIZE */
h.CONTAINERID,
h.EQUIPMENTID AS TMTT_EQUIPMENTID,
h.EQUIPMENTNAME AS TMTT_EQUIPMENTNAME,
h.TRACKINQTY,
h.TRACKINTIMESTAMP,
h.TRACKOUTTIMESTAMP,
h.FINISHEDRUNCARD,
h.SPECNAME,
h.WORKCENTERNAME,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID
ORDER BY h.TRACKINTIMESTAMP DESC, h.TRACKOUTTIMESTAMP DESC NULLS LAST
) AS rn
FROM DWH.DW_MES_LOTWIPHISTORY h
WHERE h.TRACKINTIMESTAMP >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND h.TRACKINTIMESTAMP < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND (UPPER(h.WORKCENTERNAME) LIKE '%TMTT%' OR h.WORKCENTERNAME LIKE '%測試%')
AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL
),
tmtt_deduped AS (
SELECT * FROM tmtt_records WHERE rn = 1
),
tmtt_rejects AS (
SELECT /*+ MATERIALIZE */
r.CONTAINERID,
r.LOSSREASONNAME,
SUM(NVL(r.REJECTQTY, 0) + NVL(r.STANDBYQTY, 0) + NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0) + NVL(r.PROCESSEDQTY, 0)) AS REJECTQTY
FROM DWH.DW_MES_LOTREJECTHISTORY r
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
AND (UPPER(r.WORKCENTERNAME) LIKE '%TMTT%' OR r.WORKCENTERNAME LIKE '%測試%')
AND r.LOSSREASONNAME IN ('276_腳型不良', '277_印字不良')
GROUP BY r.CONTAINERID, r.LOSSREASONNAME
),
mold_records AS (
SELECT /*+ MATERIALIZE */
m.CONTAINERID,
m.EQUIPMENTID AS MOLD_EQUIPMENTID,
m.EQUIPMENTNAME AS MOLD_EQUIPMENTNAME,
ROW_NUMBER() OVER (
PARTITION BY m.CONTAINERID
ORDER BY m.TRACKINTIMESTAMP ASC
) AS mold_rn
FROM DWH.DW_MES_LOTWIPHISTORY m
WHERE m.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
AND (m.WORKCENTERNAME LIKE '%成型%')
AND m.EQUIPMENTID IS NOT NULL
),
mold_deduped AS (
SELECT * FROM mold_records WHERE mold_rn = 1
),
product_info AS (
SELECT /*+ MATERIALIZE */
c.CONTAINERID,
c.CONTAINERNAME,
c.PJ_TYPE,
c.PRODUCTLINENAME
FROM DWH.DW_MES_CONTAINER c
WHERE c.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
),
workflow_info AS (
SELECT /*+ MATERIALIZE */
DISTINCT w.CONTAINERID,
w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE w.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
AND w.PRODUCTLINENAME <> '點測'
)
SELECT
t.CONTAINERID,
p.CONTAINERNAME,
p.PJ_TYPE,
p.PRODUCTLINENAME,
NVL(wf.WORKFLOWNAME, t.SPECNAME) AS WORKFLOW,
t.FINISHEDRUNCARD,
t.TMTT_EQUIPMENTID,
t.TMTT_EQUIPMENTNAME,
t.TRACKINQTY,
t.TRACKINTIMESTAMP,
m.MOLD_EQUIPMENTID,
m.MOLD_EQUIPMENTNAME,
r.LOSSREASONNAME,
NVL(r.REJECTQTY, 0) AS REJECTQTY
FROM tmtt_deduped t
LEFT JOIN product_info p ON t.CONTAINERID = p.CONTAINERID
LEFT JOIN workflow_info wf ON t.CONTAINERID = wf.CONTAINERID
LEFT JOIN mold_deduped m ON t.CONTAINERID = m.CONTAINERID
LEFT JOIN tmtt_rejects r ON t.CONTAINERID = r.CONTAINERID
ORDER BY t.TRACKINTIMESTAMP

View File

@@ -1,19 +0,0 @@
{% extends "_base.html" %}
{% block title %}TMTT 印字腳型不良分析{% endblock %}
{% block head_extra %}
{% set tmtt_defect_css = frontend_asset('tmtt-defect.css') %}
{% if tmtt_defect_css %}
<link rel="stylesheet" href="{{ tmtt_defect_css }}">
{% endif %}
{% endblock %}
{% block content %}
<div id="app"></div>
{% endblock %}
{% block scripts %}
{% set tmtt_defect_js = frontend_asset('tmtt-defect.js') %}
<script type="module" src="{{ tmtt_defect_js }}"></script>
{% endblock %}

View File

@@ -58,7 +58,6 @@ class AppFactoryTests(unittest.TestCase):
"/reject-history",
"/excel-query",
"/query-tool",
"/tmtt-defect",
"/api/wip/overview/summary",
"/api/wip/overview/matrix",
"/api/wip/overview/hold",
@@ -74,7 +73,6 @@ class AppFactoryTests(unittest.TestCase):
"/api/portal/navigation",
"/api/excel-query/upload",
"/api/query-tool/resolve",
"/api/tmtt-defect/analysis",
"/api/reject-history/summary",
}
missing = expected - rules

View File

@@ -57,7 +57,6 @@ def test_g1_route_availability_gate_p0_routes_are_2xx_or_3xx():
"/job-query",
"/excel-query",
"/query-tool",
"/tmtt-defect",
]
statuses = [client.get(route).status_code for route in p0_routes]

View File

@@ -46,7 +46,31 @@ def test_analysis_success(mock_query_analysis):
assert payload['success'] is True
assert payload['data']['detail_total_count'] == 2
assert payload['data']['kpi']['total_input'] == 100
mock_query_analysis.assert_called_once_with('2025-01-01', '2025-01-31', ['A', 'B'])
mock_query_analysis.assert_called_once_with(
'2025-01-01', '2025-01-31', ['A', 'B'], '測試', 'backward',
)
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
def test_analysis_with_station_and_direction(mock_query_analysis):
mock_query_analysis.return_value = {
'kpi': {'detection_lot_count': 50},
'charts': {'by_downstream_station': []},
'daily_trend': [],
'available_loss_reasons': [],
'genealogy_status': 'ready',
'detail': [],
}
client = _client()
response = client.get(
'/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31&station=成型&direction=forward'
)
assert response.status_code == 200
mock_query_analysis.assert_called_once_with(
'2025-01-01', '2025-01-31', None, '成型', 'forward',
)
def test_analysis_missing_dates_returns_400():
@@ -103,6 +127,8 @@ def test_detail_success(mock_query_detail):
'2025-01-01',
'2025-01-31',
None,
'測試',
'backward',
page=2,
page_size=200,
)
@@ -146,7 +172,9 @@ def test_export_success(mock_export_csv):
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'attachment;' in response.headers.get('Content-Disposition', '')
mock_export_csv.assert_called_once_with('2025-01-01', '2025-01-31', ['A', 'B'])
mock_export_csv.assert_called_once_with(
'2025-01-01', '2025-01-31', ['A', 'B'], '測試', 'backward',
)
@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv')
@@ -160,3 +188,20 @@ def test_export_rate_limited_returns_429(_mock_rate_limit, mock_export_csv):
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
mock_export_csv.assert_not_called()
@patch('mes_dashboard.routes.mid_section_defect_routes.query_station_options')
def test_station_options_success(mock_query_station_options):
mock_query_station_options.return_value = [
{'name': '切割', 'order': 0},
{'name': '測試', 'order': 11},
]
client = _client()
response = client.get('/api/mid-section-defect/station-options')
assert response.status_code == 200
payload = response.get_json()
assert payload['success'] is True
assert len(payload['data']) == 2
assert payload['data'][0]['name'] == '切割'

View File

@@ -12,6 +12,7 @@ from mes_dashboard.services.mid_section_defect_service import (
query_analysis,
query_analysis_detail,
query_all_loss_reasons,
query_station_options,
)
@@ -135,9 +136,9 @@ def test_query_all_loss_reasons_cache_miss_queries_and_caches_sorted_values(
@patch('mes_dashboard.services.mid_section_defect_service.try_acquire_lock', return_value=True)
@patch('mes_dashboard.services.mid_section_defect_service._fetch_upstream_history')
@patch('mes_dashboard.services.mid_section_defect_service._resolve_full_genealogy')
@patch('mes_dashboard.services.mid_section_defect_service._fetch_tmtt_data')
@patch('mes_dashboard.services.mid_section_defect_service._fetch_station_detection_data')
def test_trace_aggregation_matches_query_analysis_summary(
mock_fetch_tmtt_data,
mock_fetch_detection_data,
mock_resolve_genealogy,
mock_fetch_upstream_history,
_mock_lock,
@@ -145,7 +146,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
_mock_cache_get,
_mock_cache_set,
):
tmtt_df = pd.DataFrame([
detection_df = pd.DataFrame([
{
'CONTAINERID': 'CID-001',
'CONTAINERNAME': 'LOT-001',
@@ -155,7 +156,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
'WORKFLOW': 'WF-A',
'PRODUCTLINENAME': 'PKG-A',
'PJ_TYPE': 'TYPE-A',
'TMTT_EQUIPMENTNAME': 'TMTT-01',
'DETECTION_EQUIPMENTNAME': 'EQ-01',
'TRACKINTIMESTAMP': '2025-01-10 10:00:00',
'FINISHEDRUNCARD': 'FR-001',
},
@@ -168,7 +169,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
'WORKFLOW': 'WF-B',
'PRODUCTLINENAME': 'PKG-B',
'PJ_TYPE': 'TYPE-B',
'TMTT_EQUIPMENTNAME': 'TMTT-02',
'DETECTION_EQUIPMENTNAME': 'EQ-02',
'TRACKINTIMESTAMP': '2025-01-11 10:00:00',
'FINISHEDRUNCARD': 'FR-002',
},
@@ -211,7 +212,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
}],
}
mock_fetch_tmtt_data.return_value = tmtt_df
mock_fetch_detection_data.return_value = detection_df
mock_resolve_genealogy.return_value = ancestors
mock_fetch_upstream_history.return_value = upstream_normalized
@@ -240,3 +241,13 @@ def test_trace_aggregation_matches_query_analysis_summary(
assert staged_summary['daily_trend'] == summary['daily_trend']
assert staged_summary['charts'].keys() == summary['charts'].keys()
def test_query_station_options_returns_ordered_list():
result = query_station_options()
assert isinstance(result, list)
assert len(result) == 12
assert result[0]['name'] == '切割'
assert result[0]['order'] == 0
assert result[-1]['name'] == '測試'
assert result[-1]['order'] == 11

View File

@@ -226,42 +226,6 @@ def test_query_tool_native_smoke_resolve_history_association(client):
assert associations.get_json()["total"] == 1
def test_tmtt_defect_native_smoke_range_query_and_csv_export(client):
_login_as_admin(client)
shell = client.get("/portal-shell/tmtt-defect?start_date=2026-02-01&end_date=2026-02-11")
assert shell.status_code == 200
page = client.get("/tmtt-defect", follow_redirects=False)
if client.application.config.get("PORTAL_SPA_ENABLED", False):
assert page.status_code == 302
assert page.location.endswith("/portal-shell/tmtt-defect")
else:
assert page.status_code == 200
with (
patch(
"mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis",
return_value={
"kpi": {"total_input": 10},
"charts": {"by_workflow": []},
"detail": [],
},
),
patch(
"mes_dashboard.routes.tmtt_defect_routes.export_csv",
return_value=iter(["LOT_ID,TYPE\n", "LOT001,PRINT\n"]),
),
):
query = client.get("/api/tmtt-defect/analysis?start_date=2026-02-01&end_date=2026-02-11")
assert query.status_code == 200
assert query.get_json()["success"] is True
export = client.get("/api/tmtt-defect/export?start_date=2026-02-01&end_date=2026-02-11")
assert export.status_code == 200
assert "text/csv" in export.content_type
def test_reject_history_native_smoke_query_sections_and_export(client):
_login_as_admin(client)

View File

@@ -116,15 +116,6 @@ class TestTemplateIntegration(unittest.TestCase):
self.assertIn('mes-api.js', html)
self.assertIn('mes-toast-container', html)
def test_tmtt_defect_page_includes_base_scripts(self):
response = self.client.get('/tmtt-defect')
self.assertEqual(response.status_code, 200)
html = response.data.decode('utf-8')
self.assertIn('toast.js', html)
self.assertIn('mes-api.js', html)
self.assertIn('mes-toast-container', html)
class TestPortalDynamicDrawerRendering(unittest.TestCase):
"""Test dynamic portal drawer rendering."""
@@ -340,18 +331,6 @@ class TestMesApiUsageInTemplates(unittest.TestCase):
self.assertIn('/static/dist/query-tool.js', html)
self.assertIn('type="module"', html)
def test_tmtt_defect_page_uses_vite_module(self):
response, final_response, html = _get_response_and_html(self.client, '/tmtt-defect')
if response.status_code == 302:
self.assertTrue(response.location.endswith('/portal-shell/tmtt-defect'))
self.assertEqual(final_response.status_code, 200)
self.assertIn('/static/dist/portal-shell.js', html)
self.assertIn('type="module"', html)
else:
self.assertEqual(response.status_code, 200)
self.assertIn('/static/dist/tmtt-defect.js', html)
self.assertIn('type="module"', html)
class TestViteModuleIntegration(unittest.TestCase):
@@ -377,7 +356,6 @@ class TestViteModuleIntegration(unittest.TestCase):
('/job-query', 'job-query.js'),
('/excel-query', 'excel-query.js'),
('/query-tool', 'query-tool.js'),
('/tmtt-defect', 'tmtt-defect.js'),
]
canonical_routes = {
'/wip-overview': '/portal-shell/wip-overview',
@@ -387,7 +365,6 @@ class TestViteModuleIntegration(unittest.TestCase):
'/resource': '/portal-shell/resource',
'/resource-history': '/portal-shell/resource-history',
'/job-query': '/portal-shell/job-query',
'/tmtt-defect': '/portal-shell/tmtt-defect',
'/tables': '/portal-shell/tables',
'/excel-query': '/portal-shell/excel-query',
'/query-tool': '/portal-shell/query-tool',

View File

@@ -1,146 +0,0 @@
# -*- coding: utf-8 -*-
"""Integration tests for TMTT Defect Analysis API routes."""
import unittest
from unittest.mock import patch
import pandas as pd
class TestTmttDefectAnalysisEndpoint(unittest.TestCase):
"""Test GET /api/tmtt-defect/analysis endpoint."""
def setUp(self):
from mes_dashboard.core import database as db
db._ENGINE = None
from mes_dashboard.app import create_app
self.app = create_app()
self.client = self.app.test_client()
def test_missing_start_date(self):
resp = self.client.get('/api/tmtt-defect/analysis?end_date=2025-01-31')
self.assertEqual(resp.status_code, 400)
data = resp.get_json()
self.assertFalse(data['success'])
def test_missing_end_date(self):
resp = self.client.get('/api/tmtt-defect/analysis?start_date=2025-01-01')
self.assertEqual(resp.status_code, 400)
data = resp.get_json()
self.assertFalse(data['success'])
def test_missing_both_dates(self):
resp = self.client.get('/api/tmtt-defect/analysis')
self.assertEqual(resp.status_code, 400)
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
def test_invalid_date_format(self, mock_query):
mock_query.return_value = {'error': '日期格式無效,請使用 YYYY-MM-DD'}
resp = self.client.get(
'/api/tmtt-defect/analysis?start_date=invalid&end_date=2025-01-31'
)
self.assertEqual(resp.status_code, 400)
data = resp.get_json()
self.assertFalse(data['success'])
self.assertIn('格式', data['error'])
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
def test_exceeds_180_days(self, mock_query):
mock_query.return_value = {'error': '查詢範圍不能超過 180 天'}
resp = self.client.get(
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-12-31'
)
self.assertEqual(resp.status_code, 400)
data = resp.get_json()
self.assertIn('180', data['error'])
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
def test_successful_query(self, mock_query):
mock_query.return_value = {
'kpi': {
'total_input': 1000, 'lot_count': 10,
'print_defect_qty': 5, 'print_defect_rate': 0.5,
'lead_defect_qty': 3, 'lead_defect_rate': 0.3,
},
'charts': {
'by_workflow': [], 'by_package': [], 'by_type': [],
'by_tmtt_machine': [], 'by_mold_machine': [],
},
'detail': [],
}
resp = self.client.get(
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-01-31'
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertTrue(data['success'])
self.assertIn('kpi', data['data'])
self.assertIn('charts', data['data'])
self.assertIn('detail', data['data'])
# Verify separate defect rates
kpi = data['data']['kpi']
self.assertEqual(kpi['print_defect_qty'], 5)
self.assertEqual(kpi['lead_defect_qty'], 3)
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
def test_query_failure_returns_500(self, mock_query):
mock_query.return_value = None
resp = self.client.get(
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-01-31'
)
self.assertEqual(resp.status_code, 500)
class TestTmttDefectExportEndpoint(unittest.TestCase):
"""Test GET /api/tmtt-defect/export endpoint."""
def setUp(self):
from mes_dashboard.core import database as db
db._ENGINE = None
from mes_dashboard.app import create_app
self.app = create_app()
self.client = self.app.test_client()
def test_missing_dates(self):
resp = self.client.get('/api/tmtt-defect/export')
self.assertEqual(resp.status_code, 400)
@patch('mes_dashboard.routes.tmtt_defect_routes.export_csv')
def test_export_csv(self, mock_export):
mock_export.return_value = iter([
'\ufeff',
'LOT ID,TYPE,PACKAGE,WORKFLOW,完工流水碼,TMTT設備,MOLD設備,'
'投入數,印字不良數,印字不良率(%),腳型不良數,腳型不良率(%)\r\n',
])
resp = self.client.get(
'/api/tmtt-defect/export?start_date=2025-01-01&end_date=2025-01-31'
)
self.assertEqual(resp.status_code, 200)
self.assertIn('text/csv', resp.content_type)
self.assertIn('attachment', resp.headers.get('Content-Disposition', ''))
class TestTmttDefectPageRoute(unittest.TestCase):
"""Test page route."""
def setUp(self):
from mes_dashboard.core import database as db
db._ENGINE = None
from mes_dashboard.app import create_app
self.app = create_app()
self.client = self.app.test_client()
def test_page_requires_auth_when_dev(self):
"""Page in 'dev' status returns 403 for unauthenticated users."""
resp = self.client.get('/tmtt-defect')
# 403 because page_status is 'dev' and user is not admin
self.assertIn(resp.status_code, [200, 403])
if __name__ == '__main__':
unittest.main()

View File

@@ -1,287 +0,0 @@
# -*- coding: utf-8 -*-
"""Unit tests for TMTT Defect Analysis Service."""
import unittest
from unittest.mock import patch, MagicMock
import pandas as pd
from mes_dashboard.services.tmtt_defect_service import (
_build_kpi,
_build_chart_data,
_build_all_charts,
_build_detail_table,
_validate_date_range,
query_tmtt_defect_analysis,
PRINT_DEFECT,
LEAD_DEFECT,
)
def _make_df(rows):
"""Helper to create test DataFrame from list of dicts."""
cols = [
'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME',
'WORKFLOW', 'FINISHEDRUNCARD', 'TMTT_EQUIPMENTID',
'TMTT_EQUIPMENTNAME', 'TRACKINQTY', 'TRACKINTIMESTAMP',
'MOLD_EQUIPMENTID', 'MOLD_EQUIPMENTNAME',
'LOSSREASONNAME', 'REJECTQTY',
]
if not rows:
return pd.DataFrame(columns=cols)
df = pd.DataFrame(rows)
for c in cols:
if c not in df.columns:
df[c] = None
return df
class TestValidateDateRange(unittest.TestCase):
"""Test date range validation."""
def test_valid_range(self):
self.assertIsNone(_validate_date_range('2025-01-01', '2025-01-31'))
def test_invalid_format(self):
result = _validate_date_range('2025/01/01', '2025-01-31')
self.assertIn('格式', result)
def test_start_after_end(self):
result = _validate_date_range('2025-02-01', '2025-01-01')
self.assertIn('不能晚於', result)
def test_exceeds_max_days(self):
result = _validate_date_range('2025-01-01', '2025-12-31')
self.assertIn('180', result)
def test_exactly_max_days(self):
self.assertIsNone(_validate_date_range('2025-01-01', '2025-06-30'))
class TestBuildKpi(unittest.TestCase):
"""Test KPI calculation with separate defect rates."""
def test_empty_dataframe(self):
df = _make_df([])
kpi = _build_kpi(df)
self.assertEqual(kpi['total_input'], 0)
self.assertEqual(kpi['lot_count'], 0)
self.assertEqual(kpi['print_defect_qty'], 0)
self.assertEqual(kpi['lead_defect_qty'], 0)
self.assertEqual(kpi['print_defect_rate'], 0.0)
self.assertEqual(kpi['lead_defect_rate'], 0.0)
def test_single_lot_no_defects(self):
df = _make_df([{
'CONTAINERID': 'A001', 'TRACKINQTY': 100,
'LOSSREASONNAME': None, 'REJECTQTY': 0,
}])
kpi = _build_kpi(df)
self.assertEqual(kpi['total_input'], 100)
self.assertEqual(kpi['lot_count'], 1)
self.assertEqual(kpi['print_defect_qty'], 0)
self.assertEqual(kpi['lead_defect_qty'], 0)
def test_separate_defect_rates(self):
"""A LOT with both print and lead defects - rates calculated separately."""
df = _make_df([
{'CONTAINERID': 'A001', 'TRACKINQTY': 10000,
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 50},
{'CONTAINERID': 'A001', 'TRACKINQTY': 10000,
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 30},
])
kpi = _build_kpi(df)
# INPUT should be deduplicated (10000, not 20000)
self.assertEqual(kpi['total_input'], 10000)
self.assertEqual(kpi['lot_count'], 1)
self.assertEqual(kpi['print_defect_qty'], 50)
self.assertEqual(kpi['lead_defect_qty'], 30)
self.assertAlmostEqual(kpi['print_defect_rate'], 0.5, places=4)
self.assertAlmostEqual(kpi['lead_defect_rate'], 0.3, places=4)
def test_multiple_lots(self):
df = _make_df([
{'CONTAINERID': 'A001', 'TRACKINQTY': 100,
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2},
{'CONTAINERID': 'A002', 'TRACKINQTY': 200,
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 1},
{'CONTAINERID': 'A003', 'TRACKINQTY': 300,
'LOSSREASONNAME': None, 'REJECTQTY': 0},
])
kpi = _build_kpi(df)
self.assertEqual(kpi['total_input'], 600)
self.assertEqual(kpi['lot_count'], 3)
self.assertEqual(kpi['print_defect_qty'], 2)
self.assertEqual(kpi['lead_defect_qty'], 1)
class TestBuildChartData(unittest.TestCase):
"""Test Pareto chart data aggregation."""
def test_empty_dataframe(self):
df = _make_df([])
result = _build_chart_data(df, 'PJ_TYPE')
self.assertEqual(result, [])
def test_single_dimension_value(self):
df = _make_df([
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5},
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3},
])
result = _build_chart_data(df, 'PJ_TYPE')
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['name'], 'TypeA')
self.assertEqual(result[0]['print_defect_qty'], 5)
self.assertEqual(result[0]['lead_defect_qty'], 3)
self.assertEqual(result[0]['total_defect_qty'], 8)
self.assertAlmostEqual(result[0]['cumulative_pct'], 100.0)
def test_null_dimension_grouped_as_unknown(self):
df = _make_df([
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'MOLD_EQUIPMENTNAME': None,
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2},
])
result = _build_chart_data(df, 'MOLD_EQUIPMENTNAME')
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['name'], '(未知)')
def test_sorted_by_total_defect_desc(self):
df = _make_df([
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1},
{'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 10},
])
result = _build_chart_data(df, 'PJ_TYPE')
self.assertEqual(result[0]['name'], 'TypeB')
self.assertEqual(result[1]['name'], 'TypeA')
def test_cumulative_percentage(self):
df = _make_df([
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 6},
{'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 4},
])
result = _build_chart_data(df, 'PJ_TYPE')
# TypeA: 6/10 = 60%, TypeB: cumulative 10/10 = 100%
self.assertAlmostEqual(result[0]['cumulative_pct'], 60.0)
self.assertAlmostEqual(result[1]['cumulative_pct'], 100.0)
class TestBuildAllCharts(unittest.TestCase):
"""Test all 5 chart dimensions are built."""
def test_returns_all_dimensions(self):
df = _make_df([{
'CONTAINERID': 'A001', 'TRACKINQTY': 100,
'WORKFLOW': 'WF1', 'PRODUCTLINENAME': 'PKG1',
'PJ_TYPE': 'T1', 'TMTT_EQUIPMENTNAME': 'TMTT-1',
'MOLD_EQUIPMENTNAME': 'MOLD-1',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1,
}])
charts = _build_all_charts(df)
self.assertIn('by_workflow', charts)
self.assertIn('by_package', charts)
self.assertIn('by_type', charts)
self.assertIn('by_tmtt_machine', charts)
self.assertIn('by_mold_machine', charts)
class TestBuildDetailTable(unittest.TestCase):
"""Test detail table building."""
def test_empty_dataframe(self):
df = _make_df([])
result = _build_detail_table(df)
self.assertEqual(result, [])
def test_single_lot_aggregated(self):
"""LOT with both defect types should produce one row."""
df = _make_df([
{'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5},
{'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3},
])
result = _build_detail_table(df)
self.assertEqual(len(result), 1)
row = result[0]
self.assertEqual(row['CONTAINERNAME'], 'LOT-001')
self.assertEqual(row['INPUT_QTY'], 100)
self.assertEqual(row['PRINT_DEFECT_QTY'], 5)
self.assertEqual(row['LEAD_DEFECT_QTY'], 3)
self.assertAlmostEqual(row['PRINT_DEFECT_RATE'], 5.0, places=4)
self.assertAlmostEqual(row['LEAD_DEFECT_RATE'], 3.0, places=4)
def test_lot_with_no_defects(self):
df = _make_df([{
'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
'TRACKINQTY': 100, 'PJ_TYPE': 'T1',
'LOSSREASONNAME': None, 'REJECTQTY': 0,
}])
result = _build_detail_table(df)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['PRINT_DEFECT_QTY'], 0)
self.assertEqual(result[0]['LEAD_DEFECT_QTY'], 0)
class TestQueryTmttDefectAnalysis(unittest.TestCase):
"""Test the main entry point function."""
def setUp(self):
from mes_dashboard.core import database as db
db._ENGINE = None
@patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None)
@patch('mes_dashboard.services.tmtt_defect_service.cache_set')
@patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data')
def test_valid_query(self, mock_fetch, mock_cache_set, mock_cache_get):
mock_fetch.return_value = _make_df([{
'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2,
}])
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
self.assertIn('kpi', result)
self.assertIn('charts', result)
self.assertIn('detail', result)
self.assertNotIn('error', result)
mock_cache_set.assert_called_once()
def test_invalid_dates(self):
result = query_tmtt_defect_analysis('invalid', '2025-01-31')
self.assertIn('error', result)
def test_exceeds_max_days(self):
result = query_tmtt_defect_analysis('2025-01-01', '2025-12-31')
self.assertIn('error', result)
self.assertIn('180', result['error'])
@patch('mes_dashboard.services.tmtt_defect_service.cache_get')
def test_cache_hit(self, mock_cache_get):
cached_data = {'kpi': {}, 'charts': {}, 'detail': []}
mock_cache_get.return_value = cached_data
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
self.assertEqual(result, cached_data)
@patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None)
@patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data', return_value=None)
def test_query_failure(self, mock_fetch, mock_cache_get):
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
self.assertIsNone(result)
if __name__ == '__main__':
unittest.main()