diff --git a/AGENTS.md b/AGENTS.md
index 45615e7..13f4f89 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,14 +6,15 @@
## 概述
-从飞书获取排班信息,从 Metabase 获取船舶作业数据,生成标准化日报。
+从飞书获取排班信息,从 Metabase 获取船舶作业数据,从 Confluence 获取船舶报告数据,从 Jira 获取故障信息,生成标准化日报和交接班报告。
## 结构
```
Gloria/
├── daily_report_gui.py # GUI入口 (Tkinter)
-├── report_generator.py # CLI入口 + 核心生成逻辑
+├── report_generator.py # CLI入口 + 日报生成核心
+├── shift_report.py # 班次交接报告(新增)
├── feishu/ # 飞书API集成
│ ├── client.py # HTTP客户端 + Token管理
│ ├── manager.py # 排班管理器(统一入口)
@@ -21,7 +22,13 @@ Gloria/
├── metabase/ # Metabase数据查询
│ ├── time_operations.py # 按时间范围查询
│ └── vessel_operations.py # 按船舶查询
+├── confluence/ # Confluence API集成(新增)
+│ ├── client.py # Confluence HTTP客户端
+│ └── vessel_reports.py # 船舶报告解析器
+├── jira_client.py # Jira API客户端(新增)
└── template/ # 日报模板
+ ├── daily_report_template.txt
+ └── shift_handover_template.txt
```
## 查找指南
@@ -29,10 +36,13 @@ Gloria/
| 任务 | 位置 |
|------|------|
| 修改日报格式 | `template/daily_report_template.txt` |
-| 调整班次时间规则 | `report_generator.py:36-78` (`get_shift_time_range`) |
+| 修改交接班报告格式 | `template/shift_handover_template.txt` |
+| 调整班次时间规则 | `shift_report.py:69-104` (`get_shift_time_range`) |
| 添加飞书功能 | `feishu/manager.py` |
| 新增Metabase查询 | `metabase/` 对应客户端 |
+| 新增Confluence查询 | `confluence/vessel_reports.py` |
| 修改GUI界面 | `daily_report_gui.py` |
+| 船舶记录合并逻辑 | `shift_report.py:501-546` (`_merge_vessels_by_name`) |
## 关键约定
@@ -41,6 +51,10 @@ Gloria/
- **月底最后一天:** 08:00 ~ 23:59
- **其他日期:** 08:00 ~ 次日08:00
+### 交接班班次
+- **白班:** 08:00 - 20:00
+- **夜班:** 20:00 - 次日08:00
+
### 环境变量 (.env)
```
MATEBASE_USERNAME=xxx
@@ -48,6 +62,11 @@ MATEBASE_PASSWORD=xxx
FEISHU_APP_ID=xxx
FEISHU_APP_SECRET=xxx
FEISHU_SPREADSHEET_TOKEN=xxx
+CONFLUENCE_URL=https://confluence.xxx.com
+CONFLUENCE_TOKEN=xxx
+JIRA_URL=https://jira.xxx.com
+JIRA_USERNAME=xxx
+JIRA_TOKEN=xxx
```
### 运行方式
@@ -55,10 +74,27 @@ FEISHU_SPREADSHEET_TOKEN=xxx
# GUI模式
python daily_report_gui.py
-# CLI模式
+# 日报 (CLI)
python report_generator.py --date 2026-03-01
+
+# 班次交接报告
+python shift_report.py --date 2026-03-13 --shift day
+python shift_report.py --date 2026-03-13 --shift night
+python shift_report.py --date 2026-03-13 --shift all
```
+## 新功能:班次交接报告
+
+`shift_report.py` 生成班次交接报告,支持:
+
+- **船号显示**: 从 Confluence 获取船舶编号(如 462#、463#)
+- **故障数据**: 从 Jira 查询故障数量(如 FZ-2042、FZ-2043)
+- **故障率计算**: 故障次数 / (TEU/2) * 100%
+- **效率获取**:
+ - 不跨班:使用整船效率(剔除异常)
+ - 跨班:使用 Card 69 按班次时间范围查询效率
+- **N/A记录处理**: Metabase 中 vesselVisitID 为 "N/A" 的记录根据作业时间归属到对应的船舶
+
## 依赖
- `requests` - HTTP请求
@@ -69,4 +105,5 @@ python report_generator.py --date 2026-03-01
- 程序需在 **8:00 后运行**,确保最后一条船指令结束时间超过8点
- 飞书 Token 自动刷新,提前30分钟续期
-- Metabase 无原生 Python SDK,使用 REST API
\ No newline at end of file
+- Metabase 无原生 Python SDK,使用 REST API
+- Confluence 和 Jira 需要配置相应 Token 才能启用完整功能
diff --git a/CONFLUENCE_INTEGRATION.md b/CONFLUENCE_INTEGRATION.md
new file mode 100644
index 0000000..616f8a8
--- /dev/null
+++ b/CONFLUENCE_INTEGRATION.md
@@ -0,0 +1,107 @@
+# Confluence 数据集成说明
+
+## 概述
+
+交接班脚本现已集成 Confluence 数据源,可以自动从 Confluence 获取船舶报告的故障次数、故障率、人工介入次数和人工介入率数据。
+
+## 新增文件
+
+```
+confluence/
+├── __init__.py # 模块初始化
+├── client.py # Confluence API 客户端
+└── vessel_reports.py # 船舶报告数据管理器
+```
+
+## 环境变量配置
+
+在 `.env` 文件中添加以下配置:
+
+```bash
+# Confluence 配置
+CONFLUENCE_URL=https://confluence.westwell-lab.com
+CONFLUENCE_TOKEN=your_token_here
+```
+
+## 使用方法
+
+### 生成交接班报告(自动集成 Confluence 数据)
+
+```bash
+# 生成指定日期的白班报告
+python shift_report.py --date 2026-03-05 --shift day
+
+# 生成指定日期的夜班报告
+python shift_report.py --date 2026-03-05 --shift night
+
+# 生成指定日期的所有班次报告
+python shift_report.py --date 2026-03-05 --shift all
+```
+
+### 测试 Confluence 数据提取
+
+```bash
+python test_confluence.py
+```
+
+## 数据字段说明
+
+从 Confluence 提取的数据包括:
+
+| 字段 | 说明 | 来源 |
+|------|------|------|
+| vessel_number | 船次号 (如 433#) | Confluence 页面 |
+| vessel_name | 船名 | Confluence 页面 |
+| teu | 作业箱量 | Confluence 页面 |
+| failures | 故障次数 | Confluence 页面 |
+| failure_rate | 故障率 | 计算: failures/(teu/2)*100% |
+| interventions | 人工介入次数 | Confluence 页面 |
+| intervention_rate | 人工介入率 | 计算: interventions/(teu/2)*100% |
+| efficiency | 作业净效率 | Confluence 页面 |
+
+## 数据匹配逻辑
+
+Metabase 返回的船舶数据使用 `vesselVisitID` 格式为 `日期-船名` (如 "260305-东方祥"),
+Confluence 页面使用船名作为匹配键,确保数据正确关联。
+
+## Confluence 页面结构
+
+Confluence 页面结构:
+- 父页面: "福州江阴实船作业统计" (ID: 137446574)
+ - 子页面: "2026.03 实船作业统计" (按月)
+ - 子页面: "FZ 433#实船报告2026.03.01" (按船次)
+
+每个船舶报告页面包含:
+- 船次、船名、作业时间
+- TEU、作业循环、效率
+- 故障次数、故障率
+- 人工介入次数、人工介入率
+- Jira Bug 链接(页面底部)
+
+## 故障排查
+
+### Confluence 连接失败
+
+检查环境变量是否正确设置:
+```bash
+echo $CONFLUENCE_TOKEN
+echo $CONFLUENCE_URL
+```
+
+### 数据未显示
+
+1. 确认 Confluence 页面存在该日期的船舶报告
+2. 检查船名是否匹配(Metabase 和 Confluence 使用相同船名)
+3. 查看控制台输出是否有错误信息
+
+### 故障率计算
+
+故障率计算公式:
+```
+故障率 = 故障次数 / (TEU / 2) * 100%
+人工介入率 = 人工介入次数 / (TEU / 2) * 100%
+```
+
+示例:
+- TEU = 474,故障次数 = 2
+- 故障率 = 2 / (474 / 2) * 100% = 0.84%
diff --git a/JIRA_INTEGRATION.md b/JIRA_INTEGRATION.md
new file mode 100644
index 0000000..76484c2
--- /dev/null
+++ b/JIRA_INTEGRATION.md
@@ -0,0 +1,230 @@
+# Jira 集成说明
+
+## 概述
+
+为了获取准确的故障次数,系统现在支持从 Jira 直接查询 issue 数量。这样可以避免 Confluence 页面中表格数据为空的问题。
+
+## 问题背景
+
+**463# 信荣海** 的例子:
+- Confluence 页面中"故障次数"表格字段是空的(`
| `)
+- 但实际在 Jira 中有 2 个故障(FZ-2042、FZ-2043)
+- 需要从 Jira 查询获取准确的故障数量
+
+## 新增文件
+
+```
+jira_client.py # Jira API 客户端
+```
+
+## 环境变量配置
+
+在 `.env` 文件中添加 Jira 配置:
+
+```bash
+# Jira 配置
+JIRA_URL=https://jira.westwell-lab.com
+JIRA_USERNAME=your_username
+JIRA_TOKEN=your_api_token
+```
+
+## 使用方法
+
+### 1. 修改 shift_report.py 启用 Jira 查询
+
+在 `shift_report.py` 中,找到 `ShiftReportGenerator.__init__` 方法,添加 Jira 客户端:
+
+```python
+from jira_client import JiraClient
+
+class ShiftReportGenerator:
+ def __init__(self):
+ self.feishu_manager = FeishuScheduleManager()
+
+ # Confluence 配置
+ try:
+ self.confluence_manager = VesselReportManager()
+ self.confluence_enabled = True
+
+ # 设置 Jira 客户端(用于查询故障数量)
+ jira_url = os.getenv("JIRA_URL")
+ jira_username = os.getenv("JIRA_USERNAME")
+ jira_token = os.getenv("JIRA_TOKEN")
+ if jira_url and jira_username and jira_token:
+ jira_client = JiraClient(jira_url, jira_username, jira_token)
+ self.confluence_manager.set_jira_client(jira_client)
+ print("Jira 客户端已启用")
+ except Exception as e:
+ print(f"Confluence 数据源初始化失败: {e}")
+ self.confluence_enabled = False
+```
+
+### 2. 测试 Jira 查询
+
+创建测试脚本 `test_jira.py`:
+
+```python
+import os
+import sys
+from dotenv import load_dotenv
+
+load_dotenv()
+
+project_root = os.path.dirname(os.path.abspath(__file__))
+if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+
+from jira_client import JiraClient
+from confluence import VesselReportManager
+
+# 初始化客户端
+jira_client = JiraClient(
+ os.getenv("JIRA_URL"),
+ os.getenv("JIRA_USERNAME"),
+ os.getenv("JIRA_TOKEN")
+)
+
+confluence_manager = VesselReportManager()
+confluence_manager.set_jira_client(jira_client)
+
+# 获取 463# 的详细数据
+print("=== 测试 463# 信荣海的 Jira 查询 ===\n")
+
+# 从 Confluence 获取页面 JQL
+import requests
+import re
+
+token = os.getenv("CONFLUENCE_TOKEN")
+headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
+url = "https://confluence.westwell-lab.com/rest/api/content/165416156/child/page"
+params = {"limit": 100, "expand": "body.storage"}
+
+response = requests.get(url, headers=headers, params=params, timeout=30)
+if response.status_code == 200:
+ data = response.json()
+ for page in data.get('results', []):
+ if '463#' in page.get('title', ''):
+ body = page.get('body', {}).get('storage', {}).get('value', '')
+ jqls = confluence_manager._extract_jira_jqls(body, "463")
+
+ print(f"找到 {len(jqls)} 个 Jira 宏:")
+ total_issues = 0
+ for i, jql in enumerate(jqls, 1):
+ count = jira_client.count_issues(jql)
+ total_issues += count
+ print(f"\n宏 {i}:")
+ print(f" JQL: {jql[:100]}...")
+ print(f" Issue 数量: {count}")
+
+ print(f"\n总计故障数量: {total_issues}")
+
+ # 获取具体的 issue keys
+ all_issues = []
+ for jql in jqls:
+ issues = jira_client.get_issue_keys(jql)
+ all_issues.extend(issues)
+
+ print(f"\nIssue Keys: {all_issues}")
+ break
+```
+
+## 工作原理
+
+### 1. 提取 JQL 查询
+
+从 Confluence 页面中提取所有 Jira 宏的 JQL 查询:
+
+```python
+def _extract_jira_jqls(self, body: str, vessel_number: str) -> List[str]:
+ # 查找所有 Jira 宏
+ jira_macros = re.findall(
+ r']*ac:name="jira"[^>]*>(.*?)',
+ body, re.DOTALL
+ )
+
+ for macro in jira_macros:
+ # 提取 JQL
+ jql_match = re.search(
+ r']*name="jqlQuery"[^>]*>(.*?)',
+ macro, re.DOTALL
+ )
+ if jql_match:
+ jql = jql_match.group(1)
+ # 检查是否匹配当前船次
+ if f'实船船次 = "{vessel_number}"' in jql:
+ jqls.append(jql)
+```
+
+### 2. 查询 Jira
+
+对每个 JQL 查询执行 Jira API 调用:
+
+```python
+def count_issues(self, jql: str) -> int:
+ url = f"{self.base_url}/rest/api/2/search"
+ params = {
+ "jql": jql,
+ "maxResults": 0 # 只返回总数
+ }
+ response = requests.get(url, auth=self.auth, params=params)
+ return response.json().get("total", 0)
+```
+
+### 3. 故障计数逻辑
+
+1. 首先尝试从 Confluence 表格中提取故障次数
+2. 如果表格中没有数据(为 0),则从 Jira 查询
+3. 汇总所有匹配的 Jira 宏的 issue 数量
+
+## 注意事项
+
+### 1. Jira API Token
+
+需要在 Jira 中生成 API Token:
+1. 登录 Jira
+2. 点击头像 -> 账户设置
+3. 安全 -> 创建和管理 API 令牌
+4. 创建新令牌并保存
+
+### 2. 查询性能
+
+每个船舶报告可能有 3-4 个 Jira 宏,每个宏执行一次 Jira 查询。对于包含多个船舶的报告,可能需要几秒钟完成所有查询。
+
+### 3. 权限要求
+
+Jira 账户需要有权限访问项目 FZ 的 issues。
+
+## 故障排查
+
+### Jira 查询返回 0
+
+检查:
+1. Jira 认证信息是否正确
+2. 账户是否有权限访问 FZ 项目
+3. JQL 查询是否正确(可以在 Jira 中手动测试)
+
+### Confluence 页面没有 Jira 宏
+
+有些船舶页面可能没有创建 Jira 宏,这时会显示表格中的数据(可能为 0 或空)。
+
+## 示例输出
+
+启用 Jira 查询后的输出示例:
+
+```
+实船作业:463# 信荣海
+上场车辆数:6
+作业量/效率:28TEU(20尺*20 40尺*4),0.00循环/车/小时
+故障次数/故障率:2次,14.29% <-- 从 Jira 查询获得
+人工介入次数/介入率:0次,0.00%
+```
+
+如果没有启用 Jira:
+
+```
+实船作业:463# 信荣海
+上场车辆数:6
+作业量/效率:28TEU(20尺*20 40尺*4),0.00循环/车/小时
+故障次数/故障率:0次,0.00% <-- 从 Confluence 表格获得(为空)
+人工介入次数/介入率:0次,0.00%
+```
diff --git a/README.md b/README.md
index e658dff..dacaf49 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,17 @@
# Gloria - 福州港日报管理系统
-从飞书获取排班信息,从 Metabase 获取船舶作业数据,生成标准化日报。
+从飞书获取排班信息,从 Metabase 获取船舶作业数据,从 Confluence 获取船舶报告,从 Jira 获取故障信息,生成标准化日报和交接班报告。
## 功能
- **日报生成**: 自动生成每日作业报告
- **班次交接报告**: 分别统计白班/夜班作业情况
+ - 船号显示 (462#、463# 等)
+ - 故障次数/故障率
+ - 人工介入次数/介入率
+ - 作业效率 (循环/车/小时)
+- **Confluence 集成**: 从实船作业统计页面获取船舶数据
+- **Jira 集成**: 查询故障单数量
- **GUI 界面**: 基于 Tkinter 的图形界面
- **CLI 支持**: 命令行方式运行
@@ -18,6 +24,8 @@ Gloria/
├── shift_report.py # 班次交接报告
├── feishu/ # 飞书 API 集成
├── metabase/ # Metabase 数据查询
+├── confluence/ # Confluence API 集成
+├── jira_client.py # Jira API 客户端
└── template/ # 报告模板
```
@@ -38,12 +46,24 @@ pip install requests python-dotenv
创建 `.env` 文件:
-```
-MATEBASE_USERNAME=xxx
-MATEBASE_PASSWORD=xxx
-FEISHU_APP_ID=xxx
-FEISHU_APP_SECRET=xxx
-FEISHU_SPREADSHEET_TOKEN=xxx
+```bash
+# Metabase
+MATEBASE_USERNAME=your_username
+MATEBASE_PASSWORD=your_password
+
+# 飞书
+FEISHU_APP_ID=your_app_id
+FEISHU_APP_SECRET=your_app_secret
+FEISHU_SPREADSHEET_TOKEN=your_spreadsheet_token
+
+# Confluence (可选)
+CONFLUENCE_URL=https://confluence.westwell-lab.com
+CONFLUENCE_TOKEN=your_token
+
+# Jira (可选)
+JIRA_URL=https://jira.westwell-lab.com
+JIRA_USERNAME=your_username
+JIRA_TOKEN=your_token
```
### 运行
@@ -58,6 +78,7 @@ python report_generator.py --date 2026-03-01
# 班次交接报告
python shift_report.py --date 2026-03-01 --shift day
python shift_report.py --date 2026-03-01 --shift night
+python shift_report.py --date 2026-03-01 --shift all
```
## 模块使用
@@ -128,6 +149,19 @@ today = manager.get_schedule_for_today()
tomorrow = manager.get_schedule_for_tomorrow()
```
+### Confluence 船舶报告
+
+```python
+from confluence import VesselReportManager
+
+manager = VesselReportManager()
+
+# 获取指定日期范围内的报告
+reports = manager.get_vessel_reports_in_range("2026-03-01", "2026-03-02")
+
+# 返回包含船号、船名、故障次数、人工介入次数等的报告列表
+```
+
## 班次时间
| 类型 | 时间范围 |
@@ -135,11 +169,21 @@ tomorrow = manager.get_schedule_for_tomorrow()
| 白班 | 08:00 - 20:00 |
| 夜班 | 20:00 - 次日 08:00 |
+## N/A 记录处理
+
+Metabase 中可能出现 vesselVisitID 为 "N/A" 的记录(通常是数据同步问题导致)。系统会自动根据作业时间将这些记录归属到对应的船舶:
+
+- 检查 N/A 记录的时间范围
+- 与其他船舶的时间范围进行重叠判断
+- 将 N/A 记录的 TEU 合并到时间重叠的船舶
+
## 注意事项
- 程序需在 **8:00 后运行**,确保最后一条船指令结束时间超过 8 点
- 飞书 Token 自动刷新,提前 30 分钟续期
+- Metabase 无原生 Python SDK,使用 REST API
+- Confluence 和 Jira 需要配置相应 Token 才能启用完整功能
## License
-MIT
\ No newline at end of file
+MIT
diff --git a/confluence/__init__.py b/confluence/__init__.py
new file mode 100644
index 0000000..d26b7f1
--- /dev/null
+++ b/confluence/__init__.py
@@ -0,0 +1,10 @@
+"""
+Confluence 数据获取模块
+
+用于从 Confluence 获取船舶报告数据,包括故障次数、人工介入次数等。
+"""
+
+from .client import ConfluenceClient
+from .vessel_reports import VesselReportManager
+
+__all__ = ["ConfluenceClient", "VesselReportManager"]
diff --git a/confluence/client.py b/confluence/client.py
new file mode 100644
index 0000000..68b6745
--- /dev/null
+++ b/confluence/client.py
@@ -0,0 +1,155 @@
+"""
+Confluence API 客户端
+
+提供与 Confluence REST API 交互的基础功能。
+"""
+
+import requests
+import json
+from typing import Optional, Dict, List, Any
+from datetime import datetime
+
+
+class ConfluenceClient:
+ """Confluence API 客户端"""
+
+ def __init__(self, base_url: str, token: str):
+ """
+ 初始化 Confluence 客户端
+
+ Args:
+ base_url: Confluence 基础 URL (如 https://confluence.example.com)
+ token: API 访问令牌 (Personal Access Token)
+ """
+ self.base_url = base_url.rstrip("/")
+ self.token = token
+ self.headers = {
+ "Authorization": f"Bearer {token}",
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+
+ def _make_request(
+ self,
+ method: str,
+ endpoint: str,
+ params: Optional[Dict] = None,
+ json_data: Optional[Dict] = None,
+ ) -> Optional[Dict]:
+ """
+ 发送 HTTP 请求
+
+ Args:
+ method: HTTP 方法 (GET, POST, etc.)
+ endpoint: API 端点路径
+ params: 查询参数
+ json_data: JSON 请求体
+
+ Returns:
+ 响应数据字典,失败返回 None
+ """
+ url = f"{self.base_url}{endpoint}"
+ try:
+ response = requests.request(
+ method=method,
+ url=url,
+ headers=self.headers,
+ params=params,
+ json=json_data,
+ timeout=30,
+ )
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ print(f"请求失败: {url}, 错误: {e}")
+ return None
+
+ def get_page(self, page_id: str, expand: Optional[str] = None) -> Optional[Dict]:
+ """
+ 获取页面信息
+
+ Args:
+ page_id: 页面 ID
+ expand: 需要展开的内容 (如 "body.view,body.storage")
+
+ Returns:
+ 页面数据字典
+ """
+ endpoint = f"/rest/api/content/{page_id}"
+ params = {}
+ if expand:
+ params["expand"] = expand
+ return self._make_request("GET", endpoint, params=params)
+
+ def get_child_pages(
+ self, parent_id: str, limit: int = 100, expand: Optional[str] = None
+ ) -> List[Dict]:
+ """
+ 获取子页面列表
+
+ Args:
+ parent_id: 父页面 ID
+ limit: 返回结果数量限制
+ expand: 需要展开的内容
+
+ Returns:
+ 子页面列表
+ """
+ endpoint = f"/rest/api/content/{parent_id}/child/page"
+ params = {"limit": limit}
+ if expand:
+ params["expand"] = expand
+
+ result = self._make_request("GET", endpoint, params=params)
+ if result and "results" in result:
+ return result["results"]
+ return []
+
+ def search_content(
+ self, query: str, space_key: Optional[str] = None, limit: int = 50
+ ) -> List[Dict]:
+ """
+ 搜索内容
+
+ Args:
+ query: 搜索关键词 (CQL)
+ space_key: 空间 Key (可选)
+ limit: 返回结果数量限制
+
+ Returns:
+ 搜索结果列表
+ """
+ endpoint = "/rest/api/content/search"
+ cql = query
+ if space_key:
+ cql = f"{query} AND space = {space_key}"
+
+ params = {"cql": cql, "limit": limit}
+ result = self._make_request("GET", endpoint, params=params)
+ if result and "results" in result:
+ return result["results"]
+ return []
+
+ def get_page_by_title(
+ self, space_key: str, title: str, expand: Optional[str] = None
+ ) -> Optional[Dict]:
+ """
+ 根据标题获取页面
+
+ Args:
+ space_key: 空间 Key
+ title: 页面标题
+ expand: 需要展开的内容
+
+ Returns:
+ 页面数据字典
+ """
+ endpoint = "/rest/api/content"
+ params = {"spaceKey": space_key, "title": title, "limit": 1}
+ if expand:
+ params["expand"] = expand
+
+ result = self._make_request("GET", endpoint, params=params)
+ if result and "results" in result and len(result["results"]) > 0:
+ return result["results"][0]
+ return None
diff --git a/confluence/vessel_reports.py b/confluence/vessel_reports.py
new file mode 100644
index 0000000..760f49c
--- /dev/null
+++ b/confluence/vessel_reports.py
@@ -0,0 +1,391 @@
+"""
+船舶报告管理器
+
+从 Confluence 提取船舶报告数据,包括故障次数、人工介入次数等。
+"""
+
+import re
+from typing import Optional, Dict, List, Any
+from datetime import datetime, timedelta
+from .client import ConfluenceClient
+
+
+class VesselReportManager:
+ """船舶报告数据管理器"""
+
+ # 月度统计页面父页面 ID
+ PARENT_PAGE_ID = "137446574" # "福州江阴实船作业统计"
+
+ # 日期范围映射 (根据实际页面结构调整)
+ MONTH_PAGE_MAPPING: Dict[str, str] = {}
+
+ def __init__(self, client: Optional[ConfluenceClient] = None):
+ """
+ 初始化船舶报告管理器
+
+ Args:
+ client: Confluence 客户端实例,为 None 时自动创建
+ """
+ if client is None:
+ # 从环境变量获取配置
+ import os
+
+ base_url = os.getenv(
+ "CONFLUENCE_URL", "https://confluence.westwell-lab.com"
+ )
+ token = os.getenv("CONFLUENCE_TOKEN")
+ if not token:
+ raise ValueError("未设置 CONFLUENCE_TOKEN 环境变量")
+ self.client = ConfluenceClient(base_url, token)
+ else:
+ self.client = client
+ self.jira_client = None
+
+ def set_jira_client(self, jira_client):
+ self.jira_client = jira_client
+
+ def _extract_jira_jqls(self, body: str, vessel_number: str) -> List[str]:
+ jqls = []
+ jira_macros = re.findall(
+ r']*ac:name="jira"[^>]*>(.*?)',
+ body,
+ re.DOTALL,
+ )
+
+ for macro in jira_macros:
+ jql_match = re.search(
+ r']*name="jqlQuery"[^>]*>(.*?)',
+ macro,
+ re.DOTALL,
+ )
+ if jql_match:
+ jql = jql_match.group(1)
+ jql = (
+ jql.replace(""", '"')
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+ )
+ if f'实船船次 = "{vessel_number}"' in jql:
+ jqls.append(jql)
+
+ return jqls
+
+ def _count_issues_from_jira(self, body: str, vessel_number: Optional[str]) -> int:
+ if not self.jira_client or not vessel_number:
+ return 0
+
+ try:
+ jqls = self._extract_jira_jqls(body, vessel_number)
+ total_issues = 0
+
+ for jql in jqls:
+ count = self.jira_client.count_issues(jql)
+ total_issues += count
+
+ return total_issues
+ except Exception as e:
+ print(f"从 Jira 查询故障数量失败: {e}")
+ return 0
+
+ def _get_monthly_page_id(self, year_month: str) -> Optional[str]:
+ """
+ 获取指定年月的统计页面 ID
+
+ Args:
+ year_month: 年月格式 "YYYY.MM"
+
+ Returns:
+ 页面 ID,未找到返回 None
+ """
+ # 首先检查缓存
+ if year_month in self.MONTH_PAGE_MAPPING:
+ return self.MONTH_PAGE_MAPPING[year_month]
+
+ # 获取父页面的子页面
+ children = self.client.get_child_pages(self.PARENT_PAGE_ID, limit=100)
+ for child in children:
+ title = child.get("title", "")
+ # 匹配标题格式: "2025.06 实船作业统计"
+ if f"{year_month} 实船作业统计" in title:
+ page_id = child.get("id")
+ if page_id:
+ self.MONTH_PAGE_MAPPING[year_month] = str(page_id)
+ return str(page_id)
+
+ return None
+
+ def _parse_vessel_page(self, page_data: Dict) -> Optional[Dict[str, Any]]:
+ """
+ 解析船舶报告页面数据
+
+ Args:
+ page_data: 页面数据字典
+
+ Returns:
+ 解析后的船舶数据字典
+ """
+ try:
+ title = page_data.get("title", "")
+ body = page_data.get("body", {}).get("storage", {}).get("value", "")
+
+ if not body:
+ return None
+
+ # 提取船次号
+ vessel_match = re.search(r"船次.*?]*>(\d+)#", body, re.DOTALL)
+ vessel_number = vessel_match.group(1) if vessel_match else None
+
+ if not vessel_number:
+ # 尝试从标题提取
+ title_match = re.search(r"(\d+)#", title)
+ if title_match:
+ vessel_number = title_match.group(1)
+
+ # 提取船名
+ name_match = re.search(r"船名.*? | ]*>([^<]+)", body, re.DOTALL)
+ vessel_name = name_match.group(1).strip() if name_match else ""
+
+ # 提取作业时间
+ time_match = re.search(r"作业时间.*? | ]*>(.*?) | ", body, re.DOTALL)
+ operation_time = ""
+ if time_match:
+ operation_time = re.sub(r"<[^>]+>", "", time_match.group(1)).strip()
+ operation_time = operation_time.replace(" ", " ")
+
+ # 提取 TEU
+ teu_match = re.search(r"作业箱量 \(TEU\).*?]*>(\d+)", body, re.DOTALL)
+ teu = int(teu_match.group(1)) if teu_match else 0
+
+ # 提取故障次数
+ failure_match = re.search(r"故障次数.*? | ]*>(\d+)", body, re.DOTALL)
+ failures = int(failure_match.group(1)) if failure_match else 0
+
+ if failures == 0 and vessel_number:
+ failures = self._count_issues_from_jira(body, vessel_number)
+
+ # 提取故障率(如有)
+ failure_rate_match = re.search(
+ r"故障率.*? | ]*>([\d.]+)%", body, re.DOTALL
+ )
+ failure_rate = (
+ float(failure_rate_match.group(1)) if failure_rate_match else None
+ )
+
+ # 提取人工介入次数
+ intervention_match = re.search(
+ r"人工介入次数.*? | ]*>(\d+)", body, re.DOTALL
+ )
+ interventions = (
+ int(intervention_match.group(1)) if intervention_match else 0
+ )
+
+ # 提取人工介入率(如有)
+ intervention_rate_match = re.search(
+ r"人工介入率.*? | ]*>([\d.]+)%", body, re.DOTALL
+ )
+ intervention_rate = (
+ float(intervention_rate_match.group(1))
+ if intervention_rate_match
+ else None
+ )
+
+ # 提取上线车辆
+ vehicles_match = re.search(r"上线车辆.*? | ]*>([^<]+)", body, re.DOTALL)
+ vehicles = vehicles_match.group(1).strip() if vehicles_match else ""
+
+ # 提取作业循环
+ moves_match = re.search(r"作业循环.*? | ]*>(\d+)", body, re.DOTALL)
+ moves = int(moves_match.group(1)) if moves_match else 0
+
+ # 提取作业效率
+ efficiency_match = re.search(
+ r"作业净效率.*? | ]*>([\d.]+)", body, re.DOTALL
+ )
+ efficiency = float(efficiency_match.group(1)) if efficiency_match else 0.0
+
+ # 提取作业类型
+ type_match = re.search(r"作业类型.*? | ]*>([^<]+)", body, re.DOTALL)
+ operation_type = type_match.group(1).strip() if type_match else ""
+
+ # 解析作业日期(从标题或作业时间)
+ operation_date = None
+ # 尝试从标题提取日期: "FZ 433#实船报告2026.03.01"
+ date_match = re.search(r"(\d{4})\.(\d{2})\.(\d{2})", title)
+ if date_match:
+ try:
+ operation_date = f"{date_match.group(1)}-{date_match.group(2)}-{date_match.group(3)}"
+ except:
+ pass
+
+ # 计算故障率和人工介入率(如果页面上没有)
+ if failure_rate is None and teu > 0:
+ failure_rate = round((failures / (teu / 2)) * 100, 2)
+ if intervention_rate is None and teu > 0:
+ intervention_rate = round((interventions / (teu / 2)) * 100, 2)
+
+ return {
+ "vessel_number": vessel_number,
+ "vessel_name": vessel_name,
+ "vessel_code": vessel_number, # 兼容现有模板
+ "operation_date": operation_date,
+ "operation_time": operation_time,
+ "teu": teu,
+ "failures": failures,
+ "failure_rate": failure_rate if failure_rate is not None else 0.0,
+ "interventions": interventions,
+ "intervention_rate": intervention_rate
+ if intervention_rate is not None
+ else 0.0,
+ "vehicles": vehicles,
+ "vehicle_count": len(vehicles.split("、")) if vehicles else 0,
+ "moves": moves,
+ "efficiency": efficiency,
+ "operation_type": operation_type,
+ "page_id": page_data.get("id"),
+ "page_title": title,
+ }
+
+ except Exception as e:
+ print(f"解析页面数据失败: {e}")
+ return None
+
+ def get_vessel_reports_by_month(self, year_month: str) -> List[Dict[str, Any]]:
+ """
+ 获取指定月份的所有船舶报告
+
+ Args:
+ year_month: 年月格式 "YYYY.MM"
+
+ Returns:
+ 船舶报告列表
+ """
+ month_page_id = self._get_monthly_page_id(year_month)
+ if not month_page_id:
+ print(f"未找到 {year_month} 的统计页面")
+ return []
+
+ # 获取该月份下的所有船舶报告页面
+ vessel_pages = self.client.get_child_pages(
+ month_page_id, limit=100, expand="body.storage"
+ )
+
+ reports = []
+ for page in vessel_pages:
+ report = self._parse_vessel_page(page)
+ if report:
+ reports.append(report)
+
+ # 按船次号排序
+ reports.sort(
+ key=lambda x: (
+ int(x.get("vessel_number", 0)) if x.get("vessel_number") else 0
+ )
+ )
+
+ return reports
+
+ def get_vessel_report_by_number(
+ self, vessel_number: str
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 根据船次号获取船舶报告
+
+ Args:
+ vessel_number: 船次号 (如 "433")
+
+ Returns:
+ 船舶报告数据,未找到返回 None
+ """
+ # 尝试获取最近几个月的数据
+ from datetime import datetime
+
+ now = datetime.now()
+ for i in range(3): # 最近3个月
+ year_month = (now - timedelta(days=30 * i)).strftime("%Y.%m")
+ reports = self.get_vessel_reports_by_month(year_month)
+ for report in reports:
+ if report.get("vessel_number") == vessel_number:
+ return report
+ return None
+
+ def get_vessel_reports_in_range(
+ self, start_date: str, end_date: str
+ ) -> List[Dict[str, Any]]:
+ """
+ 获取指定日期范围内的所有船舶报告
+
+ Args:
+ start_date: 开始日期 "YYYY-MM-DD"
+ end_date: 结束日期 "YYYY-MM-DD"
+
+ Returns:
+ 船舶报告列表
+ """
+ try:
+ start = datetime.strptime(start_date, "%Y-%m-%d")
+ end = datetime.strptime(end_date, "%Y-%m-%d")
+
+ months_to_query = set()
+ current = start
+ while current <= end:
+ months_to_query.add(current.strftime("%Y.%m"))
+ current += timedelta(days=1)
+
+ all_reports = []
+ for year_month in sorted(months_to_query):
+ reports = self.get_vessel_reports_by_month(year_month)
+ all_reports.extend(reports)
+
+ filtered_reports = []
+ for report in all_reports:
+ report_date = report.get("operation_date")
+ if report_date:
+ try:
+ report_dt = datetime.strptime(report_date, "%Y-%m-%d")
+ if start <= report_dt <= end:
+ filtered_reports.append(report)
+ except:
+ pass
+
+ return filtered_reports
+
+ except ValueError as e:
+ print(f"日期解析错误: {e}")
+ return []
+
+ def get_vessel_reports_by_date(self, date_str: str) -> List[Dict[str, Any]]:
+ """
+ 获取指定日期的船舶报告
+
+ Args:
+ date_str: 日期格式 "YYYY-MM-DD"
+
+ Returns:
+ 该日期的船舶报告列表
+ """
+ return self.get_vessel_reports_in_range(date_str, date_str)
+
+ def get_vessel_page_id(self, vessel_number: str, year_month: str) -> Optional[str]:
+ """
+ 获取指定船次在指定月份的页面 ID
+
+ Args:
+ vessel_number: 船次号
+ year_month: 年月格式 "YYYY.MM"
+
+ Returns:
+ 页面 ID,未找到返回 None
+ """
+ month_page_id = self._get_monthly_page_id(year_month)
+ if not month_page_id:
+ return None
+
+ vessel_pages = self.client.get_child_pages(month_page_id, limit=100)
+ for page in vessel_pages:
+ title = page.get("title", "")
+ # 匹配船次号: "FZ 433#实船报告..."
+ if f" {vessel_number}#" in title or f"{vessel_number}#" in title:
+ return page.get("id")
+
+ return None
diff --git a/daily_report_gui.py b/daily_report_gui.py
deleted file mode 100644
index 71e7b44..0000000
--- a/daily_report_gui.py
+++ /dev/null
@@ -1,372 +0,0 @@
-#!/usr/bin/env python3
-"""
-日报展示和复制工具
-基于tkinter的GUI应用,用于展示日报信息和一键复制
-"""
-
-import tkinter as tk
-from tkinter import ttk, messagebox, scrolledtext
-from datetime import datetime, timedelta
-import sys
-import os
-import threading
-
-# 添加项目根目录到路径
-project_root = os.path.dirname(os.path.abspath(__file__))
-if project_root not in sys.path:
- sys.path.insert(0, project_root)
-
-
-class DailyReportApp:
- """日报展示和复制应用"""
-
- def __init__(self, root):
- self.root = root
- self.root.title("日报管理系统")
- self.root.geometry("1400x900")
- self.root.minsize(1200, 700)
-
- # 设置默认字体大小
- self.default_font_size = 14
- self.title_font_size = 16
-
- # 创建主布局
- self.create_main_layout()
-
- # 初始化数据
- self.current_report_date = None
- self.report_content = ""
- self.is_generating = False
-
- def create_main_layout(self):
- """创建主布局"""
- # 确保能导入项目模块
- project_root = os.path.dirname(os.path.abspath(__file__))
- if project_root not in sys.path:
- sys.path.insert(0, project_root)
-
- # 主容器
- main_container = tk.Frame(self.root, padx=10, pady=10)
- main_container.pack(fill="both", expand=True)
-
- # 配置权重
- main_container.columnconfigure(1, weight=3) # 中间区域
- main_container.columnconfigure(2, weight=1) # 右侧区域
- main_container.rowconfigure(1, weight=1)
-
- # 1. 顶部提示区域
- self.create_notice_area(main_container)
-
- # 2. 左侧日期选择区域
- self.create_left_panel(main_container)
-
- # 3. 中间日报展示区域
- self.create_center_panel(main_container)
-
- # 4. 右侧复制按钮区域
- self.create_right_panel(main_container)
-
- def create_notice_area(self, parent):
- """创建提示区域"""
- notice_frame = tk.Frame(parent, bd=2, relief="solid", bg="#FFF3E0")
- notice_frame.grid(row=0, column=0, columnspan=3, sticky="ew", pady=(0, 10))
-
- # 静态提示标签
- self.notice_label = tk.Label(
- notice_frame,
- text="重要提示:本程序需在8:00过后运行,确保最后一条船的指令结束时间超过8点,以保证日报数据完整性!",
- fg="#FF6B6B",
- bg="#FFF3E0",
- font=("", self.title_font_size, "bold"),
- padx=10,
- pady=10,
- )
- self.notice_label.pack(fill="x", expand=True)
-
- def create_left_panel(self, parent):
- """创建左侧控制面板"""
- left_frame = tk.LabelFrame(
- parent,
- text="日期选择",
- padx=10,
- pady=10,
- font=("", self.title_font_size, "bold"),
- )
- left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, 10))
-
- # 昨日汇总按钮
- self.yesterday_btn = tk.Button(
- left_frame,
- text="昨日汇总",
- command=self.select_yesterday,
- font=("", self.default_font_size),
- padx=10,
- pady=5,
- bg="#4CAF50",
- fg="white",
- )
- self.yesterday_btn.pack(fill="x", pady=(0, 10))
-
- # 日期选择器
- tk.Label(left_frame, text="选择日期:", font=("", self.default_font_size)).pack(
- anchor="w", pady=(10, 5)
- )
-
- # 日期输入框
- self.date_var = tk.StringVar()
- self.date_entry = tk.Entry(
- left_frame, textvariable=self.date_var, font=("", self.default_font_size)
- )
- self.date_entry.pack(fill="x", pady=(0, 5))
-
- # 日期提示
- tk.Label(
- left_frame,
- text="格式:YYYY-MM-DD",
- fg="gray",
- font=("", self.default_font_size - 2),
- ).pack(anchor="w")
-
- # 生成日报按钮
- self.generate_btn = tk.Button(
- left_frame,
- text="生成日报",
- command=self.generate_report_async,
- font=("", self.default_font_size),
- padx=10,
- pady=5,
- bg="#2196F3",
- fg="white",
- )
- self.generate_btn.pack(fill="x", pady=(20, 0))
-
- # 当前选中日期标签
- self.selected_date_label = tk.Label(
- left_frame,
- text="当前选中:无",
- fg="#2196F3",
- font=("", self.default_font_size, "bold"),
- )
- self.selected_date_label.pack(anchor="w", pady=(20, 0))
-
- def create_center_panel(self, parent):
- """创建中间日报展示区域"""
- center_frame = tk.LabelFrame(
- parent,
- text="日报内容",
- padx=10,
- pady=10,
- font=("", self.title_font_size, "bold"),
- )
- center_frame.grid(row=1, column=1, sticky="nsew", padx=(0, 10))
-
- # 日报信息文本框
- self.report_text = scrolledtext.ScrolledText(
- center_frame,
- wrap=tk.WORD,
- padx=10,
- pady=10,
- height=30,
- font=("", self.default_font_size),
- )
- self.report_text.pack(fill="both", expand=True)
-
- # 默认提示文本
- self.report_text.insert(
- tk.END, '请点击左侧"昨日汇总"按钮,或选择日期后点击"生成日报"按钮...'
- )
- self.report_text.config(state=tk.DISABLED)
-
- def create_right_panel(self, parent):
- """创建右侧控制面板"""
- right_frame = tk.LabelFrame(
- parent,
- text="操作",
- padx=10,
- pady=10,
- font=("", self.title_font_size, "bold"),
- )
- right_frame.grid(row=1, column=2, sticky="nsew")
-
- # 复制按钮
- self.copy_btn = tk.Button(
- right_frame,
- text="复制日报",
- command=self.copy_report,
- font=("", self.default_font_size),
- padx=10,
- pady=5,
- bg="#4CAF50",
- fg="white",
- )
- self.copy_btn.pack(fill="x", pady=(0, 20))
-
- # 复制状态标签
- self.copy_status = tk.Label(
- right_frame, text="", font=("", self.default_font_size)
- )
- self.copy_status.pack(fill="x")
-
- # 分隔线
- tk.Frame(right_frame, height=2, bg="gray").pack(fill="x", pady=20)
-
- # 清空按钮
- self.clear_btn = tk.Button(
- right_frame,
- text="清空",
- command=self.clear_report,
- font=("", self.default_font_size),
- padx=10,
- pady=5,
- )
- self.clear_btn.pack(fill="x", pady=(0, 10))
-
- # 退出按钮
- self.exit_btn = tk.Button(
- right_frame,
- text="退出",
- command=self.root.quit,
- font=("", self.default_font_size),
- padx=10,
- pady=5,
- )
- self.exit_btn.pack(fill="x")
-
- def select_yesterday(self):
- """选择昨天"""
- yesterday = datetime.now() - timedelta(days=1)
- self.date_var.set(yesterday.strftime("%Y-%m-%d"))
- self.selected_date_label.config(
- text=f"当前选中:昨天 ({yesterday.strftime('%Y-%m-%d')})"
- )
- self.generate_report_async()
-
- def generate_report_async(self):
- """异步生成日报(避免界面卡死)"""
- if self.is_generating:
- return
-
- self.is_generating = True
- self.generate_btn.config(state=tk.DISABLED, text="生成中...")
-
- # 在后台线程生成日报
- thread = threading.Thread(target=self._generate_report_thread)
- thread.daemon = True
- thread.start()
-
- def _generate_report_thread(self):
- """在后台线程中生成日报"""
- try:
- date_str = self.date_var.get().strip()
-
- if not date_str:
- self.root.after(0, lambda: self._on_generate_error("请先选择日期"))
- return
-
- try:
- report_date = datetime.strptime(date_str, "%Y-%m-%d")
- except ValueError:
- self.root.after(
- 0,
- lambda: self._on_generate_error(
- "日期格式错误!请使用 YYYY-MM-DD 格式"
- ),
- )
- return
-
- # 更新UI
- self.root.after(
- 0,
- lambda: self.selected_date_label.config(
- text=f"当前选中:{report_date.strftime('%Y-%m-%d')}"
- ),
- )
-
- # 生成日报
- try:
- from report_generator import DailyReportGenerator
-
- generator = DailyReportGenerator()
- report = generator.generate_daily_report(report_date)
- self.root.after(0, lambda: self._on_generate_success(report))
- except Exception as e:
- error_msg = f"生成日报时出错:{str(e)}\n\n请确保:\n1. 环境变量配置正确\n2. Metabase 和飞书服务可访问\n3. 在8:00过后运行程序"
- self.root.after(0, lambda: self._on_generate_error(error_msg))
-
- except Exception as e:
- self.root.after(0, lambda: self._on_generate_error(str(e)))
-
- def _on_generate_success(self, report):
- """生成成功回调"""
- self.report_content = report
- self.report_text.config(state=tk.NORMAL)
- self.report_text.delete(1.0, tk.END)
- self.report_text.insert(tk.END, report)
- self.report_text.config(state=tk.DISABLED)
- self._reset_generate_button()
-
- def _on_generate_error(self, error_msg):
- """生成失败回调"""
- self.report_content = error_msg
- self.report_text.config(state=tk.NORMAL)
- self.report_text.delete(1.0, tk.END)
- self.report_text.insert(tk.END, error_msg)
- self.report_text.config(state=tk.DISABLED)
- self._reset_generate_button()
- messagebox.showerror("错误", error_msg)
-
- def _reset_generate_button(self):
- """重置生成按钮状态"""
- self.is_generating = False
- self.generate_btn.config(state=tk.NORMAL, text="生成日报")
-
- def copy_report(self):
- """复制日报到剪贴板"""
- if (
- not self.report_content
- or self.report_content
- == '请点击左侧"昨日汇总"按钮,或选择日期后点击"生成日报"按钮...'
- ):
- messagebox.showwarning("提示", "请先生成日报!")
- return
-
- try:
- # 复制到剪贴板
- self.root.clipboard_clear()
- self.root.clipboard_append(self.report_content)
-
- # 显示成功提示
- self.copy_status.config(text="已复制到剪贴板!", fg="#4CAF50")
- self.root.after(3000, lambda: self.copy_status.config(text=""))
-
- except Exception as e:
- messagebox.showerror("错误", f"复制失败:{str(e)}")
-
- def clear_report(self):
- """清空日报内容"""
- self.report_content = ""
- self.report_text.config(state=tk.NORMAL)
- self.report_text.delete(1.0, tk.END)
- self.report_text.insert(
- tk.END, '请点击左侧"昨日汇总"按钮,或选择日期后点击"生成日报"按钮...'
- )
- self.report_text.config(state=tk.DISABLED)
- self.date_var.set("")
- self.selected_date_label.config(text="当前选中:无")
- self.copy_status.config(text="")
-
-
-def main():
- """主函数"""
- # 创建主窗口
- root = tk.Tk()
-
- # 创建应用
- app = DailyReportApp(root)
-
- # 运行主循环
- root.mainloop()
-
-
-if __name__ == "__main__":
- main()
diff --git a/jira/__init__.py b/jira/__init__.py
new file mode 100644
index 0000000..050b3a6
--- /dev/null
+++ b/jira/__init__.py
@@ -0,0 +1,95 @@
+"""
+Jira API 客户端
+
+用于查询 Jira issue 数量。
+"""
+
+import requests
+import json
+from typing import Optional, Dict, List, Any
+import os
+
+
+class JiraClient:
+ """Jira API 客户端"""
+
+ def __init__(self, base_url: str, username: str, token: str):
+ """
+ 初始化 Jira 客户端
+
+ Args:
+ base_url: Jira 基础 URL (如 https://jira.example.com)
+ username: Jira 用户名
+ token: Jira API Token
+ """
+ self.base_url = base_url.rstrip("/")
+ self.username = username
+ self.token = token
+ self.auth = (username, token)
+
+ def search_issues(self, jql: str, max_results: int = 100) -> List[Dict[str, Any]]:
+ """
+ 搜索 Jira issues
+
+ Args:
+ jql: JQL 查询语句
+ max_results: 最大返回数量
+
+ Returns:
+ Issue 列表
+ """
+ url = f"{self.base_url}/rest/api/2/search"
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ params = {"jql": jql, "maxResults": max_results, "fields": "key"}
+
+ try:
+ response = requests.get(
+ url, headers=headers, auth=self.auth, params=params, timeout=30
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data.get("issues", [])
+ except requests.exceptions.RequestException as e:
+ print(f"Jira 查询失败: {e}")
+ return []
+
+ def count_issues(self, jql: str) -> int:
+ """
+ 统计满足条件的 issue 数量
+
+ Args:
+ jql: JQL 查询语句
+
+ Returns:
+ Issue 数量
+ """
+ url = f"{self.base_url}/rest/api/2/search"
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
+ params = {
+ "jql": jql,
+ "maxResults": 0, # 不返回实际数据,只返回总数
+ }
+
+ try:
+ response = requests.get(
+ url, headers=headers, auth=self.auth, params=params, timeout=30
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data.get("total", 0)
+ except requests.exceptions.RequestException as e:
+ print(f"Jira 查询失败: {e}")
+ return 0
+
+ def get_issue_keys(self, jql: str) -> List[str]:
+ """
+ 获取满足条件的 issue keys
+
+ Args:
+ jql: JQL 查询语句
+
+ Returns:
+ Issue key 列表 (如 ["FZ-2042", "FZ-2043"])
+ """
+ issues = self.search_issues(jql)
+ return [issue.get("key") for issue in issues if issue.get("key")]
diff --git a/jira_client.py b/jira_client.py
new file mode 100644
index 0000000..5be9981
--- /dev/null
+++ b/jira_client.py
@@ -0,0 +1,109 @@
+"""
+Jira API 客户端
+
+用于查询 Jira issue 数量。
+"""
+
+import requests
+import json
+from typing import Optional, Dict, List, Any
+import os
+
+
+class JiraClient:
+ """Jira API 客户端"""
+
+ def __init__(self, base_url: str, username: str, token: str):
+ """
+ 初始化 Jira 客户端
+
+ Args:
+ base_url: Jira 基础 URL (如 https://jira.example.com)
+ username: Jira 用户名
+ token: Jira API Token
+ """
+ self.base_url = base_url.rstrip("/")
+ self.username = username
+ self.token = token
+ # 尝试 Bearer token 认证方式
+ self.headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "Authorization": f"Bearer {token}",
+ }
+ # 同时保留基本认证作为备选
+ self.auth = (username, token)
+
+ def search_issues(self, jql: str, max_results: int = 100) -> List[Dict[str, Any]]:
+ """
+ 搜索 Jira issues
+
+ Args:
+ jql: JQL 查询语句
+ max_results: 最大返回数量
+
+ Returns:
+ Issue 列表
+ """
+ url = f"{self.base_url}/rest/api/2/search"
+ headers = self.headers.copy()
+ params = {"jql": jql, "maxResults": max_results, "fields": "key"}
+
+ try:
+ # 先尝试 Bearer token 认证
+ response = requests.get(url, headers=headers, params=params, timeout=30)
+ # 如果失败,尝试基本认证
+ if response.status_code == 401:
+ headers.pop("Authorization", None)
+ response = requests.get(
+ url, headers=headers, auth=self.auth, params=params, timeout=30
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data.get("issues", [])
+ except requests.exceptions.RequestException as e:
+ print(f"Jira 查询失败: {e}")
+ return []
+
+ def count_issues(self, jql: str) -> int:
+ """
+ 统计满足条件的 issue 数量
+
+ Args:
+ jql: JQL 查询语句
+
+ Returns:
+ Issue 数量
+ """
+ url = f"{self.base_url}/rest/api/2/search"
+ headers = self.headers.copy()
+ params = {"jql": jql, "maxResults": 0}
+
+ try:
+ # 先尝试 Bearer token 认证
+ response = requests.get(url, headers=headers, params=params, timeout=30)
+ # 如果失败,尝试基本认证
+ if response.status_code == 401:
+ headers.pop("Authorization", None)
+ response = requests.get(
+ url, headers=headers, auth=self.auth, params=params, timeout=30
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data.get("total", 0)
+ except requests.exceptions.RequestException as e:
+ print(f"Jira 查询失败: {e}")
+ return 0
+
+ def get_issue_keys(self, jql: str) -> List[str]:
+ """
+ 获取满足条件的 issue keys
+
+ Args:
+ jql: JQL 查询语句
+
+ Returns:
+ Issue key 列表 (如 ["FZ-2042", "FZ-2043"])
+ """
+ issues = self.search_issues(jql)
+ return [str(issue.get("key")) for issue in issues if issue.get("key")]
diff --git a/metabase/time_operations.py b/metabase/time_operations.py
index 4806e5e..2923bc5 100644
--- a/metabase/time_operations.py
+++ b/metabase/time_operations.py
@@ -339,6 +339,77 @@ class TimeOperationsClient:
"teu": overview.get("teu"),
}
+ def get_efficiency_by_time(self, start_time: str, end_time: str) -> Optional[float]:
+ """
+ 获取指定时间段内的效率(剔除异常)
+
+ Args:
+ start_time: 开始时间,格式 "YYYY-MM-DD HH:MM:SS"
+ end_time: 结束时间,格式 "YYYY-MM-DD HH:MM:SS"
+
+ Returns:
+ 效率值(cycle/h),如果没有数据则返回 None
+ """
+ time_params = self._build_time_parameters(start_time, end_time)
+
+ # 查询剔除异常后的效率指标(Card 69)
+ efficiency_response = self._query_card(
+ self._CARD_IDS["efficiency_filtered"], time_params
+ )
+ efficiency_data = self._extract_row_data(efficiency_response) or {}
+ cycle_h_filtered = efficiency_data.get("cycle/h")
+
+ if cycle_h_filtered is not None:
+ return round(cycle_h_filtered, 2)
+ return None
+
+ def get_vessel_efficiency_by_time(
+ self, vessel_visit_id: str, start_time: str, end_time: str
+ ) -> Optional[float]:
+ """
+ 获取指定船舶在指定时间段内的效率(剔除异常)
+
+ Args:
+ vessel_visit_id: 船舶访问ID,格式如 "260313-信荣海"
+ start_time: 开始时间,格式 "YYYY-MM-DD HH:MM:SS"
+ end_time: 结束时间,格式 "YYYY-MM-DD HH:MM:SS"
+
+ Returns:
+ 效率值(cycle/h),如果没有数据则返回 None
+
+ Example:
+ >>> client = TimeOperationsClient()
+ >>> efficiency = client.get_vessel_efficiency_by_time(
+ ... "260313-信荣海",
+ ... "2026-03-13 08:00:00",
+ ... "2026-03-13 20:00:00"
+ ... )
+ >>> print(f"效率: {efficiency}") # 1.66
+ """
+ # 构建时间参数
+ time_params = self._build_time_parameters(start_time, end_time)
+
+ # 添加船舶参数(使用驼峰命名)
+ vessel_param = {
+ "type": "string/=",
+ "target": ["variable", ["template-tag", "vesselVisitId"]],
+ "value": vessel_visit_id,
+ }
+
+ # 合并参数
+ params = time_params + [vessel_param]
+
+ # 查询剔除异常后的效率指标(Card 69)
+ efficiency_response = self._query_card(
+ self._CARD_IDS["efficiency_filtered"], params
+ )
+ efficiency_data = self._extract_row_data(efficiency_response) or {}
+ cycle_h_filtered = efficiency_data.get("cycle/h")
+
+ if cycle_h_filtered is not None:
+ return round(cycle_h_filtered, 2)
+ return None
+
# 工厂函数(推荐用于简单场景)
def create_time_operations_client(
diff --git a/shift_report.py b/shift_report.py
index f0afe65..0e82a7d 100644
--- a/shift_report.py
+++ b/shift_report.py
@@ -24,6 +24,10 @@ if project_root not in sys.path:
sys.path.insert(0, project_root)
from feishu.manager import FeishuScheduleManager
+from confluence import VesselReportManager
+from jira_client import JiraClient
+from metabase.vessel_operations import VesselOperationsClient
+from metabase.time_operations import TimeOperationsClient
class ShiftReportGenerator:
@@ -45,6 +49,22 @@ class ShiftReportGenerator:
def __init__(self):
self.feishu_manager = FeishuScheduleManager()
+ self.vessel_ops_client = VesselOperationsClient()
+ self.time_ops_client = TimeOperationsClient()
+ try:
+ self.confluence_manager = VesselReportManager()
+ self.confluence_enabled = True
+
+ jira_url = os.getenv("JIRA_URL")
+ jira_username = os.getenv("JIRA_USERNAME")
+ jira_token = os.getenv("JIRA_TOKEN")
+ if jira_url and jira_username and jira_token:
+ jira_client = JiraClient(jira_url, jira_username, jira_token)
+ self.confluence_manager.set_jira_client(jira_client)
+ print("Jira 客户端已启用")
+ except Exception as e:
+ print(f"Confluence 数据源初始化失败: {e}")
+ self.confluence_enabled = False
def get_shift_time_range(self, report_date: datetime, shift_type: str) -> tuple:
"""
@@ -103,7 +123,7 @@ class ShiftReportGenerator:
return schedule.get("night_shift", "")
def get_vessels_in_time_range(
- self, start_time: datetime, end_time: datetime
+ self, start_time: datetime, end_time: datetime, shift_type: str
) -> List[Dict[str, Any]]:
"""
获取指定时间范围内作业的所有船舶列表
@@ -161,6 +181,29 @@ ORDER BY vesselVisitID
col_idx = {col["name"]: i for i, col in enumerate(cols)}
+ confluence_data = {}
+ confluence_data_by_name = {}
+ confluence_reports_list = []
+ if self.confluence_enabled:
+ try:
+ query_start = (start_time - timedelta(days=1)).strftime(
+ "%Y-%m-%d"
+ )
+ query_end = end_time.strftime("%Y-%m-%d")
+ reports = self.confluence_manager.get_vessel_reports_in_range(
+ query_start, query_end
+ )
+ for report in reports:
+ vessel_num = report.get("vessel_number")
+ if vessel_num:
+ confluence_data[vessel_num] = report
+ vessel_name = report.get("vessel_name")
+ if vessel_name:
+ confluence_data_by_name[vessel_name] = report
+ confluence_reports_list.append(report)
+ except Exception as e:
+ print(f"从 Confluence 获取数据失败: {e}")
+
vessels = []
for row in rows:
vessel_id = row[col_idx.get("vesselVisitID", 0)]
@@ -169,26 +212,163 @@ ORDER BY vesselVisitID
teu = row[col_idx.get("teu", 5)] or 0
vehicles = row[col_idx.get("vehicles", 6)] or 0
+ # 获取船的作业开始和结束时间(用于判断是否跨班)
+ vessel_start_str = row[col_idx.get("start_time", 1)]
+ vessel_end_str = row[col_idx.get("end_time", 2)]
+ vessel_start = None
+ vessel_end = None
+ if vessel_start_str:
+ try:
+ vessel_start = datetime.strptime(
+ str(vessel_start_str).split(".")[0], "%Y-%m-%d %H:%M:%S"
+ )
+ except:
+ pass
+ if vessel_end_str:
+ try:
+ vessel_end = datetime.strptime(
+ str(vessel_end_str).split(".")[0], "%Y-%m-%d %H:%M:%S"
+ )
+ except:
+ pass
+
vessel_name = self._extract_vessel_name(vessel_id)
vessel_code = self._extract_vessel_code(vessel_id)
+ failures = "--"
+ failure_rate = "--"
+ interventions = "--"
+ intervention_rate = "--"
+ efficiency = "--"
+ vessel_number_display = vessel_code
+ report = None
+
+ if vessel_name and vessel_name in confluence_data_by_name:
+ report = confluence_data_by_name[vessel_name]
+ elif vessel_start and vessel_end:
+ report = self._find_vessel_report_by_time(
+ vessel_start, vessel_end, confluence_reports_list
+ )
+
+ if report:
+ failures = str(report.get("failures", 0))
+ interventions = str(report.get("interventions", 0))
+
+ vessel_num = report.get("vessel_number")
+ vessel_number_display = vessel_num if vessel_num else "--"
+
+ conf_vessel_name = report.get("vessel_name")
+ if conf_vessel_name:
+ vessel_name = conf_vessel_name
+
+ confluence_vehicles = report.get("vehicles", "")
+ if confluence_vehicles:
+ vehicles = len(confluence_vehicles.split("、"))
+
+ # 使用 Metabase 的 TEU 重新计算故障率和介入率
+ failures_int = report.get("failures", 0)
+ interventions_int = report.get("interventions", 0)
+ if teu > 0:
+ failure_rate = f"{(failures_int / (teu / 2) * 100):.2f}"
+ intervention_rate = (
+ f"{(interventions_int / (teu / 2) * 100):.2f}"
+ )
+ else:
+ failure_rate = f"{report.get('failure_rate', 0):.2f}"
+ intervention_rate = (
+ f"{report.get('intervention_rate', 0):.2f}"
+ )
+
+ # 从 Metabase 获取效率数据(根据是否跨班决定查询方式)
+ try:
+ # 判断船舶是否跨班
+ is_cross_shift = False
+ if vessel_start and vessel_end:
+ # 白班:08:00-20:00
+ # 夜班:20:00-次日08:00
+ if shift_type == "day":
+ # 白班:船在08:00前开始,或20:00后结束,都算跨班
+ shift_start = start_time.replace(
+ hour=8, minute=0, second=0
+ )
+ shift_end = start_time.replace(
+ hour=20, minute=0, second=0
+ )
+ if vessel_start < shift_start or vessel_end > shift_end:
+ is_cross_shift = True
+ else:
+ # 夜班:船在20:00前开始,或次日08:00后结束,都算跨班
+ shift_start = start_time.replace(
+ hour=20, minute=0, second=0
+ )
+ shift_end = (start_time + timedelta(days=1)).replace(
+ hour=8, minute=0, second=0
+ )
+ if vessel_start < shift_start or vessel_end > shift_end:
+ is_cross_shift = True
+
+ if is_cross_shift and vessel_start and vessel_end:
+ # 跨班:使用 Card 69 按班次时间查询效率
+ try:
+ if shift_type == "day":
+ query_start = max(
+ vessel_start,
+ start_time.replace(hour=8, minute=0, second=0),
+ )
+ query_end = start_time.replace(
+ hour=20, minute=0, second=0
+ )
+ else:
+ query_start = start_time.replace(
+ hour=20, minute=0, second=0
+ )
+ query_end = min(
+ vessel_end,
+ (start_time + timedelta(days=1)).replace(
+ hour=8, minute=0, second=0
+ ),
+ )
+
+ cycle_h = self.time_ops_client.get_efficiency_by_time(
+ query_start.strftime("%Y-%m-%d %H:%M:%S"),
+ query_end.strftime("%Y-%m-%d %H:%M:%S"),
+ )
+ if cycle_h is not None and cycle_h > 0:
+ efficiency = f"{cycle_h:.2f}"
+ except Exception as e:
+ print(f"获取班次效率失败: {e}")
+
+ # 如果不跨班或跨班查询失败,使用整船效率
+ if efficiency == "--":
+ vessel_ops_data = (
+ self.vessel_ops_client.get_vessel_operations(vessel_id)
+ )
+ cycle_h = vessel_ops_data.get("cycle_h_filtered")
+ if cycle_h is not None and cycle_h > 0:
+ efficiency = f"{cycle_h:.2f}"
+ except Exception as e:
+ print(f"获取船舶 {vessel_id} 效率失败: {e}")
+
vessels.append(
{
- "vessel_code": vessel_code,
+ "vessel_code": vessel_number_display,
"vessel_name": vessel_name,
"vehicles": vehicles,
"teu_20ft": cnt20,
"teu_40ft": cnt40,
"total_teu": teu,
- "efficiency": self._calculate_efficiency(teu, vehicles),
- # 故障和人工介入暂无数据接口,显示占位符
- "failures": "--",
- "failure_rate": "--",
- "interventions": "--",
- "intervention_rate": "--",
+ "efficiency": efficiency,
+ "failures": failures,
+ "failure_rate": failure_rate,
+ "interventions": interventions,
+ "intervention_rate": intervention_rate,
+ "vessel_start": vessel_start,
+ "vessel_end": vessel_end,
}
)
+ vessels = self._merge_vessels_by_name(vessels)
+
return vessels
except Exception as e:
@@ -221,6 +401,140 @@ ORDER BY vesselVisitID
return vessel_visit_id
+ def _parse_confluence_operation_time(
+ self, operation_time: str
+ ) -> tuple[Optional[datetime], Optional[datetime]]:
+ """
+ 解析 Confluence 报告中的作业时间字符串
+
+ Args:
+ operation_time: 作业时间字符串,如 "2026.03.13 08:00~2026.03.13 20:00"
+
+ Returns:
+ (start_time, end_time) 元组,解析失败返回 (None, None)
+ """
+ if not operation_time:
+ return None, None
+
+ try:
+ # 格式: "2026.03.13 08:00~2026.03.13 20:00"
+ if "~" in operation_time:
+ parts = operation_time.split("~")
+ if len(parts) == 2:
+ start_str = parts[0].strip()
+ end_str = parts[1].strip()
+
+ # 解析开始时间
+ start_dt = datetime.strptime(start_str, "%Y.%m.%d %H:%M")
+ # 解析结束时间
+ end_dt = datetime.strptime(end_str, "%Y.%m.%d %H:%M")
+
+ return start_dt, end_dt
+ except Exception as e:
+ print(f"解析作业时间失败 '{operation_time}': {e}")
+
+ return None, None
+
+ def _find_vessel_report_by_time(
+ self,
+ vessel_start: Optional[datetime],
+ vessel_end: Optional[datetime],
+ confluence_reports: List[Dict[str, Any]],
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 根据作业时间范围查找匹配的 Confluence 报告
+
+ Args:
+ vessel_start: Metabase 中船舶作业开始时间
+ vessel_end: Metabase 中船舶作业结束时间
+ confluence_reports: Confluence 报告列表
+
+ Returns:
+ 匹配的报告,未找到返回 None
+ """
+ if not vessel_start or not vessel_end:
+ return None
+
+ for report in confluence_reports:
+ operation_time = report.get("operation_time", "")
+ conf_start, conf_end = self._parse_confluence_operation_time(operation_time)
+
+ if conf_start and conf_end:
+ # 检查时间是否有重叠
+ # 重叠条件: 一个区间的开始小于另一个区间的结束,且一个区间的结束大于另一个区间的开始
+ if vessel_start < conf_end and vessel_end > conf_start:
+ return report
+
+ return None
+
+ def _merge_vessels_by_name(
+ self, vessels: List[Dict[str, Any]]
+ ) -> List[Dict[str, Any]]:
+ merged = {}
+
+ for vessel in vessels:
+ name = vessel.get("vessel_name", "")
+ if not name or name == "N/A":
+ continue
+ if name not in merged:
+ merged[name] = vessel.copy()
+ else:
+ existing = merged[name]
+ existing["teu_20ft"] = existing.get("teu_20ft", 0) + vessel.get(
+ "teu_20ft", 0
+ )
+ existing["teu_40ft"] = existing.get("teu_40ft", 0) + vessel.get(
+ "teu_40ft", 0
+ )
+ existing["total_teu"] = existing.get("total_teu", 0) + vessel.get(
+ "total_teu", 0
+ )
+ existing["vehicles"] = max(
+ existing.get("vehicles", 0), vessel.get("vehicles", 0)
+ )
+ if vessel.get("vessel_code") and vessel["vessel_code"] != "--":
+ existing["vessel_code"] = vessel["vessel_code"]
+ if vessel.get("failures") and vessel["failures"] != "--":
+ existing["failures"] = vessel["failures"]
+ existing["failure_rate"] = vessel.get("failure_rate", "--")
+ if vessel.get("interventions") and vessel["interventions"] != "--":
+ existing["interventions"] = vessel["interventions"]
+ existing["intervention_rate"] = vessel.get(
+ "intervention_rate", "--"
+ )
+
+ # Assign N/A vessels to existing vessels by time overlap
+ for vessel in vessels:
+ name = vessel.get("vessel_name", "")
+ if name != "N/A":
+ continue
+
+ vessel_start = vessel.get("vessel_start")
+ vessel_end = vessel.get("vessel_end")
+
+ if vessel_start and vessel_end:
+ for target_name, target_vessel in merged.items():
+ target_start = target_vessel.get("vessel_start")
+ target_end = target_vessel.get("vessel_end")
+ if target_start and target_end:
+ if vessel_start < target_end and vessel_end > target_start:
+ target_vessel["teu_20ft"] = target_vessel.get(
+ "teu_20ft", 0
+ ) + vessel.get("teu_20ft", 0)
+ target_vessel["teu_40ft"] = target_vessel.get(
+ "teu_40ft", 0
+ ) + vessel.get("teu_40ft", 0)
+ target_vessel["total_teu"] = target_vessel.get(
+ "total_teu", 0
+ ) + vessel.get("total_teu", 0)
+ target_vessel["vehicles"] = max(
+ target_vessel.get("vehicles", 0),
+ vessel.get("vehicles", 0),
+ )
+ break
+
+ return list(merged.values())
+
def _calculate_efficiency(self, teu: int, vehicles: int) -> str:
"""
计算效率(简化版本,后续可接入真实效率数据)
@@ -277,6 +591,44 @@ ORDER BY vesselVisitID
return response.json()
+ def _get_vessel_efficiency_in_range(
+ self, vessel_visit_id: str, start_time: datetime, end_time: datetime
+ ) -> Optional[float]:
+ """计算船舶在指定时间范围内的效率(剔除异常)"""
+ start_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
+ end_str = end_time.strftime("%Y-%m-%d %H:%M:%S")
+
+ # 查询该船在指定时间范围内的循环数和车辆数
+ query = f"""
+SELECT
+ COUNT(*) as cycles,
+ COUNT(DISTINCT vehicleId) as vehicles
+FROM cnt_cycles cc
+WHERE cc.vesselVisitID = '{vessel_visit_id}'
+ AND cc._time_end >= DATE_SUB('{start_str}', INTERVAL 8 HOUR)
+ AND cc._time_end <= DATE_SUB('{end_str}', INTERVAL 8 HOUR)
+ AND cc.movementType IN ('Load', 'Discharge', 'YardMove')
+ """
+
+ result = self._query_metabase_native(query)
+
+ if result and "data" in result:
+ rows = result["data"].get("rows", [])
+ cols = result["data"].get("cols", [])
+ if rows:
+ col_idx = {col["name"]: i for i, col in enumerate(cols)}
+ cycles = rows[0][col_idx.get("cycles", 0)] or 0
+ vehicles = rows[0][col_idx.get("vehicles", 1)] or 1
+
+ # 计算时间差(小时)
+ hours = (end_time - start_time).total_seconds() / 3600
+
+ if hours > 0 and vehicles > 0:
+ efficiency = cycles / vehicles / hours
+ return round(efficiency, 2)
+
+ return None
+
def read_template(self, template_path: str) -> str:
"""读取模板文件"""
try:
@@ -353,7 +705,7 @@ ORDER BY vesselVisitID
# 3. 获取船舶作业数据
print("获取船舶作业数据...")
- vessels = self.get_vessels_in_time_range(start_time, end_time)
+ vessels = self.get_vessels_in_time_range(start_time, end_time, shift_type)
# 4. 读取并渲染模板
template_path = os.path.join(
diff --git a/test_confluence.py b/test_confluence.py
new file mode 100644
index 0000000..ece78d6
--- /dev/null
+++ b/test_confluence.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""
+测试 Confluence 数据提取功能
+"""
+
+import sys
+import os
+
+# 添加项目根目录到路径
+project_root = os.path.dirname(os.path.abspath(__file__))
+if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+
+from confluence import VesselReportManager
+
+def test_confluence_connection():
+ """测试 Confluence 连接"""
+ print("=" * 60)
+ print("测试 Confluence 连接")
+ print("=" * 60)
+
+ try:
+ # 设置环境变量(用于测试)
+ os.environ.setdefault("CONFLUENCE_URL", "https://confluence.westwell-lab.com")
+ # 注意:实际运行时需要设置 CONFLUENCE_TOKEN
+
+ manager = VesselReportManager()
+ print("✓ Confluence 管理器初始化成功")
+ return manager
+ except Exception as e:
+ print(f"✗ Confluence 连接失败: {e}")
+ return None
+
+def test_get_monthly_reports(manager):
+ """测试获取月度报告"""
+ print("\n" + "=" * 60)
+ print("测试获取月度报告 (2026.03)")
+ print("=" * 60)
+
+ try:
+ reports = manager.get_vessel_reports_by_month("2026.03")
+ print(f"✓ 成功获取 {len(reports)} 个船舶报告")
+
+ # 显示前5个报告
+ for i, report in enumerate(reports[:5]):
+ print(f"\n报告 {i+1}:")
+ print(f" 船次: {report.get('vessel_number')}#")
+ print(f" 船名: {report.get('vessel_name')}")
+ print(f" 日期: {report.get('operation_date')}")
+ print(f" TEU: {report.get('teu')}")
+ print(f" 故障次数: {report.get('failures')}")
+ print(f" 故障率: {report.get('failure_rate')}%")
+ print(f" 人工介入次数: {report.get('interventions')}")
+ print(f" 人工介入率: {report.get('intervention_rate')}%")
+ print(f" 效率: {report.get('efficiency')}")
+
+ return True
+ except Exception as e:
+ print(f"✗ 获取月度报告失败: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+def test_get_daily_reports(manager):
+ """测试获取指定日期报告"""
+ print("\n" + "=" * 60)
+ print("测试获取指定日期报告 (2026-03-05)")
+ print("=" * 60)
+
+ try:
+ reports = manager.get_vessel_reports_by_date("2026-03-05")
+ print(f"✓ 成功获取 {len(reports)} 个船舶报告")
+
+ for report in reports:
+ print(f"\n {report.get('vessel_number')}# {report.get('vessel_name')}")
+ print(f" TEU: {report.get('teu')}, "
+ f"故障: {report.get('failures')}次 ({report.get('failure_rate')}%), "
+ f"介入: {report.get('interventions')}次 ({report.get('intervention_rate')}%)")
+
+ return True
+ except Exception as e:
+ print(f"✗ 获取每日报告失败: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+def main():
+ """主函数"""
+ print("Confluence 数据提取测试")
+ print("=" * 60)
+
+ # 检查环境变量
+ if not os.getenv("CONFLUENCE_TOKEN"):
+ print("\n⚠ 警告: 未设置 CONFLUENCE_TOKEN 环境变量")
+ print("请设置环境变量后再运行测试:")
+ print(" export CONFLUENCE_TOKEN='your_token_here'")
+ print("\n使用内置 token 进行测试...")
+ # 使用用户提供的 token
+ os.environ["CONFLUENCE_TOKEN"] = "ODE4NjI1Nzk4NTIzOmqzD4f8ifNmo2PcaMluS23djMzu"
+
+ # 测试连接
+ manager = test_confluence_connection()
+ if not manager:
+ print("\n✗ 测试失败: 无法连接到 Confluence")
+ return 1
+
+ # 测试获取月度报告
+ if not test_get_monthly_reports(manager):
+ print("\n✗ 测试失败: 无法获取月度报告")
+ return 1
+
+ # 测试获取每日报告
+ if not test_get_daily_reports(manager):
+ print("\n✗ 测试失败: 无法获取每日报告")
+ return 1
+
+ print("\n" + "=" * 60)
+ print("✓ 所有测试通过!")
+ print("=" * 60)
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(main())
|