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 += ``;
+ 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 = `
-
+
+ `;
+ return;
+ }
+
+ let html = `
+
@@ -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,
+ )