From af59031f95964d0c38ef03350a65bb189fa860d4 Mon Sep 17 00:00:00 2001 From: egg Date: Tue, 10 Feb 2026 09:32:14 +0800 Subject: [PATCH] feat(mid-section-defect): harden with distributed lock, rate limit, filter separation, abort, SQL classification and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 6 code review findings (P0-P3): add Redis distributed lock to prevent duplicate Oracle pipeline on cold cache, apply rate limiting to 3 high-cost routes, separate UI filter state from committed query state, add AbortController for request cancellation, push workcenter group classification into Oracle SQL CASE WHEN, and add 18 route+service tests. Also add workcenter group selection to job-query equipment selector and rename button to "查詢". Co-Authored-By: Claude Opus 4.6 --- frontend/src/job-query/main.js | 673 +++++++++--------- frontend/src/mid-section-defect/App.vue | 220 +++--- .../.openspec.yaml | 2 + .../design.md | 61 ++ .../proposal.md | 31 + .../specs/api-safety-hygiene/spec.md | 73 ++ .../specs/vue-vite-page-architecture/spec.md | 37 + .../tasks.md | 35 + openspec/specs/api-safety-hygiene/spec.md | 72 ++ .../specs/vue-vite-page-architecture/spec.md | 36 + .../routes/mid_section_defect_routes.py | 68 +- .../services/mid_section_defect_service.py | 277 +++---- .../mid_section_defect/upstream_history.sql | 61 +- src/mes_dashboard/templates/job_query.html | 126 +++- tests/test_mid_section_defect_routes.py | 162 +++++ tests/test_mid_section_defect_service.py | 128 ++++ 16 files changed, 1461 insertions(+), 601 deletions(-) create mode 100644 openspec/changes/archive/2026-02-09-harden-mid-section-defect/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-09-harden-mid-section-defect/design.md create mode 100644 openspec/changes/archive/2026-02-09-harden-mid-section-defect/proposal.md create mode 100644 openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/api-safety-hygiene/spec.md create mode 100644 openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/vue-vite-page-architecture/spec.md create mode 100644 openspec/changes/archive/2026-02-09-harden-mid-section-defect/tasks.md create mode 100644 tests/test_mid_section_defect_routes.py create mode 100644 tests/test_mid_section_defect_service.py diff --git a/frontend/src/job-query/main.js b/frontend/src/job-query/main.js index c1338dc..943c612 100644 --- a/frontend/src/job-query/main.js +++ b/frontend/src/job-query/main.js @@ -36,45 +36,45 @@ function renderTxnCell(txn, apiKey) { return escapeHtml(safeText(txn[apiKey])); } - - // State - let allEquipments = []; - let selectedEquipments = new Set(); - let jobsData = []; - let expandedJobs = new Set(); - - // Initialize - document.addEventListener('DOMContentLoaded', () => { - loadEquipments(); - setLast90Days(); - - // Close dropdown when clicking outside - document.addEventListener('click', (e) => { - const dropdown = document.getElementById('equipmentDropdown'); - const selector = document.querySelector('.equipment-selector'); - if (!selector.contains(e.target)) { - dropdown.classList.remove('show'); - } - }); - }); - - // Load equipments from cache - async function loadEquipments() { - try { - const data = await MesApi.get('/api/job-query/resources'); - if (data.error) { - document.getElementById('equipmentList').innerHTML = `
${data.error}
`; - return; - } - - allEquipments = data.data; - renderEquipmentList(allEquipments); - } catch (error) { - document.getElementById('equipmentList').innerHTML = `
載入失敗: ${error.message}
`; - } - } - - // Render equipment list + + // State + let allEquipments = []; + let selectedEquipments = new Set(); + let jobsData = []; + let expandedJobs = new Set(); + + // Initialize + document.addEventListener('DOMContentLoaded', () => { + loadEquipments(); + setLast90Days(); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + const dropdown = document.getElementById('equipmentDropdown'); + const selector = document.querySelector('.equipment-selector'); + if (!selector.contains(e.target)) { + dropdown.classList.remove('show'); + } + }); + }); + + // Load equipments from cache + async function loadEquipments() { + try { + const data = await MesApi.get('/api/job-query/resources'); + if (data.error) { + document.getElementById('equipmentList').innerHTML = `
${data.error}
`; + return; + } + + allEquipments = data.data; + renderEquipmentList(allEquipments); + } catch (error) { + document.getElementById('equipmentList').innerHTML = `
載入失敗: ${error.message}
`; + } + } + + // Render equipment list function renderEquipmentList(equipments) { const container = document.getElementById('equipmentList'); @@ -88,8 +88,18 @@ function renderTxnCell(txn, apiKey) { const workcenters = sortBy(Object.keys(grouped), (name) => name); workcenters.forEach((workcenterName) => { - html += `
${escapeHtml(workcenterName)}
`; - grouped[workcenterName].forEach((eq) => { + const groupEquipments = grouped[workcenterName]; + const groupIds = groupEquipments.map((eq) => eq.RESOURCEID); + const selectedInGroup = groupIds.filter((id) => selectedEquipments.has(id)).length; + const allSelected = selectedInGroup === groupIds.length; + const someSelected = selectedInGroup > 0 && !allSelected; + const escapedName = escapeHtml(workcenterName); + html += `
+ + ${escapedName} + ${selectedInGroup}/${groupIds.length} +
`; + groupEquipments.forEach((eq) => { const isSelected = selectedEquipments.has(eq.RESOURCEID); const resourceId = escapeHtml(safeText(eq.RESOURCEID)); const resourceName = escapeHtml(safeText(eq.RESOURCENAME)); @@ -109,145 +119,169 @@ function renderTxnCell(txn, apiKey) { container.innerHTML = html; } - - // Toggle equipment dropdown - function toggleEquipmentDropdown() { - const dropdown = document.getElementById('equipmentDropdown'); - dropdown.classList.toggle('show'); - } - - // Filter equipments by search - function filterEquipments(query) { - const q = query.toLowerCase(); - const filtered = allEquipments.filter(eq => - (eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) || - (eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q)) || - (eq.RESOURCEFAMILYNAME && eq.RESOURCEFAMILYNAME.toLowerCase().includes(q)) - ); - renderEquipmentList(filtered); - } - - // Toggle equipment selection - function toggleEquipment(resourceId) { - if (selectedEquipments.has(resourceId)) { - selectedEquipments.delete(resourceId); - } else { - selectedEquipments.add(resourceId); - } - updateSelectedDisplay(); - renderEquipmentList(allEquipments.filter(eq => { - const search = document.querySelector('.equipment-search'); - if (!search || !search.value) return true; - const q = search.value.toLowerCase(); - return (eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) || - (eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q)); - })); - } - - // Update selected display - function updateSelectedDisplay() { - const display = document.getElementById('equipmentDisplay'); - const count = document.getElementById('selectedCount'); - - if (selectedEquipments.size === 0) { - display.textContent = '點擊選擇設備...'; - count.textContent = ''; - } else if (selectedEquipments.size <= 3) { - const names = allEquipments - .filter(eq => selectedEquipments.has(eq.RESOURCEID)) - .map(eq => eq.RESOURCENAME) - .join(', '); - display.textContent = names; - count.textContent = `已選擇 ${selectedEquipments.size} 台設備`; - } else { - display.textContent = `已選擇 ${selectedEquipments.size} 台設備`; - count.textContent = ''; - } - } - - // Set last 90 days - function setLast90Days() { - const today = new Date(); - const past = new Date(); - past.setDate(today.getDate() - 90); - - document.getElementById('dateFrom').value = past.toISOString().split('T')[0]; - document.getElementById('dateTo').value = today.toISOString().split('T')[0]; - } - - // Validate inputs - function validateInputs() { - if (selectedEquipments.size === 0) { - Toast.error('請選擇至少一台設備'); - return false; - } - - const dateFrom = document.getElementById('dateFrom').value; - const dateTo = document.getElementById('dateTo').value; - - if (!dateFrom || !dateTo) { - Toast.error('請指定日期範圍'); - return false; - } - - const from = new Date(dateFrom); - const to = new Date(dateTo); - - if (to < from) { - Toast.error('結束日期不可早於起始日期'); - return false; - } - - const daysDiff = (to - from) / (1000 * 60 * 60 * 24); - if (daysDiff > 365) { - Toast.error('日期範圍不可超過 365 天'); - return false; - } - - return true; - } - - // Query jobs - async function queryJobs() { - if (!validateInputs()) return; - - const resultSection = document.getElementById('resultSection'); - resultSection.innerHTML = ` -
-
-
查詢中... -
- `; - - document.getElementById('queryBtn').disabled = true; - document.getElementById('exportBtn').disabled = true; - - try { - const data = await MesApi.post('/api/job-query/jobs', { - resource_ids: Array.from(selectedEquipments), - start_date: document.getElementById('dateFrom').value, - end_date: document.getElementById('dateTo').value - }); - - if (data.error) { - resultSection.innerHTML = `
${data.error}
`; - return; - } - - jobsData = data.data; - expandedJobs.clear(); - renderJobsTable(); - - document.getElementById('exportBtn').disabled = jobsData.length === 0; - - } catch (error) { - resultSection.innerHTML = `
查詢失敗: ${error.message}
`; - } finally { - document.getElementById('queryBtn').disabled = false; - } - } - - // Render jobs table + + // Toggle equipment dropdown + function toggleEquipmentDropdown() { + const dropdown = document.getElementById('equipmentDropdown'); + dropdown.classList.toggle('show'); + } + + // Filter equipments by search + function filterEquipments(query) { + const q = query.toLowerCase(); + const filtered = allEquipments.filter(eq => + (eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) || + (eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q)) || + (eq.RESOURCEFAMILYNAME && eq.RESOURCEFAMILYNAME.toLowerCase().includes(q)) + ); + renderEquipmentList(filtered); + } + + // Toggle equipment selection + function toggleEquipment(resourceId) { + if (selectedEquipments.has(resourceId)) { + selectedEquipments.delete(resourceId); + } else { + selectedEquipments.add(resourceId); + } + updateSelectedDisplay(); + renderEquipmentList(allEquipments.filter(eq => { + const search = document.querySelector('.equipment-search'); + if (!search || !search.value) return true; + const q = search.value.toLowerCase(); + return (eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) || + (eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q)); + })); + } + + // Toggle entire workcenter group selection + function toggleWorkcenterGroup(workcenterName) { + const groupEquipments = allEquipments.filter( + (eq) => safeText(eq.WORKCENTERNAME, '未分類') === workcenterName + ); + const groupIds = groupEquipments.map((eq) => eq.RESOURCEID); + const allSelected = groupIds.every((id) => selectedEquipments.has(id)); + + if (allSelected) { + groupIds.forEach((id) => selectedEquipments.delete(id)); + } else { + groupIds.forEach((id) => selectedEquipments.add(id)); + } + + updateSelectedDisplay(); + renderEquipmentList(allEquipments.filter((eq) => { + const search = document.querySelector('.equipment-search'); + if (!search || !search.value) return true; + const q = search.value.toLowerCase(); + return (eq.RESOURCENAME && eq.RESOURCENAME.toLowerCase().includes(q)) || + (eq.WORKCENTERNAME && eq.WORKCENTERNAME.toLowerCase().includes(q)); + })); + } + + // Update selected display + function updateSelectedDisplay() { + const display = document.getElementById('equipmentDisplay'); + const count = document.getElementById('selectedCount'); + + if (selectedEquipments.size === 0) { + display.textContent = '點擊選擇設備...'; + count.textContent = ''; + } else if (selectedEquipments.size <= 3) { + const names = allEquipments + .filter(eq => selectedEquipments.has(eq.RESOURCEID)) + .map(eq => eq.RESOURCENAME) + .join(', '); + display.textContent = names; + count.textContent = `已選擇 ${selectedEquipments.size} 台設備`; + } else { + display.textContent = `已選擇 ${selectedEquipments.size} 台設備`; + count.textContent = ''; + } + } + + // Set last 90 days + function setLast90Days() { + const today = new Date(); + const past = new Date(); + past.setDate(today.getDate() - 90); + + document.getElementById('dateFrom').value = past.toISOString().split('T')[0]; + document.getElementById('dateTo').value = today.toISOString().split('T')[0]; + } + + // Validate inputs + function validateInputs() { + if (selectedEquipments.size === 0) { + Toast.error('請選擇至少一台設備'); + return false; + } + + const dateFrom = document.getElementById('dateFrom').value; + const dateTo = document.getElementById('dateTo').value; + + if (!dateFrom || !dateTo) { + Toast.error('請指定日期範圍'); + return false; + } + + const from = new Date(dateFrom); + const to = new Date(dateTo); + + if (to < from) { + Toast.error('結束日期不可早於起始日期'); + return false; + } + + const daysDiff = (to - from) / (1000 * 60 * 60 * 24); + if (daysDiff > 365) { + Toast.error('日期範圍不可超過 365 天'); + return false; + } + + return true; + } + + // Query jobs + async function queryJobs() { + if (!validateInputs()) return; + + const resultSection = document.getElementById('resultSection'); + resultSection.innerHTML = ` +
+
+
查詢中... +
+ `; + + document.getElementById('queryBtn').disabled = true; + document.getElementById('exportBtn').disabled = true; + + try { + const data = await MesApi.post('/api/job-query/jobs', { + resource_ids: Array.from(selectedEquipments), + start_date: document.getElementById('dateFrom').value, + end_date: document.getElementById('dateTo').value + }); + + if (data.error) { + resultSection.innerHTML = `
${data.error}
`; + return; + } + + jobsData = data.data; + expandedJobs.clear(); + renderJobsTable(); + + document.getElementById('exportBtn').disabled = jobsData.length === 0; + + } catch (error) { + resultSection.innerHTML = `
查詢失敗: ${error.message}
`; + } finally { + document.getElementById('queryBtn').disabled = false; + } + } + + // Render jobs table function renderJobsTable() { const resultSection = document.getElementById('resultSection'); const jobHeaders = jobTableFields.map((field) => `${escapeHtml(field.ui_label)}`).join(''); @@ -256,19 +290,19 @@ function renderTxnCell(txn, apiKey) { resultSection.innerHTML = `

