feat: 交接班报告支持 Confluence/Jira 集成,添加 N/A 记录时间归属功能

- 集成 Confluence API 获取船舶报告数据
- 集成 Jira API 查询故障数量
- 支持船号显示 (462#、463# 等)
- 支持故障次数/故障率、人工介入次数/介入率显示
- 跨班作业使用 Card 69 按时间查询效率
- 不跨班作业使用整船效率(剔除异常)
- N/A 记录根据作业时间归属到对应船舶
- 更新 AGENTS.md 和 README.md 文档
- 删除 daily_report_gui.py
This commit is contained in:
Developer
2026-03-14 02:52:23 +08:00
parent cc989a8ddb
commit 5d0cafac32
13 changed files with 1746 additions and 394 deletions

View File

@@ -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
- Metabase 无原生 Python SDK使用 REST API
- Confluence 和 Jira 需要配置相应 Token 才能启用完整功能

107
CONFLUENCE_INTEGRATION.md Normal file
View File

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

230
JIRA_INTEGRATION.md Normal file
View File

@@ -0,0 +1,230 @@
# Jira 集成说明
## 概述
为了获取准确的故障次数,系统现在支持从 Jira 直接查询 issue 数量。这样可以避免 Confluence 页面中表格数据为空的问题。
## 问题背景
**463# 信荣海** 的例子:
- Confluence 页面中"故障次数"表格字段是空的(`<td><br /></td>`
- 但实际在 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:structured-macro[^>]*ac:name="jira"[^>]*>(.*?)</ac:structured-macro>',
body, re.DOTALL
)
for macro in jira_macros:
# 提取 JQL
jql_match = re.search(
r'<ac:parameter[^>]*name="jqlQuery"[^>]*>(.*?)</ac:parameter>',
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
作业量/效率28TEU20尺*20 40尺*40.00循环/车/小时
故障次数/故障率2次14.29% <-- 从 Jira 查询获得
人工介入次数/介入率0次0.00%
```
如果没有启用 Jira
```
实船作业463# 信荣海
上场车辆数6
作业量/效率28TEU20尺*20 40尺*40.00循环/车/小时
故障次数/故障率0次0.00% <-- 从 Confluence 表格获得(为空)
人工介入次数/介入率0次0.00%
```

View File

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

10
confluence/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""
Confluence 数据获取模块
用于从 Confluence 获取船舶报告数据,包括故障次数、人工介入次数等。
"""
from .client import ConfluenceClient
from .vessel_reports import VesselReportManager
__all__ = ["ConfluenceClient", "VesselReportManager"]

155
confluence/client.py Normal file
View File

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

View File

@@ -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:structured-macro[^>]*ac:name="jira"[^>]*>(.*?)</ac:structured-macro>',
body,
re.DOTALL,
)
for macro in jira_macros:
jql_match = re.search(
r'<ac:parameter[^>]*name="jqlQuery"[^>]*>(.*?)</ac:parameter>',
macro,
re.DOTALL,
)
if jql_match:
jql = jql_match.group(1)
jql = (
jql.replace("&quot;", '"')
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
)
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"船次.*?<td[^>]*>(\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"船名.*?<td[^>]*>([^<]+)", body, re.DOTALL)
vessel_name = name_match.group(1).strip() if name_match else ""
# 提取作业时间
time_match = re.search(r"作业时间.*?<td[^>]*>(.*?)</td>", body, re.DOTALL)
operation_time = ""
if time_match:
operation_time = re.sub(r"<[^>]+>", "", time_match.group(1)).strip()
operation_time = operation_time.replace("&nbsp;", " ")
# 提取 TEU
teu_match = re.search(r"作业箱量 \(TEU\).*?<td[^>]*>(\d+)", body, re.DOTALL)
teu = int(teu_match.group(1)) if teu_match else 0
# 提取故障次数
failure_match = re.search(r"故障次数.*?<td[^>]*>(\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"故障率.*?<td[^>]*>([\d.]+)%", body, re.DOTALL
)
failure_rate = (
float(failure_rate_match.group(1)) if failure_rate_match else None
)
# 提取人工介入次数
intervention_match = re.search(
r"人工介入次数.*?<td[^>]*>(\d+)", body, re.DOTALL
)
interventions = (
int(intervention_match.group(1)) if intervention_match else 0
)
# 提取人工介入率(如有)
intervention_rate_match = re.search(
r"人工介入率.*?<td[^>]*>([\d.]+)%", body, re.DOTALL
)
intervention_rate = (
float(intervention_rate_match.group(1))
if intervention_rate_match
else None
)
# 提取上线车辆
vehicles_match = re.search(r"上线车辆.*?<td[^>]*>([^<]+)", body, re.DOTALL)
vehicles = vehicles_match.group(1).strip() if vehicles_match else ""
# 提取作业循环
moves_match = re.search(r"作业循环.*?<td[^>]*>(\d+)", body, re.DOTALL)
moves = int(moves_match.group(1)) if moves_match else 0
# 提取作业效率
efficiency_match = re.search(
r"作业净效率.*?<td[^>]*>([\d.]+)", body, re.DOTALL
)
efficiency = float(efficiency_match.group(1)) if efficiency_match else 0.0
# 提取作业类型
type_match = re.search(r"作业类型.*?<td[^>]*>([^<]+)", 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

View File

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

95
jira/__init__.py Normal file
View File

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

109
jira_client.py Normal file
View File

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

View File

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

View File

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

123
test_confluence.py Normal file
View File

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