From 83f974c95b02de50ed0a8095f790e9328b8002e4 Mon Sep 17 00:00:00 2001 From: MarioYang Date: Tue, 27 Jan 2026 14:01:00 +0800 Subject: [PATCH] feat: add work package completion tracking and enhance README with new features --- .../skills/week_work_package_log/README.md | 19 +- .../skills/week_work_package_log/examples.py | 37 +++- .../work_package_logger.py | 183 ++++++++++++++++- README.md | 24 +++ example.py | 192 +++++++++++++++++- 5 files changed, 435 insertions(+), 20 deletions(-) diff --git a/.claude/skills/week_work_package_log/README.md b/.claude/skills/week_work_package_log/README.md index b0102cf..07b660a 100644 --- a/.claude/skills/week_work_package_log/README.md +++ b/.claude/skills/week_work_package_log/README.md @@ -14,18 +14,27 @@ - 🔍 **智能排序**: 按總工時從高到低排序 - 📄 **多輸出格式**: 支援樹狀顯示、JSON格式,或同時輸出兩種格式 - 💬 **智能留言顯示**: 優先顯示留言,無留言時顯示 "no-comment" +- ✅ **完成度顯示**: 顯示每個work package的完成度資訊(支援遞歸計算) + - 若關閉或駁回狀態 → 顯示 100% + - 若有設定完成度 → 顯示該完成度 + - 若無設定完成度(no-info): + - 無子項目(最終層)→ 顯示 0% + - 有子項目 → 計算所有子項目完成度的平均值 + - 範例:父work package包含三個子work package完成度分別為 0%, 50%, 100%,則父work package完成度為 50% +- 📁 **Project容器**: Project作為容器顯示,提供工時統計但不顯示完成度(完成度概念僅適用於work package) ## 顯示格式範例 ``` -* P56 - production process system (24.50h) # Project 56,包含項目名稱 - * W756 - Templated object code scan and render then print (16.50h) - * W824 - code page logic implement (16.50h) - * W877 - printing & set label size (8.50h) +* P56 - production process system (24.50h) # Project作為容器,不顯示完成度 + * W756 - Templated object code scan and render then print (16.50h) [完成度: 80%] # 由子項目平均計算 + * W824 - code page logic implement (16.50h) [完成度: 100%] + * W877 - printing & set label size (8.50h) [完成度: 100%] 📝 2026-01-22 | 庭宇 施 | 完成列印功能 | 6.00h 📝 2026-01-21 | 庭宇 施 | no-comment | 2.00h - * W841 - Flexible variable type settings (4.00h) + * W841 - Flexible variable type settings (4.00h) [完成度: 60%] # 由API設定的實際完成度 📝 2026-01-22 | 庭宇 施 | no-comment | 4.00h + * W999 - New feature (0.00h) [完成度: 0%] # 最終層無設定完成度,顯示0% ``` ## 使用方式 diff --git a/.claude/skills/week_work_package_log/examples.py b/.claude/skills/week_work_package_log/examples.py index b18c3b6..162ec3b 100644 --- a/.claude/skills/week_work_package_log/examples.py +++ b/.claude/skills/week_work_package_log/examples.py @@ -160,6 +160,38 @@ def example_both_formats(): print(f"❌ 錯誤: {e}") +def example_completion_feature(): + """範例7: 完成度功能展示""" + + start_date = "2026-01-22" + end_date = "2026-01-22" + + print(f"📅 範例7: 遞歸完成度功能展示 ({start_date} ~ {end_date})") + print("=" * 70) + print("此功能會顯示每個project與work_package的完成度:") + print(" 1. 若關閉或駁回 → 100%") + print(" 2. 若有設定完成度 → 顯示該完成度") + print(" 3. 若無設定(no-info):") + print(" - 無子項目(最終層)→ 0%") + print(" - 有子項目 → 計算所有子項目完成度平均值") + print(" 4. 遞歸應用上述規則到所有層級") + print("-" * 50) + + try: + logger = WeekWorkPackageLogger() + + print("🔗 測試 API 連接...") + if not logger.test_connection(): + print("❌ 無法連接到 API") + return + + print("\n🌳 樹狀格式顯示(包含遞歸計算完成度):") + logger.fetch_and_display_time_entries(start_date, end_date) + + except Exception as e: + print(f"❌ 錯誤: {e}") + + def main(): """主函數 - 展示所有範例""" @@ -175,11 +207,12 @@ def main(): print("4. API 連接測試") print("5. JSON 格式輸出") print("6. 同時輸出兩種格式 (樹狀 + JSON)") + print("7. 完成度功能展示") print("0. 退出") print() try: - choice = input("請輸入選項 (0-6): ").strip() + choice = input("請輸入選項 (0-7): ").strip() if choice == "0": print("👋 再見!") @@ -196,6 +229,8 @@ def main(): example_json_output() elif choice == "6": example_both_formats() + elif choice == "7": + example_completion_feature() else: print("❌ 無效選項,請重新輸入") diff --git a/.claude/skills/week_work_package_log/work_package_logger.py b/.claude/skills/week_work_package_log/work_package_logger.py index 7193dda..6d91d30 100644 --- a/.claude/skills/week_work_package_log/work_package_logger.py +++ b/.claude/skills/week_work_package_log/work_package_logger.py @@ -33,6 +33,19 @@ except ImportError: PROJECT_HIERARCHY_AVAILABLE = False +# Completion status constants +COMPLETION_100_PERCENT = "100%" +COMPLETION_0_PERCENT = "0%" +NO_INFO = "no-info" +PERCENT_FORMAT = "{:.0f}%" + +# Status rejection keywords +REJECTION_KEYWORDS = ['reject', 'denied', 'declined', '駁回', '拒絕'] + +# Completion detection patterns +COMPLETION_KEYWORDS = ['100%', '完成', 'completed', 'finished'] + + class WorkPackageLogError(Exception): """Base exception for work package logging errors""" pass @@ -52,7 +65,7 @@ class InvalidDateRangeError(WorkPackageLogError): class TreeNode: """Represents a node in the hierarchy tree""" - def __init__(self, node_id: str, name: str, node_type: str, hours: float = 0.0, entries: List = None): + def __init__(self, node_id: str, name: str, node_type: str, hours: float = 0.0, entries: List = None, completion: str = None): self.id = node_id self.name = name self.type = node_type # 'project' or 'work_package' @@ -60,6 +73,8 @@ class TreeNode: self.entries = entries or [] self.children = [] self.parent = None + self._raw_completion = completion # API原始完成度資料 + self._calculated_completion = None # 快取計算後的完成度 def add_child(self, child): child.parent = self @@ -71,10 +86,96 @@ class TreeNode: for child in self.children: total += child.get_total_hours() return total + + def get_completion(self, visited_nodes: set = None) -> str: + """ + Recursively compute the completion percentage for this node. + + The calculation follows these rules: + - If a cached value exists in ``_calculated_completion``, return it immediately. + - If the raw completion (``_raw_completion``) is exactly ``"100%"``, + this node is treated as fully complete and returns ``"100%"``. + - If ``_raw_completion`` is set and not equal to ``"no-info"``, + that value is returned as-is. + - If there is no meaningful raw completion and the node has no children + (a leaf in the hierarchy), the completion is treated as ``"0%"``. + - Otherwise, the completion is derived from the children (for example, + by aggregating their completion values according to the hierarchy). + + To prevent infinite recursion in case of cycles in the hierarchy graph, + the method tracks visited node IDs in ``visited_nodes``. If the current + node's ID is already present in ``visited_nodes``, the method returns + ``"no-info"`` to indicate that a reliable completion value cannot be + determined. + + The computed completion value is cached in ``_calculated_completion`` so + that subsequent calls do not need to traverse the hierarchy again. + + Args: + visited_nodes: Optional set used internally during recursive calls + to record which node IDs have already been visited. Callers + should normally omit this argument and allow the method to + create and manage the set. + + Returns: + A string representing the completion percentage for this node + (for example, ``"0%"``, ``"50%"``, ``"100%"`` or ``"no-info"``). + """ + if visited_nodes is None: + visited_nodes = set() + + # 避免無限遞歸 + if self.id in visited_nodes: + return "no-info" + visited_nodes.add(self.id) + + # 如果已經計算過,直接返回快取 + if self._calculated_completion is not None: + return self._calculated_completion + + # 1. 檢查是否為關閉或駁回狀態 + if self._raw_completion == "100%": + self._calculated_completion = "100%" + return "100%" + + # 2. 檢查是否有設定完成度 + if self._raw_completion and self._raw_completion != "no-info": + self._calculated_completion = self._raw_completion + return self._raw_completion + + # 3. 如果是no-info,則根據子項目計算 + if not self.children: + # 無子項目,最終層,給0% + self._calculated_completion = "0%" + return "0%" + else: + # 有子項目,計算所有子項目完成度的平均值 + child_completions = [] + for child in self.children: + child_completion_str = child.get_completion(visited_nodes) + + # 將完成度字串轉換為數值 + if child_completion_str == "no-info": + child_completions.append(0) + elif child_completion_str.endswith("%"): + try: + child_completions.append(float(child_completion_str[:-1])) + except ValueError: + child_completions.append(0) + else: + child_completions.append(0) + + if child_completions: + avg_completion = sum(child_completions) / len(child_completions) + self._calculated_completion = f"{avg_completion:.0f}%" + else: + self._calculated_completion = "0%" + + return self._calculated_completion def to_dict(self) -> Dict: """Convert tree node to dictionary for JSON serialization""" - return { + result = { 'id': self.id, 'name': self.name, 'type': self.type, @@ -83,6 +184,12 @@ class TreeNode: 'time_entries': self.entries, 'children': [child.to_dict() for child in sorted(self.children, key=lambda x: x.get_total_hours(), reverse=True)] } + + # 只有work package才顯示完成度 + if self.type == 'work_package': + result['completion'] = self.get_completion() + + return result class WeekWorkPackageLogger: @@ -132,6 +239,7 @@ class WeekWorkPackageLogger: # Cache for work package and project data self.work_package_cache = {} self.project_cache = {} + self.status_cache = {} def _get_auth(self): """Return authentication tuple for API requests""" @@ -292,6 +400,62 @@ class WeekWorkPackageLogger: except Exception as e: print(f"⚠ Warning: Could not fetch work package {work_package_id}: {e}") return {} + + def _get_status_details(self, status_href: str) -> Dict: + """Get status details from API with caching""" + if status_href in self.status_cache: + return self.status_cache[status_href] + + try: + response = requests.get(f"{self.api_url}{status_href}", auth=self._get_auth(), timeout=10) + + if response.status_code == 200: + status_data = response.json() + self.status_cache[status_href] = status_data + return status_data + else: + return {} + + except Exception as e: + print(f"⚠ Warning: Could not fetch status {status_href}: {e}") + return {} + + + def _get_work_package_completion(self, work_package_id: str) -> str: + """獲取work package的原始完成度資訊""" + wp_data = self._get_work_package_details(work_package_id) + if not wp_data: + return NO_INFO + + # 1. 檢查狀態是否為關閉或駁回 + status_link = wp_data.get('_links', {}).get('status') + if status_link and status_link.get('href'): + status_data = self._get_status_details(status_link['href']) + if status_data: + # 檢查是否為關閉狀態 + if status_data.get('isClosed', False): + return COMPLETION_100_PERCENT + + # 檢查狀態名稱是否包含「駁回」相關字詞 + status_name = status_data.get('name', '').lower() + if any(keyword in status_name for keyword in REJECTION_KEYWORDS): + return COMPLETION_100_PERCENT + + # 2. 檢查work package本身的完成度設定 + percentage_done = wp_data.get('percentageDone') + if percentage_done is not None: + return f"{percentage_done}%" + + derived_percentage = wp_data.get('derivedPercentageDone') + if derived_percentage is not None: + return f"{derived_percentage}%" + + # 3. 若都沒有則填 no-info + return NO_INFO + + def _get_project_completion(self, project_id: str) -> str: + """Project不計算完成度""" + return None def _get_project_path(self, project_id: str) -> List[Tuple[str, str]]: """Get full project path from root to target project""" @@ -451,7 +615,8 @@ class WeekWorkPackageLogger: # Only attach entries to the actual work package (not parent work packages) node_entries = entries if wp_id_in_path == wp_id else [] node_hours = total_hours if wp_id_in_path == wp_id else 0 - wp_nodes[wp_id_in_path] = TreeNode(wp_id_in_path, wp_name, 'work_package', node_hours, node_entries) + wp_completion = self._get_work_package_completion(wp_id_in_path) + wp_nodes[wp_id_in_path] = TreeNode(wp_id_in_path, wp_name, 'work_package', node_hours, node_entries, wp_completion) if current_wp_node is None: current_wp_node = wp_nodes[wp_id_in_path] @@ -512,7 +677,8 @@ class WeekWorkPackageLogger: if node.type == 'work_package' and node.entries: hours_info = f" ({node.hours:.2f}h)" - print(f"{indent}* {prefix}{node.id} - {node.name}{hours_info}") + completion_info = f" [完成度: {node.get_completion()}]" + print(f"{indent}* {prefix}{node.id} - {node.name}{hours_info}{completion_info}") # Show time entries for work packages entry_indent = " " * (depth + 1) @@ -524,7 +690,14 @@ class WeekWorkPackageLogger: # For projects or work packages without direct entries total_hours = node.get_total_hours() hours_info = f" ({total_hours:.2f}h)" if total_hours > 0 else "" - print(f"{indent}* {prefix}{node.id} - {node.name}{hours_info}") + + # 只有work package才顯示完成度 + if node.type == 'work_package': + completion_info = f" [完成度: {node.get_completion()}]" + else: + completion_info = "" + + print(f"{indent}* {prefix}{node.id} - {node.name}{hours_info}{completion_info}") # Print children for child in sorted(node.children, key=lambda x: x.get_total_hours(), reverse=True): diff --git a/README.md b/README.md index 0d71075..8a53727 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ Automatically generates weekly project reports (項目週報) from exported Exce Fetches and displays time entries grouped by work packages for specified date ranges via OpenProject API. Provides detailed analysis of work package time logging patterns with user, project, and activity information. +**Key Features:** +- 🌳 **Hierarchical Display**: Shows project and work package relationships in tree format +- 📊 **Completion Tracking**: Displays work package completion percentages with intelligent calculation +- 📝 **Time Analysis**: Detailed breakdown of time entries by user, activity, and comments +- 📊 **Multiple Output Formats**: Tree view for human reading, JSON for data integration + +**Completion Calculation Logic:** +1. **Closed/Rejected work packages**: Automatically marked as 100% +2. **Explicit completion**: Uses percentageDone or derivedPercentageDone from OpenProject +3. **Recursive calculation**: Parent work packages derive completion from children averages +4. **No-info handling**: Leaf nodes default to 0%, parents calculate from children + ### 🚀 Week Report Generator from JSON (`week_report_gen_from_json`) **New Enhanced Version**: Automatically generates weekly project reports directly from OpenProject API data via JSON format. Features intelligent cell merging, hierarchical project structure recognition, and streamlined workflow. @@ -42,6 +54,18 @@ uv run --env-file .env python -c 'import os; print(os.getenv("OPENPROJECT_TOKEN" uv run --env-file .env python .claude\skills\week_report_gen\generate_report.py "temp\cost-report-2026-01-16-T-16-22-3620260116-7-1r1n4h.xls" "temp\項目週報-智能控制組-20260119.xlsx" ``` +#### Work Package Analysis +```bash +# Tree view with completion percentages +uv run --env-file .env python .claude\skills\week_work_package_log\work_package_logger.py "2026-01-22" "2026-01-22" --format tree + +# JSON output for data integration +uv run --env-file .env python .claude\skills\week_work_package_log\work_package_logger.py "2026-01-22" "2026-01-22" --format json + +# Both formats +uv run --env-file .env python .claude\skills\week_work_package_log\work_package_logger.py "2026-01-22" "2026-01-22" --format both +``` + #### New JSON-based Generator (Recommended) ```bash # Generate report for specific date range diff --git a/example.py b/example.py index 3305bbc..615ee97 100644 --- a/example.py +++ b/example.py @@ -1,21 +1,151 @@ """ -Example: Generate Weekly Report +Example: Various Report Generation Methods -This script demonstrates how to use the week_report_gen skill -to generate a weekly report from a cost report Excel file. +This script demonstrates how to use different skills: +1. week_report_gen: Generate from Excel cost report +2. week_work_package_log: Analyze work package time entries with completion tracking +3. week_report_gen_from_json: Generate report directly from API data """ import os import sys -# Add the skill directory to the path -skill_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'skills', 'week_report_gen') -sys.path.insert(0, skill_path) - -from generate_report import generate_weekly_report - def main(): + """ + Main function demonstrating various report generation methods. + """ + print("Report Generation Skills - Examples") + print("====================================") + print() + print("Choose an example to run:") + print("1. Work Package Analysis (with completion tracking)") + print("2. Weekly Report Generation (from Excel)") + print("3. Weekly Report Generation (from JSON/API)") + print() + + try: + choice = input("Enter your choice (1-3): ").strip() + + if choice == "1": + example_work_package_analysis() + elif choice == "2": + example_weekly_report_excel() + elif choice == "3": + example_weekly_report_json() + else: + print("Invalid choice. Please run the script again.") + + except KeyboardInterrupt: + print("\n\nExample interrupted by user.") + except Exception as e: + print(f"\nUnexpected error: {e}") + + +def example_work_package_analysis(): + """ + Example: Analyze work package time entries with completion tracking + """ + print("=" * 70) + print("Work Package Analysis - Example") + print("=" * 70) + print() + + # Add the skill directory to the path + base_dir = os.path.dirname(os.path.abspath(__file__)) + skill_path = os.path.join(base_dir, '.claude', 'skills', 'week_work_package_log') + sys.path.insert(0, skill_path) + + try: + from work_package_logger import WeekWorkPackageLogger + + # Initialize logger + logger = WeekWorkPackageLogger() + + # Test connection first + print("Testing OpenProject API connection...") + if not logger.test_connection(): + print("Failed to connect to OpenProject API. Check your credentials.") + return + + # Example date range (replace with actual dates) + start_date = "2026-01-22" + end_date = "2026-01-22" + + print(f"\nFetching work package data for {start_date} to {end_date}...") + print() + + # Display tree format with completion percentages + logger.fetch_and_display_time_entries(start_date, end_date, 'tree') + + print("\n" + "=" * 70) + print("JSON Output for Integration:") + print("=" * 70) + + # Get JSON format for data integration + json_data = logger.fetch_and_display_time_entries(start_date, end_date, 'json') + + if json_data: + print(f"\nTotal projects analyzed: {json_data['summary']['total_projects']}") + print(f"Total hours logged: {json_data['summary']['total_hours']}h") + + # Show completion summary for work packages + print("\nWork Package Completion Summary:") + for project in json_data['projects']: + _print_wp_completion_summary(project, 0) + + print("\n✓ Work package analysis completed successfully!") + + except ImportError as e: + print(f"Error importing work_package_logger: {e}") + print("Make sure the week_work_package_log skill is properly installed.") + except Exception as e: + print(f"Error during work package analysis: {e}") + + +def _print_wp_completion_summary(node, depth=0): + """Helper function to print work package completion summary""" + indent = " " * depth + + if node['type'] == 'work_package' and 'completion' in node: + print(f"{indent}- {node['name']}: {node['completion']} ({node['total_hours']:.1f}h)") + elif node['type'] == 'project': + print(f"{indent}📁 {node['name']} ({node['total_hours']:.1f}h)") + + for child in node.get('children', []): + _print_wp_completion_summary(child, depth + 1) + + """ + Main function demonstrating various report generation methods. + """ + print("Report Generation Skills - Examples") + print("====================================") + print() + print("Choose an example to run:") + print("1. Work Package Analysis (with completion tracking)") + print("2. Weekly Report Generation (from Excel)") + print("3. Weekly Report Generation (from JSON/API)") + print() + + try: + choice = input("Enter your choice (1-3): ").strip() + + if choice == "1": + example_work_package_analysis() + elif choice == "2": + example_weekly_report_excel() + elif choice == "3": + example_weekly_report_json() + else: + print("Invalid choice. Please run the script again.") + + except KeyboardInterrupt: + print("\n\nExample interrupted by user.") + except Exception as e: + print(f"\nUnexpected error: {e}") + + +def example_weekly_report_excel(): """ Example: Generate a weekly report from the sample cost report. """ @@ -135,5 +265,49 @@ def main(): return 1 +def example_weekly_report_json(): + """ + Example: Generate weekly report directly from OpenProject API (JSON-based) + """ + print("=" * 70) + print("Weekly Report from JSON - Example") + print("=" * 70) + print() + + # Add the skill directory to the path + base_dir = os.path.dirname(os.path.abspath(__file__)) + skill_path = os.path.join(base_dir, '.claude', 'skills', 'week_report_gen_from_json') + sys.path.insert(0, skill_path) + + try: + from generate_report import main as generate_json_report + + # Example parameters + start_date = "2026-01-20" + end_date = "2026-01-22" + output_file = os.path.join(base_dir, "temp", "週報-示例-JSON版本.xlsx") + + print(f"Generating report for {start_date} to {end_date}...") + print(f"Output file: {output_file}") + print() + + # Note: This would typically be called with command line arguments + # For demonstration, we're calling it directly + print("Note: This example shows the concept. In practice, use:") + print(f"uv run --env-file .env python .claude\\skills\\week_report_gen_from_json\\generate_report.py \"{start_date}\" \"{end_date}\" \"{output_file}\"") + print() + print("Features:") + print("- Direct API integration (no manual Excel export)") + print("- Intelligent cell merging and formatting") + print("- Hierarchical project structure recognition") + print("- Work package completion tracking") + + except ImportError as e: + print(f"Error importing week_report_gen_from_json: {e}") + print("Make sure the week_report_gen_from_json skill is properly installed.") + except Exception as e: + print(f"Error during JSON report generation: {e}") + if __name__ == "__main__": + main() sys.exit(main())