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:
47
AGENTS.md
47
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
|
||||
- Metabase 无原生 Python SDK,使用 REST API
|
||||
- Confluence 和 Jira 需要配置相应 Token 才能启用完整功能
|
||||
|
||||
107
CONFLUENCE_INTEGRATION.md
Normal file
107
CONFLUENCE_INTEGRATION.md
Normal 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
230
JIRA_INTEGRATION.md
Normal 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
|
||||
作业量/效率: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%
|
||||
```
|
||||
60
README.md
60
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
|
||||
MIT
|
||||
|
||||
10
confluence/__init__.py
Normal file
10
confluence/__init__.py
Normal 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
155
confluence/client.py
Normal 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
|
||||
391
confluence/vessel_reports.py
Normal file
391
confluence/vessel_reports.py
Normal 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(""", '"')
|
||||
.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"船次.*?<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(" ", " ")
|
||||
|
||||
# 提取 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
|
||||
@@ -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
95
jira/__init__.py
Normal 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
109
jira_client.py
Normal 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")]
|
||||
@@ -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(
|
||||
|
||||
370
shift_report.py
370
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(
|
||||
|
||||
123
test_confluence.py
Normal file
123
test_confluence.py
Normal 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())
|
||||
Reference in New Issue
Block a user