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:
egg
2026-02-24 16:16:33 +08:00
parent bb58a0e119
commit f14591c7dc
67 changed files with 2957 additions and 2931 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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'] == '切割'

View File

@@ -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

View File

@@ -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)

View File

@@ -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',

View File

@@ -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()

View File

@@ -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()