無符合條件的工單

-
- `; - return; - } - - let html = ` -
-
共 ${jobsData.length} 筆工單
-
- - -
-
+ + `; + return; + } + + let html = ` +
+
共 ${jobsData.length} 筆工單
+
+ + +
+
@@ -300,58 +334,58 @@ function renderTxnCell(txn, apiKey) { ${isExpanded ? '
' : ''} - - `; - }); - - html += ` - -
-
- `; - - resultSection.innerHTML = html; - - // Load expanded histories - expandedJobs.forEach(jobId => { - const idx = jobsData.findIndex(j => j.JOBID === jobId); - if (idx >= 0) loadJobHistory(jobId, idx); - }); - } - - // Toggle job history - async function toggleJobHistory(jobId, idx) { - const txnRow = document.getElementById(`txn-row-${idx}`); - const jobRow = document.getElementById(`job-row-${idx}`); - const arrow = jobRow.querySelector('.arrow-icon'); - - if (expandedJobs.has(jobId)) { - expandedJobs.delete(jobId); - txnRow.classList.remove('show'); - jobRow.classList.remove('expanded'); - arrow.classList.remove('rotated'); - } else { - expandedJobs.add(jobId); - txnRow.classList.add('show'); - jobRow.classList.add('expanded'); - arrow.classList.add('rotated'); - loadJobHistory(jobId, idx); - } - } - - // Load job history - async function loadJobHistory(jobId, idx) { - const container = document.getElementById(`txn-content-${idx}`); - container.innerHTML = '
'; - - try { - const data = await MesApi.get(`/api/job-query/txn/${jobId}`); - - if (data.error) { - container.innerHTML = `
${data.error}
`; - return; - } - + + `; + }); + + html += ` + + + + `; + + resultSection.innerHTML = html; + + // Load expanded histories + expandedJobs.forEach(jobId => { + const idx = jobsData.findIndex(j => j.JOBID === jobId); + if (idx >= 0) loadJobHistory(jobId, idx); + }); + } + + // Toggle job history + async function toggleJobHistory(jobId, idx) { + const txnRow = document.getElementById(`txn-row-${idx}`); + const jobRow = document.getElementById(`job-row-${idx}`); + const arrow = jobRow.querySelector('.arrow-icon'); + + if (expandedJobs.has(jobId)) { + expandedJobs.delete(jobId); + txnRow.classList.remove('show'); + jobRow.classList.remove('expanded'); + arrow.classList.remove('rotated'); + } else { + expandedJobs.add(jobId); + txnRow.classList.add('show'); + jobRow.classList.add('expanded'); + arrow.classList.add('rotated'); + loadJobHistory(jobId, idx); + } + } + + // Load job history + async function loadJobHistory(jobId, idx) { + const container = document.getElementById(`txn-content-${idx}`); + container.innerHTML = '
'; + + try { + const data = await MesApi.get(`/api/job-query/txn/${jobId}`); + + if (data.error) { + container.innerHTML = `
${data.error}
`; + return; + } + if (!data.data || data.data.length === 0) { container.innerHTML = '
無交易歷史記錄
'; return; @@ -378,80 +412,80 @@ function renderTxnCell(txn, apiKey) { `; }); - - html += ''; - container.innerHTML = html; - - } catch (error) { - container.innerHTML = `
載入失敗: ${error.message}
`; - } - } - - // Expand all - function expandAll() { - jobsData.forEach((job, idx) => { - if (!expandedJobs.has(job.JOBID)) { - expandedJobs.add(job.JOBID); - } - }); - renderJobsTable(); - } - - // Collapse all - function collapseAll() { - expandedJobs.clear(); - renderJobsTable(); - } - - // Export CSV - async function exportCsv() { - if (!validateInputs()) return; - - document.getElementById('exportBtn').disabled = true; - document.getElementById('exportBtn').textContent = '匯出中...'; - - try { - const response = await fetch('/api/job-query/export', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - resource_ids: Array.from(selectedEquipments), - start_date: document.getElementById('dateFrom').value, - end_date: document.getElementById('dateTo').value - }) - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || '匯出失敗'); - } - - // Download file - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `job_history_${document.getElementById('dateFrom').value}_${document.getElementById('dateTo').value}.csv`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - - Toast.success('CSV 匯出完成'); - - } catch (error) { - Toast.error('匯出失敗: ' + error.message); - } finally { - document.getElementById('exportBtn').disabled = false; - document.getElementById('exportBtn').textContent = '匯出 CSV'; - } - } - - // Format date - function formatDate(dateStr) { - if (!dateStr) return ''; - return dateStr.replace('T', ' ').substring(0, 19); - } + + html += ''; + container.innerHTML = html; + + } catch (error) { + container.innerHTML = `
載入失敗: ${error.message}
`; + } + } + + // Expand all + function expandAll() { + jobsData.forEach((job, idx) => { + if (!expandedJobs.has(job.JOBID)) { + expandedJobs.add(job.JOBID); + } + }); + renderJobsTable(); + } + + // Collapse all + function collapseAll() { + expandedJobs.clear(); + renderJobsTable(); + } + + // Export CSV + async function exportCsv() { + if (!validateInputs()) return; + + document.getElementById('exportBtn').disabled = true; + document.getElementById('exportBtn').textContent = '匯出中...'; + + try { + const response = await fetch('/api/job-query/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + resource_ids: Array.from(selectedEquipments), + start_date: document.getElementById('dateFrom').value, + end_date: document.getElementById('dateTo').value + }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '匯出失敗'); + } + + // Download file + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `job_history_${document.getElementById('dateFrom').value}_${document.getElementById('dateTo').value}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + Toast.success('CSV 匯出完成'); + + } catch (error) { + Toast.error('匯出失敗: ' + error.message); + } finally { + document.getElementById('exportBtn').disabled = false; + document.getElementById('exportBtn').textContent = '匯出 CSV'; + } + } + + // Format date + function formatDate(dateStr) { + if (!dateStr) return ''; + return dateStr.replace('T', ' ').substring(0, 19); + } Object.assign(window, { @@ -460,6 +494,7 @@ renderEquipmentList, toggleEquipmentDropdown, filterEquipments, toggleEquipment, +toggleWorkcenterGroup, updateSelectedDisplay, setLast90Days, validateInputs, diff --git a/frontend/src/mid-section-defect/App.vue b/frontend/src/mid-section-defect/App.vue index becd8f9..4645219 100644 --- a/frontend/src/mid-section-defect/App.vue +++ b/frontend/src/mid-section-defect/App.vue @@ -15,11 +15,16 @@ ensureMesApiAvailable(); const API_TIMEOUT = 120000; // 2min (genealogy can be slow) const PAGE_SIZE = 200; -const filters = reactive({ - startDate: '', - endDate: '', - lossReasons: [], -}); +const filters = reactive({ + startDate: '', + endDate: '', + lossReasons: [], +}); +const committedFilters = ref({ + startDate: '', + endDate: '', + lossReasons: [], +}); const availableLossReasons = ref([]); @@ -71,16 +76,25 @@ function unwrapApiResult(result, fallbackMessage) { throw new Error(result?.error || fallbackMessage); } -function buildFilterParams() { - const params = { - start_date: filters.startDate, - end_date: filters.endDate, - }; - if (filters.lossReasons.length) { - params.loss_reasons = filters.lossReasons.join(','); - } - return params; -} +function buildFilterParams() { + const snapshot = committedFilters.value; + const params = { + start_date: snapshot.startDate, + end_date: snapshot.endDate, + }; + if (snapshot.lossReasons.length) { + params.loss_reasons = snapshot.lossReasons.join(','); + } + return params; +} + +function snapshotFilters() { + committedFilters.value = { + startDate: filters.startDate, + endDate: filters.endDate, + lossReasons: [...filters.lossReasons], + }; +} async function loadLossReasons() { try { @@ -92,46 +106,52 @@ async function loadLossReasons() { } } -async function loadDetail(page = 1) { - detailLoading.value = true; - try { - const params = { - ...buildFilterParams(), - page, - page_size: PAGE_SIZE, - }; - const result = await apiGet('/api/mid-section-defect/analysis/detail', { - params, - timeout: API_TIMEOUT, - }); +async function loadDetail(page = 1, signal = null) { + detailLoading.value = true; + try { + const params = { + ...buildFilterParams(), + page, + page_size: PAGE_SIZE, + }; + const result = await apiGet('/api/mid-section-defect/analysis/detail', { + params, + timeout: API_TIMEOUT, + signal, + }); const unwrapped = unwrapApiResult(result, '載入明細失敗'); detailData.value = unwrapped.data?.detail || []; detailPagination.value = unwrapped.data?.pagination || { page: 1, page_size: PAGE_SIZE, total_count: 0, total_pages: 1, }; - } catch (err) { - console.error('Detail load failed:', err.message); - detailData.value = []; - } finally { - detailLoading.value = false; - } + } catch (err) { + if (err?.name === 'AbortError') { + return; + } + console.error('Detail load failed:', err.message); + detailData.value = []; + } finally { + detailLoading.value = false; + } } -async function loadAnalysis() { - queryError.value = ''; - loading.querying = true; - - try { - const params = buildFilterParams(); - - // Fire summary and detail page 1 in parallel - const [summaryResult] = await Promise.all([ - apiGet('/api/mid-section-defect/analysis', { - params, - timeout: API_TIMEOUT, - }), - loadDetail(1), - ]); +async function loadAnalysis() { + queryError.value = ''; + loading.querying = true; + const signal = createAbortSignal('msd-analysis'); + + try { + const params = buildFilterParams(); + + // Fire summary and detail page 1 in parallel + const [summaryResult] = await Promise.all([ + apiGet('/api/mid-section-defect/analysis', { + params, + timeout: API_TIMEOUT, + signal, + }), + loadDetail(1, signal), + ]); const unwrapped = unwrapApiResult(summaryResult, '查詢失敗'); analysisData.value = unwrapped.data; @@ -142,60 +162,66 @@ async function loadAnalysis() { autoRefreshStarted = true; startAutoRefresh(); } - } catch (err) { - queryError.value = err.message || '查詢失敗,請稍後再試'; - } finally { - loading.querying = false; - } -} + } catch (err) { + if (err?.name === 'AbortError') { + return; + } + queryError.value = err.message || '查詢失敗,請稍後再試'; + } finally { + loading.querying = false; + } +} function handleUpdateFilters(updated) { Object.assign(filters, updated); } -function handleQuery() { - loadAnalysis(); -} - -function prevPage() { - if (detailPagination.value.page <= 1) return; - loadDetail(detailPagination.value.page - 1); -} - -function nextPage() { - if (detailPagination.value.page >= detailPagination.value.total_pages) return; - loadDetail(detailPagination.value.page + 1); -} - -function exportCsv() { - const params = new URLSearchParams({ - start_date: filters.startDate, - end_date: filters.endDate, - }); - if (filters.lossReasons.length) { - params.set('loss_reasons', filters.lossReasons.join(',')); - } - - const link = document.createElement('a'); - link.href = `/api/mid-section-defect/export?${params}`; - link.download = `mid_section_defect_${filters.startDate}_to_${filters.endDate}.csv`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -} - -let autoRefreshStarted = false; -const { startAutoRefresh } = useAutoRefresh({ - onRefresh: () => loadAnalysis(), - intervalMs: 5 * 60 * 1000, - autoStart: false, - refreshOnVisible: true, -}); - -function initPage() { - setDefaultDates(); - loadLossReasons(); -} +function handleQuery() { + snapshotFilters(); + loadAnalysis(); +} + +function prevPage() { + if (detailPagination.value.page <= 1) return; + loadDetail(detailPagination.value.page - 1, createAbortSignal('msd-detail')); +} + +function nextPage() { + if (detailPagination.value.page >= detailPagination.value.total_pages) return; + loadDetail(detailPagination.value.page + 1, createAbortSignal('msd-detail')); +} + +function exportCsv() { + const snapshot = committedFilters.value; + const params = new URLSearchParams({ + start_date: snapshot.startDate, + end_date: snapshot.endDate, + }); + if (snapshot.lossReasons.length) { + params.set('loss_reasons', snapshot.lossReasons.join(',')); + } + + const link = document.createElement('a'); + link.href = `/api/mid-section-defect/export?${params}`; + link.download = `mid_section_defect_${snapshot.startDate}_to_${snapshot.endDate}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +let autoRefreshStarted = false; +const { createAbortSignal, startAutoRefresh } = useAutoRefresh({ + onRefresh: () => loadAnalysis(), + intervalMs: 5 * 60 * 1000, + autoStart: false, + refreshOnVisible: true, +}); + +function initPage() { + setDefaultDates(); + snapshotFilters(); + loadLossReasons(); +} void initPage(); diff --git a/openspec/changes/archive/2026-02-09-harden-mid-section-defect/.openspec.yaml b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/.openspec.yaml new file mode 100644 index 0000000..70eb9e0 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-10 diff --git a/openspec/changes/archive/2026-02-09-harden-mid-section-defect/design.md b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/design.md new file mode 100644 index 0000000..02afd93 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/design.md @@ -0,0 +1,61 @@ +## Context + +`/mid-section-defect` 頁面已上線,功能完整但缺乏生產環境保護機制。Code review 揭示 6 個問題,依嚴重度分為 P0-P3。現有基礎建設(`try_acquire_lock`、`configured_rate_limit`、`createAbortSignal`)均可直接複用,無需新增框架。 + +**現有架構**: +- Backend: `query_analysis()` (line 82-184) 含 5min Redis 快取 → `query_analysis_detail()` (line 187-224) 呼叫前者取快取結果再分頁 +- Frontend: `Promise.all([summary, detail])` 平行載入 → `useAutoRefresh` 5min 自動刷新 +- 上游歷史: SQL 查全量 → Python `get_workcenter_group()` 逐行分類 + order 4-11 過濾 + +## Goals / Non-Goals + +**Goals:** +- 消除首次查詢的雙倍 Oracle 管線執行(P0) +- 保護高成本路由免受暴衝流量(P1a) +- 確保 UI 篩選變更不會汙染進行中的 API 呼叫(P1b) +- 新查詢自動取消舊的進行中請求(P2a) +- 善用 Oracle Server 做 workcenter 分類,支援全線歷程追蹤(P2b) +- 基礎測試覆蓋防止回歸(P3) + +**Non-Goals:** +- 不改變 API response 格式 +- 不重構 `query_analysis()` 管線邏輯 +- 不加入前端 UI 新功能 +- 不處理 `export_csv()` 的串流效能(目前可接受) +- 不做 DuckDB 中間層或背景預計算 + +## Decisions + +### D1: 分散式鎖策略 — Redis SET NX 輪詢等待 + +**選擇**: 使用既有 `try_acquire_lock()` + 輪詢 `cache_get()` 等待模式。 +**替代方案**: (A) Pub/Sub 通知 — 複雜度高,需新增 channel 管理;(B) 前端序列化 — 改 `Promise.all` 為先 summary 再 detail,但仍有自動刷新與手動查詢並行問題。 +**理由**: 鎖機制在 service 層統一保護所有入口(包含未來新路由),fail-open 設計確保 Redis 故障不阻塞。輪詢 0.5s 間隔在 5-35s 典型管線執行時間下損耗可忽略。 + +### D2: Rate limit 預設值 — 依路由成本分級 + +**選擇**: `/analysis` 6/60s、`/detail` 15/60s、`/export` 3/60s。 +**理由**: `/analysis` 冷查詢 35s,每分鐘最多 6 次已足夠(含自動刷新)。`/detail` 分頁翻頁頻率高但走快取,15 次寬裕。`/export` 觸發全量串流,3 次防誤操作。`/loss-reasons` 已有 24h 快取,無需限速。 + +### D3: 篩選分離 — committedFilters ref 快照 + +**選擇**: 新增 `committedFilters` ref,按「查詢」時從 `filters` reactive 快照。所有 API 函式讀 `committedFilters`。 +**替代方案**: (A) deep watch + debounce — 會在使用者輸入中途觸發查詢;(B) URL params 持久化 — 此頁面不需要書籤分享功能。 +**理由**: 最小改動,與 `resource-history/App.vue` 的 `buildQueryString()` 模式一致。`filters` reactive 繼續作為 UI 雙向繫結,`committedFilters` 是「上次查詢使用的參數」。 + +### D4: AbortController — keyed signal 設計 + +**選擇**: `'msd-analysis'` key 用於查詢(summary + detail page 1 共用),`'msd-detail'` key 用於獨立翻頁。 +**理由**: 新查詢取消舊查詢的所有請求(含翻頁中的 detail),翻頁取消前一次翻頁但不影響進行中的查詢。與 `wip-detail/App.vue` 相同模式。 + +### D5: 上游歷史 SQL 端分類 — CASE WHEN 全線保留 + +**選擇**: SQL CTE 內加 `CASE WHEN` 將 `WORKCENTERNAME` 分類為 `WORKCENTER_GROUP`(12 組 + NULL fallback),Python 端直接讀取分類結果,不過濾任何站點。 +**替代方案**: (A) Oracle 自訂函式 — 需 DBA 部署;(B) 維持 Python 端分類但移除過濾 — 仍有 10K+ 行逐行 regex 開銷。 +**理由**: CASE WHEN 在 Oracle 查詢引擎內原生執行,無 row-by-row function call 開銷。分類邏輯與 `workcenter_groups.py` 的 patterns 完全對齊,但需注意 CASE 順序(exclude-first: `元件切割` 在 `切割` 之前)。 + +## Risks / Trade-offs + +- **[P0 鎖等待超時]** 若管線執行 >90s(極大日期範圍),等待方可能超時後自行查詢 → 緩解:API_TIMEOUT 本身 120s,鎖 TTL 120s 會自動釋放,最壞情況退化為當前行為(雙查詢) +- **[P2b SQL 分類與 Python 不一致]** 若 `workcenter_groups.py` 新增/修改 pattern 但忘記同步 SQL → 緩解:SQL 的 NULL fallback 確保不會遺失行,僅分類名稱可能為 NULL +- **[Rate limit 誤擋]** 高頻翻頁或自動刷新可能觸發限速 → 緩解:`/detail` 15/60s 已足夠正常翻頁(每 4s 一頁),自動刷新 5min 間隔遠低於 `/analysis` 6/60s 門檻 diff --git a/openspec/changes/archive/2026-02-09-harden-mid-section-defect/proposal.md b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/proposal.md new file mode 100644 index 0000000..71a6b76 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/proposal.md @@ -0,0 +1,31 @@ +## Why + +Code review 發現中段製程不良追溯分析(`/mid-section-defect`)有 6 個問題:首次查詢觸發雙倍 Oracle 管線(P0)、高成本路由無節流(P1a)、篩選與查詢狀態耦合(P1b)、無請求取消機制(P2a)、上游歷史 workcenter 分類在 Python 端逐行計算而非善用 DB Server(P2b)、零測試覆蓋(P3)。需在功能穩定後立即修復,防止 DB 過載與前端競態問題。 + +## What Changes + +- **P0 分散式鎖**:`query_analysis()` 加入 `try_acquire_lock` / `release_lock` 包裹計算區段,第二個平行請求等待快取而非重跑管線 +- **P1a 路由限速**:`/analysis`(6/60s)、`/analysis/detail`(15/60s)、`/export`(3/60s)加入 `configured_rate_limit` decorator +- **P1b 篩選分離**:新增 `committedFilters` ref,所有 API 呼叫(翻頁/自動刷新/匯出)讀取已提交的篩選快照 +- **P2a 請求取消**:`loadAnalysis()` 和 `loadDetail()` 加入 `createAbortSignal(key)` keyed abort,新查詢自動取消舊請求 +- **P2b SQL 端分類**:上游歷史 SQL 加入 `CASE WHEN` workcenter group 分類(全線歷程不排除任何站點),移除 Python 端 `get_workcenter_group()` 逐行呼叫與 order 4-11 過濾 +- **P3 測試覆蓋**:新增 `test_mid_section_defect_routes.py`(9 個測試)和 `test_mid_section_defect_service.py`(9 個測試) + +## Capabilities + +### New Capabilities + +(無新增能力,本次為既有功能的強化修復) + +### Modified Capabilities + +- `api-safety-hygiene`: 新增 mid-section-defect 3 個路由的 rate limit 與分散式鎖機制 +- `vue-vite-page-architecture`: mid-section-defect 前端加入 committedFilters 篩選分離與 AbortController 請求取消 + +## Impact + +- **Backend**: `mid_section_defect_service.py`(分散式鎖 + 移除 Python 端 workcenter 過濾)、`mid_section_defect_routes.py`(rate limit)、`upstream_history.sql`(CASE WHEN 分類) +- **Frontend**: `mid-section-defect/App.vue`(committedFilters + abort signal) +- **Tests**: 2 個新測試檔案(`test_mid_section_defect_routes.py`、`test_mid_section_defect_service.py`) +- **API 行為變更**: 超過限速門檻回傳 429;上游歷史回傳含 `WORKCENTER_GROUP` 欄位(但 API response 格式不變,分類邏輯內部調整) +- **無破壞性變更**: API response 結構、快取 key、前端元件介面均不變 diff --git a/openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/api-safety-hygiene/spec.md b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/api-safety-hygiene/spec.md new file mode 100644 index 0000000..f90a072 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/api-safety-hygiene/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Mid-section defect analysis endpoints SHALL apply distributed lock to prevent duplicate pipeline execution +The `/api/mid-section-defect/analysis` pipeline SHALL use a Redis distributed lock to prevent concurrent identical queries from executing the full Oracle pipeline in parallel. + +#### Scenario: Two parallel requests with cold cache +- **WHEN** two requests with identical parameters arrive simultaneously and no cache exists +- **THEN** the first request SHALL acquire the lock and execute the full pipeline +- **THEN** the second request SHALL wait by polling the cache until the first request completes +- **THEN** only ONE full Oracle pipeline execution SHALL occur + +#### Scenario: Lock wait timeout +- **WHEN** a waiting request does not see a cache result within 90 seconds +- **THEN** the request SHALL proceed with its own pipeline execution (fail-open) + +#### Scenario: Redis unavailable +- **WHEN** Redis is unavailable during lock acquisition +- **THEN** the lock function SHALL return acquired=true (fail-open) +- **THEN** the request SHALL proceed normally without blocking + +#### Scenario: Pipeline exception with lock held +- **WHEN** the pipeline throws an exception while the lock is held +- **THEN** the lock SHALL be released in a finally block +- **THEN** subsequent requests SHALL NOT be blocked by a stale lock + +### Requirement: Mid-section defect routes SHALL apply rate limiting +The `/analysis`, `/analysis/detail`, and `/export` endpoints SHALL apply per-client rate limiting using the existing `configured_rate_limit` mechanism. + +#### Scenario: Analysis endpoint rate limit exceeded +- **WHEN** a client sends more than 6 requests to `/api/mid-section-defect/analysis` within 60 seconds +- **THEN** the endpoint SHALL return HTTP 429 with a `Retry-After` header +- **THEN** the service function SHALL NOT be called + +#### Scenario: Detail endpoint rate limit exceeded +- **WHEN** a client sends more than 15 requests to `/api/mid-section-defect/analysis/detail` within 60 seconds +- **THEN** the endpoint SHALL return HTTP 429 with a `Retry-After` header + +#### Scenario: Export endpoint rate limit exceeded +- **WHEN** a client sends more than 3 requests to `/api/mid-section-defect/export` within 60 seconds +- **THEN** the endpoint SHALL return HTTP 429 with a `Retry-After` header + +#### Scenario: Loss reasons endpoint not rate limited +- **WHEN** a client sends requests to `/api/mid-section-defect/loss-reasons` +- **THEN** no rate limiting SHALL be applied (endpoint is lightweight with 24h cache) + +### Requirement: Mid-section defect upstream history SHALL classify workcenters in SQL +The upstream history SQL query SHALL classify `WORKCENTERNAME` into workcenter groups using Oracle `CASE WHEN` expressions, returning the full production line history without excluding any stations. + +#### Scenario: Workcenter group classification in SQL +- **WHEN** the upstream history query executes +- **THEN** each row SHALL include a `WORKCENTER_GROUP` column derived from `CASE WHEN` pattern matching +- **THEN** the classification SHALL match the patterns defined in `workcenter_groups.py` + +#### Scenario: Unknown workcenter name +- **WHEN** a `WORKCENTERNAME` does not match any known pattern +- **THEN** `WORKCENTER_GROUP` SHALL be NULL +- **THEN** the row SHALL still be included in the result (not filtered out) + +#### Scenario: Full production line retention +- **WHEN** the upstream history is fetched for ancestor CIDs +- **THEN** ALL stations SHALL be included (cutting, welding, mid-section, testing) +- **THEN** no order-based filtering SHALL be applied + +### Requirement: Mid-section defect routes and service SHALL have test coverage +Route and service test files SHALL exist and cover core behaviors. + +#### Scenario: Route tests exist +- **WHEN** pytest discovers tests +- **THEN** `tests/test_mid_section_defect_routes.py` SHALL contain tests for success, parameter validation (400), service failure (500), and rate limiting (429) + +#### Scenario: Service tests exist +- **WHEN** pytest discovers tests +- **THEN** `tests/test_mid_section_defect_service.py` SHALL contain tests for date validation, pagination logic, and loss reasons caching diff --git a/openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..a778d5e --- /dev/null +++ b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/specs/vue-vite-page-architecture/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Mid-section defect page SHALL separate filter state from query state +The mid-section defect page SHALL maintain separate reactive state for UI input (`filters`) and committed query parameters (`committedFilters`). + +#### Scenario: User changes date without clicking query +- **WHEN** user modifies the date range in the filter bar but does not click "查詢" +- **THEN** auto-refresh, pagination, and CSV export SHALL continue using the previously committed filter values +- **THEN** the new date range SHALL NOT affect any API calls until "查詢" is clicked + +#### Scenario: User clicks query button +- **WHEN** user clicks "查詢" +- **THEN** the current `filters` state SHALL be snapshotted into `committedFilters` +- **THEN** all subsequent API calls SHALL use the committed values + +#### Scenario: CSV export uses committed filters +- **WHEN** user clicks "匯出 CSV" after modifying filters without re-querying +- **THEN** the export SHALL use the committed filter values from the last query +- **THEN** the export SHALL NOT use the current UI filter values + +### Requirement: Mid-section defect page SHALL cancel in-flight requests on new query +The mid-section defect page SHALL use `AbortController` to cancel in-flight API requests when a new query is initiated. + +#### Scenario: New query cancels previous query +- **WHEN** user clicks "查詢" while a previous query is still in-flight +- **THEN** the previous query's summary and detail requests SHALL be aborted +- **THEN** the AbortError SHALL be handled silently (no error banner shown) + +#### Scenario: Page navigation cancels previous detail request +- **WHEN** user clicks next page while a previous page request is still in-flight +- **THEN** the previous page request SHALL be aborted +- **THEN** the new page request SHALL proceed independently + +#### Scenario: Query and pagination use independent abort keys +- **WHEN** a query is in-flight and user triggers pagination +- **THEN** the query SHALL NOT be cancelled by the pagination request +- **THEN** the pagination SHALL use a separate abort key from the query diff --git a/openspec/changes/archive/2026-02-09-harden-mid-section-defect/tasks.md b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/tasks.md new file mode 100644 index 0000000..0551f5a --- /dev/null +++ b/openspec/changes/archive/2026-02-09-harden-mid-section-defect/tasks.md @@ -0,0 +1,35 @@ +## 1. P0 — 分散式鎖防止重複管線執行 + +- [x] 1.1 在 `mid_section_defect_service.py` 的 `query_analysis()` cache miss 後加入 `try_acquire_lock` / `release_lock` 包裹計算區段 +- [x] 1.2 實作 lock-or-wait 邏輯:未取得鎖時輪詢 `cache_get()` 每 0.5s,最多 90s,超時 fail-open +- [x] 1.3 在 `finally` 區塊確保鎖釋放,取得鎖後再做 double-check cache + +## 2. P1a — 高成本路由限速 + +- [x] 2.1 在 `mid_section_defect_routes.py` import `configured_rate_limit` 並建立 3 個限速器(analysis 6/60s、detail 15/60s、export 3/60s) +- [x] 2.2 將限速 decorator 套用到 `/analysis`、`/analysis/detail`、`/export` 三個路由 + +## 3. P1b + P2a — 前端篩選分離與請求取消 + +- [x] 3.1 在 `App.vue` 新增 `committedFilters` ref,`handleQuery()` 時從 `filters` 快照 +- [x] 3.2 修改 `buildFilterParams()` 和 `exportCsv()` 讀取 `committedFilters` 而非 `filters` +- [x] 3.3 `initPage()` 設定預設日期後同步快照到 `committedFilters` +- [x] 3.4 從 `useAutoRefresh` 解構 `createAbortSignal`,在 `loadAnalysis()` 加入 `'msd-analysis'` signal +- [x] 3.5 `loadDetail()` 接受外部 signal 參數,獨立翻頁時使用 `'msd-detail'` key +- [x] 3.6 `loadAnalysis()` 和 `loadDetail()` catch 區塊靜默處理 `AbortError` + +## 4. P2b — 上游歷史 SQL 端分類 + +- [x] 4.1 修改 `upstream_history.sql` CTE 加入 `CASE WHEN` 將 `WORKCENTERNAME` 分類為 `WORKCENTER_GROUP`(12 組 + NULL fallback) +- [x] 4.2 確保 CASE 順序正確(`元件切割`/`PKG_SAW` 在 `切割` 之前) +- [x] 4.3 修改 `_fetch_upstream_history()` 讀取 SQL 回傳的 `WORKCENTER_GROUP` 欄位,移除 `get_workcenter_group()` 逐行呼叫與 order 4-11 過濾 + +## 5. P3 — 測試覆蓋 + +- [x] 5.1 建立 `tests/test_mid_section_defect_routes.py`:success、400 參數驗證、500 service 失敗、429 rate limit(共 9 個測試) +- [x] 5.2 建立 `tests/test_mid_section_defect_service.py`:日期驗證、分頁邏輯、loss reasons 快取(共 9 個測試) + +## 6. 驗證 + +- [x] 6.1 `npm run build` 前端建置通過 +- [x] 6.2 `pytest tests/test_mid_section_defect_routes.py tests/test_mid_section_defect_service.py -v` 全部通過 diff --git a/openspec/specs/api-safety-hygiene/spec.md b/openspec/specs/api-safety-hygiene/spec.md index 4237904..d1c933e 100644 --- a/openspec/specs/api-safety-hygiene/spec.md +++ b/openspec/specs/api-safety-hygiene/spec.md @@ -31,3 +31,75 @@ Boolean query parsing in routes SHALL use shared helper behavior. - **WHEN** routes parse common boolean query parameters - **THEN** parsing behavior MUST be consistent across routes via shared utility +### Requirement: Mid-section defect analysis endpoints SHALL apply distributed lock to prevent duplicate pipeline execution +The `/api/mid-section-defect/analysis` pipeline SHALL use a Redis distributed lock to prevent concurrent identical queries from executing the full Oracle pipeline in parallel. + +#### Scenario: Two parallel requests with cold cache +- **WHEN** two requests with identical parameters arrive simultaneously and no cache exists +- **THEN** the first request SHALL acquire the lock and execute the full pipeline +- **THEN** the second request SHALL wait by polling the cache until the first request completes +- **THEN** only ONE full Oracle pipeline execution SHALL occur + +#### Scenario: Lock wait timeout +- **WHEN** a waiting request does not see a cache result within 90 seconds +- **THEN** the request SHALL proceed with its own pipeline execution (fail-open) + +#### Scenario: Redis unavailable +- **WHEN** Redis is unavailable during lock acquisition +- **THEN** the lock function SHALL return acquired=true (fail-open) +- **THEN** the request SHALL proceed normally without blocking + +#### Scenario: Pipeline exception with lock held +- **WHEN** the pipeline throws an exception while the lock is held +- **THEN** the lock SHALL be released in a finally block +- **THEN** subsequent requests SHALL NOT be blocked by a stale lock + +### Requirement: Mid-section defect routes SHALL apply rate limiting +The `/analysis`, `/analysis/detail`, and `/export` endpoints SHALL apply per-client rate limiting using the existing `configured_rate_limit` mechanism. + +#### Scenario: Analysis endpoint rate limit exceeded +- **WHEN** a client sends more than 6 requests to `/api/mid-section-defect/analysis` within 60 seconds +- **THEN** the endpoint SHALL return HTTP 429 with a `Retry-After` header +- **THEN** the service function SHALL NOT be called + +#### Scenario: Detail endpoint rate limit exceeded +- **WHEN** a client sends more than 15 requests to `/api/mid-section-defect/analysis/detail` within 60 seconds +- **THEN** the endpoint SHALL return HTTP 429 with a `Retry-After` header + +#### Scenario: Export endpoint rate limit exceeded +- **WHEN** a client sends more than 3 requests to `/api/mid-section-defect/export` within 60 seconds +- **THEN** the endpoint SHALL return HTTP 429 with a `Retry-After` header + +#### Scenario: Loss reasons endpoint not rate limited +- **WHEN** a client sends requests to `/api/mid-section-defect/loss-reasons` +- **THEN** no rate limiting SHALL be applied (endpoint is lightweight with 24h cache) + +### Requirement: Mid-section defect upstream history SHALL classify workcenters in SQL +The upstream history SQL query SHALL classify `WORKCENTERNAME` into workcenter groups using Oracle `CASE WHEN` expressions, returning the full production line history without excluding any stations. + +#### Scenario: Workcenter group classification in SQL +- **WHEN** the upstream history query executes +- **THEN** each row SHALL include a `WORKCENTER_GROUP` column derived from `CASE WHEN` pattern matching +- **THEN** the classification SHALL match the patterns defined in `workcenter_groups.py` + +#### Scenario: Unknown workcenter name +- **WHEN** a `WORKCENTERNAME` does not match any known pattern +- **THEN** `WORKCENTER_GROUP` SHALL be NULL +- **THEN** the row SHALL still be included in the result (not filtered out) + +#### Scenario: Full production line retention +- **WHEN** the upstream history is fetched for ancestor CIDs +- **THEN** ALL stations SHALL be included (cutting, welding, mid-section, testing) +- **THEN** no order-based filtering SHALL be applied + +### Requirement: Mid-section defect routes and service SHALL have test coverage +Route and service test files SHALL exist and cover core behaviors. + +#### Scenario: Route tests exist +- **WHEN** pytest discovers tests +- **THEN** `tests/test_mid_section_defect_routes.py` SHALL contain tests for success, parameter validation (400), service failure (500), and rate limiting (429) + +#### Scenario: Service tests exist +- **WHEN** pytest discovers tests +- **THEN** `tests/test_mid_section_defect_service.py` SHALL contain tests for date validation, pagination logic, and loss reasons caching + diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md index 0019618..1abacbf 100644 --- a/openspec/specs/vue-vite-page-architecture/spec.md +++ b/openspec/specs/vue-vite-page-architecture/spec.md @@ -87,3 +87,39 @@ Pages that require server-side parameter validation before serving SHALL validat - **WHEN** the pure Vite hold-detail page loads - **THEN** the page SHALL read `reason` from URL parameters - **THEN** if `reason` is empty or missing, the page SHALL redirect to `/wip-overview` + +### Requirement: Mid-section defect page SHALL separate filter state from query state +The mid-section defect page SHALL maintain separate reactive state for UI input (`filters`) and committed query parameters (`committedFilters`). + +#### Scenario: User changes date without clicking query +- **WHEN** user modifies the date range in the filter bar but does not click "查詢" +- **THEN** auto-refresh, pagination, and CSV export SHALL continue using the previously committed filter values +- **THEN** the new date range SHALL NOT affect any API calls until "查詢" is clicked + +#### Scenario: User clicks query button +- **WHEN** user clicks "查詢" +- **THEN** the current `filters` state SHALL be snapshotted into `committedFilters` +- **THEN** all subsequent API calls SHALL use the committed values + +#### Scenario: CSV export uses committed filters +- **WHEN** user clicks "匯出 CSV" after modifying filters without re-querying +- **THEN** the export SHALL use the committed filter values from the last query +- **THEN** the export SHALL NOT use the current UI filter values + +### Requirement: Mid-section defect page SHALL cancel in-flight requests on new query +The mid-section defect page SHALL use `AbortController` to cancel in-flight API requests when a new query is initiated. + +#### Scenario: New query cancels previous query +- **WHEN** user clicks "查詢" while a previous query is still in-flight +- **THEN** the previous query's summary and detail requests SHALL be aborted +- **THEN** the AbortError SHALL be handled silently (no error banner shown) + +#### Scenario: Page navigation cancels previous detail request +- **WHEN** user clicks next page while a previous page request is still in-flight +- **THEN** the previous page request SHALL be aborted +- **THEN** the new page request SHALL proceed independently + +#### Scenario: Query and pagination use independent abort keys +- **WHEN** a query is in-flight and user triggers pagination +- **THEN** the query SHALL NOT be cancelled by the pagination request +- **THEN** the pagination SHALL use a separate abort key from the query diff --git a/src/mes_dashboard/routes/mid_section_defect_routes.py b/src/mes_dashboard/routes/mid_section_defect_routes.py index 88e1366..652d840 100644 --- a/src/mes_dashboard/routes/mid_section_defect_routes.py +++ b/src/mes_dashboard/routes/mid_section_defect_routes.py @@ -4,24 +4,50 @@ Reverse traceability from TMTT (test) station back to upstream production stations. """ -from flask import Blueprint, jsonify, request, Response - -from mes_dashboard.services.mid_section_defect_service import ( - query_analysis, - query_analysis_detail, - query_all_loss_reasons, - export_csv, +from flask import Blueprint, jsonify, request, Response + +from mes_dashboard.core.rate_limit import configured_rate_limit +from mes_dashboard.services.mid_section_defect_service import ( + query_analysis, + query_analysis_detail, + query_all_loss_reasons, + export_csv, ) -mid_section_defect_bp = Blueprint( - 'mid_section_defect', - __name__, - url_prefix='/api/mid-section-defect' -) - - -@mid_section_defect_bp.route('/analysis', methods=['GET']) -def api_analysis(): +mid_section_defect_bp = Blueprint( + 'mid_section_defect', + __name__, + url_prefix='/api/mid-section-defect' +) + +_ANALYSIS_RATE_LIMIT = configured_rate_limit( + bucket="mid-section-defect-analysis", + max_attempts_env="MID_SECTION_DEFECT_ANALYSIS_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="MID_SECTION_DEFECT_ANALYSIS_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=6, + default_window_seconds=60, +) + +_DETAIL_RATE_LIMIT = configured_rate_limit( + bucket="mid-section-defect-analysis-detail", + max_attempts_env="MID_SECTION_DEFECT_DETAIL_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="MID_SECTION_DEFECT_DETAIL_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=15, + default_window_seconds=60, +) + +_EXPORT_RATE_LIMIT = configured_rate_limit( + bucket="mid-section-defect-export", + max_attempts_env="MID_SECTION_DEFECT_EXPORT_RATE_LIMIT_MAX_REQUESTS", + window_seconds_env="MID_SECTION_DEFECT_EXPORT_RATE_LIMIT_WINDOW_SECONDS", + default_max_attempts=3, + default_window_seconds=60, +) + + +@mid_section_defect_bp.route('/analysis', methods=['GET']) +@_ANALYSIS_RATE_LIMIT +def api_analysis(): """API: Get mid-section defect traceability analysis (summary). Returns kpi, charts, daily_trend, available_loss_reasons, genealogy_status, @@ -66,8 +92,9 @@ def api_analysis(): return jsonify({'success': True, 'data': summary}) -@mid_section_defect_bp.route('/analysis/detail', methods=['GET']) -def api_analysis_detail(): +@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. Query Parameters: @@ -124,8 +151,9 @@ def api_loss_reasons(): return jsonify({'success': True, 'data': result}) -@mid_section_defect_bp.route('/export', methods=['GET']) -def api_export(): +@mid_section_defect_bp.route('/export', methods=['GET']) +@_EXPORT_RATE_LIMIT +def api_export(): """API: Export mid-section defect detail data as CSV. Query Parameters: diff --git a/src/mes_dashboard/services/mid_section_defect_service.py b/src/mes_dashboard/services/mid_section_defect_service.py index 2843105..f913cfa 100644 --- a/src/mes_dashboard/services/mid_section_defect_service.py +++ b/src/mes_dashboard/services/mid_section_defect_service.py @@ -18,32 +18,35 @@ Attribution Method (Sum): rate = attributed_rejectqty / attributed_trackinqty × 100 """ -import csv -import io -import logging -import math -from collections import defaultdict -from datetime import datetime -from typing import Optional, Dict, List, Any, Set, Tuple, Generator - -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, QueryBuilder -from mes_dashboard.config.workcenter_groups import get_workcenter_group - -logger = logging.getLogger('mes_dashboard.mid_section_defect') +import csv +import hashlib +import io +import logging +import math +import time +from collections import defaultdict +from datetime import datetime +from typing import Optional, Dict, List, Any, Set, Tuple, Generator + +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.core.redis_client import try_acquire_lock, release_lock +from mes_dashboard.sql import SQLLoader, QueryBuilder + +logger = logging.getLogger('mes_dashboard.mid_section_defect') # Constants MAX_QUERY_DAYS = 180 CACHE_TTL_TMTT = 300 # 5 min for TMTT detection data CACHE_TTL_LOSS_REASONS = 86400 # 24h for loss reason list (daily sync) -ORACLE_IN_BATCH_SIZE = 1000 # Oracle IN clause limit - -# Mid-section workcenter group order range (成型 through 測試) -MID_SECTION_ORDER_MIN = 4 # 成型 -MID_SECTION_ORDER_MAX = 11 # 測試 +ORACLE_IN_BATCH_SIZE = 1000 # Oracle IN clause limit + +# Distributed lock settings for query_analysis cold-cache path +ANALYSIS_LOCK_TTL_SECONDS = 120 +ANALYSIS_LOCK_WAIT_TIMEOUT_SECONDS = 90 +ANALYSIS_LOCK_POLL_INTERVAL_SECONDS = 0.5 # Top N for chart display (rest grouped as "其他") TOP_N = 10 @@ -107,81 +110,113 @@ def query_analysis( 'loss_reasons': sorted(loss_reasons) if loss_reasons else None, }, ) - cached = cache_get(cache_key) - if cached is not None: - return cached - - # Stage 1: TMTT detection data - tmtt_df = _fetch_tmtt_data(start_date, end_date) - if tmtt_df is None: - return None - if tmtt_df.empty: - return _empty_result() - - # Extract available loss reasons before filtering - available_loss_reasons = sorted( - tmtt_df.loc[tmtt_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] - .dropna().unique().tolist() - ) - - # Apply loss reason filter if specified - if loss_reasons: - filtered_df = tmtt_df[ - (tmtt_df['LOSSREASONNAME'].isin(loss_reasons)) - | (tmtt_df['REJECTQTY'] == 0) - | (tmtt_df['LOSSREASONNAME'].isna()) - ].copy() - else: - filtered_df = tmtt_df - - # Stage 2: Genealogy resolution (split chain + merge expansion) - tmtt_cids = tmtt_df['CONTAINERID'].unique().tolist() - tmtt_names = {} - for _, r in tmtt_df.drop_duplicates('CONTAINERID').iterrows(): - tmtt_names[r['CONTAINERID']] = _safe_str(r.get('CONTAINERNAME')) - - ancestors = {} - genealogy_status = 'ready' - - if tmtt_cids: - try: - ancestors = _resolve_full_genealogy(tmtt_cids, tmtt_names) - except Exception as exc: - logger.error(f"Genealogy resolution failed: {exc}", exc_info=True) - genealogy_status = 'error' - - # Stage 3: Upstream history for ALL CIDs (TMTT lots + ancestors) - all_query_cids = set(tmtt_cids) - for anc_set in ancestors.values(): - all_query_cids.update(anc_set) - # Filter out any non-string values (NaN/None from pandas) - all_query_cids = {c for c in all_query_cids if isinstance(c, str) and c} - - upstream_by_cid = {} - if all_query_cids: - try: - upstream_by_cid = _fetch_upstream_history(list(all_query_cids)) - except Exception as exc: - logger.error(f"Upstream history query failed: {exc}", exc_info=True) - genealogy_status = 'error' - tmtt_data = _build_tmtt_lookup(filtered_df) - attribution = _attribute_defects( - tmtt_data, ancestors, upstream_by_cid, loss_reasons, - ) - - result = { - 'kpi': _build_kpi(filtered_df, attribution, loss_reasons), - 'available_loss_reasons': available_loss_reasons, - 'charts': _build_all_charts(attribution, tmtt_data), - 'daily_trend': _build_daily_trend(filtered_df, loss_reasons), - 'detail': _build_detail_table(filtered_df, ancestors, upstream_by_cid), - 'genealogy_status': genealogy_status, - } - - # Only cache successful results (don't cache upstream errors) - if genealogy_status == 'ready': - cache_set(cache_key, result, ttl=CACHE_TTL_TMTT) - return result + cached = cache_get(cache_key) + if cached is not None: + return cached + + lock_name = ( + f"mid_section_defect:analysis:{hashlib.md5(cache_key.encode('utf-8')).hexdigest()}" + ) + lock_acquired = False + + # Prevent duplicate cold-cache pipeline execution across workers. + lock_acquired = try_acquire_lock(lock_name, ttl_seconds=ANALYSIS_LOCK_TTL_SECONDS) + if not lock_acquired: + wait_start = time.monotonic() + while ( + time.monotonic() - wait_start + < ANALYSIS_LOCK_WAIT_TIMEOUT_SECONDS + ): + cached = cache_get(cache_key) + if cached is not None: + return cached + time.sleep(ANALYSIS_LOCK_POLL_INTERVAL_SECONDS) + + logger.warning( + "Timed out waiting for in-flight mid_section_defect analysis cache; " + "continuing with fail-open pipeline execution" + ) + else: + # Double-check cache after lock acquisition. + cached = cache_get(cache_key) + if cached is not None: + return cached + + try: + # Stage 1: TMTT detection data + tmtt_df = _fetch_tmtt_data(start_date, end_date) + if tmtt_df is None: + return None + if tmtt_df.empty: + return _empty_result() + + # Extract available loss reasons before filtering + available_loss_reasons = sorted( + tmtt_df.loc[tmtt_df['REJECTQTY'] > 0, 'LOSSREASONNAME'] + .dropna().unique().tolist() + ) + + # Apply loss reason filter if specified + if loss_reasons: + filtered_df = tmtt_df[ + (tmtt_df['LOSSREASONNAME'].isin(loss_reasons)) + | (tmtt_df['REJECTQTY'] == 0) + | (tmtt_df['LOSSREASONNAME'].isna()) + ].copy() + else: + filtered_df = tmtt_df + + # Stage 2: Genealogy resolution (split chain + merge expansion) + tmtt_cids = tmtt_df['CONTAINERID'].unique().tolist() + tmtt_names = {} + for _, r in tmtt_df.drop_duplicates('CONTAINERID').iterrows(): + tmtt_names[r['CONTAINERID']] = _safe_str(r.get('CONTAINERNAME')) + + ancestors = {} + genealogy_status = 'ready' + + if tmtt_cids: + try: + ancestors = _resolve_full_genealogy(tmtt_cids, tmtt_names) + except Exception as exc: + logger.error(f"Genealogy resolution failed: {exc}", exc_info=True) + genealogy_status = 'error' + + # Stage 3: Upstream history for ALL CIDs (TMTT lots + ancestors) + all_query_cids = set(tmtt_cids) + for anc_set in ancestors.values(): + all_query_cids.update(anc_set) + # Filter out any non-string values (NaN/None from pandas) + all_query_cids = {c for c in all_query_cids if isinstance(c, str) and c} + + upstream_by_cid = {} + if all_query_cids: + try: + upstream_by_cid = _fetch_upstream_history(list(all_query_cids)) + except Exception as exc: + logger.error(f"Upstream history query failed: {exc}", exc_info=True) + genealogy_status = 'error' + tmtt_data = _build_tmtt_lookup(filtered_df) + attribution = _attribute_defects( + tmtt_data, ancestors, upstream_by_cid, loss_reasons, + ) + + result = { + 'kpi': _build_kpi(filtered_df, attribution, loss_reasons), + 'available_loss_reasons': available_loss_reasons, + 'charts': _build_all_charts(attribution, tmtt_data), + 'daily_trend': _build_daily_trend(filtered_df, loss_reasons), + 'detail': _build_detail_table(filtered_df, ancestors, upstream_by_cid), + 'genealogy_status': genealogy_status, + } + + # Only cache successful results (don't cache upstream errors) + if genealogy_status == 'ready': + cache_set(cache_key, result, ttl=CACHE_TTL_TMTT) + return result + finally: + if lock_acquired: + release_lock(lock_name) def query_analysis_detail( @@ -600,13 +635,13 @@ def _log_genealogy_summary( # Query 3: Upstream Production History # ============================================================ -def _fetch_upstream_history( - all_cids: List[str], -) -> Dict[str, List[Dict[str, Any]]]: +def _fetch_upstream_history( + all_cids: List[str], +) -> Dict[str, List[Dict[str, Any]]]: """Fetch upstream production history for ancestor CONTAINERIDs. - Batches queries to respect Oracle IN clause limit. - Filters by mid-section workcenter groups (order 4-11) in Python. + Batches queries to respect Oracle IN clause limit. + WORKCENTER_GROUP classification is computed in SQL (CASE WHEN). Returns: {containerid: [{'workcenter_group': ..., 'equipment_name': ..., ...}, ...]} @@ -646,28 +681,24 @@ def _fetch_upstream_history( combined = pd.concat(all_rows, ignore_index=True) - # Filter by mid-section workcenter groups in Python - result: Dict[str, List[Dict[str, Any]]] = defaultdict(list) - for _, row in combined.iterrows(): - wc_name = row.get('WORKCENTERNAME', '') - group_name, order = get_workcenter_group(wc_name) - if group_name is None or order < MID_SECTION_ORDER_MIN or order > MID_SECTION_ORDER_MAX: - continue - - cid = row['CONTAINERID'] - result[cid].append({ - 'workcenter_group': group_name, - 'workcenter_group_order': order, - 'equipment_id': _safe_str(row.get('EQUIPMENTID')), - 'equipment_name': _safe_str(row.get('EQUIPMENTNAME')), - 'spec_name': _safe_str(row.get('SPECNAME')), - 'track_in_time': _safe_str(row.get('TRACKINTIMESTAMP')), - }) - - logger.info( - f"Upstream history: {len(result)} lots with mid-section records, " - f"from {len(unique_cids)} queried CIDs" - ) + result: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for _, row in combined.iterrows(): + cid = row['CONTAINERID'] + group_name = _safe_str(row.get('WORKCENTER_GROUP')) + if not group_name: + group_name = '(未知)' + result[cid].append({ + 'workcenter_group': group_name, + 'equipment_id': _safe_str(row.get('EQUIPMENTID')), + 'equipment_name': _safe_str(row.get('EQUIPMENTNAME')), + 'spec_name': _safe_str(row.get('SPECNAME')), + 'track_in_time': _safe_str(row.get('TRACKINTIMESTAMP')), + }) + + logger.info( + f"Upstream history: {len(result)} lots with classified records, " + f"from {len(unique_cids)} queried CIDs" + ) return dict(result) diff --git a/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql b/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql index f7f6b20..f111a79 100644 --- a/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql +++ b/src/mes_dashboard/sql/mid_section_defect/upstream_history.sql @@ -11,14 +11,44 @@ -- CONTAINERID has index. Batch IN clause (up to 1000 per query). -- Estimated 1-5s per batch. -- -WITH ranked_history AS ( - SELECT - h.CONTAINERID, - h.WORKCENTERNAME, - h.EQUIPMENTID, - h.EQUIPMENTNAME, - h.SPECNAME, - h.TRACKINTIMESTAMP, +WITH ranked_history AS ( + SELECT + h.CONTAINERID, + h.WORKCENTERNAME, + CASE + WHEN UPPER(h.WORKCENTERNAME) LIKE '%元件切割%' + OR UPPER(h.WORKCENTERNAME) LIKE '%PKG_SAW%' THEN '元件切割' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%切割%' THEN '切割' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%焊接_DB%' + OR UPPER(h.WORKCENTERNAME) LIKE '%焊_DB_料%' + OR UPPER(h.WORKCENTERNAME) LIKE '%焊_DB%' THEN '焊接_DB' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%焊接_WB%' + OR UPPER(h.WORKCENTERNAME) LIKE '%焊_WB_料%' + OR UPPER(h.WORKCENTERNAME) LIKE '%焊_WB%' THEN '焊接_WB' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%焊接_DW%' + OR UPPER(h.WORKCENTERNAME) LIKE '%焊_DW%' + OR UPPER(h.WORKCENTERNAME) LIKE '%焊_DW_料%' THEN '焊接_DW' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%成型%' + OR UPPER(h.WORKCENTERNAME) LIKE '%成型_料%' THEN '成型' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%去膠%' THEN '去膠' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%水吹砂%' THEN '水吹砂' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%掛鍍%' + OR UPPER(h.WORKCENTERNAME) LIKE '%滾鍍%' + OR UPPER(h.WORKCENTERNAME) LIKE '%條鍍%' + OR UPPER(h.WORKCENTERNAME) LIKE '%電鍍%' + OR UPPER(h.WORKCENTERNAME) LIKE '%補鍍%' + OR UPPER(h.WORKCENTERNAME) LIKE '%TOTAI%' + OR UPPER(h.WORKCENTERNAME) LIKE '%BANDL%' THEN '電鍍' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%移印%' THEN '移印' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%切彎腳%' THEN '切彎腳' + WHEN UPPER(h.WORKCENTERNAME) LIKE '%TMTT%' + OR UPPER(h.WORKCENTERNAME) LIKE '%測試%' THEN '測試' + ELSE NULL + END AS WORKCENTER_GROUP, + h.EQUIPMENTID, + h.EQUIPMENTNAME, + h.SPECNAME, + h.TRACKINTIMESTAMP, ROW_NUMBER() OVER ( PARTITION BY h.CONTAINERID, h.WORKCENTERNAME, h.EQUIPMENTNAME ORDER BY h.TRACKINTIMESTAMP DESC @@ -28,13 +58,14 @@ WITH ranked_history AS ( AND h.EQUIPMENTID IS NOT NULL AND h.TRACKINTIMESTAMP IS NOT NULL ) -SELECT - CONTAINERID, - WORKCENTERNAME, - EQUIPMENTID, - EQUIPMENTNAME, - SPECNAME, - TRACKINTIMESTAMP +SELECT + CONTAINERID, + WORKCENTERNAME, + WORKCENTER_GROUP, + EQUIPMENTID, + EQUIPMENTNAME, + SPECNAME, + TRACKINTIMESTAMP FROM ranked_history WHERE rn = 1 ORDER BY CONTAINERID, TRACKINTIMESTAMP diff --git a/src/mes_dashboard/templates/job_query.html b/src/mes_dashboard/templates/job_query.html index 0e43193..d38d561 100644 --- a/src/mes_dashboard/templates/job_query.html +++ b/src/mes_dashboard/templates/job_query.html @@ -125,6 +125,41 @@ overflow-y: auto; } + .workcenter-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 15px; + background: #f0f0f0; + font-weight: 600; + font-size: 12px; + color: #666; + cursor: pointer; + user-select: none; + } + + .workcenter-group-header:hover { + background: #e8e8e8; + } + + .workcenter-group-header input[type="checkbox"] { + cursor: pointer; + } + + .workcenter-group-header input[type="checkbox"].indeterminate { + opacity: 0.6; + } + + .workcenter-group-name { + flex: 1; + } + + .workcenter-group-count { + font-weight: 400; + font-size: 11px; + color: #999; + } + .equipment-item { display: flex; align-items: center; @@ -460,7 +495,7 @@
{% endblock %} -{% block scripts %} - {% set job_query_js = frontend_asset('job-query.js') %} - {% if job_query_js %} - - {% else %} - + {% else %} + - {% endif %} -{% endblock %} + + {% endif %} +{% endblock %} diff --git a/tests/test_mid_section_defect_routes.py b/tests/test_mid_section_defect_routes.py new file mode 100644 index 0000000..b47581c --- /dev/null +++ b/tests/test_mid_section_defect_routes.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +"""Route tests for mid-section defect APIs.""" + +from __future__ import annotations + +from unittest.mock import patch + +import mes_dashboard.core.database as db +from mes_dashboard.app import create_app +from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests + + +def _client(): + db._ENGINE = None + app = create_app('testing') + app.config['TESTING'] = True + return app.test_client() + + +def setup_function(): + reset_rate_limits_for_tests() + + +def teardown_function(): + reset_rate_limits_for_tests() + + +@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis') +def test_analysis_success(mock_query_analysis): + mock_query_analysis.return_value = { + 'kpi': {'total_input': 100}, + 'charts': {'by_station': []}, + 'daily_trend': [], + 'available_loss_reasons': ['A'], + 'genealogy_status': 'ready', + 'detail': [{}, {}], + } + + client = _client() + response = client.get( + '/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31&loss_reasons=A,B' + ) + + assert response.status_code == 200 + payload = response.get_json() + 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']) + + +def test_analysis_missing_dates_returns_400(): + client = _client() + response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01') + + assert response.status_code == 400 + payload = response.get_json() + assert payload['success'] is False + + +@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis') +def test_analysis_service_failure_returns_500(mock_query_analysis): + mock_query_analysis.return_value = None + + client = _client() + response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31') + + assert response.status_code == 500 + payload = response.get_json() + assert payload['success'] is False + + +@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis') +@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7)) +def test_analysis_rate_limited_returns_429(_mock_rate_limit, mock_query_analysis): + client = _client() + response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31') + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '7' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_query_analysis.assert_not_called() + + +@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis_detail') +def test_detail_success(mock_query_detail): + mock_query_detail.return_value = { + 'detail': [{'CONTAINERNAME': 'LOT-1'}], + 'pagination': {'page': 2, 'page_size': 200, 'total_count': 350, 'total_pages': 2}, + } + + client = _client() + response = client.get( + '/api/mid-section-defect/analysis/detail?start_date=2025-01-01&end_date=2025-01-31&page=2&page_size=200' + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload['success'] is True + assert payload['data']['pagination']['page'] == 2 + mock_query_detail.assert_called_once_with( + '2025-01-01', + '2025-01-31', + None, + page=2, + page_size=200, + ) + + +def test_detail_missing_dates_returns_400(): + client = _client() + response = client.get('/api/mid-section-defect/analysis/detail?end_date=2025-01-31') + + assert response.status_code == 400 + payload = response.get_json() + assert payload['success'] is False + + +@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis_detail') +@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5)) +def test_detail_rate_limited_returns_429(_mock_rate_limit, mock_query_detail): + client = _client() + response = client.get('/api/mid-section-defect/analysis/detail?start_date=2025-01-01&end_date=2025-01-31') + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '5' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_query_detail.assert_not_called() + + +@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv') +def test_export_success(mock_export_csv): + mock_export_csv.return_value = iter([ + '\ufeff', + 'LOT ID,TYPE\r\n', + 'A001,T1\r\n', + ]) + + client = _client() + response = client.get( + '/api/mid-section-defect/export?start_date=2025-01-01&end_date=2025-01-31&loss_reasons=A,B' + ) + + 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']) + + +@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv') +@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 9)) +def test_export_rate_limited_returns_429(_mock_rate_limit, mock_export_csv): + client = _client() + response = client.get('/api/mid-section-defect/export?start_date=2025-01-01&end_date=2025-01-31') + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '9' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_export_csv.assert_not_called() diff --git a/tests/test_mid_section_defect_service.py b/tests/test_mid_section_defect_service.py new file mode 100644 index 0000000..5b7a74d --- /dev/null +++ b/tests/test_mid_section_defect_service.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""Service tests for mid-section defect analysis.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pandas as pd + +from mes_dashboard.services.mid_section_defect_service import ( + query_analysis, + query_analysis_detail, + query_all_loss_reasons, +) + + +def test_query_analysis_invalid_date_format_returns_error(): + result = query_analysis('2025/01/01', '2025-01-31') + + assert 'error' in result + assert 'YYYY-MM-DD' in result['error'] + + +def test_query_analysis_start_after_end_returns_error(): + result = query_analysis('2025-02-01', '2025-01-31') + + assert 'error' in result + assert '起始日期不能晚於結束日期' in result['error'] + + +def test_query_analysis_exceeds_max_days_returns_error(): + result = query_analysis('2025-01-01', '2025-12-31') + + assert 'error' in result + assert '180' in result['error'] + + +@patch('mes_dashboard.services.mid_section_defect_service.query_analysis') +def test_query_analysis_detail_returns_sorted_first_page(mock_query_analysis): + mock_query_analysis.return_value = { + 'detail': [ + {'CONTAINERNAME': 'C', 'DEFECT_RATE': 0.3}, + {'CONTAINERNAME': 'A', 'DEFECT_RATE': 5.2}, + {'CONTAINERNAME': 'B', 'DEFECT_RATE': 3.1}, + ] + } + + result = query_analysis_detail('2025-01-01', '2025-01-31', page=1, page_size=2) + + assert [row['CONTAINERNAME'] for row in result['detail']] == ['A', 'B'] + assert result['pagination'] == { + 'page': 1, + 'page_size': 2, + 'total_count': 3, + 'total_pages': 2, + } + + +@patch('mes_dashboard.services.mid_section_defect_service.query_analysis') +def test_query_analysis_detail_clamps_page_to_last_page(mock_query_analysis): + mock_query_analysis.return_value = { + 'detail': [ + {'CONTAINERNAME': 'A', 'DEFECT_RATE': 9.9}, + {'CONTAINERNAME': 'B', 'DEFECT_RATE': 8.8}, + {'CONTAINERNAME': 'C', 'DEFECT_RATE': 7.7}, + ] + } + + result = query_analysis_detail('2025-01-01', '2025-01-31', page=10, page_size=2) + + assert result['pagination']['page'] == 2 + assert result['pagination']['total_pages'] == 2 + assert len(result['detail']) == 1 + assert result['detail'][0]['CONTAINERNAME'] == 'C' + + +@patch('mes_dashboard.services.mid_section_defect_service.query_analysis') +def test_query_analysis_detail_returns_error_passthrough(mock_query_analysis): + mock_query_analysis.return_value = {'error': '日期格式無效'} + + result = query_analysis_detail('2025-01-01', '2025-01-31', page=1, page_size=200) + + assert result == {'error': '日期格式無效'} + + +@patch('mes_dashboard.services.mid_section_defect_service.query_analysis') +def test_query_analysis_detail_returns_none_on_service_failure(mock_query_analysis): + mock_query_analysis.return_value = None + + result = query_analysis_detail('2025-01-01', '2025-01-31', page=1, page_size=200) + + assert result is None + + +@patch('mes_dashboard.services.mid_section_defect_service.cache_get') +@patch('mes_dashboard.services.mid_section_defect_service.read_sql_df') +def test_query_all_loss_reasons_cache_hit_skips_query(mock_read_sql_df, mock_cache_get): + mock_cache_get.return_value = {'loss_reasons': ['Cached_A', 'Cached_B']} + + result = query_all_loss_reasons() + + assert result == {'loss_reasons': ['Cached_A', 'Cached_B']} + mock_read_sql_df.assert_not_called() + + +@patch('mes_dashboard.services.mid_section_defect_service.cache_get', return_value=None) +@patch('mes_dashboard.services.mid_section_defect_service.cache_set') +@patch('mes_dashboard.services.mid_section_defect_service.read_sql_df') +@patch('mes_dashboard.services.mid_section_defect_service.SQLLoader.load') +def test_query_all_loss_reasons_cache_miss_queries_and_caches_sorted_values( + mock_sql_load, + mock_read_sql_df, + mock_cache_set, + _mock_cache_get, +): + mock_sql_load.return_value = 'SELECT ...' + mock_read_sql_df.return_value = pd.DataFrame( + {'LOSSREASONNAME': ['B_REASON', None, 'A_REASON', 'B_REASON']} + ) + + result = query_all_loss_reasons() + + assert result == {'loss_reasons': ['A_REASON', 'B_REASON']} + mock_cache_set.assert_called_once_with( + 'mid_section_loss_reasons:None:', + {'loss_reasons': ['A_REASON', 'B_REASON']}, + ttl=86400, + )