feat(mid-section-defect): full-line bidirectional defect trace center with dual query mode
Transform /mid-section-defect from TMTT-only backward analysis into a full-line bidirectional defect traceability center supporting all detection stations. Key changes: - Parameterized station detection: any workcenter group as detection station - Bidirectional tracing: backward (upstream attribution) + forward (downstream reject rates) - Dual query mode: date range OR LOT/工單/WAFER container-based seed resolution - Multi-select filters for upstream station, equipment model (RESOURCEFAMILYNAME), and loss reasons - Progressive 3-stage trace pipeline (seed-resolve → lineage → events) with streaming UI - Equipment model lookup via resource cache instead of SPECNAME - Session caching, auto-refresh, searchable MultiSelect with fuzzy matching - Remove legacy tmtt-defect module (fully superseded) - Archive openspec change artifacts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,6 @@ class AppFactoryTests(unittest.TestCase):
|
||||
"/reject-history",
|
||||
"/excel-query",
|
||||
"/query-tool",
|
||||
"/tmtt-defect",
|
||||
"/api/wip/overview/summary",
|
||||
"/api/wip/overview/matrix",
|
||||
"/api/wip/overview/hold",
|
||||
@@ -74,7 +73,6 @@ class AppFactoryTests(unittest.TestCase):
|
||||
"/api/portal/navigation",
|
||||
"/api/excel-query/upload",
|
||||
"/api/query-tool/resolve",
|
||||
"/api/tmtt-defect/analysis",
|
||||
"/api/reject-history/summary",
|
||||
}
|
||||
missing = expected - rules
|
||||
|
||||
@@ -57,7 +57,6 @@ def test_g1_route_availability_gate_p0_routes_are_2xx_or_3xx():
|
||||
"/job-query",
|
||||
"/excel-query",
|
||||
"/query-tool",
|
||||
"/tmtt-defect",
|
||||
]
|
||||
|
||||
statuses = [client.get(route).status_code for route in p0_routes]
|
||||
|
||||
@@ -46,7 +46,31 @@ def test_analysis_success(mock_query_analysis):
|
||||
assert payload['success'] is True
|
||||
assert payload['data']['detail_total_count'] == 2
|
||||
assert payload['data']['kpi']['total_input'] == 100
|
||||
mock_query_analysis.assert_called_once_with('2025-01-01', '2025-01-31', ['A', 'B'])
|
||||
mock_query_analysis.assert_called_once_with(
|
||||
'2025-01-01', '2025-01-31', ['A', 'B'], '測試', 'backward',
|
||||
)
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
|
||||
def test_analysis_with_station_and_direction(mock_query_analysis):
|
||||
mock_query_analysis.return_value = {
|
||||
'kpi': {'detection_lot_count': 50},
|
||||
'charts': {'by_downstream_station': []},
|
||||
'daily_trend': [],
|
||||
'available_loss_reasons': [],
|
||||
'genealogy_status': 'ready',
|
||||
'detail': [],
|
||||
}
|
||||
|
||||
client = _client()
|
||||
response = client.get(
|
||||
'/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31&station=成型&direction=forward'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_query_analysis.assert_called_once_with(
|
||||
'2025-01-01', '2025-01-31', None, '成型', 'forward',
|
||||
)
|
||||
|
||||
|
||||
def test_analysis_missing_dates_returns_400():
|
||||
@@ -103,6 +127,8 @@ def test_detail_success(mock_query_detail):
|
||||
'2025-01-01',
|
||||
'2025-01-31',
|
||||
None,
|
||||
'測試',
|
||||
'backward',
|
||||
page=2,
|
||||
page_size=200,
|
||||
)
|
||||
@@ -146,7 +172,9 @@ def test_export_success(mock_export_csv):
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
assert 'attachment;' in response.headers.get('Content-Disposition', '')
|
||||
mock_export_csv.assert_called_once_with('2025-01-01', '2025-01-31', ['A', 'B'])
|
||||
mock_export_csv.assert_called_once_with(
|
||||
'2025-01-01', '2025-01-31', ['A', 'B'], '測試', 'backward',
|
||||
)
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv')
|
||||
@@ -160,3 +188,20 @@ def test_export_rate_limited_returns_429(_mock_rate_limit, mock_export_csv):
|
||||
payload = response.get_json()
|
||||
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
|
||||
mock_export_csv.assert_not_called()
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.mid_section_defect_routes.query_station_options')
|
||||
def test_station_options_success(mock_query_station_options):
|
||||
mock_query_station_options.return_value = [
|
||||
{'name': '切割', 'order': 0},
|
||||
{'name': '測試', 'order': 11},
|
||||
]
|
||||
|
||||
client = _client()
|
||||
response = client.get('/api/mid-section-defect/station-options')
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload['success'] is True
|
||||
assert len(payload['data']) == 2
|
||||
assert payload['data'][0]['name'] == '切割'
|
||||
|
||||
@@ -12,6 +12,7 @@ from mes_dashboard.services.mid_section_defect_service import (
|
||||
query_analysis,
|
||||
query_analysis_detail,
|
||||
query_all_loss_reasons,
|
||||
query_station_options,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,9 +136,9 @@ def test_query_all_loss_reasons_cache_miss_queries_and_caches_sorted_values(
|
||||
@patch('mes_dashboard.services.mid_section_defect_service.try_acquire_lock', return_value=True)
|
||||
@patch('mes_dashboard.services.mid_section_defect_service._fetch_upstream_history')
|
||||
@patch('mes_dashboard.services.mid_section_defect_service._resolve_full_genealogy')
|
||||
@patch('mes_dashboard.services.mid_section_defect_service._fetch_tmtt_data')
|
||||
@patch('mes_dashboard.services.mid_section_defect_service._fetch_station_detection_data')
|
||||
def test_trace_aggregation_matches_query_analysis_summary(
|
||||
mock_fetch_tmtt_data,
|
||||
mock_fetch_detection_data,
|
||||
mock_resolve_genealogy,
|
||||
mock_fetch_upstream_history,
|
||||
_mock_lock,
|
||||
@@ -145,7 +146,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
|
||||
_mock_cache_get,
|
||||
_mock_cache_set,
|
||||
):
|
||||
tmtt_df = pd.DataFrame([
|
||||
detection_df = pd.DataFrame([
|
||||
{
|
||||
'CONTAINERID': 'CID-001',
|
||||
'CONTAINERNAME': 'LOT-001',
|
||||
@@ -155,7 +156,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
|
||||
'WORKFLOW': 'WF-A',
|
||||
'PRODUCTLINENAME': 'PKG-A',
|
||||
'PJ_TYPE': 'TYPE-A',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-01',
|
||||
'DETECTION_EQUIPMENTNAME': 'EQ-01',
|
||||
'TRACKINTIMESTAMP': '2025-01-10 10:00:00',
|
||||
'FINISHEDRUNCARD': 'FR-001',
|
||||
},
|
||||
@@ -168,7 +169,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
|
||||
'WORKFLOW': 'WF-B',
|
||||
'PRODUCTLINENAME': 'PKG-B',
|
||||
'PJ_TYPE': 'TYPE-B',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-02',
|
||||
'DETECTION_EQUIPMENTNAME': 'EQ-02',
|
||||
'TRACKINTIMESTAMP': '2025-01-11 10:00:00',
|
||||
'FINISHEDRUNCARD': 'FR-002',
|
||||
},
|
||||
@@ -211,7 +212,7 @@ def test_trace_aggregation_matches_query_analysis_summary(
|
||||
}],
|
||||
}
|
||||
|
||||
mock_fetch_tmtt_data.return_value = tmtt_df
|
||||
mock_fetch_detection_data.return_value = detection_df
|
||||
mock_resolve_genealogy.return_value = ancestors
|
||||
mock_fetch_upstream_history.return_value = upstream_normalized
|
||||
|
||||
@@ -240,3 +241,13 @@ def test_trace_aggregation_matches_query_analysis_summary(
|
||||
|
||||
assert staged_summary['daily_trend'] == summary['daily_trend']
|
||||
assert staged_summary['charts'].keys() == summary['charts'].keys()
|
||||
|
||||
|
||||
def test_query_station_options_returns_ordered_list():
|
||||
result = query_station_options()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 12
|
||||
assert result[0]['name'] == '切割'
|
||||
assert result[0]['order'] == 0
|
||||
assert result[-1]['name'] == '測試'
|
||||
assert result[-1]['order'] == 11
|
||||
|
||||
@@ -226,42 +226,6 @@ def test_query_tool_native_smoke_resolve_history_association(client):
|
||||
assert associations.get_json()["total"] == 1
|
||||
|
||||
|
||||
def test_tmtt_defect_native_smoke_range_query_and_csv_export(client):
|
||||
_login_as_admin(client)
|
||||
|
||||
shell = client.get("/portal-shell/tmtt-defect?start_date=2026-02-01&end_date=2026-02-11")
|
||||
assert shell.status_code == 200
|
||||
|
||||
page = client.get("/tmtt-defect", follow_redirects=False)
|
||||
if client.application.config.get("PORTAL_SPA_ENABLED", False):
|
||||
assert page.status_code == 302
|
||||
assert page.location.endswith("/portal-shell/tmtt-defect")
|
||||
else:
|
||||
assert page.status_code == 200
|
||||
|
||||
with (
|
||||
patch(
|
||||
"mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis",
|
||||
return_value={
|
||||
"kpi": {"total_input": 10},
|
||||
"charts": {"by_workflow": []},
|
||||
"detail": [],
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"mes_dashboard.routes.tmtt_defect_routes.export_csv",
|
||||
return_value=iter(["LOT_ID,TYPE\n", "LOT001,PRINT\n"]),
|
||||
),
|
||||
):
|
||||
query = client.get("/api/tmtt-defect/analysis?start_date=2026-02-01&end_date=2026-02-11")
|
||||
assert query.status_code == 200
|
||||
assert query.get_json()["success"] is True
|
||||
|
||||
export = client.get("/api/tmtt-defect/export?start_date=2026-02-01&end_date=2026-02-11")
|
||||
assert export.status_code == 200
|
||||
assert "text/csv" in export.content_type
|
||||
|
||||
|
||||
def test_reject_history_native_smoke_query_sections_and_export(client):
|
||||
_login_as_admin(client)
|
||||
|
||||
|
||||
@@ -116,15 +116,6 @@ class TestTemplateIntegration(unittest.TestCase):
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
def test_tmtt_defect_page_includes_base_scripts(self):
|
||||
response = self.client.get('/tmtt-defect')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('toast.js', html)
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
|
||||
class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
"""Test dynamic portal drawer rendering."""
|
||||
@@ -340,18 +331,6 @@ class TestMesApiUsageInTemplates(unittest.TestCase):
|
||||
self.assertIn('/static/dist/query-tool.js', html)
|
||||
self.assertIn('type="module"', html)
|
||||
|
||||
def test_tmtt_defect_page_uses_vite_module(self):
|
||||
response, final_response, html = _get_response_and_html(self.client, '/tmtt-defect')
|
||||
|
||||
if response.status_code == 302:
|
||||
self.assertTrue(response.location.endswith('/portal-shell/tmtt-defect'))
|
||||
self.assertEqual(final_response.status_code, 200)
|
||||
self.assertIn('/static/dist/portal-shell.js', html)
|
||||
self.assertIn('type="module"', html)
|
||||
else:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('/static/dist/tmtt-defect.js', html)
|
||||
self.assertIn('type="module"', html)
|
||||
|
||||
|
||||
class TestViteModuleIntegration(unittest.TestCase):
|
||||
@@ -377,7 +356,6 @@ class TestViteModuleIntegration(unittest.TestCase):
|
||||
('/job-query', 'job-query.js'),
|
||||
('/excel-query', 'excel-query.js'),
|
||||
('/query-tool', 'query-tool.js'),
|
||||
('/tmtt-defect', 'tmtt-defect.js'),
|
||||
]
|
||||
canonical_routes = {
|
||||
'/wip-overview': '/portal-shell/wip-overview',
|
||||
@@ -387,7 +365,6 @@ class TestViteModuleIntegration(unittest.TestCase):
|
||||
'/resource': '/portal-shell/resource',
|
||||
'/resource-history': '/portal-shell/resource-history',
|
||||
'/job-query': '/portal-shell/job-query',
|
||||
'/tmtt-defect': '/portal-shell/tmtt-defect',
|
||||
'/tables': '/portal-shell/tables',
|
||||
'/excel-query': '/portal-shell/excel-query',
|
||||
'/query-tool': '/portal-shell/query-tool',
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Integration tests for TMTT Defect Analysis API routes."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class TestTmttDefectAnalysisEndpoint(unittest.TestCase):
|
||||
"""Test GET /api/tmtt-defect/analysis endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
self.app = create_app()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_missing_start_date(self):
|
||||
resp = self.client.get('/api/tmtt-defect/analysis?end_date=2025-01-31')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_missing_end_date(self):
|
||||
resp = self.client.get('/api/tmtt-defect/analysis?start_date=2025-01-01')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_missing_both_dates(self):
|
||||
resp = self.client.get('/api/tmtt-defect/analysis')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_invalid_date_format(self, mock_query):
|
||||
mock_query.return_value = {'error': '日期格式無效,請使用 YYYY-MM-DD'}
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=invalid&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('格式', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_exceeds_180_days(self, mock_query):
|
||||
mock_query.return_value = {'error': '查詢範圍不能超過 180 天'}
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-12-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
data = resp.get_json()
|
||||
self.assertIn('180', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_successful_query(self, mock_query):
|
||||
mock_query.return_value = {
|
||||
'kpi': {
|
||||
'total_input': 1000, 'lot_count': 10,
|
||||
'print_defect_qty': 5, 'print_defect_rate': 0.5,
|
||||
'lead_defect_qty': 3, 'lead_defect_rate': 0.3,
|
||||
},
|
||||
'charts': {
|
||||
'by_workflow': [], 'by_package': [], 'by_type': [],
|
||||
'by_tmtt_machine': [], 'by_mold_machine': [],
|
||||
},
|
||||
'detail': [],
|
||||
}
|
||||
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json()
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('kpi', data['data'])
|
||||
self.assertIn('charts', data['data'])
|
||||
self.assertIn('detail', data['data'])
|
||||
|
||||
# Verify separate defect rates
|
||||
kpi = data['data']['kpi']
|
||||
self.assertEqual(kpi['print_defect_qty'], 5)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 3)
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.query_tmtt_defect_analysis')
|
||||
def test_query_failure_returns_500(self, mock_query):
|
||||
mock_query.return_value = None
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/analysis?start_date=2025-01-01&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
|
||||
|
||||
class TestTmttDefectExportEndpoint(unittest.TestCase):
|
||||
"""Test GET /api/tmtt-defect/export endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
self.app = create_app()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_missing_dates(self):
|
||||
resp = self.client.get('/api/tmtt-defect/export')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
@patch('mes_dashboard.routes.tmtt_defect_routes.export_csv')
|
||||
def test_export_csv(self, mock_export):
|
||||
mock_export.return_value = iter([
|
||||
'\ufeff',
|
||||
'LOT ID,TYPE,PACKAGE,WORKFLOW,完工流水碼,TMTT設備,MOLD設備,'
|
||||
'投入數,印字不良數,印字不良率(%),腳型不良數,腳型不良率(%)\r\n',
|
||||
])
|
||||
resp = self.client.get(
|
||||
'/api/tmtt-defect/export?start_date=2025-01-01&end_date=2025-01-31'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn('text/csv', resp.content_type)
|
||||
self.assertIn('attachment', resp.headers.get('Content-Disposition', ''))
|
||||
|
||||
|
||||
class TestTmttDefectPageRoute(unittest.TestCase):
|
||||
"""Test page route."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
self.app = create_app()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_page_requires_auth_when_dev(self):
|
||||
"""Page in 'dev' status returns 403 for unauthenticated users."""
|
||||
resp = self.client.get('/tmtt-defect')
|
||||
# 403 because page_status is 'dev' and user is not admin
|
||||
self.assertIn(resp.status_code, [200, 403])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,287 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for TMTT Defect Analysis Service."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.services.tmtt_defect_service import (
|
||||
_build_kpi,
|
||||
_build_chart_data,
|
||||
_build_all_charts,
|
||||
_build_detail_table,
|
||||
_validate_date_range,
|
||||
query_tmtt_defect_analysis,
|
||||
PRINT_DEFECT,
|
||||
LEAD_DEFECT,
|
||||
)
|
||||
|
||||
|
||||
def _make_df(rows):
|
||||
"""Helper to create test DataFrame from list of dicts."""
|
||||
cols = [
|
||||
'CONTAINERID', 'CONTAINERNAME', 'PJ_TYPE', 'PRODUCTLINENAME',
|
||||
'WORKFLOW', 'FINISHEDRUNCARD', 'TMTT_EQUIPMENTID',
|
||||
'TMTT_EQUIPMENTNAME', 'TRACKINQTY', 'TRACKINTIMESTAMP',
|
||||
'MOLD_EQUIPMENTID', 'MOLD_EQUIPMENTNAME',
|
||||
'LOSSREASONNAME', 'REJECTQTY',
|
||||
]
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=cols)
|
||||
df = pd.DataFrame(rows)
|
||||
for c in cols:
|
||||
if c not in df.columns:
|
||||
df[c] = None
|
||||
return df
|
||||
|
||||
|
||||
class TestValidateDateRange(unittest.TestCase):
|
||||
"""Test date range validation."""
|
||||
|
||||
def test_valid_range(self):
|
||||
self.assertIsNone(_validate_date_range('2025-01-01', '2025-01-31'))
|
||||
|
||||
def test_invalid_format(self):
|
||||
result = _validate_date_range('2025/01/01', '2025-01-31')
|
||||
self.assertIn('格式', result)
|
||||
|
||||
def test_start_after_end(self):
|
||||
result = _validate_date_range('2025-02-01', '2025-01-01')
|
||||
self.assertIn('不能晚於', result)
|
||||
|
||||
def test_exceeds_max_days(self):
|
||||
result = _validate_date_range('2025-01-01', '2025-12-31')
|
||||
self.assertIn('180', result)
|
||||
|
||||
def test_exactly_max_days(self):
|
||||
self.assertIsNone(_validate_date_range('2025-01-01', '2025-06-30'))
|
||||
|
||||
|
||||
class TestBuildKpi(unittest.TestCase):
|
||||
"""Test KPI calculation with separate defect rates."""
|
||||
|
||||
def test_empty_dataframe(self):
|
||||
df = _make_df([])
|
||||
kpi = _build_kpi(df)
|
||||
self.assertEqual(kpi['total_input'], 0)
|
||||
self.assertEqual(kpi['lot_count'], 0)
|
||||
self.assertEqual(kpi['print_defect_qty'], 0)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 0)
|
||||
self.assertEqual(kpi['print_defect_rate'], 0.0)
|
||||
self.assertEqual(kpi['lead_defect_rate'], 0.0)
|
||||
|
||||
def test_single_lot_no_defects(self):
|
||||
df = _make_df([{
|
||||
'CONTAINERID': 'A001', 'TRACKINQTY': 100,
|
||||
'LOSSREASONNAME': None, 'REJECTQTY': 0,
|
||||
}])
|
||||
kpi = _build_kpi(df)
|
||||
self.assertEqual(kpi['total_input'], 100)
|
||||
self.assertEqual(kpi['lot_count'], 1)
|
||||
self.assertEqual(kpi['print_defect_qty'], 0)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 0)
|
||||
|
||||
def test_separate_defect_rates(self):
|
||||
"""A LOT with both print and lead defects - rates calculated separately."""
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 10000,
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 50},
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 10000,
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 30},
|
||||
])
|
||||
kpi = _build_kpi(df)
|
||||
# INPUT should be deduplicated (10000, not 20000)
|
||||
self.assertEqual(kpi['total_input'], 10000)
|
||||
self.assertEqual(kpi['lot_count'], 1)
|
||||
self.assertEqual(kpi['print_defect_qty'], 50)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 30)
|
||||
self.assertAlmostEqual(kpi['print_defect_rate'], 0.5, places=4)
|
||||
self.assertAlmostEqual(kpi['lead_defect_rate'], 0.3, places=4)
|
||||
|
||||
def test_multiple_lots(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100,
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2},
|
||||
{'CONTAINERID': 'A002', 'TRACKINQTY': 200,
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 1},
|
||||
{'CONTAINERID': 'A003', 'TRACKINQTY': 300,
|
||||
'LOSSREASONNAME': None, 'REJECTQTY': 0},
|
||||
])
|
||||
kpi = _build_kpi(df)
|
||||
self.assertEqual(kpi['total_input'], 600)
|
||||
self.assertEqual(kpi['lot_count'], 3)
|
||||
self.assertEqual(kpi['print_defect_qty'], 2)
|
||||
self.assertEqual(kpi['lead_defect_qty'], 1)
|
||||
|
||||
|
||||
class TestBuildChartData(unittest.TestCase):
|
||||
"""Test Pareto chart data aggregation."""
|
||||
|
||||
def test_empty_dataframe(self):
|
||||
df = _make_df([])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_single_dimension_value(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5},
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3},
|
||||
])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['name'], 'TypeA')
|
||||
self.assertEqual(result[0]['print_defect_qty'], 5)
|
||||
self.assertEqual(result[0]['lead_defect_qty'], 3)
|
||||
self.assertEqual(result[0]['total_defect_qty'], 8)
|
||||
self.assertAlmostEqual(result[0]['cumulative_pct'], 100.0)
|
||||
|
||||
def test_null_dimension_grouped_as_unknown(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'MOLD_EQUIPMENTNAME': None,
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2},
|
||||
])
|
||||
result = _build_chart_data(df, 'MOLD_EQUIPMENTNAME')
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['name'], '(未知)')
|
||||
|
||||
def test_sorted_by_total_defect_desc(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1},
|
||||
{'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 10},
|
||||
])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
self.assertEqual(result[0]['name'], 'TypeB')
|
||||
self.assertEqual(result[1]['name'], 'TypeA')
|
||||
|
||||
def test_cumulative_percentage(self):
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeA',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 6},
|
||||
{'CONTAINERID': 'A002', 'TRACKINQTY': 100, 'PJ_TYPE': 'TypeB',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 4},
|
||||
])
|
||||
result = _build_chart_data(df, 'PJ_TYPE')
|
||||
# TypeA: 6/10 = 60%, TypeB: cumulative 10/10 = 100%
|
||||
self.assertAlmostEqual(result[0]['cumulative_pct'], 60.0)
|
||||
self.assertAlmostEqual(result[1]['cumulative_pct'], 100.0)
|
||||
|
||||
|
||||
class TestBuildAllCharts(unittest.TestCase):
|
||||
"""Test all 5 chart dimensions are built."""
|
||||
|
||||
def test_returns_all_dimensions(self):
|
||||
df = _make_df([{
|
||||
'CONTAINERID': 'A001', 'TRACKINQTY': 100,
|
||||
'WORKFLOW': 'WF1', 'PRODUCTLINENAME': 'PKG1',
|
||||
'PJ_TYPE': 'T1', 'TMTT_EQUIPMENTNAME': 'TMTT-1',
|
||||
'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 1,
|
||||
}])
|
||||
charts = _build_all_charts(df)
|
||||
self.assertIn('by_workflow', charts)
|
||||
self.assertIn('by_package', charts)
|
||||
self.assertIn('by_type', charts)
|
||||
self.assertIn('by_tmtt_machine', charts)
|
||||
self.assertIn('by_mold_machine', charts)
|
||||
|
||||
|
||||
class TestBuildDetailTable(unittest.TestCase):
|
||||
"""Test detail table building."""
|
||||
|
||||
def test_empty_dataframe(self):
|
||||
df = _make_df([])
|
||||
result = _build_detail_table(df)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_single_lot_aggregated(self):
|
||||
"""LOT with both defect types should produce one row."""
|
||||
df = _make_df([
|
||||
{'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
|
||||
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 5},
|
||||
{'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
|
||||
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': LEAD_DEFECT, 'REJECTQTY': 3},
|
||||
])
|
||||
result = _build_detail_table(df)
|
||||
self.assertEqual(len(result), 1)
|
||||
row = result[0]
|
||||
self.assertEqual(row['CONTAINERNAME'], 'LOT-001')
|
||||
self.assertEqual(row['INPUT_QTY'], 100)
|
||||
self.assertEqual(row['PRINT_DEFECT_QTY'], 5)
|
||||
self.assertEqual(row['LEAD_DEFECT_QTY'], 3)
|
||||
self.assertAlmostEqual(row['PRINT_DEFECT_RATE'], 5.0, places=4)
|
||||
self.assertAlmostEqual(row['LEAD_DEFECT_RATE'], 3.0, places=4)
|
||||
|
||||
def test_lot_with_no_defects(self):
|
||||
df = _make_df([{
|
||||
'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1',
|
||||
'LOSSREASONNAME': None, 'REJECTQTY': 0,
|
||||
}])
|
||||
result = _build_detail_table(df)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['PRINT_DEFECT_QTY'], 0)
|
||||
self.assertEqual(result[0]['LEAD_DEFECT_QTY'], 0)
|
||||
|
||||
|
||||
class TestQueryTmttDefectAnalysis(unittest.TestCase):
|
||||
"""Test the main entry point function."""
|
||||
|
||||
def setUp(self):
|
||||
from mes_dashboard.core import database as db
|
||||
db._ENGINE = None
|
||||
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None)
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_set')
|
||||
@patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data')
|
||||
def test_valid_query(self, mock_fetch, mock_cache_set, mock_cache_get):
|
||||
mock_fetch.return_value = _make_df([{
|
||||
'CONTAINERID': 'A001', 'CONTAINERNAME': 'LOT-001',
|
||||
'TRACKINQTY': 100, 'PJ_TYPE': 'T1', 'PRODUCTLINENAME': 'P1',
|
||||
'WORKFLOW': 'WF1', 'FINISHEDRUNCARD': 'RC001',
|
||||
'TMTT_EQUIPMENTNAME': 'TMTT-1', 'MOLD_EQUIPMENTNAME': 'MOLD-1',
|
||||
'LOSSREASONNAME': PRINT_DEFECT, 'REJECTQTY': 2,
|
||||
}])
|
||||
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
|
||||
self.assertIn('kpi', result)
|
||||
self.assertIn('charts', result)
|
||||
self.assertIn('detail', result)
|
||||
self.assertNotIn('error', result)
|
||||
mock_cache_set.assert_called_once()
|
||||
|
||||
def test_invalid_dates(self):
|
||||
result = query_tmtt_defect_analysis('invalid', '2025-01-31')
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_exceeds_max_days(self):
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-12-31')
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('180', result['error'])
|
||||
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_get')
|
||||
def test_cache_hit(self, mock_cache_get):
|
||||
cached_data = {'kpi': {}, 'charts': {}, 'detail': []}
|
||||
mock_cache_get.return_value = cached_data
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
|
||||
self.assertEqual(result, cached_data)
|
||||
|
||||
@patch('mes_dashboard.services.tmtt_defect_service.cache_get', return_value=None)
|
||||
@patch('mes_dashboard.services.tmtt_defect_service._fetch_base_data', return_value=None)
|
||||
def test_query_failure(self, mock_fetch, mock_cache_get):
|
||||
result = query_tmtt_defect_analysis('2025-01-01', '2025-01-31')
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user