feat(mid-section-defect): full-line bidirectional defect trace center with dual query mode
Transform /mid-section-defect from TMTT-only backward analysis into a full-line bidirectional defect traceability center supporting all detection stations. Key changes: - Parameterized station detection: any workcenter group as detection station - Bidirectional tracing: backward (upstream attribution) + forward (downstream reject rates) - Dual query mode: date range OR LOT/工單/WAFER container-based seed resolution - Multi-select filters for upstream station, equipment model (RESOURCEFAMILYNAME), and loss reasons - Progressive 3-stage trace pipeline (seed-resolve → lineage → events) with streaming UI - Equipment model lookup via resource cache instead of SPECNAME - Session caching, auto-refresh, searchable MultiSelect with fuzzy matching - Remove legacy tmtt-defect module (fully superseded) - Archive openspec change artifacts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
15
.env.example
15
.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
|
||||
# ============================================================
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [] },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import TraceProgressBar from '../shared-composables/TraceProgressBar.vue';
|
||||
|
||||
import FilterBar from './components/FilterBar.vue';
|
||||
import KpiCards from './components/KpiCards.vue';
|
||||
import MultiSelect from './components/MultiSelect.vue';
|
||||
import ParetoChart from './components/ParetoChart.vue';
|
||||
import TrendChart from './components/TrendChart.vue';
|
||||
import DetailTable from './components/DetailTable.vue';
|
||||
@@ -16,18 +17,84 @@ ensureMesApiAvailable();
|
||||
|
||||
const API_TIMEOUT = 120000;
|
||||
const PAGE_SIZE = 200;
|
||||
const SESSION_CACHE_KEY = 'msd:cache';
|
||||
const SESSION_CACHE_TTL = 5 * 60 * 1000; // 5 min, matches backend Redis TTL
|
||||
const CHART_TOP_N = 10;
|
||||
|
||||
function buildMachineChartFromAttribution(records) {
|
||||
if (!records || records.length === 0) return [];
|
||||
const agg = {};
|
||||
for (const rec of records) {
|
||||
const key = rec.EQUIPMENT_NAME || '(未知)';
|
||||
if (!agg[key]) agg[key] = { input_qty: 0, defect_qty: 0, lot_count: 0 };
|
||||
agg[key].input_qty += rec.INPUT_QTY;
|
||||
agg[key].defect_qty += rec.DEFECT_QTY;
|
||||
agg[key].lot_count += rec.DETECTION_LOT_COUNT;
|
||||
}
|
||||
const sorted = Object.entries(agg).sort((a, b) => b[1].defect_qty - a[1].defect_qty);
|
||||
const items = [];
|
||||
const other = { input_qty: 0, defect_qty: 0, lot_count: 0 };
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const [name, data] = sorted[i];
|
||||
if (i < CHART_TOP_N) {
|
||||
const rate = data.input_qty > 0 ? Math.round((data.defect_qty / data.input_qty) * 1e6) / 1e4 : 0;
|
||||
items.push({ name, input_qty: data.input_qty, defect_qty: data.defect_qty, defect_rate: rate, lot_count: data.lot_count });
|
||||
} else {
|
||||
other.input_qty += data.input_qty;
|
||||
other.defect_qty += data.defect_qty;
|
||||
other.lot_count += data.lot_count;
|
||||
}
|
||||
}
|
||||
if (other.defect_qty > 0 || other.input_qty > 0) {
|
||||
const rate = other.input_qty > 0 ? Math.round((other.defect_qty / other.input_qty) * 1e6) / 1e4 : 0;
|
||||
items.push({ name: '其他', ...other, defect_rate: rate });
|
||||
}
|
||||
const totalDefects = items.reduce((s, d) => s + d.defect_qty, 0);
|
||||
let cumsum = 0;
|
||||
for (const item of items) {
|
||||
cumsum += item.defect_qty;
|
||||
item.cumulative_pct = totalDefects > 0 ? Math.round((cumsum / totalDefects) * 1e4) / 100 : 0;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const stationOptions = ref([]);
|
||||
(async () => {
|
||||
try {
|
||||
const result = await apiGet('/api/mid-section-defect/station-options');
|
||||
if (result?.success && Array.isArray(result.data)) {
|
||||
stationOptions.value = result.data;
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
})();
|
||||
const stationLabelMap = computed(() => {
|
||||
const m = {};
|
||||
for (const opt of stationOptions.value) {
|
||||
m[opt.name] = opt.label || opt.name;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const filters = reactive({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
lossReasons: [],
|
||||
station: '測試',
|
||||
direction: 'backward',
|
||||
});
|
||||
const committedFilters = ref({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
lossReasons: [],
|
||||
station: '測試',
|
||||
direction: 'backward',
|
||||
});
|
||||
|
||||
const queryMode = ref('date_range');
|
||||
const containerInputType = ref('lot');
|
||||
const containerInput = ref('');
|
||||
const resolutionInfo = ref(null);
|
||||
|
||||
const availableLossReasons = ref([]);
|
||||
const trace = useTraceProgress({ profile: 'mid_section_defect' });
|
||||
|
||||
@@ -54,6 +121,69 @@ const loading = reactive({
|
||||
|
||||
const hasQueried = ref(false);
|
||||
const queryError = ref('');
|
||||
const restoredFromCache = ref(false);
|
||||
|
||||
const upstreamStationFilter = ref([]);
|
||||
const upstreamSpecFilter = ref([]);
|
||||
const upstreamStationOptions = computed(() => {
|
||||
const attribution = analysisData.value?.attribution;
|
||||
if (!Array.isArray(attribution) || attribution.length === 0) return [];
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
for (const rec of attribution) {
|
||||
const group = rec.WORKCENTER_GROUP;
|
||||
if (group && !seen.has(group)) {
|
||||
seen.add(group);
|
||||
options.push({ value: group, label: stationLabelMap.value[group] || group });
|
||||
}
|
||||
}
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label, 'zh-TW'));
|
||||
});
|
||||
const upstreamSpecOptions = computed(() => {
|
||||
const attribution = analysisData.value?.attribution;
|
||||
if (!Array.isArray(attribution) || attribution.length === 0) return [];
|
||||
// Apply station filter first so spec options are contextual
|
||||
const base = upstreamStationFilter.value.length > 0
|
||||
? attribution.filter(rec => upstreamStationFilter.value.includes(rec.WORKCENTER_GROUP))
|
||||
: attribution;
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
for (const rec of base) {
|
||||
const family = rec.RESOURCEFAMILYNAME;
|
||||
if (family && family !== '(未知)' && !seen.has(family)) {
|
||||
seen.add(family);
|
||||
options.push(family);
|
||||
}
|
||||
}
|
||||
return options.sort((a, b) => a.localeCompare(b, 'zh-TW'));
|
||||
});
|
||||
const filteredByMachineData = computed(() => {
|
||||
const attribution = analysisData.value?.attribution;
|
||||
const hasFilter = upstreamStationFilter.value.length > 0 || upstreamSpecFilter.value.length > 0;
|
||||
if (!hasFilter || !Array.isArray(attribution) || attribution.length === 0) {
|
||||
return analysisData.value?.charts?.by_machine ?? [];
|
||||
}
|
||||
const filtered = attribution.filter(rec => {
|
||||
if (upstreamStationFilter.value.length > 0 && !upstreamStationFilter.value.includes(rec.WORKCENTER_GROUP)) return false;
|
||||
if (upstreamSpecFilter.value.length > 0 && !upstreamSpecFilter.value.includes(rec.RESOURCEFAMILYNAME)) return false;
|
||||
return true;
|
||||
});
|
||||
return filtered.length > 0 ? buildMachineChartFromAttribution(filtered) : [];
|
||||
});
|
||||
|
||||
const isForward = computed(() => committedFilters.value.direction === 'forward');
|
||||
const committedStation = computed(() => {
|
||||
const key = committedFilters.value.station || '測試';
|
||||
return stationLabelMap.value[key] || key;
|
||||
});
|
||||
|
||||
const headerSubtitle = computed(() => {
|
||||
const station = committedStation.value;
|
||||
if (isForward.value) {
|
||||
return `${station}站不良批次 → 追蹤倖存批次下游表現`;
|
||||
}
|
||||
return `${station}站不良 → 回溯上游機台歸因`;
|
||||
});
|
||||
|
||||
const hasTraceError = computed(() => (
|
||||
Boolean(trace.stage_errors.seed)
|
||||
@@ -67,7 +197,9 @@ const showTraceProgress = computed(() => (
|
||||
));
|
||||
const eventsAggregation = computed(() => trace.stage_results.events?.aggregation || null);
|
||||
const showAnalysisSkeleton = computed(() => hasQueried.value && loading.querying && !eventsAggregation.value);
|
||||
const showAnalysisCharts = computed(() => hasQueried.value && Boolean(eventsAggregation.value));
|
||||
const showAnalysisCharts = computed(() => hasQueried.value && (Boolean(eventsAggregation.value) || restoredFromCache.value));
|
||||
|
||||
const skeletonChartCount = computed(() => (isForward.value ? 4 : 6));
|
||||
|
||||
function emptyAnalysisData() {
|
||||
return {
|
||||
@@ -105,12 +237,20 @@ function unwrapApiResult(result, fallbackMessage) {
|
||||
function buildFilterParams() {
|
||||
const snapshot = committedFilters.value;
|
||||
const params = {
|
||||
start_date: snapshot.startDate,
|
||||
end_date: snapshot.endDate,
|
||||
station: snapshot.station,
|
||||
direction: snapshot.direction,
|
||||
};
|
||||
if (snapshot.lossReasons.length) {
|
||||
params.loss_reasons = snapshot.lossReasons;
|
||||
}
|
||||
if (snapshot.queryMode === 'container') {
|
||||
params.mode = 'container';
|
||||
params.resolve_type = snapshot.containerInputType === 'lot' ? 'lot_id' : snapshot.containerInputType;
|
||||
params.values = snapshot.containerValues;
|
||||
} else {
|
||||
params.start_date = snapshot.startDate;
|
||||
params.end_date = snapshot.endDate;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -119,6 +259,8 @@ function buildDetailParams() {
|
||||
const params = {
|
||||
start_date: snapshot.startDate,
|
||||
end_date: snapshot.endDate,
|
||||
station: snapshot.station,
|
||||
direction: snapshot.direction,
|
||||
};
|
||||
if (snapshot.lossReasons.length) {
|
||||
params.loss_reasons = snapshot.lossReasons.join(',');
|
||||
@@ -126,11 +268,23 @@ function buildDetailParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
function parseContainerValues() {
|
||||
return containerInput.value
|
||||
.split(/[\n,;]+/)
|
||||
.map((v) => v.trim().replace(/\*/g, '%'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function snapshotFilters() {
|
||||
committedFilters.value = {
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate,
|
||||
lossReasons: [...filters.lossReasons],
|
||||
station: filters.station,
|
||||
direction: filters.direction,
|
||||
queryMode: queryMode.value,
|
||||
containerInputType: containerInputType.value,
|
||||
containerValues: parseContainerValues(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,16 +337,30 @@ async function loadDetail(page = 1, signal = null) {
|
||||
|
||||
async function loadAnalysis() {
|
||||
queryError.value = '';
|
||||
restoredFromCache.value = false;
|
||||
resolutionInfo.value = null;
|
||||
upstreamStationFilter.value = [];
|
||||
upstreamSpecFilter.value = [];
|
||||
trace.abort();
|
||||
trace.reset();
|
||||
loading.querying = true;
|
||||
hasQueried.value = true;
|
||||
analysisData.value = emptyAnalysisData();
|
||||
|
||||
const isContainerMode = committedFilters.value.queryMode === 'container';
|
||||
|
||||
try {
|
||||
const params = buildFilterParams();
|
||||
await trace.execute(params);
|
||||
|
||||
// Extract resolution info for container mode
|
||||
if (isContainerMode && trace.stage_results.seed) {
|
||||
resolutionInfo.value = {
|
||||
resolved_count: trace.stage_results.seed.seed_count || 0,
|
||||
not_found: trace.stage_results.seed.not_found || [],
|
||||
};
|
||||
}
|
||||
|
||||
if (eventsAggregation.value) {
|
||||
analysisData.value = {
|
||||
...analysisData.value,
|
||||
@@ -206,7 +374,11 @@ async function loadAnalysis() {
|
||||
}
|
||||
|
||||
if (!stageError || trace.completed_stages.value.includes('events')) {
|
||||
await loadDetail(1, createAbortSignal('msd-detail'));
|
||||
// Container mode: no detail/export (no date range for legacy API)
|
||||
if (!isContainerMode) {
|
||||
await loadDetail(1, createAbortSignal('msd-detail'));
|
||||
}
|
||||
saveSession();
|
||||
}
|
||||
|
||||
if (!autoRefreshStarted) {
|
||||
@@ -247,6 +419,8 @@ function exportCsv() {
|
||||
const params = new URLSearchParams({
|
||||
start_date: snapshot.startDate,
|
||||
end_date: snapshot.endDate,
|
||||
station: snapshot.station,
|
||||
direction: snapshot.direction,
|
||||
});
|
||||
if (snapshot.lossReasons.length) {
|
||||
params.set('loss_reasons', snapshot.lossReasons.join(','));
|
||||
@@ -254,7 +428,7 @@ function exportCsv() {
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/mid-section-defect/export?${params.toString()}`;
|
||||
link.download = `mid_section_defect_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
|
||||
link.download = `mid_section_defect_${snapshot.station}_${snapshot.direction}_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -271,7 +445,53 @@ const { createAbortSignal, startAutoRefresh } = useAutoRefresh({
|
||||
refreshOnVisible: true,
|
||||
});
|
||||
|
||||
function saveSession() {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify({
|
||||
ts: Date.now(),
|
||||
committedFilters: committedFilters.value,
|
||||
filters: { ...filters },
|
||||
analysisData: analysisData.value,
|
||||
detailData: detailData.value,
|
||||
detailPagination: detailPagination.value,
|
||||
availableLossReasons: availableLossReasons.value,
|
||||
queryMode: queryMode.value,
|
||||
containerInputType: containerInputType.value,
|
||||
containerInput: containerInput.value,
|
||||
resolutionInfo: resolutionInfo.value,
|
||||
}));
|
||||
} catch { /* quota exceeded or unavailable */ }
|
||||
}
|
||||
|
||||
function restoreSession() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
if (Date.now() - data.ts > SESSION_CACHE_TTL) {
|
||||
sessionStorage.removeItem(SESSION_CACHE_KEY);
|
||||
return false;
|
||||
}
|
||||
Object.assign(filters, data.filters);
|
||||
committedFilters.value = data.committedFilters;
|
||||
analysisData.value = data.analysisData;
|
||||
detailData.value = data.detailData || [];
|
||||
detailPagination.value = data.detailPagination || { page: 1, page_size: PAGE_SIZE, total_count: 0, total_pages: 1 };
|
||||
availableLossReasons.value = data.availableLossReasons || [];
|
||||
queryMode.value = data.queryMode || 'date_range';
|
||||
containerInputType.value = data.containerInputType || 'lot';
|
||||
containerInput.value = data.containerInput || '';
|
||||
resolutionInfo.value = data.resolutionInfo || null;
|
||||
hasQueried.value = true;
|
||||
restoredFromCache.value = true;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initPage() {
|
||||
if (restoreSession()) return;
|
||||
setDefaultDates();
|
||||
snapshotFilters();
|
||||
void loadLossReasons();
|
||||
@@ -283,16 +503,24 @@ void initPage();
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-header">
|
||||
<h1>中段製程不良追溯分析</h1>
|
||||
<p class="header-desc">TMTT 測試站不良回溯至上游機台 / 站點 / 製程</p>
|
||||
<h1>製程不良追溯分析</h1>
|
||||
<p class="header-desc">{{ headerSubtitle }}</p>
|
||||
</header>
|
||||
|
||||
<FilterBar
|
||||
:filters="filters"
|
||||
:loading="loading.querying"
|
||||
:available-loss-reasons="availableLossReasons"
|
||||
:station-options="stationOptions"
|
||||
:query-mode="queryMode"
|
||||
:container-input-type="containerInputType"
|
||||
:container-input="containerInput"
|
||||
:resolution-info="resolutionInfo"
|
||||
@update-filters="handleUpdateFilters"
|
||||
@query="handleQuery"
|
||||
@update:query-mode="queryMode = $event"
|
||||
@update:container-input-type="containerInputType = $event"
|
||||
@update:container-input="containerInput = $event"
|
||||
/>
|
||||
|
||||
<TraceProgressBar
|
||||
@@ -306,7 +534,7 @@ void initPage();
|
||||
|
||||
<template v-if="hasQueried">
|
||||
<div v-if="analysisData.genealogy_status === 'error'" class="warning-banner">
|
||||
追溯分析未完成(genealogy 查詢失敗),圖表僅顯示 TMTT 站點數據。
|
||||
追溯分析未完成(genealogy 查詢失敗),圖表僅顯示偵測站數據。
|
||||
</div>
|
||||
|
||||
<div v-if="showAnalysisSkeleton" class="trace-skeleton-section">
|
||||
@@ -314,29 +542,65 @@ void initPage();
|
||||
<div v-for="index in 6" :key="`kpi-${index}`" class="trace-skeleton-card trace-skeleton-pulse"></div>
|
||||
</div>
|
||||
<div class="trace-skeleton-chart-grid">
|
||||
<div v-for="index in 6" :key="`chart-${index}`" class="trace-skeleton-chart trace-skeleton-pulse"></div>
|
||||
<div v-for="index in skeletonChartCount" :key="`chart-${index}`" class="trace-skeleton-chart trace-skeleton-pulse"></div>
|
||||
<div class="trace-skeleton-chart trace-skeleton-trend trace-skeleton-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="trace-fade">
|
||||
<div v-if="showAnalysisCharts">
|
||||
<KpiCards :kpi="analysisData.kpi" :loading="false" />
|
||||
<KpiCards
|
||||
:kpi="analysisData.kpi"
|
||||
:loading="false"
|
||||
:direction="committedFilters.direction"
|
||||
:station-label="committedStation"
|
||||
/>
|
||||
|
||||
<div class="charts-section">
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依站點歸因" :data="analysisData.charts?.by_station" />
|
||||
<ParetoChart title="依不良原因" :data="analysisData.charts?.by_loss_reason" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依上游機台歸因" :data="analysisData.charts?.by_machine" />
|
||||
<ParetoChart title="依 TMTT 機台" :data="analysisData.charts?.by_tmtt_machine" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依製程 (WORKFLOW)" :data="analysisData.charts?.by_workflow" />
|
||||
<ParetoChart title="依封裝 (PACKAGE)" :data="analysisData.charts?.by_package" />
|
||||
</div>
|
||||
<div class="charts-row charts-row-full">
|
||||
<template v-if="!isForward">
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依上游機台歸因" :data="filteredByMachineData">
|
||||
<template #header-extra>
|
||||
<div class="chart-inline-filters">
|
||||
<MultiSelect
|
||||
v-if="upstreamStationOptions.length > 1"
|
||||
:model-value="upstreamStationFilter"
|
||||
:options="upstreamStationOptions"
|
||||
placeholder="全部站點"
|
||||
@update:model-value="upstreamStationFilter = $event; upstreamSpecFilter = []"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-if="upstreamSpecOptions.length > 1"
|
||||
:model-value="upstreamSpecFilter"
|
||||
:options="upstreamSpecOptions"
|
||||
placeholder="全部型號"
|
||||
@update:model-value="upstreamSpecFilter = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ParetoChart>
|
||||
<ParetoChart title="依不良原因" :data="analysisData.charts?.by_loss_reason" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依偵測機台" :data="analysisData.charts?.by_detection_machine" />
|
||||
<ParetoChart title="依製程 (WORKFLOW)" :data="analysisData.charts?.by_workflow" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依封裝 (PACKAGE)" :data="analysisData.charts?.by_package" />
|
||||
<ParetoChart title="依 TYPE" :data="analysisData.charts?.by_pj_type" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依下游站點" :data="analysisData.charts?.by_downstream_station" />
|
||||
<ParetoChart title="依下游不良原因" :data="analysisData.charts?.by_downstream_loss_reason" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依下游機台" :data="analysisData.charts?.by_downstream_machine" />
|
||||
<ParetoChart title="依偵測機台" :data="analysisData.charts?.by_detection_machine" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="committedFilters.queryMode !== 'container'" class="charts-row charts-row-full">
|
||||
<TrendChart :data="analysisData.daily_trend" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,9 +608,11 @@ void initPage();
|
||||
</transition>
|
||||
|
||||
<DetailTable
|
||||
v-if="committedFilters.queryMode !== 'container'"
|
||||
:data="detailData"
|
||||
:loading="detailLoading"
|
||||
:pagination="detailPagination"
|
||||
:direction="committedFilters.direction"
|
||||
@export-csv="exportCsv"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
@@ -354,7 +620,7 @@ void initPage();
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loading.querying" class="empty-state">
|
||||
<p>請選擇日期範圍與不良原因,點擊「查詢」開始分析。</p>
|
||||
<p>請選擇偵測站與查詢條件,點擊「查詢」開始分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({ page: 1, page_size: 200, total_count: 0, total_pages: 1 }),
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'backward',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
|
||||
@@ -23,12 +27,12 @@ const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
|
||||
const sortField = ref('DEFECT_RATE');
|
||||
const sortAsc = ref(false);
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS_BACKWARD = [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID', width: '140px' },
|
||||
{ key: 'PJ_TYPE', label: 'TYPE', width: '80px' },
|
||||
{ key: 'PRODUCTLINENAME', label: 'PACKAGE', width: '90px' },
|
||||
{ key: 'WORKFLOW', label: 'WORKFLOW', width: '100px' },
|
||||
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT 設備', width: '110px' },
|
||||
{ key: 'DETECTION_EQUIPMENTNAME', label: '偵測設備', width: '110px' },
|
||||
{ key: 'INPUT_QTY', label: '投入數', width: '70px', numeric: true },
|
||||
{ key: 'LOSS_REASON', label: '不良原因', width: '130px' },
|
||||
{ key: 'DEFECT_QTY', label: '不良數', width: '70px', numeric: true },
|
||||
@@ -37,6 +41,21 @@ const COLUMNS = [
|
||||
{ key: 'UPSTREAM_MACHINES', label: '上游機台', width: '200px' },
|
||||
];
|
||||
|
||||
const COLUMNS_FORWARD = [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID', width: '140px' },
|
||||
{ key: 'DETECTION_EQUIPMENTNAME', label: '偵測設備', width: '120px' },
|
||||
{ key: 'TRACKINQTY', label: '偵測投入', width: '80px', numeric: true },
|
||||
{ key: 'DEFECT_QTY', label: '偵測不良', width: '80px', numeric: true },
|
||||
{ key: 'DOWNSTREAM_STATIONS_REACHED', label: '下游到達站數', width: '100px', numeric: true },
|
||||
{ key: 'DOWNSTREAM_TOTAL_REJECT', label: '下游不良總數', width: '100px', numeric: true },
|
||||
{ key: 'DOWNSTREAM_REJECT_RATE', label: '下游不良率(%)', width: '110px', numeric: true },
|
||||
{ key: 'WORST_DOWNSTREAM_STATION', label: '最差下游站', width: '120px' },
|
||||
];
|
||||
|
||||
const activeColumns = computed(() => (
|
||||
props.direction === 'forward' ? COLUMNS_FORWARD : COLUMNS_BACKWARD
|
||||
));
|
||||
|
||||
const sortedData = computed(() => {
|
||||
if (!props.data || !props.data.length) return [];
|
||||
const field = sortField.value;
|
||||
@@ -80,7 +99,7 @@ function sortIcon(field) {
|
||||
|
||||
function formatCell(value, col) {
|
||||
if (value == null || value === '') return '-';
|
||||
if (col.key === 'DEFECT_RATE') return Number(value).toFixed(2);
|
||||
if (col.key === 'DEFECT_RATE' || col.key === 'DOWNSTREAM_REJECT_RATE') return Number(value).toFixed(2);
|
||||
if (col.numeric) return Number(value).toLocaleString();
|
||||
return value;
|
||||
}
|
||||
@@ -104,7 +123,7 @@ function formatCell(value, col) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in COLUMNS"
|
||||
v-for="col in activeColumns"
|
||||
:key="col.key"
|
||||
:style="{ width: col.width }"
|
||||
class="sortable"
|
||||
@@ -116,12 +135,12 @@ function formatCell(value, col) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in sortedData" :key="idx">
|
||||
<td v-for="col in COLUMNS" :key="col.key" :class="{ numeric: col.numeric }">
|
||||
<td v-for="col in activeColumns" :key="col.key" :class="{ numeric: col.numeric }">
|
||||
{{ formatCell(row[col.key], col) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!sortedData.length">
|
||||
<td :colspan="COLUMNS.length" class="empty-row">暫無資料</td>
|
||||
<td :colspan="activeColumns.length" class="empty-row">暫無資料</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -14,9 +14,35 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
stationOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
queryMode: {
|
||||
type: String,
|
||||
default: 'date_range',
|
||||
},
|
||||
containerInputType: {
|
||||
type: String,
|
||||
default: 'lot',
|
||||
},
|
||||
containerInput: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
resolutionInfo: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-filters', 'query']);
|
||||
const emit = defineEmits([
|
||||
'update-filters',
|
||||
'query',
|
||||
'update:queryMode',
|
||||
'update:containerInputType',
|
||||
'update:containerInput',
|
||||
]);
|
||||
|
||||
function updateFilters(patch) {
|
||||
emit('update-filters', {
|
||||
@@ -29,29 +55,112 @@ function updateFilters(patch) {
|
||||
<template>
|
||||
<section class="section-card">
|
||||
<div class="section-inner">
|
||||
<!-- Mode toggle tabs -->
|
||||
<div class="mode-tab-row">
|
||||
<button
|
||||
type="button"
|
||||
:class="['mode-tab', { active: queryMode === 'date_range' }]"
|
||||
@click="$emit('update:queryMode', 'date_range')"
|
||||
>
|
||||
日期區間
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['mode-tab', { active: queryMode === 'container' }]"
|
||||
@click="$emit('update:queryMode', 'container')"
|
||||
>
|
||||
LOT / 工單 / WAFER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<!-- Shared: detection station -->
|
||||
<div class="filter-field">
|
||||
<label for="msd-start-date">開始</label>
|
||||
<input
|
||||
id="msd-start-date"
|
||||
type="date"
|
||||
:value="filters.startDate"
|
||||
<label for="msd-station">偵測站</label>
|
||||
<select
|
||||
id="msd-station"
|
||||
:value="filters.station"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ startDate: $event.target.value })"
|
||||
/>
|
||||
class="filter-select"
|
||||
@change="updateFilters({ station: $event.target.value })"
|
||||
>
|
||||
<option
|
||||
v-for="opt in stationOptions"
|
||||
:key="opt.name"
|
||||
:value="opt.name"
|
||||
>
|
||||
{{ opt.label || opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-field">
|
||||
<label for="msd-end-date">結束</label>
|
||||
<input
|
||||
id="msd-end-date"
|
||||
type="date"
|
||||
:value="filters.endDate"
|
||||
<!-- Container mode: input type -->
|
||||
<div v-if="queryMode === 'container'" class="filter-field">
|
||||
<label for="msd-container-type">輸入類型</label>
|
||||
<select
|
||||
id="msd-container-type"
|
||||
class="filter-select"
|
||||
:value="containerInputType"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ endDate: $event.target.value })"
|
||||
/>
|
||||
@change="$emit('update:containerInputType', $event.target.value)"
|
||||
>
|
||||
<option value="lot">LOT</option>
|
||||
<option value="work_order">工單</option>
|
||||
<option value="wafer_lot">WAFER LOT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Shared: direction -->
|
||||
<div class="filter-field">
|
||||
<label>方向</label>
|
||||
<div class="direction-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="direction-btn"
|
||||
:class="{ active: filters.direction === 'backward' }"
|
||||
:disabled="loading"
|
||||
@click="updateFilters({ direction: 'backward' })"
|
||||
>
|
||||
反向追溯
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="direction-btn"
|
||||
:class="{ active: filters.direction === 'forward' }"
|
||||
:disabled="loading"
|
||||
@click="updateFilters({ direction: 'forward' })"
|
||||
>
|
||||
正向追溯
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date range mode: dates -->
|
||||
<template v-if="queryMode === 'date_range'">
|
||||
<div class="filter-field">
|
||||
<label for="msd-start-date">開始</label>
|
||||
<input
|
||||
id="msd-start-date"
|
||||
type="date"
|
||||
:value="filters.startDate"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ startDate: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-field">
|
||||
<label for="msd-end-date">結束</label>
|
||||
<input
|
||||
id="msd-end-date"
|
||||
type="date"
|
||||
:value="filters.endDate"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ endDate: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shared: loss reasons -->
|
||||
<div class="filter-field">
|
||||
<label>不良原因</label>
|
||||
<MultiSelect
|
||||
@@ -72,6 +181,33 @@ function updateFilters(patch) {
|
||||
查詢
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Container mode: textarea input -->
|
||||
<div v-if="queryMode === 'container'" class="container-input-row">
|
||||
<textarea
|
||||
class="filter-textarea"
|
||||
rows="3"
|
||||
:value="containerInput"
|
||||
:disabled="loading"
|
||||
placeholder="每行一個,支援 * 或 % wildcard GA26020001-A00-001 GA260200% ..."
|
||||
@input="$emit('update:containerInput', $event.target.value)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Resolution info (container mode) -->
|
||||
<div
|
||||
v-if="resolutionInfo && queryMode === 'container'"
|
||||
class="resolution-info"
|
||||
>
|
||||
已解析 {{ resolutionInfo.resolved_count }} 筆容器
|
||||
<template v-if="resolutionInfo.not_found?.length > 0">
|
||||
<span class="resolution-warn">
|
||||
({{ resolutionInfo.not_found.length }} 筆未找到:
|
||||
{{ resolutionInfo.not_found.slice(0, 10).join(', ')
|
||||
}}{{ resolutionInfo.not_found.length > 10 ? '...' : '' }})
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -18,12 +18,18 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const rootRef = ref(null);
|
||||
const searchRef = ref(null);
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const normalizedOptions = computed(() => {
|
||||
return props.options.map((option) => {
|
||||
@@ -43,6 +49,14 @@ const normalizedOptions = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) return normalizedOptions.value;
|
||||
return normalizedOptions.value.filter(
|
||||
(opt) => opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedSet = computed(() => new Set((props.modelValue || []).map((value) => String(value))));
|
||||
|
||||
const selectedText = computed(() => {
|
||||
@@ -71,6 +85,17 @@ function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
searchQuery.value = '';
|
||||
nextTick(() => {
|
||||
if (props.searchable && searchRef.value) {
|
||||
searchRef.value.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function isSelected(value) {
|
||||
return selectedSet.value.has(String(value));
|
||||
}
|
||||
@@ -131,9 +156,19 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="multi-select-dropdown">
|
||||
<div v-if="searchable" class="multi-select-search">
|
||||
<input
|
||||
ref="searchRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="multi-select-search-input"
|
||||
placeholder="搜尋..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="multi-select-options">
|
||||
<button
|
||||
v-for="option in normalizedOptions"
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="multi-select-option"
|
||||
@@ -142,6 +177,9 @@ onBeforeUnmount(() => {
|
||||
<input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" />
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
<div v-if="filteredOptions.length === 0" class="multi-select-no-match">
|
||||
無符合項目
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="multi-select-actions">
|
||||
|
||||
@@ -120,7 +120,10 @@ const chartOption = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-title">{{ title }}</h3>
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">{{ title }}</h3>
|
||||
<slot name="header-extra" />
|
||||
</div>
|
||||
<VChart
|
||||
v-if="chartOption"
|
||||
class="chart-canvas"
|
||||
|
||||
@@ -71,6 +71,83 @@ body {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ====== Mode Tabs ====== */
|
||||
.mode-tab-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
background: #f8fafc;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
background: var(--msd-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-tab:not(:last-child) {
|
||||
border-right: 1px solid var(--msd-border);
|
||||
}
|
||||
|
||||
.mode-tab:hover:not(.active) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ====== Container Input ====== */
|
||||
.container-input-row {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
|
||||
.filter-textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.filter-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--msd-primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.filter-textarea:disabled {
|
||||
background: #f1f5f9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ====== Resolution Info ====== */
|
||||
.resolution-info {
|
||||
padding: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resolution-warn {
|
||||
color: #b45309;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ====== Filter Bar ====== */
|
||||
.filter-row {
|
||||
display: flex;
|
||||
@@ -139,6 +216,54 @@ body {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* ====== Filter Select ====== */
|
||||
.filter-select {
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* ====== Direction Toggle ====== */
|
||||
.direction-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.direction-btn {
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.direction-btn + .direction-btn {
|
||||
border-left: 1px solid var(--msd-border);
|
||||
}
|
||||
|
||||
.direction-btn.active {
|
||||
background: var(--msd-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.direction-btn:hover:not(:disabled):not(.active) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.direction-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ====== MultiSelect ====== */
|
||||
.multi-select {
|
||||
position: relative;
|
||||
@@ -220,6 +345,33 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.multi-select-search {
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
|
||||
.multi-select-search-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 5px;
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.multi-select-search-input:focus {
|
||||
border-color: var(--msd-primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.multi-select-no-match {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--msd-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.multi-select-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -322,6 +474,18 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-header .chart-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
@@ -329,6 +493,48 @@ body {
|
||||
color: var(--msd-text);
|
||||
}
|
||||
|
||||
.chart-inline-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-inline-filters .multi-select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.chart-inline-filters .multi-select-trigger {
|
||||
padding: 3px 7px;
|
||||
font-size: 12px;
|
||||
border-color: var(--msd-border);
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.chart-inline-filters .multi-select-dropdown {
|
||||
min-width: 220px;
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.chart-inline-filter {
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 5px;
|
||||
padding: 3px 7px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-inline-filter:focus {
|
||||
outline: 2px solid var(--msd-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
|
||||
@@ -62,14 +62,14 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
() => import('../query-tool/App.vue'),
|
||||
[() => import('../query-tool/style.css')],
|
||||
),
|
||||
'/tmtt-defect': createNativeLoader(
|
||||
() => import('../tmtt-defect/App.vue'),
|
||||
[() => import('../tmtt-defect/style.css')],
|
||||
),
|
||||
'/tables': createNativeLoader(
|
||||
() => import('../tables/App.vue'),
|
||||
[() => import('../tables/style.css')],
|
||||
),
|
||||
'/mid-section-defect': createNativeLoader(
|
||||
() => import('../mid-section-defect/App.vue'),
|
||||
[() => import('../mid-section-defect/style.css')],
|
||||
),
|
||||
'/admin/performance': createNativeLoader(
|
||||
() => import('../admin-performance/App.vue'),
|
||||
[() => import('../admin-performance/style.css')],
|
||||
|
||||
@@ -9,7 +9,6 @@ const IN_SCOPE_REPORT_ROUTES = Object.freeze([
|
||||
'/resource-history',
|
||||
'/qc-gate',
|
||||
'/job-query',
|
||||
'/tmtt-defect',
|
||||
'/tables',
|
||||
'/excel-query',
|
||||
'/query-tool',
|
||||
@@ -165,17 +164,6 @@ const ROUTE_CONTRACTS = Object.freeze({
|
||||
scope: 'in-scope',
|
||||
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
|
||||
}),
|
||||
'/tmtt-defect': buildContract({
|
||||
route: '/tmtt-defect',
|
||||
routeId: 'tmtt-defect',
|
||||
renderMode: 'native',
|
||||
owner: 'frontend-mes-reporting',
|
||||
title: 'TMTT Defect',
|
||||
rollbackStrategy: 'fallback_to_legacy_route',
|
||||
visibilityPolicy: 'released_or_admin',
|
||||
scope: 'in-scope',
|
||||
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
|
||||
}),
|
||||
'/admin/pages': buildContract({
|
||||
route: '/admin/pages',
|
||||
routeId: 'admin-pages',
|
||||
@@ -236,7 +224,7 @@ const ROUTE_CONTRACTS = Object.freeze({
|
||||
routeId: 'mid-section-defect',
|
||||
renderMode: 'native',
|
||||
owner: 'frontend-mes-reporting',
|
||||
title: '中段製程不良追溯',
|
||||
title: '製程不良追溯分析',
|
||||
rollbackStrategy: 'fallback_to_legacy_route',
|
||||
visibilityPolicy: 'released_or_admin',
|
||||
scope: 'in-scope',
|
||||
|
||||
@@ -8,6 +8,7 @@ const DEFAULT_STAGE_TIMEOUT_MS = 60000;
|
||||
const PROFILE_DOMAINS = Object.freeze({
|
||||
query_tool: ['history', 'materials', 'rejects', 'holds', 'jobs'],
|
||||
mid_section_defect: ['upstream_history'],
|
||||
mid_section_defect_forward: ['upstream_history', 'downstream_rejects'],
|
||||
});
|
||||
|
||||
function stageKey(stageName) {
|
||||
@@ -31,23 +32,42 @@ function normalizeSeedContainerIds(seedPayload) {
|
||||
return containerIds;
|
||||
}
|
||||
|
||||
function collectAllContainerIds(seedContainerIds, lineagePayload) {
|
||||
function collectAllContainerIds(seedContainerIds, lineagePayload, direction) {
|
||||
const seen = new Set(seedContainerIds);
|
||||
const merged = [...seedContainerIds];
|
||||
const ancestors = lineagePayload?.ancestors || {};
|
||||
Object.values(ancestors).forEach((values) => {
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
|
||||
if (direction === 'forward') {
|
||||
const childrenMap = lineagePayload?.children_map || {};
|
||||
const queue = [...seedContainerIds];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const children = childrenMap[current];
|
||||
if (!Array.isArray(children)) continue;
|
||||
for (const child of children) {
|
||||
const id = String(child || '').trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
queue.push(id);
|
||||
}
|
||||
}
|
||||
values.forEach((value) => {
|
||||
const id = String(value || '').trim();
|
||||
if (!id || seen.has(id)) {
|
||||
} else {
|
||||
const ancestors = lineagePayload?.ancestors || {};
|
||||
Object.values(ancestors).forEach((values) => {
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
values.forEach((value) => {
|
||||
const id = String(value || '').trim();
|
||||
if (!id || seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -89,7 +109,11 @@ export function useTraceProgress({ profile } = {}) {
|
||||
}
|
||||
|
||||
async function execute(params = {}) {
|
||||
const domains = PROFILE_DOMAINS[profile];
|
||||
const direction = params.direction || 'backward';
|
||||
const domainKey = profile === 'mid_section_defect' && direction === 'forward'
|
||||
? 'mid_section_defect_forward'
|
||||
: profile;
|
||||
const domains = PROFILE_DOMAINS[domainKey];
|
||||
if (!domains) {
|
||||
throw new Error(`Unsupported trace profile: ${profile}`);
|
||||
}
|
||||
@@ -123,13 +147,14 @@ export function useTraceProgress({ profile } = {}) {
|
||||
profile,
|
||||
container_ids: seedContainerIds,
|
||||
cache_key: seedPayload?.cache_key || null,
|
||||
params,
|
||||
},
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
);
|
||||
stage_results.lineage = lineagePayload;
|
||||
completed_stages.value = [...completed_stages.value, 'lineage'];
|
||||
|
||||
const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload);
|
||||
const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload, direction);
|
||||
current_stage.value = 'events';
|
||||
const eventsPayload = await apiPost(
|
||||
'/api/trace/events',
|
||||
@@ -142,6 +167,7 @@ export function useTraceProgress({ profile } = {}) {
|
||||
seed_container_ids: seedContainerIds,
|
||||
lineage: {
|
||||
ancestors: lineagePayload?.ancestors || {},
|
||||
children_map: lineagePayload?.children_map || {},
|
||||
},
|
||||
},
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
|
||||
import TmttChartCard from './components/TmttChartCard.vue';
|
||||
import TmttDetailTable from './components/TmttDetailTable.vue';
|
||||
import TmttKpiCards from './components/TmttKpiCards.vue';
|
||||
import { useTmttDefectData } from './composables/useTmttDefectData.js';
|
||||
|
||||
const {
|
||||
startDate,
|
||||
endDate,
|
||||
loading,
|
||||
errorMessage,
|
||||
hasData,
|
||||
kpi,
|
||||
charts,
|
||||
dailyTrend,
|
||||
filteredRows,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
activeFilter,
|
||||
sortState,
|
||||
queryData,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
toggleSort,
|
||||
exportCsv,
|
||||
} = useTmttDefectData();
|
||||
|
||||
const paretoCharts = [
|
||||
{ key: 'by_workflow', field: 'WORKFLOW', title: '依 WORKFLOW' },
|
||||
{ key: 'by_package', field: 'PRODUCTLINENAME', title: '依 PACKAGE' },
|
||||
{ key: 'by_type', field: 'PJ_TYPE', title: '依 TYPE' },
|
||||
{ key: 'by_tmtt_machine', field: 'TMTT_EQUIPMENTNAME', title: '依 TMTT 機台' },
|
||||
{ key: 'by_mold_machine', field: 'MOLD_EQUIPMENTNAME', title: '依 MOLD 機台' },
|
||||
];
|
||||
|
||||
const detailCountLabel = computed(() => {
|
||||
if (!activeFilter.value) {
|
||||
return `${filteredCount.value} 筆`;
|
||||
}
|
||||
return `${filteredCount.value} / ${totalCount.value} 筆`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-page u-content-shell">
|
||||
<header class="tmtt-header">
|
||||
<h1>TMTT 印字與腳型不良分析</h1>
|
||||
<p>Legacy rewrite exemplar:Vue 元件化 + Shared UI + Tailwind token layer</p>
|
||||
</header>
|
||||
|
||||
<div class="u-panel-stack">
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<div class="tmtt-block-title">查詢條件</div>
|
||||
</template>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="tmtt-field">
|
||||
<span>起始日期</span>
|
||||
<input v-model="startDate" type="date" />
|
||||
</label>
|
||||
<label class="tmtt-field">
|
||||
<span>結束日期</span>
|
||||
<input v-model="endDate" type="date" />
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button type="button" class="tmtt-btn tmtt-btn-primary" :disabled="loading" @click="queryData">
|
||||
{{ loading ? '查詢中...' : '查詢' }}
|
||||
</button>
|
||||
<button type="button" class="tmtt-btn tmtt-btn-success" :disabled="loading" @click="exportCsv">
|
||||
匯出 CSV
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
</SectionCard>
|
||||
|
||||
<p v-if="errorMessage" class="tmtt-error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<template v-if="hasData">
|
||||
<TmttKpiCards :kpi="kpi" />
|
||||
|
||||
<div class="tmtt-chart-grid">
|
||||
<TmttChartCard
|
||||
v-for="config in paretoCharts"
|
||||
:key="config.key"
|
||||
:title="config.title"
|
||||
mode="pareto"
|
||||
:field="config.field"
|
||||
:selected-value="activeFilter?.value || ''"
|
||||
:data="charts[config.key] || []"
|
||||
@select="setFilter"
|
||||
/>
|
||||
|
||||
<TmttChartCard
|
||||
title="每日印字不良率趨勢"
|
||||
mode="print-trend"
|
||||
:data="dailyTrend"
|
||||
line-label="印字不良率"
|
||||
line-color="#ef4444"
|
||||
/>
|
||||
|
||||
<TmttChartCard
|
||||
title="每日腳型不良率趨勢"
|
||||
mode="lead-trend"
|
||||
:data="dailyTrend"
|
||||
line-label="腳型不良率"
|
||||
line-color="#f59e0b"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<div class="tmtt-detail-header">
|
||||
<div>
|
||||
<strong>明細清單</strong>
|
||||
<span class="tmtt-detail-count">({{ detailCountLabel }})</span>
|
||||
</div>
|
||||
<div class="tmtt-detail-actions">
|
||||
<StatusBadge
|
||||
v-if="activeFilter"
|
||||
tone="warning"
|
||||
:text="activeFilter.label"
|
||||
/>
|
||||
<button
|
||||
v-if="activeFilter"
|
||||
type="button"
|
||||
class="tmtt-btn tmtt-btn-ghost"
|
||||
@click="clearFilter"
|
||||
>
|
||||
清除篩選
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<TmttDetailTable :rows="filteredRows" :sort-state="sortState" @sort="toggleSort" />
|
||||
</SectionCard>
|
||||
</template>
|
||||
|
||||
<SectionCard v-else>
|
||||
<div class="tmtt-empty-state">
|
||||
<p>請選擇日期範圍後點擊「查詢」。</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,177 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import { GridComponent, LegendComponent, TooltipComponent, TitleComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, TitleComponent]);
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'pareto',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
field: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lineLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lineColor: {
|
||||
type: String,
|
||||
default: '#6366f1',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
function emptyOption() {
|
||||
return {
|
||||
title: {
|
||||
text: '無資料',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: { color: '#94a3b8', fontSize: 14 },
|
||||
},
|
||||
xAxis: { show: false },
|
||||
yAxis: { show: false },
|
||||
series: [],
|
||||
};
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const data = props.data || [];
|
||||
if (!data.length) {
|
||||
return emptyOption();
|
||||
}
|
||||
|
||||
if (props.mode === 'pareto') {
|
||||
const names = data.map((item) => item.name);
|
||||
const printRates = data.map((item) => Number(item.print_defect_rate || 0));
|
||||
const leadRates = data.map((item) => Number(item.lead_defect_rate || 0));
|
||||
const cumPct = data.map((item) => Number(item.cumulative_pct || 0));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
},
|
||||
legend: { data: ['印字不良率', '腳型不良率', '累積%'], bottom: 0 },
|
||||
grid: { left: 56, right: 56, top: 24, bottom: names.length > 8 ? 90 : 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
axisLabel: {
|
||||
rotate: names.length > 8 ? 35 : 0,
|
||||
interval: 0,
|
||||
formatter: (value) => (value.length > 16 ? `${value.slice(0, 16)}...` : value),
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '累積%', max: 100 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '印字不良率',
|
||||
type: 'bar',
|
||||
stack: 'defect',
|
||||
data: printRates,
|
||||
itemStyle: { color: '#ef4444' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '腳型不良率',
|
||||
type: 'bar',
|
||||
stack: 'defect',
|
||||
data: leadRates,
|
||||
itemStyle: { color: '#f59e0b' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '累積%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: cumPct,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const dates = data.map((item) => item.date);
|
||||
const lineValues = data.map((item) => Number(item[props.mode === 'print-trend' ? 'print_defect_rate' : 'lead_defect_rate'] || 0));
|
||||
const inputValues = data.map((item) => Number(item.input_qty || 0));
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [props.lineLabel || '趨勢', '投入數'], bottom: 0 },
|
||||
grid: { left: 56, right: 56, top: 24, bottom: 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { rotate: dates.length > 14 ? 35 : 0 },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '投入數' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: props.lineLabel || '趨勢',
|
||||
type: 'line',
|
||||
data: lineValues,
|
||||
itemStyle: { color: props.lineColor },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
},
|
||||
{
|
||||
name: '投入數',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: inputValues,
|
||||
itemStyle: { color: '#c7d2fe' },
|
||||
barMaxWidth: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(params) {
|
||||
if (props.mode !== 'pareto' || params?.componentType !== 'series' || !params?.name || !props.field) {
|
||||
return;
|
||||
}
|
||||
emit('select', {
|
||||
field: props.field,
|
||||
value: params.name,
|
||||
label: `${props.field}: ${params.name}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="tmtt-chart-card">
|
||||
<h3>{{ title }}</h3>
|
||||
<VChart class="tmtt-chart-canvas" :option="chartOption" autoresize @click="handleClick" />
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,87 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
sortState: {
|
||||
type: Object,
|
||||
default: () => ({ column: '', asc: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sort']);
|
||||
|
||||
const columns = computed(() => [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID' },
|
||||
{ key: 'PJ_TYPE', label: 'TYPE' },
|
||||
{ key: 'PRODUCTLINENAME', label: 'PACKAGE' },
|
||||
{ key: 'WORKFLOW', label: 'WORKFLOW' },
|
||||
{ key: 'FINISHEDRUNCARD', label: '完工流水碼' },
|
||||
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT設備' },
|
||||
{ key: 'MOLD_EQUIPMENTNAME', label: 'MOLD設備' },
|
||||
{ key: 'INPUT_QTY', label: '投入數', numeric: true },
|
||||
{ key: 'PRINT_DEFECT_QTY', label: '印字不良', numeric: true, danger: true },
|
||||
{ key: 'PRINT_DEFECT_RATE', label: '印字不良率(%)', numeric: true, danger: true, decimal: 4 },
|
||||
{ key: 'LEAD_DEFECT_QTY', label: '腳型不良', numeric: true, warning: true },
|
||||
{ key: 'LEAD_DEFECT_RATE', label: '腳型不良率(%)', numeric: true, warning: true, decimal: 4 },
|
||||
]);
|
||||
|
||||
function formatNumber(value, decimal = null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
if (Number.isInteger(decimal) && decimal >= 0) {
|
||||
return n.toFixed(decimal);
|
||||
}
|
||||
return n.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function sortIndicator(key) {
|
||||
if (props.sortState?.column !== key) {
|
||||
return '';
|
||||
}
|
||||
return props.sortState?.asc ? '▲' : '▼';
|
||||
}
|
||||
|
||||
function cellClass(column) {
|
||||
const classes = [];
|
||||
if (column.numeric) classes.push('is-numeric');
|
||||
if (column.danger) classes.push('is-danger');
|
||||
if (column.warning) classes.push('is-warning');
|
||||
return classes;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-detail-table-wrap">
|
||||
<table class="tmtt-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
<button type="button" class="tmtt-sort-btn" @click="emit('sort', column.key)">
|
||||
{{ column.label }}
|
||||
<span class="tmtt-sort-indicator">{{ sortIndicator(column.key) }}</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="columns.length" class="tmtt-empty-row">無資料</td>
|
||||
</tr>
|
||||
<tr v-for="(row, index) in rows" v-else :key="`${row.CONTAINERNAME || 'row'}-${index}`">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${row.CONTAINERNAME || 'row'}-${column.key}-${index}`"
|
||||
:class="cellClass(column)"
|
||||
>
|
||||
<template v-if="column.numeric">{{ formatNumber(row[column.key], column.decimal) }}</template>
|
||||
<template v-else>{{ row[column.key] || '' }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
kpi: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
function fmtNumber(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toFixed(4);
|
||||
}
|
||||
|
||||
const cards = computed(() => {
|
||||
const value = props.kpi || {};
|
||||
return [
|
||||
{ key: 'total_input', label: '投入數', display: fmtNumber(value.total_input), tone: 'neutral' },
|
||||
{ key: 'lot_count', label: 'LOT 數', display: fmtNumber(value.lot_count), tone: 'neutral' },
|
||||
{ key: 'print_defect_qty', label: '印字不良數', display: fmtNumber(value.print_defect_qty), tone: 'danger' },
|
||||
{ key: 'print_defect_rate', label: '印字不良率', display: fmtRate(value.print_defect_rate), unit: '%', tone: 'danger' },
|
||||
{ key: 'lead_defect_qty', label: '腳型不良數', display: fmtNumber(value.lead_defect_qty), tone: 'warning' },
|
||||
{ key: 'lead_defect_rate', label: '腳型不良率', display: fmtRate(value.lead_defect_rate), unit: '%', tone: 'warning' },
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-kpi-grid">
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="tmtt-kpi-card"
|
||||
:class="`tone-${card.tone}`"
|
||||
>
|
||||
<p class="tmtt-kpi-label">{{ card.label }}</p>
|
||||
<p class="tmtt-kpi-value">
|
||||
{{ card.display }}
|
||||
<span v-if="card.unit" class="tmtt-kpi-unit">{{ card.unit }}</span>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import '../styles/tailwind.css';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ const WAVE_A_ROUTES = Object.freeze([
|
||||
'/resource',
|
||||
'/resource-history',
|
||||
'/qc-gate',
|
||||
'/tmtt-defect',
|
||||
]);
|
||||
|
||||
const WAVE_B_NATIVE_ROUTES = Object.freeze([
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-23
|
||||
@@ -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.
|
||||
@@ -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 註冊
|
||||
@@ -0,0 +1,94 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: FilterBar SHALL include station dropdown
|
||||
FilterBar SHALL display a `<select>` dropdown populated from `GET /api/mid-section-defect/station-options` on mount. The dropdown SHALL default to '測試' and emit `station` via the `update-filters` mechanism.
|
||||
|
||||
#### Scenario: Station dropdown loads on mount
|
||||
- **WHEN** the FilterBar component mounts
|
||||
- **THEN** it SHALL fetch station options from the API and populate the dropdown with 12 workcenter groups
|
||||
- **THEN** the default selection SHALL be '測試'
|
||||
|
||||
#### Scenario: Station selection updates filters
|
||||
- **WHEN** user selects a different station
|
||||
- **THEN** `update-filters` SHALL emit with the new `station` value
|
||||
|
||||
### Requirement: FilterBar SHALL include direction toggle
|
||||
FilterBar SHALL display a toggle button group with two options: '反向追溯' (`backward`) and '正向追溯' (`forward`). Default SHALL be `backward`.
|
||||
|
||||
#### Scenario: Direction toggle switches direction
|
||||
- **WHEN** user clicks '正向追溯'
|
||||
- **THEN** `update-filters` SHALL emit with `direction: 'forward'`
|
||||
- **THEN** the active button SHALL visually indicate the selected direction
|
||||
|
||||
### Requirement: KPI cards SHALL display direction-aware labels
|
||||
KpiCards component SHALL accept `direction` and `stationLabel` props and switch card labels between backward and forward modes.
|
||||
|
||||
#### Scenario: Backward KPI labels
|
||||
- **WHEN** `direction='backward'`
|
||||
- **THEN** KPI cards SHALL display existing labels: 偵測批次數, 偵測不良數, 上游追溯批次數, 上游站點數, etc.
|
||||
|
||||
#### Scenario: Forward KPI labels
|
||||
- **WHEN** `direction='forward'`
|
||||
- **THEN** KPI cards SHALL display: 偵測批次數, 偵測不良數, 追蹤批次數, 下游到達站數, 下游不良總數, 下游不良率
|
||||
|
||||
### Requirement: Chart layout SHALL switch by direction
|
||||
App.vue SHALL render direction-appropriate chart sets.
|
||||
|
||||
#### Scenario: Backward chart layout
|
||||
- **WHEN** `direction='backward'`
|
||||
- **THEN** SHALL render 6 Pareto charts: by_station, by_loss_reason, by_machine, by_detection_machine, by_workflow, by_package
|
||||
|
||||
#### Scenario: Forward chart layout
|
||||
- **WHEN** `direction='forward'`
|
||||
- **THEN** SHALL render 4 Pareto charts: by_downstream_station, by_downstream_loss_reason, by_downstream_machine, by_detection_machine
|
||||
|
||||
### Requirement: Detail table columns SHALL switch by direction
|
||||
DetailTable component SHALL accept a `direction` prop and render direction-appropriate columns.
|
||||
|
||||
#### Scenario: Backward detail columns
|
||||
- **WHEN** `direction='backward'`
|
||||
- **THEN** columns SHALL match existing backward layout (CONTAINERID, station history, upstream machine attribution, etc.)
|
||||
|
||||
#### Scenario: Forward detail columns
|
||||
- **WHEN** `direction='forward'`
|
||||
- **THEN** columns SHALL include: CONTAINERID, 偵測設備, 偵測投入, 偵測不良, 下游到達站數, 下游不良總數, 下游不良率, 最差下游站
|
||||
|
||||
### Requirement: Page header SHALL reflect station and direction
|
||||
Page title SHALL be '製程不良追溯分析'. Subtitle SHALL dynamically reflect station and direction.
|
||||
|
||||
#### Scenario: Backward subtitle
|
||||
- **WHEN** `station='電鍍', direction='backward'`
|
||||
- **THEN** subtitle SHALL indicate: `電鍍站不良 → 回溯上游機台歸因`
|
||||
|
||||
#### Scenario: Forward subtitle
|
||||
- **WHEN** `station='成型', direction='forward'`
|
||||
- **THEN** subtitle SHALL indicate: `成型站不良批次 → 追蹤倖存批次下游表現`
|
||||
|
||||
### Requirement: CSV export SHALL include direction-appropriate columns
|
||||
Export SHALL produce CSV with columns matching the current direction's detail table.
|
||||
|
||||
#### Scenario: Forward CSV export
|
||||
- **WHEN** user exports with `direction='forward'`
|
||||
- **THEN** CSV SHALL contain forward-specific columns (detection equipment, downstream stats)
|
||||
|
||||
### Requirement: Page metadata SHALL be updated
|
||||
`page_status.json` SHALL update the page name from '中段製程不良追溯' to '製程不良追溯分析'.
|
||||
|
||||
#### Scenario: Page name in page_status.json
|
||||
- **WHEN** the page metadata is read
|
||||
- **THEN** the name for `mid-section-defect` SHALL be '製程不良追溯分析'
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: TMTT印字腳型不良分析 page
|
||||
**Reason**: Functionality fully superseded by generalized traceability center (station=測試 + loss reasons filter for 276_腳型不良/277_印字不良)
|
||||
**Migration**: Use `/mid-section-defect` with station=測試 and filter loss reasons to 276_腳型不良 or 277_印字不良
|
||||
|
||||
#### Scenario: TMTT defect page removal
|
||||
- **WHEN** the change is complete
|
||||
- **THEN** `frontend/src/tmtt-defect/` directory SHALL be removed
|
||||
- **THEN** `src/mes_dashboard/routes/tmtt_defect_routes.py` SHALL be removed
|
||||
- **THEN** `src/mes_dashboard/services/tmtt_defect_service.py` SHALL be removed
|
||||
- **THEN** `src/mes_dashboard/sql/tmtt_defect/` directory SHALL be removed
|
||||
- **THEN** `nativeModuleRegistry.js` SHALL have tmtt-defect registration removed
|
||||
- **THEN** `page_status.json` SHALL have tmtt-defect entry removed
|
||||
@@ -0,0 +1,76 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Forward pipeline SHALL trace surviving lots downstream
|
||||
When `direction=forward`, the system SHALL execute a forward tracing pipeline: detection station rejects → forward lineage (descendants) → downstream WIP + downstream rejects → forward attribution engine.
|
||||
|
||||
#### Scenario: Forward pipeline stages
|
||||
- **WHEN** `query_analysis(station='成型', direction='forward')` is called
|
||||
- **THEN** the pipeline SHALL execute in order:
|
||||
1. Fetch detection data at 成型 station (lots with rejects in date range)
|
||||
2. Resolve forward lineage via `LineageEngine.resolve_forward_tree(detection_cids)`
|
||||
3. Collect tracked CIDs = detection CIDs ∪ all descendants
|
||||
4. Fetch WIP history for tracked CIDs (with TRACKINQTY)
|
||||
5. Fetch downstream reject records for tracked CIDs
|
||||
6. Run forward attribution engine
|
||||
7. Build KPI, charts, detail table, and trend data
|
||||
|
||||
#### Scenario: No descendants found
|
||||
- **WHEN** forward lineage returns an empty descendants map
|
||||
- **THEN** KPI SHALL show zero downstream rejects and zero downstream stations reached
|
||||
- **THEN** charts and detail table SHALL be empty arrays
|
||||
|
||||
### Requirement: downstream_rejects.sql SHALL query reject records for tracked lots
|
||||
`downstream_rejects.sql` SHALL query `DW_MES_LOTREJECTHISTORY` for batched CONTAINERIDs with the standard `WORKCENTER_GROUP` CASE WHEN classification.
|
||||
|
||||
#### Scenario: Downstream rejects query output columns
|
||||
- **WHEN** the SQL is executed
|
||||
- **THEN** it SHALL return: `CONTAINERID`, `WORKCENTERNAME`, `WORKCENTER_GROUP`, `LOSSREASONNAME`, `EQUIPMENTNAME`, `REJECT_TOTAL_QTY`, `TXNDATE`
|
||||
|
||||
#### Scenario: Batched IN clause for large CID sets
|
||||
- **WHEN** tracked CIDs exceed 1000
|
||||
- **THEN** the system SHALL batch queries in groups of 1000 (same pattern as `upstream_history.sql`)
|
||||
|
||||
### Requirement: upstream_history.sql SHALL include TRACKINQTY
|
||||
The `upstream_history.sql` query SHALL include `h.TRACKINQTY` in both the `ranked_history` CTE and the final SELECT output.
|
||||
|
||||
#### Scenario: TRACKINQTY in output
|
||||
- **WHEN** the SQL is executed
|
||||
- **THEN** each row SHALL include `TRACKINQTY` representing the input quantity at that station
|
||||
- **THEN** NULL values SHALL be handled as 0 via COALESCE
|
||||
|
||||
### Requirement: Forward attribution engine SHALL compute per-station reject rates
|
||||
The forward attribution engine SHALL aggregate reject data by downstream station (stations with order > detection station's order) and compute reject rates using TRACKINQTY as denominator.
|
||||
|
||||
#### Scenario: Forward attribution calculation
|
||||
- **WHEN** tracked lots reach downstream station Y with total TRACKINQTY=1000 and REJECT_TOTAL_QTY=50
|
||||
- **THEN** station Y's reject rate SHALL be `50 / 1000 × 100 = 5.0%`
|
||||
|
||||
#### Scenario: Only downstream stations included
|
||||
- **WHEN** detection station is 成型 (order=4)
|
||||
- **THEN** attribution SHALL only include stations with order > 4 (去膠, 水吹砂, 電鍍, 移印, 切彎腳, 元件切割, 測試)
|
||||
- **THEN** stations with order ≤ 4 SHALL be excluded from forward attribution
|
||||
|
||||
#### Scenario: Zero input quantity guard
|
||||
- **WHEN** a downstream station has TRACKINQTY sum = 0 for tracked lots
|
||||
- **THEN** reject rate SHALL be 0 (not division error)
|
||||
|
||||
### Requirement: Forward KPI SHALL summarize downstream impact
|
||||
Forward direction KPI SHALL include: detection lot count, detection defect quantity, tracked lot count (detection + descendants), downstream stations reached, downstream total rejects, and overall downstream reject rate.
|
||||
|
||||
#### Scenario: Forward KPI fields
|
||||
- **WHEN** forward analysis completes
|
||||
- **THEN** KPI SHALL contain `detection_lot_count`, `detection_defect_qty`, `tracked_lot_count`, `downstream_stations_reached`, `downstream_total_reject`, `downstream_reject_rate`
|
||||
|
||||
### Requirement: Forward charts SHALL show downstream distribution
|
||||
Forward direction charts SHALL include: by_downstream_station (Pareto by station reject qty), by_downstream_machine (Pareto by equipment), by_downstream_loss_reason (Pareto by reason), by_detection_machine (Pareto by detection station equipment).
|
||||
|
||||
#### Scenario: Forward chart keys
|
||||
- **WHEN** forward analysis completes
|
||||
- **THEN** charts SHALL contain keys: `by_downstream_station`, `by_downstream_machine`, `by_downstream_loss_reason`, `by_detection_machine`
|
||||
|
||||
### Requirement: Forward detail table SHALL show per-lot downstream tracking
|
||||
Forward direction detail table SHALL show one row per detection lot with downstream tracking summary.
|
||||
|
||||
#### Scenario: Forward detail columns
|
||||
- **WHEN** forward detail is requested
|
||||
- **THEN** each row SHALL include: CONTAINERID, DETECTION_EQUIPMENTNAME, TRACKINQTY (at detection), detection reject qty, downstream stations reached count, downstream total rejects, downstream reject rate, worst downstream station (highest reject rate)
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Detection SQL SHALL be parameterized by workcenter group
|
||||
The system SHALL replace hardcoded TMTT station filtering with a `{{ STATION_FILTER }}` template placeholder in `station_detection.sql`. The filter SHALL be built from `WORKCENTER_GROUPS[station]['patterns']` and `['exclude']` defined in `workcenter_groups.py`, generating OR-LIKE clauses with bind parameters.
|
||||
|
||||
#### Scenario: Station filter built from workcenter group patterns
|
||||
- **WHEN** `station='電鍍'` is requested
|
||||
- **THEN** the system SHALL build a SQL fragment: `UPPER(h.WORKCENTERNAME) LIKE :wc_p0 OR UPPER(h.WORKCENTERNAME) LIKE :wc_p1 OR ...` with bind values `['%掛鍍%', '%滾鍍%', '%條鍍%', '%電鍍%', '%補鍍%', '%TOTAI%', '%BANDL%']`
|
||||
|
||||
#### Scenario: Station filter respects exclude patterns
|
||||
- **WHEN** `station='切割'` is requested (which has `exclude: ['元件切割', 'PKG_SAW']`)
|
||||
- **THEN** the filter SHALL include patterns for '切割' AND exclude patterns via `AND UPPER(h.WORKCENTERNAME) NOT LIKE :wc_ex0 AND NOT LIKE :wc_ex1`
|
||||
|
||||
#### Scenario: Default station is 測試
|
||||
- **WHEN** no `station` parameter is provided
|
||||
- **THEN** the system SHALL default to `station='測試'` (patterns: `['TMTT', '測試']`)
|
||||
- **THEN** results SHALL be equivalent to the previous hardcoded TMTT behavior
|
||||
|
||||
### Requirement: station_detection.sql SHALL generalize tmtt_detection.sql
|
||||
`station_detection.sql` SHALL be a new SQL file that replaces `tmtt_detection.sql` with parameterized station filtering. Column aliases SHALL use `DETECTION_` prefix instead of `TMTT_` prefix.
|
||||
|
||||
#### Scenario: SQL column renaming
|
||||
- **WHEN** `station_detection.sql` is executed
|
||||
- **THEN** output columns SHALL include `DETECTION_EQUIPMENTID` and `DETECTION_EQUIPMENTNAME` (not `TMTT_EQUIPMENTID` / `TMTT_EQUIPMENTNAME`)
|
||||
|
||||
#### Scenario: Both WIP and reject CTEs use station filter
|
||||
- **WHEN** the SQL is executed
|
||||
- **THEN** both the WIP history CTE and the reject history CTE SHALL apply `{{ STATION_FILTER }}` to filter by the selected station
|
||||
|
||||
### Requirement: Station options endpoint SHALL return all workcenter groups
|
||||
`GET /api/mid-section-defect/station-options` SHALL return the 12 workcenter groups from `WORKCENTER_GROUPS` as an ordered list with `name` and `order` fields.
|
||||
|
||||
#### Scenario: Station options response format
|
||||
- **WHEN** the endpoint is called
|
||||
- **THEN** it SHALL return a JSON array of 12 objects: `[{"name": "切割", "order": 0}, {"name": "焊接_DB", "order": 1}, ...]` sorted by order
|
||||
|
||||
### Requirement: All API endpoints SHALL accept station and direction parameters
|
||||
All `/api/mid-section-defect/*` endpoints (`/analysis`, `/analysis/detail`, `/loss-reasons`, `/export`) SHALL accept `station` (string, default `'測試'`) and `direction` (string, `'backward'` | `'forward'`, default `'backward'`) query parameters.
|
||||
|
||||
#### Scenario: Parameters passed to service layer
|
||||
- **WHEN** `/api/mid-section-defect/analysis?station=成型&direction=forward` is called
|
||||
- **THEN** `query_analysis()` SHALL receive `station='成型'` and `direction='forward'`
|
||||
|
||||
#### Scenario: Invalid station rejected
|
||||
- **WHEN** a station name not in `WORKCENTER_GROUPS` is provided
|
||||
- **THEN** the endpoint SHALL return HTTP 400 with an error message
|
||||
|
||||
### Requirement: Cache key SHALL include station and direction
|
||||
The cache key for analysis results SHALL include `station` and `direction` to prevent cross-contamination between different query contexts.
|
||||
|
||||
#### Scenario: Different station/direction combinations cached separately
|
||||
- **WHEN** `station=測試, direction=backward` is queried, then `station=成型, direction=forward` is queried
|
||||
- **THEN** each SHALL have its own independent cache entry
|
||||
@@ -0,0 +1,25 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: EventFetcher SHALL provide unified cached event querying across domains
|
||||
`EventFetcher` SHALL encapsulate batch event queries with L1/L2 layered cache and rate limit bucket configuration, supporting domains: `history`, `materials`, `rejects`, `holds`, `jobs`, `upstream_history`, `downstream_rejects`.
|
||||
|
||||
#### Scenario: Cache miss for event domain query
|
||||
- **WHEN** `EventFetcher` is called for a domain with container IDs and no cache exists
|
||||
- **THEN** the domain query SHALL execute against Oracle via `read_sql_df()`
|
||||
- **THEN** the result SHALL be stored in L2 Redis cache with key format `evt:{domain}:{sorted_cids_hash}`
|
||||
- **THEN** L1 memory cache SHALL also be populated (aligned with `core/cache.py` LayeredCache pattern)
|
||||
|
||||
#### Scenario: Cache hit for event domain query
|
||||
- **WHEN** `EventFetcher` is called for a domain and L2 Redis cache contains a valid entry
|
||||
- **THEN** the cached result SHALL be returned without executing Oracle query
|
||||
- **THEN** DB connection pool SHALL NOT be consumed
|
||||
|
||||
#### Scenario: Rate limit bucket per domain
|
||||
- **WHEN** `EventFetcher` is used from a route handler
|
||||
- **THEN** each domain SHALL have a configurable rate limit bucket aligned with `configured_rate_limit()` pattern
|
||||
- **THEN** rate limit configuration SHALL be overridable via environment variables
|
||||
|
||||
#### Scenario: downstream_rejects domain query
|
||||
- **WHEN** `EventFetcher` is called with domain `downstream_rejects`
|
||||
- **THEN** it SHALL load `mid_section_defect/downstream_rejects.sql` via `SQLLoader.load_with_params()` with `DESCENDANT_FILTER` set to the batched IN clause condition
|
||||
- **THEN** the query SHALL return reject records with `WORKCENTER_GROUP` classification
|
||||
@@ -0,0 +1,32 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: query-tool lineage tab SHALL load on-demand
|
||||
The query-tool lineage tree SHALL auto-fire lineage API calls after lot resolution with concurrency-limited parallel requests and progressive rendering, while preserving on-demand expand/collapse for tree navigation.
|
||||
|
||||
The mid_section_defect profile SHALL support a `direction` parameter that controls lineage resolution direction: `backward` uses `resolve_full_genealogy()` (ancestors), `forward` uses `resolve_forward_tree()` (descendants).
|
||||
|
||||
`useTraceProgress.js` `PROFILE_DOMAINS` for `mid_section_defect` SHALL include `'upstream_history'` for backward and `['upstream_history', 'downstream_rejects']` for forward. Domain selection SHALL be handled by the backend based on `direction` in params.
|
||||
|
||||
`collectAllContainerIds()` SHALL support forward direction by collecting descendants from `children_map` (instead of ancestors) when `direction='forward'` is present in params.
|
||||
|
||||
#### Scenario: Auto-fire lineage after resolve
|
||||
- **WHEN** lot resolution completes with N resolved lots
|
||||
- **THEN** lineage SHALL be fetched via `POST /api/trace/lineage` for each lot automatically
|
||||
- **THEN** concurrent requests SHALL be limited to 3 at a time to respect rate limits (10/60s)
|
||||
- **THEN** response time SHALL be ≤3s per individual lot
|
||||
|
||||
#### Scenario: Multiple lots lineage results cached
|
||||
- **WHEN** lineage data has been fetched for multiple lots
|
||||
- **THEN** each lot's lineage data SHALL be preserved independently (not re-fetched)
|
||||
- **WHEN** a new resolve query is executed
|
||||
- **THEN** all cached lineage data SHALL be cleared
|
||||
|
||||
#### Scenario: Mid-section defect backward lineage
|
||||
- **WHEN** profile is `mid_section_defect` and direction is `backward`
|
||||
- **THEN** lineage stage SHALL call `resolve_full_genealogy()` to get ancestor container IDs
|
||||
- **THEN** `collectAllContainerIds()` SHALL merge seed IDs with ancestor IDs
|
||||
|
||||
#### Scenario: Mid-section defect forward lineage
|
||||
- **WHEN** profile is `mid_section_defect` and direction is `forward`
|
||||
- **THEN** lineage stage SHALL call `resolve_forward_tree()` to get descendant container IDs
|
||||
- **THEN** `collectAllContainerIds()` SHALL merge seed IDs with descendant IDs from `children_map`
|
||||
@@ -0,0 +1,62 @@
|
||||
## 1. SQL Layer
|
||||
|
||||
- [x] 1.1 Create `station_detection.sql` — copy `tmtt_detection.sql`, replace hardcoded TMTT filter with `{{ STATION_FILTER }}` / `{{ STATION_FILTER_REJECTS }}` placeholders, rename `TMTT_EQUIPMENTID/NAME` → `DETECTION_EQUIPMENTID/NAME`
|
||||
- [x] 1.2 Create `downstream_rejects.sql` — query `DW_MES_LOTREJECTHISTORY` for batched CONTAINERIDs with `WORKCENTER_GROUP` CASE WHEN, returning CONTAINERID, WORKCENTERNAME, WORKCENTER_GROUP, LOSSREASONNAME, EQUIPMENTNAME, REJECT_TOTAL_QTY, TXNDATE
|
||||
- [x] 1.3 Modify `upstream_history.sql` — add `h.TRACKINQTY` (with COALESCE to 0) to `ranked_history` CTE and final SELECT
|
||||
|
||||
## 2. Backend Service — Station Parameterization
|
||||
|
||||
- [x] 2.1 Add `_build_station_filter(station_name, column_prefix)` to `mid_section_defect_service.py` — reads `WORKCENTER_GROUPS` patterns/exclude, builds OR-LIKE SQL with bind params
|
||||
- [x] 2.2 Replace `_fetch_tmtt_data()` with `_fetch_station_detection_data(start_date, end_date, station)` — uses `station_detection.sql` + `_build_station_filter()`
|
||||
- [x] 2.3 Update all public API signatures (`query_analysis`, `query_analysis_detail`, `export_csv`, `resolve_trace_seed_lots`, `build_trace_aggregation_from_events`) to accept `station` and `direction` params (default `'測試'`/`'backward'`)
|
||||
- [x] 2.4 Add station+direction to cache keys
|
||||
- [x] 2.5 Rename all internal `TMTT_` → `DETECTION_` references (variables, dict keys, DIMENSION_MAP entries)
|
||||
|
||||
## 3. Backend Service — Forward Pipeline
|
||||
|
||||
- [x] 3.1 Extract existing backward logic into `_run_backward_pipeline(start_date, end_date, station, loss_reasons)`
|
||||
- [x] 3.2 Add `_fetch_downstream_rejects(tracked_cids)` — batch query using `downstream_rejects.sql`
|
||||
- [x] 3.3 Implement `_attribute_forward_defects(detection_df, detection_cids, downstream_wip, downstream_rejects, station_order)` — per-station reject rate using TRACKINQTY denominator
|
||||
- [x] 3.4 Implement `_run_forward_pipeline(start_date, end_date, station, loss_reasons)` — full 8-stage pipeline (detection → forward lineage → downstream WIP+rejects → attribution → KPI/charts/detail)
|
||||
- [x] 3.5 Implement `_build_forward_kpi()`, `_build_forward_charts()`, `_build_forward_detail_table()` builders
|
||||
- [x] 3.6 Add direction dispatch in `query_analysis()`: backward → `_run_backward_pipeline()`, forward → `_run_forward_pipeline()`
|
||||
- [x] 3.7 Add `query_station_options()` — returns ordered workcenter groups list
|
||||
|
||||
## 4. Backend Routes & EventFetcher
|
||||
|
||||
- [x] 4.1 Update `mid_section_defect_routes.py` — add `station` + `direction` query params to all endpoints, add station validation, add `GET /station-options` endpoint
|
||||
- [x] 4.2 Update `trace_routes.py` — `_seed_resolve_mid_section_defect()` passes `station`; lineage stage uses direction to choose `resolve_full_genealogy()` vs `resolve_forward_tree()`; events stage passes direction for domain selection
|
||||
- [x] 4.3 Add `downstream_rejects` domain to `event_fetcher.py` — in `SUPPORTED_EVENT_DOMAINS` and `_build_domain_sql()`, loading `mid_section_defect/downstream_rejects.sql`
|
||||
|
||||
## 5. Frontend — FilterBar & App
|
||||
|
||||
- [x] 5.1 Update `FilterBar.vue` — add station `<select>` dropdown (fetches from `/station-options` on mount), add direction toggle button group (反向追溯/正向追溯), emit station+direction via `update-filters`
|
||||
- [x] 5.2 Update `App.vue` — add `station: '測試'` and `direction: 'backward'` to filters reactive, include in `buildFilterParams()`, add computed `isForward`, switch chart layout by direction, update page header to '製程不良追溯分析' with dynamic subtitle
|
||||
- [x] 5.3 Update `useTraceProgress.js` — add `downstream_rejects` to `PROFILE_DOMAINS.mid_section_defect` for forward, update `collectAllContainerIds()` to support `children_map` for forward direction
|
||||
|
||||
## 6. Frontend — Direction-Aware Components
|
||||
|
||||
- [x] 6.1 Update `KpiCards.vue` — accept `direction` + `stationLabel` props, switch card labels between backward/forward modes
|
||||
- [x] 6.2 Update `DetailTable.vue` — accept `direction` prop, switch column definitions between backward (existing) and forward (偵測設備, 偵測投入, 偵測不良, 下游到達站數, 下游不良總數, 下游不良率, 最差下游站)
|
||||
- [x] 6.3 Add `.direction-toggle` styles to `style.css`
|
||||
|
||||
## 7. Remove TMTT Defect Page
|
||||
|
||||
- [x] 7.1 Delete `frontend/src/tmtt-defect/` directory
|
||||
- [x] 7.2 Delete `src/mes_dashboard/routes/tmtt_defect_routes.py`
|
||||
- [x] 7.3 Delete `src/mes_dashboard/services/tmtt_defect_service.py`
|
||||
- [x] 7.4 Delete `src/mes_dashboard/sql/tmtt_defect/` directory
|
||||
- [x] 7.5 Remove tmtt-defect registration from `nativeModuleRegistry.js`, `routeContracts.js`, `vite.config.js`, `page_status.json`, `routes/__init__.py`, `app.py`, `page_registry.py`, and all migration baseline/config files
|
||||
- [x] 7.6 Delete related test files and update remaining tests referencing tmtt-defect
|
||||
|
||||
## 8. Config & Metadata
|
||||
|
||||
- [x] 8.1 Update `page_status.json` — rename mid-section-defect page name from '中段製程不良追溯' to '製程不良追溯分析', remove tmtt-defect entry
|
||||
|
||||
## 9. Verification
|
||||
|
||||
- [x] 9.1 Run `python -m pytest tests/test_mid_section_defect_*.py -v` — all 22 tests pass
|
||||
- [x] 9.2 Run `cd frontend && node --test` — 69/69 frontend tests pass
|
||||
- [x] 9.3 Run all change-relevant backend tests (app_factory, navigation_contract, full_modernization_gates, page_registry, portal_shell_wave_b_native_smoke) — 64/64 pass
|
||||
- [x] 9.4 Verify backward compat: `station=測試, direction=backward` produces identical data (renamed columns) — 25,415 detail rows, DETECTION_EQUIPMENTNAME columns (no TMTT_), KPI/charts/genealogy all correct
|
||||
- [x] 9.5 Verify forward basic: `station=成型 (order=4), direction=forward` → 8 downstream stations, 1,673 detail rows, downstream reject distribution: 測試 1.67%, 水吹砂 0.03%, 切彎腳 0.03%, 去膠 0.02%, 電鍍 0.01%, 移印 0.01%
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@ def main() -> int:
|
||||
"/job-query",
|
||||
"/excel-query",
|
||||
"/query-tool",
|
||||
"/tmtt-defect",
|
||||
]
|
||||
|
||||
legacy = _measure_routes(legacy_routes, portal_spa_enabled=False)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- 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
|
||||
@@ -11,6 +11,7 @@ from mes_dashboard.services.mid_section_defect_service import (
|
||||
query_analysis,
|
||||
query_analysis_detail,
|
||||
query_all_loss_reasons,
|
||||
query_station_options,
|
||||
export_csv,
|
||||
)
|
||||
|
||||
@@ -45,22 +46,36 @@ _EXPORT_RATE_LIMIT = configured_rate_limit(
|
||||
)
|
||||
|
||||
|
||||
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 mid-section defect traceability analysis (summary).
|
||||
|
||||
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.
|
||||
"""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,6 +98,7 @@ 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})
|
||||
@@ -95,17 +107,14 @@ def api_analysis():
|
||||
@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.
|
||||
"""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:
|
||||
@@ -154,18 +153,12 @@ def api_loss_reasons():
|
||||
@mid_section_defect_bp.route('/export', methods=['GET'])
|
||||
@_EXPORT_RATE_LIMIT
|
||||
def api_export():
|
||||
"""API: Export mid-section defect detail data as CSV.
|
||||
"""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}',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from mes_dashboard.core.cache import cache_get, cache_set
|
||||
@@ -17,6 +19,7 @@ from mes_dashboard.sql import QueryBuilder, SQLLoader
|
||||
logger = logging.getLogger("mes_dashboard.event_fetcher")
|
||||
|
||||
ORACLE_IN_BATCH_SIZE = 1000
|
||||
EVENT_FETCHER_MAX_WORKERS = int(os.getenv('EVENT_FETCHER_MAX_WORKERS', '4'))
|
||||
|
||||
_DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
|
||||
"history": {
|
||||
@@ -77,6 +80,15 @@ _DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
|
||||
"default_max": 20,
|
||||
"default_window": 60,
|
||||
},
|
||||
"downstream_rejects": {
|
||||
"filter_column": "r.CONTAINERID",
|
||||
"cache_ttl": 300,
|
||||
"bucket": "event-downstream-rejects",
|
||||
"max_env": "EVT_DOWNSTREAM_REJECTS_RATE_MAX_REQUESTS",
|
||||
"window_env": "EVT_DOWNSTREAM_REJECTS_RATE_WINDOW_SECONDS",
|
||||
"default_max": 20,
|
||||
"default_window": 60,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +167,12 @@ class EventFetcher:
|
||||
ANCESTOR_FILTER=condition_sql,
|
||||
)
|
||||
|
||||
if domain == "downstream_rejects":
|
||||
return SQLLoader.load_with_params(
|
||||
"mid_section_defect/downstream_rejects",
|
||||
DESCENDANT_FILTER=condition_sql,
|
||||
)
|
||||
|
||||
if domain == "history":
|
||||
sql = SQLLoader.load("query_tool/lot_history")
|
||||
sql = EventFetcher._replace_container_filter(sql, condition_sql)
|
||||
@@ -219,36 +237,63 @@ class EventFetcher:
|
||||
filter_column = spec["filter_column"]
|
||||
match_mode = spec.get("match_mode", "in")
|
||||
|
||||
for i in range(0, len(normalized_ids), ORACLE_IN_BATCH_SIZE):
|
||||
batch = normalized_ids[i:i + ORACLE_IN_BATCH_SIZE]
|
||||
def _fetch_batch(batch_ids):
|
||||
builder = QueryBuilder()
|
||||
if match_mode == "contains":
|
||||
builder.add_or_like_conditions(filter_column, batch, position="both")
|
||||
builder.add_or_like_conditions(filter_column, batch_ids, position="both")
|
||||
else:
|
||||
builder.add_in_condition(filter_column, batch)
|
||||
|
||||
builder.add_in_condition(filter_column, batch_ids)
|
||||
sql = EventFetcher._build_domain_sql(domain, builder.get_conditions_sql())
|
||||
df = read_sql_df(sql, builder.params)
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
return batch_ids, read_sql_df(sql, builder.params)
|
||||
|
||||
def _sanitize_record(d):
|
||||
"""Replace NaN/NaT values with None for JSON-safe serialization."""
|
||||
for k, v in d.items():
|
||||
if isinstance(v, float) and math.isnan(v):
|
||||
d[k] = None
|
||||
return d
|
||||
|
||||
def _process_batch_result(batch_ids, df):
|
||||
if df is None or df.empty:
|
||||
return
|
||||
for _, row in df.iterrows():
|
||||
if domain == "jobs":
|
||||
record = row.to_dict()
|
||||
record = _sanitize_record(row.to_dict())
|
||||
containers = record.get("CONTAINERIDS")
|
||||
if not isinstance(containers, str) or not containers:
|
||||
continue
|
||||
for cid in batch:
|
||||
for cid in batch_ids:
|
||||
if cid in containers:
|
||||
enriched = dict(record)
|
||||
enriched["CONTAINERID"] = cid
|
||||
grouped[cid].append(enriched)
|
||||
continue
|
||||
|
||||
cid = row.get("CONTAINERID")
|
||||
if not isinstance(cid, str) or not cid:
|
||||
continue
|
||||
grouped[cid].append(row.to_dict())
|
||||
grouped[cid].append(_sanitize_record(row.to_dict()))
|
||||
|
||||
batches = [
|
||||
normalized_ids[i:i + ORACLE_IN_BATCH_SIZE]
|
||||
for i in range(0, len(normalized_ids), ORACLE_IN_BATCH_SIZE)
|
||||
]
|
||||
|
||||
if len(batches) <= 1:
|
||||
for batch in batches:
|
||||
batch_ids, df = _fetch_batch(batch)
|
||||
_process_batch_result(batch_ids, df)
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=min(len(batches), EVENT_FETCHER_MAX_WORKERS)) as executor:
|
||||
futures = {executor.submit(_fetch_batch, b): b for b in batches}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
batch_ids, df = future.result()
|
||||
_process_batch_result(batch_ids, df)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"EventFetcher batch query failed domain=%s batch_size=%s",
|
||||
domain, len(futures[future]), exc_info=True,
|
||||
)
|
||||
|
||||
result = dict(grouped)
|
||||
cache_set(cache_key, result, ttl=_DOMAIN_SPECS[domain]["cache_ttl"])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,27 +1,23 @@
|
||||
-- Mid-Section Defect Traceability - TMTT Detection Data (Query 1)
|
||||
-- Returns LOT-level data with TMTT input, ALL defects, and lot metadata
|
||||
-- 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 (TMTT station records)
|
||||
-- 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)
|
||||
--
|
||||
-- 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 (
|
||||
WITH detection_records AS (
|
||||
SELECT /*+ MATERIALIZE */
|
||||
h.CONTAINERID,
|
||||
h.EQUIPMENTID AS TMTT_EQUIPMENTID,
|
||||
h.EQUIPMENTNAME AS TMTT_EQUIPMENTNAME,
|
||||
h.EQUIPMENTID AS DETECTION_EQUIPMENTID,
|
||||
h.EQUIPMENTNAME AS DETECTION_EQUIPMENTNAME,
|
||||
h.TRACKINQTY,
|
||||
h.TRACKINTIMESTAMP,
|
||||
h.TRACKOUTTIMESTAMP,
|
||||
@@ -35,14 +31,14 @@ WITH tmtt_records AS (
|
||||
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 ({{ STATION_FILTER }})
|
||||
AND h.EQUIPMENTID IS NOT NULL
|
||||
AND h.TRACKINTIMESTAMP IS NOT NULL
|
||||
),
|
||||
tmtt_deduped AS (
|
||||
SELECT * FROM tmtt_records WHERE rn = 1
|
||||
detection_deduped AS (
|
||||
SELECT * FROM detection_records WHERE rn = 1
|
||||
),
|
||||
tmtt_rejects AS (
|
||||
detection_rejects AS (
|
||||
SELECT /*+ MATERIALIZE */
|
||||
r.CONTAINERID,
|
||||
r.LOSSREASONNAME,
|
||||
@@ -51,7 +47,7 @@ tmtt_rejects AS (
|
||||
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 ({{ STATION_FILTER_REJECTS }})
|
||||
GROUP BY r.CONTAINERID, r.LOSSREASONNAME
|
||||
),
|
||||
lot_metadata AS (
|
||||
@@ -62,14 +58,14 @@ lot_metadata AS (
|
||||
c.PJ_TYPE,
|
||||
c.PRODUCTLINENAME
|
||||
FROM DWH.DW_MES_CONTAINER c
|
||||
WHERE c.CONTAINERID IN (SELECT CONTAINERID FROM tmtt_deduped)
|
||||
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 tmtt_deduped)
|
||||
WHERE w.CONTAINERID IN (SELECT CONTAINERID FROM detection_deduped)
|
||||
AND w.PRODUCTLINENAME <> '點測'
|
||||
)
|
||||
SELECT
|
||||
@@ -80,14 +76,14 @@ SELECT
|
||||
m.PRODUCTLINENAME,
|
||||
NVL(wf.WORKFLOWNAME, t.SPECNAME) AS WORKFLOW,
|
||||
t.FINISHEDRUNCARD,
|
||||
t.TMTT_EQUIPMENTID,
|
||||
t.TMTT_EQUIPMENTNAME,
|
||||
t.DETECTION_EQUIPMENTID,
|
||||
t.DETECTION_EQUIPMENTNAME,
|
||||
t.TRACKINQTY,
|
||||
t.TRACKINTIMESTAMP,
|
||||
r.LOSSREASONNAME,
|
||||
NVL(r.REJECTQTY, 0) AS REJECTQTY
|
||||
FROM tmtt_deduped t
|
||||
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 tmtt_rejects r ON t.CONTAINERID = r.CONTAINERID
|
||||
LEFT JOIN detection_rejects r ON t.CONTAINERID = r.CONTAINERID
|
||||
ORDER BY t.TRACKINTIMESTAMP
|
||||
@@ -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
|
||||
@@ -49,6 +49,7 @@ WITH ranked_history AS (
|
||||
h.EQUIPMENTNAME,
|
||||
h.SPECNAME,
|
||||
h.TRACKINTIMESTAMP,
|
||||
NVL(h.TRACKINQTY, 0) AS TRACKINQTY,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY h.CONTAINERID, h.WORKCENTERNAME, h.EQUIPMENTNAME
|
||||
ORDER BY h.TRACKINTIMESTAMP DESC
|
||||
@@ -65,7 +66,8 @@ SELECT
|
||||
EQUIPMENTID,
|
||||
EQUIPMENTNAME,
|
||||
SPECNAME,
|
||||
TRACKINTIMESTAMP
|
||||
TRACKINTIMESTAMP,
|
||||
TRACKINQTY
|
||||
FROM ranked_history
|
||||
WHERE rn = 1
|
||||
ORDER BY CONTAINERID, TRACKINTIMESTAMP
|
||||
|
||||
@@ -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
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}TMTT 印字腳型不良分析{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
{% set tmtt_defect_css = frontend_asset('tmtt-defect.css') %}
|
||||
{% if tmtt_defect_css %}
|
||||
<link rel="stylesheet" href="{{ tmtt_defect_css }}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="app"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% set tmtt_defect_js = frontend_asset('tmtt-defect.js') %}
|
||||
<script type="module" src="{{ tmtt_defect_js }}"></script>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'] == '切割'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user