feat: add work package completion tracking and enhance README with new features

This commit is contained in:
MarioYang 2026-01-27 14:01:00 +08:00
parent 7f386fce89
commit 83f974c95b
5 changed files with 435 additions and 20 deletions

View File

@ -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%
```
## 使用方式

View File

@ -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("❌ 無效選項,請重新輸入")

View File

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

View File

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

View File

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