diff --git a/.env.example b/.env.example index 94f7cac..d6a1bfa 100644 --- a/.env.example +++ b/.env.example @@ -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 # ============================================================ diff --git a/data/modernization_feature_flags.json b/data/modernization_feature_flags.json index c01088d..dd14e4d 100644 --- a/data/modernization_feature_flags.json +++ b/data/modernization_feature_flags.json @@ -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} } } diff --git a/data/page_status.json b/data/page_status.json index b094ae6..68e5b26 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -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", diff --git a/docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json b/docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json index d8681e8..443b5ba 100644 --- a/docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json +++ b/docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json @@ -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"], diff --git a/docs/migration/full-modernization-architecture-blueprint/exception_registry.json b/docs/migration/full-modernization-architecture-blueprint/exception_registry.json index 47ce49a..1e0afd1 100644 --- a/docs/migration/full-modernization-architecture-blueprint/exception_registry.json +++ b/docs/migration/full-modernization-architecture-blueprint/exception_registry.json @@ -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", diff --git a/docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json b/docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json index ab9ae30..1066dbc 100644 --- a/docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json +++ b/docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json @@ -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": [] }, diff --git a/docs/migration/full-modernization-architecture-blueprint/quality_gate_report.json b/docs/migration/full-modernization-architecture-blueprint/quality_gate_report.json new file mode 100644 index 0000000..c072630 --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/quality_gate_report.json @@ -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 +} diff --git a/docs/migration/full-modernization-architecture-blueprint/route_contracts.json b/docs/migration/full-modernization-architecture-blueprint/route_contracts.json index 9bd63cf..55821ec 100644 --- a/docs/migration/full-modernization-architecture-blueprint/route_contracts.json +++ b/docs/migration/full-modernization-architecture-blueprint/route_contracts.json @@ -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", diff --git a/docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json b/docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json index bcb00b2..9b9fa08 100644 --- a/docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json +++ b/docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json @@ -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" }, diff --git a/docs/migration/portal-no-iframe/baseline_drawer_visibility.json b/docs/migration/portal-no-iframe/baseline_drawer_visibility.json index cf834dc..49524ef 100644 --- a/docs/migration/portal-no-iframe/baseline_drawer_visibility.json +++ b/docs/migration/portal-no-iframe/baseline_drawer_visibility.json @@ -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 } ] } diff --git a/frontend/src/mid-section-defect/App.vue b/frontend/src/mid-section-defect/App.vue index 640e4b9..e91dbb8 100644 --- a/frontend/src/mid-section-defect/App.vue +++ b/frontend/src/mid-section-defect/App.vue @@ -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(); diff --git a/frontend/src/mid-section-defect/components/DetailTable.vue b/frontend/src/mid-section-defect/components/DetailTable.vue index 24f0bd2..26bfe42 100644 --- a/frontend/src/mid-section-defect/components/DetailTable.vue +++ b/frontend/src/mid-section-defect/components/DetailTable.vue @@ -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) { - + {{ formatCell(row[col.key], col) }} - 暫無資料 + 暫無資料 diff --git a/frontend/src/mid-section-defect/components/FilterBar.vue b/frontend/src/mid-section-defect/components/FilterBar.vue index 719bf28..894d813 100644 --- a/frontend/src/mid-section-defect/components/FilterBar.vue +++ b/frontend/src/mid-section-defect/components/FilterBar.vue @@ -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) { diff --git a/frontend/src/mid-section-defect/components/KpiCards.vue b/frontend/src/mid-section-defect/components/KpiCards.vue index a813a49..fc5d52c 100644 --- a/frontend/src/mid-section-defect/components/KpiCards.vue +++ b/frontend/src/mid-section-defect/components/KpiCards.vue @@ -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(); diff --git a/frontend/src/mid-section-defect/components/MultiSelect.vue b/frontend/src/mid-section-defect/components/MultiSelect.vue index e89b23f..0b25c22 100644 --- a/frontend/src/mid-section-defect/components/MultiSelect.vue +++ b/frontend/src/mid-section-defect/components/MultiSelect.vue @@ -1,5 +1,5 @@ - - diff --git a/frontend/src/tmtt-defect/components/TmttChartCard.vue b/frontend/src/tmtt-defect/components/TmttChartCard.vue deleted file mode 100644 index 7667444..0000000 --- a/frontend/src/tmtt-defect/components/TmttChartCard.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - diff --git a/frontend/src/tmtt-defect/components/TmttDetailTable.vue b/frontend/src/tmtt-defect/components/TmttDetailTable.vue deleted file mode 100644 index 4770338..0000000 --- a/frontend/src/tmtt-defect/components/TmttDetailTable.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/frontend/src/tmtt-defect/components/TmttKpiCards.vue b/frontend/src/tmtt-defect/components/TmttKpiCards.vue deleted file mode 100644 index b6e8deb..0000000 --- a/frontend/src/tmtt-defect/components/TmttKpiCards.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/frontend/src/tmtt-defect/composables/useTmttDefectData.js b/frontend/src/tmtt-defect/composables/useTmttDefectData.js deleted file mode 100644 index 05d8136..0000000 --- a/frontend/src/tmtt-defect/composables/useTmttDefectData.js +++ /dev/null @@ -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, - }; -} diff --git a/frontend/src/tmtt-defect/main.js b/frontend/src/tmtt-defect/main.js deleted file mode 100644 index 0119637..0000000 --- a/frontend/src/tmtt-defect/main.js +++ /dev/null @@ -1,7 +0,0 @@ -import { createApp } from 'vue'; - -import '../styles/tailwind.css'; -import App from './App.vue'; -import './style.css'; - -createApp(App).mount('#app'); diff --git a/frontend/src/tmtt-defect/style.css b/frontend/src/tmtt-defect/style.css deleted file mode 100644 index 354251b..0000000 --- a/frontend/src/tmtt-defect/style.css +++ /dev/null @@ -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; - } -} diff --git a/frontend/tests/portal-shell-parity-table-chart-matrix.test.js b/frontend/tests/portal-shell-parity-table-chart-matrix.test.js index ed4c561..67d1704 100644 --- a/frontend/tests/portal-shell-parity-table-chart-matrix.test.js +++ b/frontend/tests/portal-shell-parity-table-chart-matrix.test.js @@ -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', () => { diff --git a/frontend/tests/portal-shell-route-query.test.js b/frontend/tests/portal-shell-route-query.test.js index ad1245f..4c805c9 100644 --- a/frontend/tests/portal-shell-route-query.test.js +++ b/frontend/tests/portal-shell-route-query.test.js @@ -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', ); }); diff --git a/frontend/tests/portal-shell-wave-a-smoke.test.js b/frontend/tests/portal-shell-wave-a-smoke.test.js index 7d9542e..568bf6a 100644 --- a/frontend/tests/portal-shell-wave-a-smoke.test.js +++ b/frontend/tests/portal-shell-wave-a-smoke.test.js @@ -15,7 +15,6 @@ const WAVE_A_ROUTES = Object.freeze([ '/resource', '/resource-history', '/qc-gate', - '/tmtt-defect', ]); const WAVE_B_NATIVE_ROUTES = Object.freeze([ diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 35fc4f4..bd33437 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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') diff --git a/openspec/changes/archive/2026-02-24-full-line-defect-trace/.openspec.yaml b/openspec/changes/archive/2026-02-24-full-line-defect-trace/.openspec.yaml new file mode 100644 index 0000000..eac8ef7 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-full-line-defect-trace/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-23 diff --git a/openspec/changes/archive/2026-02-24-full-line-defect-trace/design.md b/openspec/changes/archive/2026-02-24-full-line-defect-trace/design.md new file mode 100644 index 0000000..32e210e --- /dev/null +++ b/openspec/changes/archive/2026-02-24-full-line-defect-trace/design.md @@ -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. diff --git a/openspec/changes/archive/2026-02-24-full-line-defect-trace/proposal.md b/openspec/changes/archive/2026-02-24-full-line-defect-trace/proposal.md new file mode 100644 index 0000000..87064f0 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-full-line-defect-trace/proposal.md @@ -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 註冊 diff --git a/openspec/changes/archive/2026-02-24-full-line-defect-trace/specs/defect-trace-bidirectional-ui/spec.md b/openspec/changes/archive/2026-02-24-full-line-defect-trace/specs/defect-trace-bidirectional-ui/spec.md new file mode 100644 index 0000000..3a2c868 --- /dev/null +++ b/openspec/changes/archive/2026-02-24-full-line-defect-trace/specs/defect-trace-bidirectional-ui/spec.md @@ -0,0 +1,94 @@ +## ADDED Requirements + +### Requirement: FilterBar SHALL include station dropdown +FilterBar SHALL display a `` 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% diff --git a/scripts/check_full_modernization_gates.py b/scripts/check_full_modernization_gates.py index 6704f97..887ad1e 100755 --- a/scripts/check_full_modernization_gates.py +++ b/scripts/check_full_modernization_gates.py @@ -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"], diff --git a/scripts/generate_portal_shell_route_view_baseline.py b/scripts/generate_portal_shell_route_view_baseline.py index 6ec2655..235270f 100644 --- a/scripts/generate_portal_shell_route_view_baseline.py +++ b/scripts/generate_portal_shell_route_view_baseline.py @@ -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", } diff --git a/scripts/record_portal_performance_baseline.py b/scripts/record_portal_performance_baseline.py index ca5e280..59dcc34 100644 --- a/scripts/record_portal_performance_baseline.py +++ b/scripts/record_portal_performance_baseline.py @@ -138,7 +138,6 @@ def main() -> int: "/job-query", "/excel-query", "/query-tool", - "/tmtt-defect", ] legacy = _measure_routes(legacy_routes, portal_spa_enabled=False) diff --git a/scripts/start_server.sh b/scripts/start_server.sh index c3a354e..6c3d012 100755 --- a/scripts/start_server.sh +++ b/scripts/start_server.sh @@ -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" ) diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index 054feac..0d9f19f 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -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.""" diff --git a/src/mes_dashboard/routes/__init__.py b/src/mes_dashboard/routes/__init__.py index 44d46f4..d43f307 100644 --- a/src/mes_dashboard/routes/__init__.py +++ b/src/mes_dashboard/routes/__init__.py @@ -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', diff --git a/src/mes_dashboard/routes/mid_section_defect_routes.py b/src/mes_dashboard/routes/mid_section_defect_routes.py index 652d840..fc6e1fc 100644 --- a/src/mes_dashboard/routes/mid_section_defect_routes.py +++ b/src/mes_dashboard/routes/mid_section_defect_routes.py @@ -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}', diff --git a/src/mes_dashboard/routes/tmtt_defect_routes.py b/src/mes_dashboard/routes/tmtt_defect_routes.py deleted file mode 100644 index 78b11fc..0000000 --- a/src/mes_dashboard/routes/tmtt_defect_routes.py +++ /dev/null @@ -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' - } - ) diff --git a/src/mes_dashboard/routes/trace_routes.py b/src/mes_dashboard/routes/trace_routes.py index 9cb35a1..ab3ea72 100644 --- a/src/mes_dashboard/routes/trace_routes.py +++ b/src/mes_dashboard/routes/trace_routes.py @@ -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: diff --git a/src/mes_dashboard/services/event_fetcher.py b/src/mes_dashboard/services/event_fetcher.py index a4f3481..30afe42 100644 --- a/src/mes_dashboard/services/event_fetcher.py +++ b/src/mes_dashboard/services/event_fetcher.py @@ -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"]) diff --git a/src/mes_dashboard/services/mid_section_defect_service.py b/src/mes_dashboard/services/mid_section_defect_service.py index 8f92b98..9ee42b2 100644 --- a/src/mes_dashboard/services/mid_section_defect_service.py +++ b/src/mes_dashboard/services/mid_section_defect_service.py @@ -1,21 +1,35 @@ # -*- coding: utf-8 -*- -"""Mid-Section Defect Traceability Analysis Service. +"""Defect Traceability Analysis Service. -Reverse traceability from TMTT (test) station back to upstream production stations. -Traces LOT genealogy (splits + merges) to attribute TMTT defects to upstream machines. +Bidirectional traceability from any detection station: + - Backward: detection station defects → trace upstream → attribute to upstream machines + - Forward: detection station defects → trace surviving lots downstream → downstream reject rates -Data Pipeline: - Query 1: TMTT Detection → TMTT lots with ALL loss reasons - Query 2a: Split Chain BFS → CONTAINER.SPLITFROMID upward traversal - Query 2b: Merge Expansion → COMBINEDASSYLOTS reverse merge lookup - Query 3: Upstream History → LOTWIPHISTORY for ALL ancestor CONTAINERIDs - Python: Resolve ancestors → Attribute defects → Aggregate +Data Pipeline (Backward): + Query 1: Station Detection → detection lots with ALL loss reasons (parameterized station) + Query 2a: Split Chain BFS → CONTAINER.SPLITFROMID upward traversal + Query 2b: Merge Expansion → COMBINEDASSYLOTS reverse merge lookup + Query 3: Upstream History → LOTWIPHISTORY for ALL ancestor CONTAINERIDs + Python: Resolve ancestors → Attribute defects → Aggregate -Attribution Method (Sum): +Data Pipeline (Forward): + Query 1: Station Detection → detection lots with ALL loss reasons (parameterized station) + Query 2: Forward Lineage → resolve_forward_tree for descendants + Query 3: WIP History → LOTWIPHISTORY for ALL tracked CONTAINERIDs + Query 4: Downstream Rejects → LOTREJECTHISTORY for downstream stations + Python: Forward attribution → Aggregate + +Attribution Method (Backward - Sum): For upstream machine M at station S: - attributed_rejectqty = SUM(TMTT REJECTQTY for all linked TMTT lots) - attributed_trackinqty = SUM(TMTT TRACKINQTY for all linked TMTT lots) + attributed_rejectqty = SUM(detection REJECTQTY for all linked lots) + attributed_trackinqty = SUM(detection TRACKINQTY for all linked lots) rate = attributed_rejectqty / attributed_trackinqty × 100 + +Attribution Method (Forward): + For downstream station Y (order > detection station order): + total_input = SUM(TRACKINQTY for lots reaching Y) + total_reject = SUM(REJECT_TOTAL_QTY at Y) + rate = total_reject / total_input × 100 """ import csv @@ -23,8 +37,10 @@ import hashlib import io import logging import math +import os import time from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor from datetime import datetime from typing import Optional, Dict, List, Any, Set, Tuple, Generator @@ -36,14 +52,17 @@ from mes_dashboard.core.redis_client import try_acquire_lock, release_lock from mes_dashboard.sql import SQLLoader from mes_dashboard.services.event_fetcher import EventFetcher from mes_dashboard.services.lineage_engine import LineageEngine +from mes_dashboard.config.workcenter_groups import WORKCENTER_GROUPS, get_group_order logger = logging.getLogger('mes_dashboard.mid_section_defect') # Constants MAX_QUERY_DAYS = 180 -CACHE_TTL_TMTT = 300 # 5 min for TMTT detection data +CACHE_TTL_DETECTION = 300 # 5 min for detection data CACHE_TTL_LOSS_REASONS = 86400 # 24h for loss reason list (daily sync) +FORWARD_PIPELINE_MAX_WORKERS = int(os.getenv('FORWARD_PIPELINE_MAX_WORKERS', '2')) + # Distributed lock settings for query_analysis cold-cache path ANALYSIS_LOCK_TTL_SECONDS = 120 ANALYSIS_LOCK_WAIT_TIMEOUT_SECONDS = 90 @@ -52,24 +71,32 @@ ANALYSIS_LOCK_POLL_INTERVAL_SECONDS = 0.5 # Top N for chart display (rest grouped as "其他") TOP_N = 10 -# Dimension column mapping for attribution charts +# Dimension column mapping for backward attribution charts DIMENSION_MAP = { 'by_station': 'WORKCENTER_GROUP', 'by_machine': 'EQUIPMENT_NAME', 'by_workflow': 'WORKFLOW', 'by_package': 'PRODUCTLINENAME', 'by_pj_type': 'PJ_TYPE', - 'by_tmtt_machine': 'TMTT_EQUIPMENTNAME', + 'by_detection_machine': 'DETECTION_EQUIPMENTNAME', } -# CSV export column config -CSV_COLUMNS = [ +# Forward dimension column mapping +FORWARD_DIMENSION_MAP = { + 'by_downstream_station': 'WORKCENTER_GROUP', + 'by_downstream_machine': 'EQUIPMENT_NAME', + 'by_downstream_loss_reason': 'LOSSREASONNAME', + 'by_detection_machine': 'DETECTION_EQUIPMENTNAME', +} + +# CSV export column config (backward) +CSV_COLUMNS_BACKWARD = [ ('CONTAINERNAME', 'LOT ID'), ('PJ_TYPE', 'TYPE'), ('PRODUCTLINENAME', 'PACKAGE'), ('WORKFLOW', 'WORKFLOW'), ('FINISHEDRUNCARD', '完工流水碼'), - ('TMTT_EQUIPMENTNAME', 'TMTT設備'), + ('DETECTION_EQUIPMENTNAME', '偵測設備'), ('INPUT_QTY', '投入數'), ('LOSS_REASON', '不良原因'), ('DEFECT_QTY', '不良數'), @@ -78,6 +105,24 @@ CSV_COLUMNS = [ ('UPSTREAM_MACHINES', '上游機台'), ] +# CSV export column config (forward) +CSV_COLUMNS_FORWARD = [ + ('CONTAINERNAME', 'LOT ID'), + ('PJ_TYPE', 'TYPE'), + ('PRODUCTLINENAME', 'PACKAGE'), + ('WORKFLOW', 'WORKFLOW'), + ('DETECTION_EQUIPMENTNAME', '偵測設備'), + ('INPUT_QTY', '偵測投入'), + ('DEFECT_QTY', '偵測不良'), + ('DOWNSTREAM_STATIONS', '下游到達站數'), + ('DOWNSTREAM_REJECTS', '下游不良總數'), + ('DOWNSTREAM_REJECT_RATE', '下游不良率(%)'), + ('WORST_DOWNSTREAM', '最差下游站'), +] + +# Valid direction values +VALID_DIRECTIONS = ('backward', 'forward') + # ============================================================ # Public API @@ -87,18 +132,22 @@ def query_analysis( start_date: str, end_date: str, loss_reasons: Optional[List[str]] = None, + station: str = '測試', + direction: str = 'backward', ) -> Optional[Dict[str, Any]]: - """Main entry point for mid-section defect traceability analysis. + """Main entry point for defect traceability analysis. Args: start_date: Start date (YYYY-MM-DD) end_date: End date (YYYY-MM-DD) loss_reasons: Optional list of loss reasons to filter (None = all) + station: Workcenter group name for detection station (default '測試') + direction: 'backward' or 'forward' Returns: Dict with kpi, charts, detail, available_loss_reasons, genealogy_status. """ - error = _validate_date_range(start_date, end_date) + error = _validate_params(start_date, end_date, station, direction) if error: return {'error': error} @@ -109,6 +158,8 @@ def query_analysis( 'start_date': start_date, 'end_date': end_date, 'loss_reasons': sorted(loss_reasons) if loss_reasons else None, + 'station': station, + 'direction': direction, }, ) cached = cache_get(cache_key) @@ -144,76 +195,22 @@ def query_analysis( return cached try: - # Stage 1: TMTT detection data - tmtt_df = _fetch_tmtt_data(start_date, end_date) - if tmtt_df is None: - return None - if tmtt_df.empty: - return _empty_result() - - # Extract available loss reasons before filtering - available_loss_reasons = sorted( - tmtt_df.loc[tmtt_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] - .dropna().unique().tolist() - ) - - # Apply loss reason filter if specified - if loss_reasons: - filtered_df = tmtt_df[ - (tmtt_df['LOSSREASONNAME'].isin(loss_reasons)) - | (tmtt_df['REJECTQTY'] == 0) - | (tmtt_df['LOSSREASONNAME'].isna()) - ].copy() + if direction == 'forward': + result = _run_forward_pipeline( + start_date, end_date, station, loss_reasons, + ) else: - filtered_df = tmtt_df + result = _run_backward_pipeline( + start_date, end_date, station, loss_reasons, + ) - # Stage 2: Genealogy resolution (split chain + merge expansion) - tmtt_cids = tmtt_df['CONTAINERID'].unique().tolist() - tmtt_names = {} - for _, r in tmtt_df.drop_duplicates('CONTAINERID').iterrows(): - tmtt_names[r['CONTAINERID']] = _safe_str(r.get('CONTAINERNAME')) + if result is None: + return None - ancestors = {} - genealogy_status = 'ready' - - if tmtt_cids: - try: - ancestors = _resolve_full_genealogy(tmtt_cids, tmtt_names) - except Exception as exc: - logger.error(f"Genealogy resolution failed: {exc}", exc_info=True) - genealogy_status = 'error' - - # Stage 3: Upstream history for ALL CIDs (TMTT lots + ancestors) - all_query_cids = set(tmtt_cids) - for anc_set in ancestors.values(): - all_query_cids.update(anc_set) - # Filter out any non-string values (NaN/None from pandas) - all_query_cids = {c for c in all_query_cids if isinstance(c, str) and c} - - upstream_by_cid = {} - if all_query_cids: - try: - upstream_by_cid = _fetch_upstream_history(list(all_query_cids)) - except Exception as exc: - logger.error(f"Upstream history query failed: {exc}", exc_info=True) - genealogy_status = 'error' - tmtt_data = _build_tmtt_lookup(filtered_df) - attribution = _attribute_defects( - tmtt_data, ancestors, upstream_by_cid, loss_reasons, - ) - - result = { - 'kpi': _build_kpi(filtered_df, attribution, loss_reasons), - 'available_loss_reasons': available_loss_reasons, - 'charts': _build_all_charts(attribution, tmtt_data), - 'daily_trend': _build_daily_trend(filtered_df, loss_reasons), - 'detail': _build_detail_table(filtered_df, ancestors, upstream_by_cid), - 'genealogy_status': genealogy_status, - } - - # Only cache successful results (don't cache upstream errors) + # Only cache successful results + genealogy_status = result.get('genealogy_status', 'ready') if genealogy_status == 'ready': - cache_set(cache_key, result, ttl=CACHE_TTL_TMTT) + cache_set(cache_key, result, ttl=CACHE_TTL_DETECTION) return result finally: if lock_acquired: @@ -259,20 +256,21 @@ def parse_loss_reasons_param(loss_reasons: Any) -> Optional[List[str]]: def resolve_trace_seed_lots( start_date: str, end_date: str, + station: str = '測試', ) -> Optional[Dict[str, Any]]: """Resolve seed lots for staged mid-section trace API.""" error = _validate_date_range(start_date, end_date) if error: return {'error': error} - tmtt_df = _fetch_tmtt_data(start_date, end_date) - if tmtt_df is None: + detection_df = _fetch_station_detection_data(start_date, end_date, station) + if detection_df is None: return None - if tmtt_df.empty: + if detection_df.empty: return {'seeds': [], 'seed_count': 0} seeds = [] - unique_rows = tmtt_df.drop_duplicates(subset=['CONTAINERID']) + unique_rows = detection_df.drop_duplicates(subset=['CONTAINERID']) for _, row in unique_rows.iterrows(): cid = _safe_str(row.get('CONTAINERID')) if not cid: @@ -292,26 +290,40 @@ def resolve_trace_seed_lots( def build_trace_aggregation_from_events( - start_date: str, - end_date: str, + start_date: Optional[str], + end_date: Optional[str], *, loss_reasons: Optional[List[str]] = None, seed_container_ids: Optional[List[str]] = None, lineage_ancestors: Optional[Dict[str, Any]] = None, upstream_events_by_cid: Optional[Dict[str, List[Dict[str, Any]]]] = None, + station: str = '測試', + direction: str = 'backward', + mode: str = 'date_range', ) -> Optional[Dict[str, Any]]: """Build mid-section summary payload from staged events data.""" + if mode == 'container': + return _build_trace_aggregation_container_mode( + loss_reasons=loss_reasons, + seed_container_ids=seed_container_ids, + lineage_ancestors=lineage_ancestors, + upstream_events_by_cid=upstream_events_by_cid, + station=station, + direction=direction, + ) + + # date_range mode error = _validate_date_range(start_date, end_date) if error: return {'error': error} normalized_loss_reasons = parse_loss_reasons_param(loss_reasons) - tmtt_df = _fetch_tmtt_data(start_date, end_date) - if tmtt_df is None: + detection_df = _fetch_station_detection_data(start_date, end_date, station) + if detection_df is None: return None - if tmtt_df.empty: - empty_result = _empty_result() + if detection_df.empty: + empty_result = _empty_result(direction) return { 'kpi': empty_result['kpi'], 'charts': empty_result['charts'], @@ -322,29 +334,29 @@ def build_trace_aggregation_from_events( } available_loss_reasons = sorted( - tmtt_df.loc[tmtt_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] + detection_df.loc[detection_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] .dropna().unique().tolist() ) if normalized_loss_reasons: - filtered_df = tmtt_df[ - (tmtt_df['LOSSREASONNAME'].isin(normalized_loss_reasons)) - | (tmtt_df['REJECTQTY'] == 0) - | (tmtt_df['LOSSREASONNAME'].isna()) + filtered_df = detection_df[ + (detection_df['LOSSREASONNAME'].isin(normalized_loss_reasons)) + | (detection_df['REJECTQTY'] == 0) + | (detection_df['LOSSREASONNAME'].isna()) ].copy() else: - filtered_df = tmtt_df + filtered_df = detection_df - tmtt_data = _build_tmtt_lookup(filtered_df) + detection_data = _build_detection_lookup(filtered_df) normalized_ancestors = _normalize_lineage_ancestors( lineage_ancestors, seed_container_ids=seed_container_ids, - fallback_seed_ids=list(tmtt_data.keys()), + fallback_seed_ids=list(detection_data.keys()), ) normalized_upstream = _normalize_upstream_event_records(upstream_events_by_cid or {}) attribution = _attribute_defects( - tmtt_data, + detection_data, normalized_ancestors, normalized_upstream, normalized_loss_reasons, @@ -352,7 +364,7 @@ def build_trace_aggregation_from_events( detail = _build_detail_table(filtered_df, normalized_ancestors, normalized_upstream) seed_ids = [ - cid for cid in (seed_container_ids or list(tmtt_data.keys())) + cid for cid in (seed_container_ids or list(detection_data.keys())) if isinstance(cid, str) and cid.strip() ] genealogy_status = 'ready' @@ -361,11 +373,101 @@ def build_trace_aggregation_from_events( return { 'kpi': _build_kpi(filtered_df, attribution, normalized_loss_reasons), - 'charts': _build_all_charts(attribution, tmtt_data), + 'charts': _build_all_charts(attribution, detection_data), 'daily_trend': _build_daily_trend(filtered_df, normalized_loss_reasons), 'available_loss_reasons': available_loss_reasons, 'genealogy_status': genealogy_status, 'detail_total_count': len(detail), + 'attribution': attribution, + } + + +def _build_trace_aggregation_container_mode( + *, + loss_reasons: Optional[List[str]] = None, + seed_container_ids: Optional[List[str]] = None, + lineage_ancestors: Optional[Dict[str, Any]] = None, + upstream_events_by_cid: Optional[Dict[str, List[Dict[str, Any]]]] = None, + station: str = '測試', + direction: str = 'backward', +) -> Optional[Dict[str, Any]]: + """Container mode aggregation: same attribution pipeline, no date range.""" + if not seed_container_ids: + empty_result = _empty_result(direction) + return { + 'kpi': empty_result['kpi'], + 'charts': empty_result['charts'], + 'daily_trend': [], + 'available_loss_reasons': [], + 'genealogy_status': 'ready', + 'detail_total_count': 0, + 'attribution': [], + } + + normalized_loss_reasons = parse_loss_reasons_param(loss_reasons) + + detection_df = _fetch_detection_by_container_ids(seed_container_ids, station) + if detection_df is None: + return None + if detection_df.empty: + empty_result = _empty_result(direction) + return { + 'kpi': empty_result['kpi'], + 'charts': empty_result['charts'], + 'daily_trend': [], + 'available_loss_reasons': [], + 'genealogy_status': 'ready', + 'detail_total_count': 0, + 'attribution': [], + 'container_mode_hint': '所選容器在此偵測站無記錄', + } + + available_loss_reasons = sorted( + detection_df.loc[detection_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] + .dropna().unique().tolist() + ) + + if normalized_loss_reasons: + filtered_df = detection_df[ + (detection_df['LOSSREASONNAME'].isin(normalized_loss_reasons)) + | (detection_df['REJECTQTY'] == 0) + | (detection_df['LOSSREASONNAME'].isna()) + ].copy() + else: + filtered_df = detection_df + + detection_data = _build_detection_lookup(filtered_df) + normalized_ancestors = _normalize_lineage_ancestors( + lineage_ancestors, + seed_container_ids=seed_container_ids, + fallback_seed_ids=list(detection_data.keys()), + ) + normalized_upstream = _normalize_upstream_event_records(upstream_events_by_cid or {}) + + attribution = _attribute_defects( + detection_data, + normalized_ancestors, + normalized_upstream, + normalized_loss_reasons, + ) + detail = _build_detail_table(filtered_df, normalized_ancestors, normalized_upstream) + + seed_ids = [ + cid for cid in seed_container_ids + if isinstance(cid, str) and cid.strip() + ] + genealogy_status = 'ready' + if seed_ids and lineage_ancestors is None: + genealogy_status = 'error' + + return { + 'kpi': _build_kpi(filtered_df, attribution, normalized_loss_reasons), + 'charts': _build_all_charts(attribution, detection_data), + 'daily_trend': [], + 'available_loss_reasons': available_loss_reasons, + 'genealogy_status': genealogy_status, + 'detail_total_count': len(detail), + 'attribution': attribution, } @@ -373,6 +475,8 @@ def query_analysis_detail( start_date: str, end_date: str, loss_reasons: Optional[List[str]] = None, + station: str = '測試', + direction: str = 'backward', page: int = 1, page_size: int = 200, ) -> Optional[Dict[str, Any]]: @@ -381,15 +485,16 @@ def query_analysis_detail( Calls query_analysis() which handles caching internally. Sorts detail by DEFECT_RATE descending (worst first) before paginating. """ - 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 None if 'error' in result: return result detail = result.get('detail', []) + sort_key = 'DOWNSTREAM_REJECT_RATE' if direction == 'forward' else 'DEFECT_RATE' detail_sorted = sorted( - detail, key=lambda r: r.get('DEFECT_RATE', 0), reverse=True, + detail, key=lambda r: r.get(sort_key, 0), reverse=True, ) total_count = len(detail_sorted) @@ -409,7 +514,7 @@ def query_analysis_detail( def query_all_loss_reasons() -> Optional[Dict[str, Any]]: - """Get all TMTT loss reasons (cached daily in Redis). + """Get all loss reasons (cached daily in Redis). Lightweight query: DISTINCT LOSSREASONNAME from last 180 days. Cached with 24h TTL — suitable for dropdown population on page load. @@ -443,13 +548,16 @@ def export_csv( start_date: str, end_date: str, loss_reasons: Optional[List[str]] = None, + station: str = '測試', + direction: str = 'backward', ) -> Generator[str, None, None]: """Stream CSV export of detail data. Yields: CSV lines as strings. """ - result = query_analysis(start_date, end_date, loss_reasons) + result = query_analysis(start_date, end_date, loss_reasons, station, direction) + columns = CSV_COLUMNS_FORWARD if direction == 'forward' else CSV_COLUMNS_BACKWARD # BOM for Excel UTF-8 compatibility yield '\ufeff' @@ -457,7 +565,7 @@ def export_csv( output = io.StringIO() writer = csv.writer(output) - writer.writerow([label for _, label in CSV_COLUMNS]) + writer.writerow([label for _, label in columns]) yield output.getvalue() output.seek(0) output.truncate(0) @@ -466,12 +574,27 @@ def export_csv( return for row in result.get('detail', []): - writer.writerow([row.get(col, '') for col, _ in CSV_COLUMNS]) + writer.writerow([row.get(col, '') for col, _ in columns]) yield output.getvalue() output.seek(0) output.truncate(0) +def query_station_options() -> List[Dict[str, Any]]: + """Return ordered list of workcenter groups for station dropdown. + + Each entry includes: + name: group key (used as the station parameter value) + label: primary DB pattern name for display (e.g. 'TMTT' for '測試') + order: display sequence + """ + options = [] + for name, cfg in WORKCENTER_GROUPS.items(): + primary_pattern = cfg['patterns'][0] if cfg.get('patterns') else name + options.append({'name': name, 'label': primary_pattern, 'order': cfg['order']}) + return sorted(options, key=lambda x: x['order']) + + # ============================================================ # Helpers # ============================================================ @@ -503,7 +626,20 @@ def _safe_int(v, default=0): return int(_safe_float(v, float(default))) -def _empty_result() -> Dict[str, Any]: +def _empty_result(direction: str = 'backward') -> Dict[str, Any]: + if direction == 'forward': + return { + 'kpi': { + 'detection_lot_count': 0, 'detection_defect_qty': 0, + 'tracked_lot_count': 0, 'downstream_stations_reached': 0, + 'downstream_total_reject': 0, 'downstream_reject_rate': 0.0, + }, + 'available_loss_reasons': [], + 'charts': {k: [] for k in FORWARD_DIMENSION_MAP}, + 'daily_trend': [], + 'detail': [], + 'genealogy_status': 'ready', + } return { 'kpi': { 'total_input': 0, 'lot_count': 0, @@ -515,6 +651,7 @@ def _empty_result() -> Dict[str, Any]: 'daily_trend': [], 'detail': [], 'genealogy_status': 'ready', + 'attribution': [], } @@ -538,66 +675,393 @@ def _validate_date_range(start_date: str, end_date: str) -> Optional[str]: return None +def _validate_station(station: str) -> Optional[str]: + if station not in WORKCENTER_GROUPS: + valid_names = ', '.join(sorted(WORKCENTER_GROUPS.keys())) + return f'無效偵測站: {station}(有效值: {valid_names})' + return None + + +def _validate_params( + start_date: str, end_date: str, station: str, direction: str, +) -> Optional[str]: + error = _validate_date_range(start_date, end_date) + if error: + return error + error = _validate_station(station) + if error: + return error + if direction not in VALID_DIRECTIONS: + return f'無效方向: {direction}(有效值: backward, forward)' + return None + + # ============================================================ -# Query 1: TMTT Detection Data +# Station Filter Builder # ============================================================ -def _fetch_tmtt_data(start_date: str, end_date: str) -> Optional[pd.DataFrame]: - """Execute tmtt_detection.sql and return raw DataFrame.""" +def _build_station_filter( + station_name: str, + column_prefix: str = 'h', +) -> Tuple[str, Dict[str, str]]: + """Build SQL WHERE fragment for station workcenter name matching. + + Args: + station_name: Workcenter group name (key in WORKCENTER_GROUPS) + column_prefix: Table alias prefix (e.g. 'h' for h.WORKCENTERNAME) + + Returns: + (sql_fragment, bind_params) tuple. + """ + config = WORKCENTER_GROUPS[station_name] + patterns = config['patterns'] + excludes = config.get('exclude', []) + + col = f"UPPER({column_prefix}.WORKCENTERNAME)" + parts = [] + params = {} + + for i, pattern in enumerate(patterns): + param_name = f"wc_p{i}" + parts.append(f"{col} LIKE :{param_name}") + params[param_name] = f"%{pattern.upper()}%" + + include_sql = ' OR '.join(parts) + + if excludes: + excl_parts = [] + for i, excl in enumerate(excludes): + param_name = f"wc_ex{i}" + excl_parts.append(f"{col} NOT LIKE :{param_name}") + params[param_name] = f"%{excl.upper()}%" + exclude_sql = ' AND '.join(excl_parts) + fragment = f"({include_sql}) AND ({exclude_sql})" + else: + fragment = f"({include_sql})" + + return fragment, params + + +# ============================================================ +# Query 1: Station Detection Data (parameterized) +# ============================================================ + +def _fetch_station_detection_data( + start_date: str, + end_date: str, + station: str = '測試', +) -> Optional[pd.DataFrame]: + """Execute station_detection.sql and return raw DataFrame.""" cache_key = make_cache_key( - "mid_section_tmtt", - filters={'start_date': start_date, 'end_date': end_date}, + "mid_section_detection", + filters={ + 'start_date': start_date, + 'end_date': end_date, + 'station': station, + }, ) cached = cache_get(cache_key) if cached is not None: - # Cache stores list-of-dicts (JSON-serializable), reconstruct DataFrame if isinstance(cached, list): return pd.DataFrame(cached) if cached else pd.DataFrame() return None try: - sql = SQLLoader.load("mid_section_defect/tmtt_detection") - params = {'start_date': start_date, 'end_date': end_date} - df = read_sql_df(sql, params) + wip_filter, wip_params = _build_station_filter(station, 'h') + rej_filter, rej_params = _build_station_filter(station, 'r') + + sql = SQLLoader.load_with_params( + "mid_section_defect/station_detection", + STATION_FILTER=wip_filter, + STATION_FILTER_REJECTS=rej_filter, + ) + bind_params = { + 'start_date': start_date, + 'end_date': end_date, + **wip_params, + **rej_params, + } + df = read_sql_df(sql, bind_params) if df is None: - logger.error("TMTT detection query returned None") + logger.error("Station detection query returned None (station=%s)", station) return None logger.info( - f"TMTT detection: {len(df)} rows, " - f"{df['CONTAINERID'].nunique() if not df.empty else 0} unique lots" + "Station detection (%s): %d rows, %d unique lots", + station, + len(df), + df['CONTAINERID'].nunique() if not df.empty else 0, ) - # Cache as list-of-dicts for JSON serialization via Redis - cache_set(cache_key, df.to_dict('records'), ttl=CACHE_TTL_TMTT) + cache_set(cache_key, df.to_dict('records'), ttl=CACHE_TTL_DETECTION) return df except Exception as exc: - logger.error(f"TMTT detection query failed: {exc}", exc_info=True) + logger.error("Station detection query failed (station=%s): %s", station, exc, exc_info=True) return None +def _fetch_detection_by_container_ids( + container_ids: List[str], + station: str = '測試', +) -> Optional[pd.DataFrame]: + """Fetch detection data for explicit container IDs (container query mode). + + Uses station_detection_by_ids.sql with CONTAINERID IN clause. + """ + if not container_ids: + return pd.DataFrame() + + cache_key = make_cache_key( + "mid_section_detection_by_ids", + filters={ + 'cids': sorted(container_ids), + 'station': station, + }, + ) + cached = cache_get(cache_key) + if cached is not None: + if isinstance(cached, list): + return pd.DataFrame(cached) if cached else pd.DataFrame() + return None + + try: + wip_filter, wip_params = _build_station_filter(station, 'h') + rej_filter, rej_params = _build_station_filter(station, 'r') + + # Build CONTAINERID IN clause with quoted values + quoted_ids = ", ".join(f"'{cid}'" for cid in container_ids) + + sql = SQLLoader.load_with_params( + "mid_section_defect/station_detection_by_ids", + CONTAINER_IDS=quoted_ids, + STATION_FILTER=wip_filter, + STATION_FILTER_REJECTS=rej_filter, + ) + bind_params = {**wip_params, **rej_params} + df = read_sql_df(sql, bind_params) + if df is None: + logger.error("Container detection query returned None (station=%s)", station) + return None + logger.info( + "Container detection (%s): %d rows, %d unique lots from %d input IDs", + station, + len(df), + df['CONTAINERID'].nunique() if not df.empty else 0, + len(container_ids), + ) + cache_set(cache_key, df.to_dict('records'), ttl=CACHE_TTL_DETECTION) + return df + except Exception as exc: + logger.error("Container detection query failed (station=%s): %s", station, exc, exc_info=True) + return None + + +# ============================================================ +# Backward Pipeline +# ============================================================ + +def _run_backward_pipeline( + start_date: str, + end_date: str, + station: str, + loss_reasons: Optional[List[str]], +) -> Optional[Dict[str, Any]]: + """Run the backward traceability pipeline (detection → upstream attribution).""" + detection_df = _fetch_station_detection_data(start_date, end_date, station) + if detection_df is None: + return None + if detection_df.empty: + return _empty_result('backward') + + # Extract available loss reasons before filtering + available_loss_reasons = sorted( + detection_df.loc[detection_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] + .dropna().unique().tolist() + ) + + # Apply loss reason filter if specified + if loss_reasons: + filtered_df = detection_df[ + (detection_df['LOSSREASONNAME'].isin(loss_reasons)) + | (detection_df['REJECTQTY'] == 0) + | (detection_df['LOSSREASONNAME'].isna()) + ].copy() + else: + filtered_df = detection_df + + # Stage 2: Genealogy resolution (split chain + merge expansion) + detection_cids = detection_df['CONTAINERID'].unique().tolist() + detection_names = {} + for _, r in detection_df.drop_duplicates('CONTAINERID').iterrows(): + detection_names[r['CONTAINERID']] = _safe_str(r.get('CONTAINERNAME')) + + ancestors = {} + genealogy_status = 'ready' + + if detection_cids: + try: + ancestors = _resolve_full_genealogy(detection_cids, detection_names) + except Exception as exc: + logger.error(f"Genealogy resolution failed: {exc}", exc_info=True) + genealogy_status = 'error' + + # Stage 3: Upstream history for ALL CIDs (detection lots + ancestors) + all_query_cids = set(detection_cids) + for anc_set in ancestors.values(): + all_query_cids.update(anc_set) + all_query_cids = {c for c in all_query_cids if isinstance(c, str) and c} + + upstream_by_cid = {} + if all_query_cids: + try: + upstream_by_cid = _fetch_upstream_history(list(all_query_cids)) + except Exception as exc: + logger.error(f"Upstream history query failed: {exc}", exc_info=True) + genealogy_status = 'error' + + detection_data = _build_detection_lookup(filtered_df) + attribution = _attribute_defects( + detection_data, ancestors, upstream_by_cid, loss_reasons, + ) + + return { + 'kpi': _build_kpi(filtered_df, attribution, loss_reasons), + 'available_loss_reasons': available_loss_reasons, + 'charts': _build_all_charts(attribution, detection_data), + 'daily_trend': _build_daily_trend(filtered_df, loss_reasons), + 'detail': _build_detail_table(filtered_df, ancestors, upstream_by_cid), + 'genealogy_status': genealogy_status, + 'attribution': attribution, + } + + +# ============================================================ +# Forward Pipeline +# ============================================================ + +def _run_forward_pipeline( + start_date: str, + end_date: str, + station: str, + loss_reasons: Optional[List[str]], +) -> Optional[Dict[str, Any]]: + """Run the forward traceability pipeline (detection → downstream reject rates).""" + station_order = get_group_order(station) + + # Stage 1: Detection data + detection_df = _fetch_station_detection_data(start_date, end_date, station) + if detection_df is None: + return None + if detection_df.empty: + return _empty_result('forward') + + available_loss_reasons = sorted( + detection_df.loc[detection_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] + .dropna().unique().tolist() + ) + + # Apply loss reason filter + if loss_reasons: + filtered_df = detection_df[ + (detection_df['LOSSREASONNAME'].isin(loss_reasons)) + | (detection_df['REJECTQTY'] == 0) + | (detection_df['LOSSREASONNAME'].isna()) + ].copy() + else: + filtered_df = detection_df + + # Stage 2: Filter to lots WITH rejects + defect_cids = filtered_df.loc[ + filtered_df['REJECTQTY'] > 0, 'CONTAINERID' + ].unique().tolist() + if not defect_cids: + result = _empty_result('forward') + result['available_loss_reasons'] = available_loss_reasons + return result + + detection_names = {} + for _, r in detection_df.drop_duplicates('CONTAINERID').iterrows(): + detection_names[r['CONTAINERID']] = _safe_str(r.get('CONTAINERNAME')) + + # Stage 3: Forward lineage (descendants) + genealogy_status = 'ready' + children_map = {} + try: + forward_result = LineageEngine.resolve_forward_tree(defect_cids, detection_names) + children_map = forward_result.get('children_map', {}) + except Exception as exc: + logger.error("Forward lineage resolution failed: %s", exc, exc_info=True) + genealogy_status = 'error' + + # Stage 4: Collect all tracked CIDs (detection + all descendants) + tracked_cids = set(defect_cids) + for parent, children in children_map.items(): + if isinstance(children, (list, set)): + tracked_cids.update(c for c in children if isinstance(c, str) and c) + tracked_cids = {c for c in tracked_cids if isinstance(c, str) and c} + + # Stages 5+6: WIP history and downstream rejects in parallel + wip_by_cid = {} + downstream_rejects = {} + if tracked_cids: + tracked_list = list(tracked_cids) + with ThreadPoolExecutor(max_workers=FORWARD_PIPELINE_MAX_WORKERS) as executor: + wip_future = executor.submit(_fetch_upstream_history, tracked_list) + rejects_future = executor.submit(_fetch_downstream_rejects, tracked_list) + + try: + wip_by_cid = wip_future.result() + except Exception as exc: + logger.error("Forward WIP history query failed: %s", exc, exc_info=True) + genealogy_status = 'error' + + try: + downstream_rejects = rejects_future.result() + except Exception as exc: + logger.error("Downstream rejects query failed: %s", exc, exc_info=True) + genealogy_status = 'error' + + # Stage 7: Forward attribution + detection_data = _build_detection_lookup(filtered_df) + forward_attr = _attribute_forward_defects( + detection_data, defect_cids, wip_by_cid, downstream_rejects, station_order, + ) + + # Stage 8: Build result + return { + 'kpi': _build_forward_kpi(detection_data, forward_attr), + 'available_loss_reasons': available_loss_reasons, + 'charts': _build_forward_charts(forward_attr, detection_data), + 'daily_trend': _build_daily_trend(filtered_df, loss_reasons), + 'detail': _build_forward_detail_table( + filtered_df, defect_cids, wip_by_cid, downstream_rejects, station_order, + ), + 'genealogy_status': genealogy_status, + } + + # ============================================================ # Query 2: LOT Genealogy # ============================================================ def _resolve_full_genealogy( - tmtt_cids: List[str], - tmtt_names: Dict[str, str], + detection_cids: List[str], + detection_names: Dict[str, str], ) -> Dict[str, Set[str]]: - """Resolve full genealogy for TMTT lots via shared LineageEngine.""" - result = LineageEngine.resolve_full_genealogy(tmtt_cids, tmtt_names) + """Resolve full genealogy for detection lots via shared LineageEngine.""" + result = LineageEngine.resolve_full_genealogy(detection_cids, detection_names) ancestors = result.get("ancestors", {}) if isinstance(result, dict) else result - _log_genealogy_summary(ancestors, tmtt_cids, 0) + _log_genealogy_summary(ancestors, detection_cids, 0) return ancestors def _log_genealogy_summary( ancestors: Dict[str, Set[str]], - tmtt_cids: List[str], + detection_cids: List[str], merge_count: int, ) -> None: total_ancestors = sum(len(v) for v in ancestors.values()) lots_with_ancestors = sum(1 for v in ancestors.values() if v) logger.info( - f"Genealogy resolved: {lots_with_ancestors}/{len(tmtt_cids)} lots have ancestors, " + f"Genealogy resolved: {lots_with_ancestors}/{len(detection_cids)} lots have ancestors, " f"{total_ancestors} total ancestor links, " f"{merge_count} merge sources" ) @@ -632,6 +1096,48 @@ def _fetch_upstream_history( return dict(result) +# ============================================================ +# Query 4: Downstream Reject Records (Forward) +# ============================================================ + +def _fetch_downstream_rejects( + tracked_cids: List[str], +) -> Dict[str, List[Dict[str, Any]]]: + """Fetch downstream reject records for tracked CONTAINERIDs. + + Returns: + {containerid: [{'workcenter_group': ..., 'lossreasonname': ..., ...}]} + """ + if not tracked_cids: + return {} + + unique_cids = list(set(tracked_cids)) + events_by_cid = EventFetcher.fetch_events(unique_cids, "downstream_rejects") + + result: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for cid, events in events_by_cid.items(): + cid_value = _safe_str(cid) + if not cid_value: + continue + for event in events: + group_name = _safe_str(event.get('WORKCENTER_GROUP')) + if not group_name: + continue + result[cid_value].append({ + 'workcenter_group': group_name, + 'lossreasonname': _safe_str(event.get('LOSSREASONNAME')), + 'equipment_name': _safe_str(event.get('EQUIPMENTNAME')), + 'reject_total_qty': _safe_int(event.get('REJECT_TOTAL_QTY')), + 'txndate': _safe_str(event.get('TXNDATE')), + }) + + logger.info( + "Downstream rejects: %d lots with records, from %d queried CIDs", + len(result), len(unique_cids), + ) + return dict(result) + + def _normalize_lineage_ancestors( lineage_ancestors: Optional[Dict[str, Any]], *, @@ -691,18 +1197,19 @@ def _normalize_upstream_event_records( 'equipment_name': _safe_str(event.get('EQUIPMENTNAME')), 'spec_name': _safe_str(event.get('SPECNAME')), 'track_in_time': _safe_str(event.get('TRACKINTIMESTAMP')), + 'trackinqty': _safe_int(event.get('TRACKINQTY')), }) return dict(result) # ============================================================ -# TMTT Data Lookup +# Detection Data Lookup # ============================================================ -def _build_tmtt_lookup( +def _build_detection_lookup( df: pd.DataFrame, ) -> Dict[str, Dict[str, Any]]: - """Build lookup dict from TMTT DataFrame. + """Build lookup dict from detection DataFrame. Returns: {containerid: { @@ -712,7 +1219,7 @@ def _build_tmtt_lookup( 'workflow': str, 'productlinename': str, 'pj_type': str, - 'tmtt_equipmentname': str, + 'detection_equipmentname': str, 'trackintimestamp': str, }} """ @@ -730,7 +1237,7 @@ def _build_tmtt_lookup( 'workflow': _safe_str(row.get('WORKFLOW')), 'productlinename': _safe_str(row.get('PRODUCTLINENAME')), 'pj_type': _safe_str(row.get('PJ_TYPE')), - 'tmtt_equipmentname': _safe_str(row.get('TMTT_EQUIPMENTNAME')), + 'detection_equipmentname': _safe_str(row.get('DETECTION_EQUIPMENTNAME')), 'trackintimestamp': _safe_str(row.get('TRACKINTIMESTAMP')), } @@ -745,19 +1252,19 @@ def _build_tmtt_lookup( # ============================================================ -# Defect Attribution Engine +# Backward Defect Attribution Engine # ============================================================ def _attribute_defects( - tmtt_data: Dict[str, Dict[str, Any]], + detection_data: Dict[str, Dict[str, Any]], ancestors: Dict[str, Set[str]], upstream_by_cid: Dict[str, List[Dict[str, Any]]], loss_reasons: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: - """Attribute TMTT defects to upstream machines. + """Attribute detection station defects to upstream machines. For each upstream machine M at station S: - - Find all TMTT lots whose ancestors (or self) used M + - Find all detection lots whose ancestors (or self) used M - attributed_rejectqty = SUM(selected REJECTQTY) - attributed_trackinqty = SUM(TRACKINQTY) - rate = attributed_rejectqty / attributed_trackinqty × 100 @@ -765,13 +1272,11 @@ def _attribute_defects( Returns: List of attribution records, one per (workcenter_group, equipment_name). """ - # machine_key → set of TMTT lot CIDs - machine_to_tmtt: Dict[Tuple[str, str, str], Set[str]] = defaultdict(set) + machine_to_detection: Dict[Tuple[str, str, str], Set[str]] = defaultdict(set) - for tmtt_cid, data in tmtt_data.items(): - ancestor_set = ancestors.get(tmtt_cid, set()) - # Include the TMTT lot itself (it may have upstream history if no split) - all_cids = ancestor_set | {tmtt_cid} + for det_cid, data in detection_data.items(): + ancestor_set = ancestors.get(det_cid, set()) + all_cids = ancestor_set | {det_cid} for anc_cid in all_cids: for record in upstream_by_cid.get(anc_cid, []): @@ -780,24 +1285,34 @@ def _attribute_defects( record['equipment_name'], record['equipment_id'], ) - machine_to_tmtt[machine_key].add(tmtt_cid) + machine_to_detection[machine_key].add(det_cid) + + # Look up equipment model (RESOURCEFAMILYNAME) from resource cache + all_eq_ids = list({eq_id for _, _, eq_id in machine_to_detection.keys() if eq_id}) + eq_family_map: Dict[str, str] = {} + if all_eq_ids: + from mes_dashboard.services.resource_cache import get_resources_by_ids + resources = get_resources_by_ids(all_eq_ids) + for r in resources: + rid = str(r.get('RESOURCEID', '')) + family = r.get('RESOURCEFAMILYNAME') or '' + if rid and family: + eq_family_map[rid] = family - # Calculate attribution per machine attribution = [] - for machine_key, tmtt_lot_set in machine_to_tmtt.items(): + for machine_key, det_lot_set in machine_to_detection.items(): wc_group, eq_name, eq_id = machine_key total_trackinqty = sum( - tmtt_data[cid]['trackinqty'] for cid in tmtt_lot_set - if cid in tmtt_data + detection_data[cid]['trackinqty'] for cid in det_lot_set + if cid in detection_data ) - # Sum defects for selected loss reasons total_rejectqty = 0 - for cid in tmtt_lot_set: - if cid not in tmtt_data: + for cid in det_lot_set: + if cid not in detection_data: continue - by_reason = tmtt_data[cid]['rejectqty_by_reason'] + by_reason = detection_data[cid]['rejectqty_by_reason'] if loss_reasons: for reason in loss_reasons: total_rejectqty += by_reason.get(reason, 0) @@ -806,47 +1321,128 @@ def _attribute_defects( rate = round(total_rejectqty / total_trackinqty * 100, 4) if total_trackinqty else 0.0 - # Collect dimension metadata from linked TMTT lots workflows = set() packages = set() pj_types = set() - tmtt_machines = set() - for cid in tmtt_lot_set: - if cid not in tmtt_data: + detection_machines = set() + for cid in det_lot_set: + if cid not in detection_data: continue - d = tmtt_data[cid] + d = detection_data[cid] if d['workflow']: workflows.add(d['workflow']) if d['productlinename']: packages.add(d['productlinename']) if d['pj_type']: pj_types.add(d['pj_type']) - if d['tmtt_equipmentname']: - tmtt_machines.add(d['tmtt_equipmentname']) + if d['detection_equipmentname']: + detection_machines.add(d['detection_equipmentname']) attribution.append({ 'WORKCENTER_GROUP': wc_group, 'EQUIPMENT_NAME': eq_name, 'EQUIPMENT_ID': eq_id, - 'TMTT_LOT_COUNT': len(tmtt_lot_set), + 'RESOURCEFAMILYNAME': eq_family_map.get(eq_id, '(未知)'), + 'DETECTION_LOT_COUNT': len(det_lot_set), 'INPUT_QTY': total_trackinqty, 'DEFECT_QTY': total_rejectqty, 'DEFECT_RATE': rate, - # Flatten multi-valued dimensions for charting 'WORKFLOW': ', '.join(sorted(workflows)) if workflows else '(未知)', 'PRODUCTLINENAME': ', '.join(sorted(packages)) if packages else '(未知)', 'PJ_TYPE': ', '.join(sorted(pj_types)) if pj_types else '(未知)', - 'TMTT_EQUIPMENTNAME': ', '.join(sorted(tmtt_machines)) if tmtt_machines else '(未知)', + 'DETECTION_EQUIPMENTNAME': ', '.join(sorted(detection_machines)) if detection_machines else '(未知)', }) - # Sort by defect rate DESC attribution.sort(key=lambda x: x['DEFECT_RATE'], reverse=True) - return attribution # ============================================================ -# KPI Builder +# Forward Defect Attribution Engine +# ============================================================ + +def _attribute_forward_defects( + detection_data: Dict[str, Dict[str, Any]], + defect_cids: List[str], + wip_by_cid: Dict[str, List[Dict[str, Any]]], + downstream_rejects: Dict[str, List[Dict[str, Any]]], + station_order: int, +) -> Dict[str, Dict[str, Any]]: + """Attribute forward: for each downstream station, compute reject rate. + + Only stations with order > detection station_order are included. + + Returns: + {workcenter_group: { + 'lots_reached': int, 'total_input': int, 'total_reject': int, + 'reject_rate': float, + 'machines': {eq_name: {'input': int, 'reject': int}}, + 'loss_reasons': {reason: qty}, + }} + """ + station_agg: Dict[str, Dict[str, Any]] = defaultdict(lambda: { + 'lots_reached': set(), + 'total_input': 0, + 'total_reject': 0, + 'machines': defaultdict(lambda: {'input': 0, 'reject': 0}), + 'loss_reasons': defaultdict(int), + }) + + # Aggregate WIP records at downstream stations + for cid in wip_by_cid: + for record in wip_by_cid[cid]: + wc_group = record.get('workcenter_group', '') + if not wc_group: + continue + group_order = get_group_order(wc_group) + if group_order <= station_order: + continue + eq_name = record.get('equipment_name', '(NA)') + trackinqty = record.get('trackinqty', 0) + + agg = station_agg[wc_group] + agg['lots_reached'].add(cid) + agg['total_input'] += trackinqty + agg['machines'][eq_name]['input'] += trackinqty + + # Aggregate reject records at downstream stations + for cid in downstream_rejects: + for record in downstream_rejects[cid]: + wc_group = record.get('workcenter_group', '') + if not wc_group: + continue + group_order = get_group_order(wc_group) + if group_order <= station_order: + continue + eq_name = record.get('equipment_name', '(NA)') + reject_qty = record.get('reject_total_qty', 0) + reason = record.get('lossreasonname', '(未填寫)') + + agg = station_agg[wc_group] + agg['total_reject'] += reject_qty + agg['machines'][eq_name]['reject'] += reject_qty + agg['loss_reasons'][reason] += reject_qty + + # Finalize + result = {} + for wc_group, agg in station_agg.items(): + total_input = agg['total_input'] + total_reject = agg['total_reject'] + reject_rate = round(total_reject / total_input * 100, 4) if total_input else 0.0 + result[wc_group] = { + 'lots_reached': len(agg['lots_reached']), + 'total_input': total_input, + 'total_reject': total_reject, + 'reject_rate': reject_rate, + 'machines': dict(agg['machines']), + 'loss_reasons': dict(agg['loss_reasons']), + } + + return result + + +# ============================================================ +# KPI Builders # ============================================================ def _build_kpi( @@ -854,7 +1450,7 @@ def _build_kpi( attribution: List[Dict[str, Any]], loss_reasons: Optional[List[str]] = None, ) -> Dict[str, Any]: - """Build KPI summary.""" + """Build backward KPI summary.""" if df.empty: return { 'total_input': 0, 'lot_count': 0, @@ -862,12 +1458,10 @@ def _build_kpi( 'top_loss_reason': '', 'affected_machine_count': 0, } - # Deduplicate for INPUT unique_lots = df.drop_duplicates(subset=['CONTAINERID']) total_input = int(unique_lots['TRACKINQTY'].sum()) lot_count = len(unique_lots) - # Defect totals defect_rows = df[df['REJECTQTY'] > 0] if loss_reasons: defect_rows = defect_rows[defect_rows['LOSSREASONNAME'].isin(loss_reasons)] @@ -877,14 +1471,12 @@ def _build_kpi( total_defect_qty / total_input * 100, 4 ) if total_input else 0.0 - # Top loss reason top_reason = '' if not defect_rows.empty: reason_sums = defect_rows.groupby('LOSSREASONNAME')['REJECTQTY'].sum() if not reason_sums.empty: top_reason = _safe_str(reason_sums.idxmax()) - # Count unique upstream machines with defects attributed affected_machines = sum(1 for a in attribution if a['DEFECT_QTY'] > 0) return { @@ -897,6 +1489,35 @@ def _build_kpi( } +def _build_forward_kpi( + detection_data: Dict[str, Dict[str, Any]], + forward_attr: Dict[str, Dict[str, Any]], +) -> Dict[str, Any]: + """Build forward KPI summary.""" + detection_lot_count = len(detection_data) + detection_defect_qty = sum( + sum(d['rejectqty_by_reason'].values()) for d in detection_data.values() + ) + + tracked_lot_count = detection_lot_count + downstream_stations_reached = len(forward_attr) + downstream_total_reject = sum(a['total_reject'] for a in forward_attr.values()) + downstream_total_input = sum(a['total_input'] for a in forward_attr.values()) + downstream_reject_rate = ( + round(downstream_total_reject / downstream_total_input * 100, 4) + if downstream_total_input else 0.0 + ) + + return { + 'detection_lot_count': detection_lot_count, + 'detection_defect_qty': detection_defect_qty, + 'tracked_lot_count': tracked_lot_count, + 'downstream_stations_reached': downstream_stations_reached, + 'downstream_total_reject': downstream_total_reject, + 'downstream_reject_rate': downstream_reject_rate, + } + + # ============================================================ # Chart Builders # ============================================================ @@ -905,21 +1526,15 @@ def _build_chart_data( records: List[Dict[str, Any]], dimension: str, ) -> List[Dict[str, Any]]: - """Build Top N + Other Pareto chart data for a given dimension. - - Groups attribution records by dimension, sums defect qty, takes top N, - groups rest as "其他". - """ + """Build Top N + Other Pareto chart data for a given dimension.""" if not records: return [] - # Aggregate by dimension dim_agg: Dict[str, Dict[str, Any]] = defaultdict( lambda: {'input_qty': 0, 'defect_qty': 0, 'lot_count': 0} ) for rec in records: key = rec.get(dimension, '(未知)') or '(未知)' - # For multi-valued dimensions (comma-separated), split and attribute to each if ',' in key: keys = [k.strip() for k in key.split(',')] else: @@ -927,12 +1542,10 @@ def _build_chart_data( for k in keys: dim_agg[k]['input_qty'] += rec['INPUT_QTY'] dim_agg[k]['defect_qty'] += rec['DEFECT_QTY'] - dim_agg[k]['lot_count'] += rec['TMTT_LOT_COUNT'] + dim_agg[k]['lot_count'] += rec['DETECTION_LOT_COUNT'] - # Sort by defect qty DESC sorted_items = sorted(dim_agg.items(), key=lambda x: x[1]['defect_qty'], reverse=True) - # Top N + Other items = [] other = {'input_qty': 0, 'defect_qty': 0, 'lot_count': 0} for i, (name, data) in enumerate(sorted_items): @@ -960,7 +1573,6 @@ def _build_chart_data( 'lot_count': other['lot_count'], }) - # Add cumulative percentage total_defects = sum(item['defect_qty'] for item in items) cumsum = 0 for item in items: @@ -974,7 +1586,7 @@ def _build_loss_reason_chart( df: pd.DataFrame, loss_reasons: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: - """Build loss reason distribution chart from TMTT data (not attribution).""" + """Build loss reason distribution chart from detection data (not attribution).""" if df.empty: return [] @@ -985,11 +1597,9 @@ def _build_loss_reason_chart( if defect_rows.empty: return [] - # Aggregate by loss reason reason_agg = defect_rows.groupby('LOSSREASONNAME')['REJECTQTY'].sum() reason_agg = reason_agg.sort_values(ascending=False) - # Deduplicated input per loss reason unique_lots = df.drop_duplicates(subset=['CONTAINERID']) total_input = int(unique_lots['TRACKINQTY'].sum()) @@ -1012,17 +1622,15 @@ def _build_loss_reason_chart( def _build_all_charts( attribution: List[Dict[str, Any]], - tmtt_data: Dict[str, Dict[str, Any]], + detection_data: Dict[str, Dict[str, Any]], ) -> Dict[str, List[Dict]]: - """Build chart data for all dimensions.""" + """Build chart data for all backward dimensions.""" charts = {} for key, dim_col in DIMENSION_MAP.items(): charts[key] = _build_chart_data(attribution, dim_col) - # Loss reason chart is built from TMTT data directly (not attribution) - # Reconstruct a minimal df from tmtt_data for the loss reason chart loss_rows = [] - for cid, data in tmtt_data.items(): + for cid, data in detection_data.items(): trackinqty = data['trackinqty'] if data['rejectqty_by_reason']: for reason, qty in data['rejectqty_by_reason'].items(): @@ -1048,6 +1656,117 @@ def _build_all_charts( return charts +def _build_forward_charts( + forward_attr: Dict[str, Dict[str, Any]], + detection_data: Dict[str, Dict[str, Any]], +) -> Dict[str, List[Dict]]: + """Build chart data for forward direction.""" + charts = {} + + # by_downstream_station + station_items = [] + for wc_group, agg in forward_attr.items(): + station_items.append({ + 'name': wc_group, + 'input_qty': agg['total_input'], + 'defect_qty': agg['total_reject'], + 'defect_rate': agg['reject_rate'], + 'lot_count': agg['lots_reached'], + }) + station_items.sort(key=lambda x: x['defect_qty'], reverse=True) + total_defects = sum(s['defect_qty'] for s in station_items) + cumsum = 0 + for item in station_items: + cumsum += item['defect_qty'] + item['cumulative_pct'] = round(cumsum / total_defects * 100, 2) if total_defects else 0.0 + charts['by_downstream_station'] = station_items + + # by_downstream_machine + machine_agg: Dict[str, Dict[str, int]] = defaultdict( + lambda: {'input': 0, 'reject': 0}, + ) + for wc_group, agg in forward_attr.items(): + for eq_name, m_data in agg['machines'].items(): + machine_agg[eq_name]['input'] += m_data['input'] + machine_agg[eq_name]['reject'] += m_data['reject'] + + machine_items = [] + for eq_name, data in sorted(machine_agg.items(), key=lambda x: x[1]['reject'], reverse=True): + rate = round(data['reject'] / data['input'] * 100, 4) if data['input'] else 0.0 + machine_items.append({ + 'name': eq_name, + 'input_qty': data['input'], + 'defect_qty': data['reject'], + 'defect_rate': rate, + }) + if len(machine_items) > TOP_N: + top = machine_items[:TOP_N] + other_input = sum(m['input_qty'] for m in machine_items[TOP_N:]) + other_reject = sum(m['defect_qty'] for m in machine_items[TOP_N:]) + rate = round(other_reject / other_input * 100, 4) if other_input else 0.0 + top.append({'name': '其他', 'input_qty': other_input, 'defect_qty': other_reject, 'defect_rate': rate}) + machine_items = top + total_defects = sum(m['defect_qty'] for m in machine_items) + cumsum = 0 + for item in machine_items: + cumsum += item['defect_qty'] + item['cumulative_pct'] = round(cumsum / total_defects * 100, 2) if total_defects else 0.0 + charts['by_downstream_machine'] = machine_items + + # by_downstream_loss_reason + reason_agg: Dict[str, int] = defaultdict(int) + for wc_group, agg in forward_attr.items(): + for reason, qty in agg['loss_reasons'].items(): + reason_agg[reason] += qty + reason_items = [] + for reason, qty in sorted(reason_agg.items(), key=lambda x: x[1], reverse=True): + reason_items.append({'name': reason, 'defect_qty': qty}) + if len(reason_items) > TOP_N: + top = reason_items[:TOP_N] + other_qty = sum(r['defect_qty'] for r in reason_items[TOP_N:]) + top.append({'name': '其他', 'defect_qty': other_qty}) + reason_items = top + total_defects = sum(r['defect_qty'] for r in reason_items) + cumsum = 0 + for item in reason_items: + cumsum += item['defect_qty'] + item['cumulative_pct'] = round(cumsum / total_defects * 100, 2) if total_defects else 0.0 + charts['by_downstream_loss_reason'] = reason_items + + # by_detection_machine + det_machine_agg: Dict[str, Dict[str, int]] = defaultdict( + lambda: {'input_qty': 0, 'defect_qty': 0, 'lot_count': 0}, + ) + for cid, data in detection_data.items(): + eq = data['detection_equipmentname'] or '(未知)' + det_machine_agg[eq]['input_qty'] += data['trackinqty'] + det_machine_agg[eq]['defect_qty'] += sum(data['rejectqty_by_reason'].values()) + det_machine_agg[eq]['lot_count'] += 1 + det_items = [] + for eq, data in sorted(det_machine_agg.items(), key=lambda x: x[1]['defect_qty'], reverse=True): + rate = round(data['defect_qty'] / data['input_qty'] * 100, 4) if data['input_qty'] else 0.0 + det_items.append({ + 'name': eq, 'input_qty': data['input_qty'], 'defect_qty': data['defect_qty'], + 'defect_rate': rate, 'lot_count': data['lot_count'], + }) + if len(det_items) > TOP_N: + top = det_items[:TOP_N] + other_input = sum(d['input_qty'] for d in det_items[TOP_N:]) + other_defect = sum(d['defect_qty'] for d in det_items[TOP_N:]) + other_lots = sum(d['lot_count'] for d in det_items[TOP_N:]) + rate = round(other_defect / other_input * 100, 4) if other_input else 0.0 + top.append({'name': '其他', 'input_qty': other_input, 'defect_qty': other_defect, 'defect_rate': rate, 'lot_count': other_lots}) + det_items = top + total_defects = sum(d['defect_qty'] for d in det_items) + cumsum = 0 + for item in det_items: + cumsum += item['defect_qty'] + item['cumulative_pct'] = round(cumsum / total_defects * 100, 2) if total_defects else 0.0 + charts['by_detection_machine'] = det_items + + return charts + + # ============================================================ # Daily Trend # ============================================================ @@ -1063,14 +1782,12 @@ def _build_daily_trend( 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 defect_rows = work_df[work_df['REJECTQTY'] > 0] if loss_reasons: defect_rows = defect_rows[defect_rows['LOSSREASONNAME'].isin(loss_reasons)] @@ -1105,7 +1822,7 @@ def _build_daily_trend( # ============================================================ -# Detail Table +# Detail Tables # ============================================================ def _build_detail_table( @@ -1113,20 +1830,18 @@ def _build_detail_table( ancestors: Dict[str, Set[str]], upstream_by_cid: Dict[str, List[Dict[str, Any]]], ) -> List[Dict[str, Any]]: - """Build LOT-level detail table with upstream machine info.""" + """Build LOT-level detail table with upstream machine info (backward).""" if df.empty: return [] - # Unique LOT info lot_cols = [ 'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME', - 'WORKFLOW', 'FINISHEDRUNCARD', 'TMTT_EQUIPMENTNAME', 'TRACKINQTY', + 'WORKFLOW', 'FINISHEDRUNCARD', 'DETECTION_EQUIPMENTNAME', 'TRACKINQTY', ] lots = df.drop_duplicates(subset=['CONTAINERID'])[ [c for c in lot_cols if c in df.columns] ].copy() - # Aggregate defects per LOT per loss reason defect_rows = df[df['REJECTQTY'] > 0] lot_defects: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int)) for _, row in defect_rows.iterrows(): @@ -1143,13 +1858,11 @@ def _build_detail_table( ancestor_set = ancestors.get(cid, set()) all_cids = ancestor_set | {cid} - # Collect upstream machines upstream_machines = set() for anc_cid in all_cids: for rec in upstream_by_cid.get(anc_cid, []): upstream_machines.add(f"{rec['workcenter_group']}/{rec['equipment_name']}") - # Build one row per loss reason for this LOT reasons = lot_defects.get(cid, {}) if reasons: for reason, qty in sorted(reasons.items()): @@ -1160,7 +1873,7 @@ def _build_detail_table( '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')), + 'DETECTION_EQUIPMENTNAME': _safe_str(row.get('DETECTION_EQUIPMENTNAME')), 'INPUT_QTY': input_qty, 'LOSS_REASON': reason, 'DEFECT_QTY': qty, @@ -1175,7 +1888,7 @@ def _build_detail_table( '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')), + 'DETECTION_EQUIPMENTNAME': _safe_str(row.get('DETECTION_EQUIPMENTNAME')), 'INPUT_QTY': input_qty, 'LOSS_REASON': '', 'DEFECT_QTY': 0, @@ -1185,3 +1898,75 @@ def _build_detail_table( }) return result + + +def _build_forward_detail_table( + df: pd.DataFrame, + defect_cids: List[str], + wip_by_cid: Dict[str, List[Dict[str, Any]]], + downstream_rejects: Dict[str, List[Dict[str, Any]]], + station_order: int, +) -> List[Dict[str, Any]]: + """Build LOT-level detail table for forward tracing.""" + if df.empty: + return [] + + lot_cols = [ + 'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME', + 'WORKFLOW', 'DETECTION_EQUIPMENTNAME', 'TRACKINQTY', + ] + lots = df[df['CONTAINERID'].isin(defect_cids)].drop_duplicates(subset=['CONTAINERID'])[ + [c for c in lot_cols if c in df.columns] + ].copy() + + # Aggregate defects per LOT at detection station + defect_rows = df[(df['REJECTQTY'] > 0) & (df['CONTAINERID'].isin(defect_cids))] + lot_defect_qty: Dict[str, int] = defaultdict(int) + for _, row in defect_rows.iterrows(): + cid = row['CONTAINERID'] + lot_defect_qty[cid] += _safe_int(row.get('REJECTQTY')) + + result = [] + for _, row in lots.iterrows(): + cid = row['CONTAINERID'] + input_qty = _safe_int(row.get('TRACKINQTY')) + det_defect = lot_defect_qty.get(cid, 0) + + ds_stations = set() + ds_total_reject = 0 + ds_station_reject: Dict[str, int] = defaultdict(int) + + for record in wip_by_cid.get(cid, []): + wc = record.get('workcenter_group', '') + if wc and get_group_order(wc) > station_order: + ds_stations.add(wc) + + for record in downstream_rejects.get(cid, []): + wc = record.get('workcenter_group', '') + if wc and get_group_order(wc) > station_order: + qty = record.get('reject_total_qty', 0) + ds_total_reject += qty + ds_station_reject[wc] += qty + ds_stations.add(wc) + + ds_rate = round(ds_total_reject / input_qty * 100, 4) if input_qty else 0.0 + + worst = '' + if ds_station_reject: + worst = max(ds_station_reject, key=ds_station_reject.get) + + 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')), + 'DETECTION_EQUIPMENTNAME': _safe_str(row.get('DETECTION_EQUIPMENTNAME')), + 'INPUT_QTY': input_qty, + 'DEFECT_QTY': det_defect, + 'DOWNSTREAM_STATIONS': len(ds_stations), + 'DOWNSTREAM_REJECTS': ds_total_reject, + 'DOWNSTREAM_REJECT_RATE': ds_rate, + 'WORST_DOWNSTREAM': worst, + }) + + return result diff --git a/src/mes_dashboard/services/page_registry.py b/src/mes_dashboard/services/page_registry.py index 95ddb8d..5b8f99f 100644 --- a/src/mes_dashboard/services/page_registry.py +++ b/src/mes_dashboard/services/page_registry.py @@ -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, diff --git a/src/mes_dashboard/services/tmtt_defect_service.py b/src/mes_dashboard/services/tmtt_defect_service.py deleted file mode 100644 index 0506320..0000000 --- a/src/mes_dashboard/services/tmtt_defect_service.py +++ /dev/null @@ -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 diff --git a/src/mes_dashboard/sql/mid_section_defect/downstream_rejects.sql b/src/mes_dashboard/sql/mid_section_defect/downstream_rejects.sql new file mode 100644 index 0000000..6327a74 --- /dev/null +++ b/src/mes_dashboard/sql/mid_section_defect/downstream_rejects.sql @@ -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 diff --git a/src/mes_dashboard/sql/mid_section_defect/tmtt_detection.sql b/src/mes_dashboard/sql/mid_section_defect/station_detection.sql similarity index 62% rename from src/mes_dashboard/sql/mid_section_defect/tmtt_detection.sql rename to src/mes_dashboard/sql/mid_section_defect/station_detection.sql index ad47584..2f210d7 100644 --- a/src/mes_dashboard/sql/mid_section_defect/tmtt_detection.sql +++ b/src/mes_dashboard/sql/mid_section_defect/station_detection.sql @@ -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 diff --git a/src/mes_dashboard/sql/mid_section_defect/station_detection_by_ids.sql b/src/mes_dashboard/sql/mid_section_defect/station_detection_by_ids.sql new file mode 100644 index 0000000..d61a551 --- /dev/null +++ b/src/mes_dashboard/sql/mid_section_defect/station_detection_by_ids.sql @@ -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 diff --git a/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql b/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql index f111a79..a208166 100644 --- a/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql +++ b/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql @@ -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 diff --git a/src/mes_dashboard/sql/tmtt_defect/base_data.sql b/src/mes_dashboard/sql/tmtt_defect/base_data.sql deleted file mode 100644 index 60c8485..0000000 --- a/src/mes_dashboard/sql/tmtt_defect/base_data.sql +++ /dev/null @@ -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 diff --git a/src/mes_dashboard/templates/tmtt_defect.html b/src/mes_dashboard/templates/tmtt_defect.html deleted file mode 100644 index c62b127..0000000 --- a/src/mes_dashboard/templates/tmtt_defect.html +++ /dev/null @@ -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 %} - - {% endif %} -{% endblock %} - -{% block content %} -
-{% endblock %} - -{% block scripts %} - {% set tmtt_defect_js = frontend_asset('tmtt-defect.js') %} - -{% endblock %} diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index e421b90..167b17f 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -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 diff --git a/tests/test_cutover_gates.py b/tests/test_cutover_gates.py index b79adcd..dc64382 100644 --- a/tests/test_cutover_gates.py +++ b/tests/test_cutover_gates.py @@ -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] diff --git a/tests/test_mid_section_defect_routes.py b/tests/test_mid_section_defect_routes.py index b47581c..45e2fc5 100644 --- a/tests/test_mid_section_defect_routes.py +++ b/tests/test_mid_section_defect_routes.py @@ -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'] == '切割' diff --git a/tests/test_mid_section_defect_service.py b/tests/test_mid_section_defect_service.py index e4246c0..135223e 100644 --- a/tests/test_mid_section_defect_service.py +++ b/tests/test_mid_section_defect_service.py @@ -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 diff --git a/tests/test_portal_shell_wave_b_native_smoke.py b/tests/test_portal_shell_wave_b_native_smoke.py index 41cb182..990c3fc 100644 --- a/tests/test_portal_shell_wave_b_native_smoke.py +++ b/tests/test_portal_shell_wave_b_native_smoke.py @@ -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) diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index d035105..50f98eb 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -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', diff --git a/tests/test_tmtt_defect_routes.py b/tests/test_tmtt_defect_routes.py deleted file mode 100644 index d6635c4..0000000 --- a/tests/test_tmtt_defect_routes.py +++ /dev/null @@ -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() diff --git a/tests/test_tmtt_defect_service.py b/tests/test_tmtt_defect_service.py deleted file mode 100644 index 43834ca..0000000 --- a/tests/test_tmtt_defect_service.py +++ /dev/null @@ -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()