#!/usr/bin/env python3 """Generate baseline and contract-freeze artifacts for shell route-view migration.""" from __future__ import annotations import json import re from datetime import datetime, timezone from pathlib import Path from typing import Any from mes_dashboard.services.navigation_contract import ( compute_drawer_visibility, validate_drawer_page_contract, validate_route_migration_contract, ) ROOT = Path(__file__).resolve().parent.parent PAGE_STATUS_FILE = ROOT / "data" / "page_status.json" OUT_DIR = ROOT / "docs" / "migration" / "portal-shell-route-view-integration" TARGET_ROUTE_CONTRACTS: list[dict[str, Any]] = [ { "route": "/wip-overview", "page_name": "WIP 即時概況", "render_mode": "native", "required_query_keys": ["workorder", "lotid", "package", "type", "status"], "source_dir": "frontend/src/wip-overview", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/wip-detail", "page_name": "WIP 詳細列表", "render_mode": "native", "required_query_keys": ["workcenter", "workorder", "lotid", "package", "type", "status"], "source_dir": "frontend/src/wip-detail", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/hold-overview", "page_name": "Hold 即時概況", "render_mode": "native", "required_query_keys": [], "source_dir": "frontend/src/hold-overview", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/hold-detail", "page_name": "Hold 詳細查詢", "render_mode": "native", "required_query_keys": ["reason"], "source_dir": "frontend/src/hold-detail", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/hold-history", "page_name": "Hold 歷史報表", "render_mode": "native", "required_query_keys": [], "source_dir": "frontend/src/hold-history", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/resource", "page_name": "設備即時狀況", "render_mode": "native", "required_query_keys": [], "source_dir": "frontend/src/resource-status", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/resource-history", "page_name": "設備歷史績效", "render_mode": "native", "required_query_keys": [ "start_date", "end_date", "granularity", "workcenter_groups", "families", "resource_ids", "is_production", "is_key", "is_monitor", ], "source_dir": "frontend/src/resource-history", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/qc-gate", "page_name": "QC-GATE 狀態", "render_mode": "native", "required_query_keys": [], "source_dir": "frontend/src/qc-gate", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/job-query", "page_name": "設備維修查詢", "render_mode": "native", "required_query_keys": [], "source_dir": "frontend/src/job-query", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/excel-query", "page_name": "Excel 查詢工具", "render_mode": "native", "required_query_keys": [], "source_dir": "frontend/src/excel-query", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, { "route": "/query-tool", "page_name": "Query Tool", "render_mode": "native", "required_query_keys": [], "source_dir": "frontend/src/query-tool", "owner": "frontend-mes-reporting", "rollback_strategy": "fallback_to_legacy_route", }, ] CRITICAL_API_PAYLOAD_CONTRACTS = { "/api/wip/overview/summary": { "required_keys": ["dataUpdateDate", "runLots", "queueLots", "holdLots"], "notes": "WIP summary cards", }, "/api/wip/overview/matrix": { "required_keys": ["workcenters", "packages", "matrix", "workcenter_totals"], "notes": "WIP matrix table", }, "/api/wip/hold-detail/summary": { "required_keys": ["workcenterCount", "packageCount", "lotCount"], "notes": "Hold detail KPI cards", }, "/api/hold-overview/matrix": { "required_keys": ["rows", "totals"], "notes": "Hold overview matrix interaction", }, "/api/hold-history/list": { "required_keys": ["rows", "summary"], "notes": "Hold history table and summary sync", }, "/api/resource/status": { "required_keys": ["rows", "summary"], "notes": "Realtime resource status table", }, "/api/resource/history/summary": { "required_keys": ["kpi", "trend", "heatmap", "workcenter_comparison"], "notes": "Resource history charts", }, "/api/resource/history/detail": { "required_keys": ["data"], "notes": "Resource history detail table", }, "/api/qc-gate/summary": { "required_keys": ["summary", "table", "pareto"], "notes": "QC-GATE chart/table linked view", }, } ROUTE_NOTES = { "/wip-overview": "filter URL sync + status drill-down to detail", "/wip-detail": "workcenter deep-link + list/detail continuity", "/hold-overview": "summary/matrix/lot interactions must remain stable", "/hold-detail": "requires reason; missing reason redirects", "/hold-history": "trend/pareto/duration/table interactions", "/resource": "status summary + table filtering semantics", "/resource-history": "date/granularity/group/family/resource/flags contract", "/qc-gate": "chart-table linked filtering parity", "/job-query": "resource/date query + txn detail + export", "/excel-query": "upload/detect/query/export workflow", "/query-tool": "resolve/history/associations/equipment-period workflows", } API_PATTERN = re.compile(r"[\"'`](/api/[A-Za-z0-9_./-]+)") def _iso_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def write_json(path: Path, payload: dict[str, Any]) -> None: path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def _collect_source_files(source_dir: Path) -> list[Path]: if not source_dir.exists(): return [] files: list[Path] = [] for path in source_dir.rglob("*"): if path.is_file() and path.suffix in {".vue", ".js", ".ts"}: files.append(path) return sorted(files) def _collect_interaction_evidence(entry: dict[str, Any]) -> dict[str, Any]: source_dir = ROOT / str(entry["source_dir"]) files = _collect_source_files(source_dir) rel_files = [str(path.relative_to(ROOT)) for path in files] chart_files: list[str] = [] table_files: list[str] = [] filter_files: list[str] = [] matrix_files: list[str] = [] sort_files: list[str] = [] pagination_files: list[str] = [] legend_files: list[str] = [] tooltip_files: list[str] = [] api_endpoints: set[str] = set() for path in files: rel = str(path.relative_to(ROOT)) text = path.read_text(encoding="utf-8", errors="ignore") lower = text.lower() name_lower = path.name.lower() if "chart" in name_lower or "echarts" in lower or "vchart" in lower: chart_files.append(rel) if "table" in name_lower or " dict[str, Any]: routes = {} for entry in TARGET_ROUTE_CONTRACTS: route = entry["route"] routes[route] = { "query_keys": entry["required_query_keys"], "render_mode": entry["render_mode"], "notes": ROUTE_NOTES.get(route, ""), } return {"generated_at": _iso_now(), "routes": routes} def _build_route_contract_payload() -> dict[str, Any]: payload_routes: list[dict[str, Any]] = [] for entry in TARGET_ROUTE_CONTRACTS: payload_routes.append( { "route_id": str(entry["route"]).strip("/").replace("/", "-") or "root", "route": entry["route"], "page_name": entry["page_name"], "render_mode": entry["render_mode"], "required_query_keys": entry["required_query_keys"], "owner": entry["owner"], "rollback_strategy": entry["rollback_strategy"], "source_dir": entry["source_dir"], } ) return { "generated_at": _iso_now(), "description": "Route-level migration contract freeze for shell route-view integration.", "routes": payload_routes, } def _render_route_parity_matrix_markdown( route_contract: dict[str, Any], evidence_by_route: dict[str, Any], ) -> str: lines = [ "# Route Parity Matrix (Shell Route-View Integration)", "", f"Generated at: `{route_contract['generated_at']}`", "", "| Route | Mode | Required Query Keys | Table / Filter Focus | Chart / Matrix Focus | Owner | Rollback |", "| --- | --- | --- | --- | --- | --- | --- |", ] for item in route_contract["routes"]: route = item["route"] evidence = evidence_by_route.get(route, {}) table = evidence.get("table", {}) chart = evidence.get("chart", {}) matrix = evidence.get("matrix", {}) query_keys = ", ".join(item.get("required_query_keys", [])) or "-" table_focus = ( f"table_files={len(table.get('component_files', []))}; " f"sort={'Y' if table.get('has_sort_logic') else 'N'}; " f"pagination={'Y' if table.get('has_pagination') else 'N'}" ) chart_focus = ( f"chart_files={len(chart.get('component_files', []))}; " f"legend={'Y' if chart.get('has_legend_logic') else 'N'}; " f"tooltip={'Y' if chart.get('has_tooltip_logic') else 'N'}; " f"matrix={'Y' if matrix.get('has_matrix_interaction') else 'N'}" ) lines.append( f"| `{route}` | `{item['render_mode']}` | `{query_keys}` | " f"{table_focus} | {chart_focus} | `{item['owner']}` | `{item['rollback_strategy']}` |" ) lines.extend( [ "", "## Notes", "", "- Matrix and chart/table links are validated further in per-page smoke and parity tests.", "- All target routes are in native mode; no iframe/wrapper runtime host remains in shell content path.", ] ) return "\n".join(lines) + "\n" def _render_contract_markdown(route_contract: dict[str, Any]) -> str: lines = [ "# Route Migration Contract Freeze", "", f"Generated at: `{route_contract['generated_at']}`", "", "This contract freezes route ownership and migration mode for shell cutover governance.", "", "| Route ID | Route | Mode | Required Query Keys | Owner | Rollback Strategy |", "| --- | --- | --- | --- | --- | --- |", ] for item in route_contract["routes"]: query_keys = ", ".join(item.get("required_query_keys", [])) or "-" lines.append( f"| `{item['route_id']}` | `{item['route']}` | `{item['render_mode']}` | " f"`{query_keys}` | `{item['owner']}` | `{item['rollback_strategy']}` |" ) lines.extend( [ "", "## Validation Rules", "", "- Missing route definitions are treated as blocking contract errors.", "- Duplicate route definitions are rejected.", "- `render_mode` MUST be `native` or `wrapper`.", "- `owner` and `rollback_strategy` MUST be non-empty.", ] ) return "\n".join(lines) + "\n" def main() -> None: OUT_DIR.mkdir(parents=True, exist_ok=True) raw = json.loads(PAGE_STATUS_FILE.read_text(encoding="utf-8")) visibility = { "generated_at": _iso_now(), "source": str(PAGE_STATUS_FILE.relative_to(ROOT)), "admin": compute_drawer_visibility(raw, is_admin=True), "non_admin": compute_drawer_visibility(raw, is_admin=False), } write_json(OUT_DIR / "baseline_drawer_visibility.json", visibility) drawer_validation = { "generated_at": _iso_now(), "source": str(PAGE_STATUS_FILE.relative_to(ROOT)), "errors": validate_drawer_page_contract(raw), } write_json(OUT_DIR / "baseline_drawer_contract_validation.json", drawer_validation) route_query_contracts = _build_route_query_contracts() write_json(OUT_DIR / "baseline_route_query_contracts.json", route_query_contracts) payload_contracts = { "generated_at": _iso_now(), "source": "frontend API contracts observed in report modules", "apis": CRITICAL_API_PAYLOAD_CONTRACTS, } write_json(OUT_DIR / "baseline_api_payload_contracts.json", payload_contracts) route_contract = _build_route_contract_payload() write_json(OUT_DIR / "route_migration_contract.json", route_contract) required_routes = {str(item["route"]) for item in TARGET_ROUTE_CONTRACTS} contract_errors = validate_route_migration_contract(route_contract, required_routes=required_routes) contract_validation = { "generated_at": _iso_now(), "errors": contract_errors, } write_json(OUT_DIR / "route_migration_contract_validation.json", contract_validation) evidence_by_route = { str(item["route"]): _collect_interaction_evidence(item) for item in TARGET_ROUTE_CONTRACTS } interaction_evidence = { "generated_at": _iso_now(), "capture_scope": [str(item["route"]) for item in TARGET_ROUTE_CONTRACTS], "routes": evidence_by_route, } write_json(OUT_DIR / "baseline_interaction_evidence.json", interaction_evidence) (OUT_DIR / "route_parity_matrix.md").write_text( _render_route_parity_matrix_markdown(route_contract, evidence_by_route), encoding="utf-8", ) (OUT_DIR / "route_migration_contract.md").write_text( _render_contract_markdown(route_contract), encoding="utf-8", ) if contract_errors: raise SystemExit( "Generated artifacts, but route migration contract has errors: " + "; ".join(contract_errors) ) print("Generated shell route-view baseline artifacts under", OUT_DIR) if __name__ == "__main__": main()