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:
45
AGENTS.md
45
AGENTS.md
@@ -6,14 +6,15 @@
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
从飞书获取排班信息,从 Metabase 获取船舶作业数据,生成标准化日报。
|
从飞书获取排班信息,从 Metabase 获取船舶作业数据,从 Confluence 获取船舶报告数据,从 Jira 获取故障信息,生成标准化日报和交接班报告。
|
||||||
|
|
||||||
## 结构
|
## 结构
|
||||||
|
|
||||||
```
|
```
|
||||||
Gloria/
|
Gloria/
|
||||||
├── daily_report_gui.py # GUI入口 (Tkinter)
|
├── daily_report_gui.py # GUI入口 (Tkinter)
|
||||||
├── report_generator.py # CLI入口 + 核心生成逻辑
|
├── report_generator.py # CLI入口 + 日报生成核心
|
||||||
|
├── shift_report.py # 班次交接报告(新增)
|
||||||
├── feishu/ # 飞书API集成
|
├── feishu/ # 飞书API集成
|
||||||
│ ├── client.py # HTTP客户端 + Token管理
|
│ ├── client.py # HTTP客户端 + Token管理
|
||||||
│ ├── manager.py # 排班管理器(统一入口)
|
│ ├── manager.py # 排班管理器(统一入口)
|
||||||
@@ -21,7 +22,13 @@ Gloria/
|
|||||||
├── metabase/ # Metabase数据查询
|
├── metabase/ # Metabase数据查询
|
||||||
│ ├── time_operations.py # 按时间范围查询
|
│ ├── time_operations.py # 按时间范围查询
|
||||||
│ └── vessel_operations.py # 按船舶查询
|
│ └── vessel_operations.py # 按船舶查询
|
||||||
|
├── confluence/ # Confluence API集成(新增)
|
||||||
|
│ ├── client.py # Confluence HTTP客户端
|
||||||
|
│ └── vessel_reports.py # 船舶报告解析器
|
||||||
|
├── jira_client.py # Jira API客户端(新增)
|
||||||
└── template/ # 日报模板
|
└── template/ # 日报模板
|
||||||
|
├── daily_report_template.txt
|
||||||
|
└── shift_handover_template.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## 查找指南
|
## 查找指南
|
||||||
@@ -29,10 +36,13 @@ Gloria/
|
|||||||
| 任务 | 位置 |
|
| 任务 | 位置 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 修改日报格式 | `template/daily_report_template.txt` |
|
| 修改日报格式 | `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` |
|
| 添加飞书功能 | `feishu/manager.py` |
|
||||||
| 新增Metabase查询 | `metabase/` 对应客户端 |
|
| 新增Metabase查询 | `metabase/` 对应客户端 |
|
||||||
|
| 新增Confluence查询 | `confluence/vessel_reports.py` |
|
||||||
| 修改GUI界面 | `daily_report_gui.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 ~ 23:59
|
||||||
- **其他日期:** 08:00 ~ 次日08:00
|
- **其他日期:** 08:00 ~ 次日08:00
|
||||||
|
|
||||||
|
### 交接班班次
|
||||||
|
- **白班:** 08:00 - 20:00
|
||||||
|
- **夜班:** 20:00 - 次日08:00
|
||||||
|
|
||||||
### 环境变量 (.env)
|
### 环境变量 (.env)
|
||||||
```
|
```
|
||||||
MATEBASE_USERNAME=xxx
|
MATEBASE_USERNAME=xxx
|
||||||
@@ -48,6 +62,11 @@ MATEBASE_PASSWORD=xxx
|
|||||||
FEISHU_APP_ID=xxx
|
FEISHU_APP_ID=xxx
|
||||||
FEISHU_APP_SECRET=xxx
|
FEISHU_APP_SECRET=xxx
|
||||||
FEISHU_SPREADSHEET_TOKEN=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模式
|
# GUI模式
|
||||||
python daily_report_gui.py
|
python daily_report_gui.py
|
||||||
|
|
||||||
# CLI模式
|
# 日报 (CLI)
|
||||||
python report_generator.py --date 2026-03-01
|
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请求
|
- `requests` - HTTP请求
|
||||||
@@ -70,3 +106,4 @@ python report_generator.py --date 2026-03-01
|
|||||||
- 程序需在 **8:00 后运行**,确保最后一条船指令结束时间超过8点
|
- 程序需在 **8:00 后运行**,确保最后一条船指令结束时间超过8点
|
||||||
- 飞书 Token 自动刷新,提前30分钟续期
|
- 飞书 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%
|
||||||
|
```
|
||||||
58
README.md
58
README.md
@@ -1,11 +1,17 @@
|
|||||||
# Gloria - 福州港日报管理系统
|
# Gloria - 福州港日报管理系统
|
||||||
|
|
||||||
从飞书获取排班信息,从 Metabase 获取船舶作业数据,生成标准化日报。
|
从飞书获取排班信息,从 Metabase 获取船舶作业数据,从 Confluence 获取船舶报告,从 Jira 获取故障信息,生成标准化日报和交接班报告。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **日报生成**: 自动生成每日作业报告
|
- **日报生成**: 自动生成每日作业报告
|
||||||
- **班次交接报告**: 分别统计白班/夜班作业情况
|
- **班次交接报告**: 分别统计白班/夜班作业情况
|
||||||
|
- 船号显示 (462#、463# 等)
|
||||||
|
- 故障次数/故障率
|
||||||
|
- 人工介入次数/介入率
|
||||||
|
- 作业效率 (循环/车/小时)
|
||||||
|
- **Confluence 集成**: 从实船作业统计页面获取船舶数据
|
||||||
|
- **Jira 集成**: 查询故障单数量
|
||||||
- **GUI 界面**: 基于 Tkinter 的图形界面
|
- **GUI 界面**: 基于 Tkinter 的图形界面
|
||||||
- **CLI 支持**: 命令行方式运行
|
- **CLI 支持**: 命令行方式运行
|
||||||
|
|
||||||
@@ -18,6 +24,8 @@ Gloria/
|
|||||||
├── shift_report.py # 班次交接报告
|
├── shift_report.py # 班次交接报告
|
||||||
├── feishu/ # 飞书 API 集成
|
├── feishu/ # 飞书 API 集成
|
||||||
├── metabase/ # Metabase 数据查询
|
├── metabase/ # Metabase 数据查询
|
||||||
|
├── confluence/ # Confluence API 集成
|
||||||
|
├── jira_client.py # Jira API 客户端
|
||||||
└── template/ # 报告模板
|
└── template/ # 报告模板
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,12 +46,24 @@ pip install requests python-dotenv
|
|||||||
|
|
||||||
创建 `.env` 文件:
|
创建 `.env` 文件:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
MATEBASE_USERNAME=xxx
|
# Metabase
|
||||||
MATEBASE_PASSWORD=xxx
|
MATEBASE_USERNAME=your_username
|
||||||
FEISHU_APP_ID=xxx
|
MATEBASE_PASSWORD=your_password
|
||||||
FEISHU_APP_SECRET=xxx
|
|
||||||
FEISHU_SPREADSHEET_TOKEN=xxx
|
# 飞书
|
||||||
|
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 day
|
||||||
python shift_report.py --date 2026-03-01 --shift night
|
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()
|
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,10 +169,20 @@ tomorrow = manager.get_schedule_for_tomorrow()
|
|||||||
| 白班 | 08:00 - 20:00 |
|
| 白班 | 08:00 - 20:00 |
|
||||||
| 夜班 | 20:00 - 次日 08:00 |
|
| 夜班 | 20:00 - 次日 08:00 |
|
||||||
|
|
||||||
|
## N/A 记录处理
|
||||||
|
|
||||||
|
Metabase 中可能出现 vesselVisitID 为 "N/A" 的记录(通常是数据同步问题导致)。系统会自动根据作业时间将这些记录归属到对应的船舶:
|
||||||
|
|
||||||
|
- 检查 N/A 记录的时间范围
|
||||||
|
- 与其他船舶的时间范围进行重叠判断
|
||||||
|
- 将 N/A 记录的 TEU 合并到时间重叠的船舶
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 程序需在 **8:00 后运行**,确保最后一条船指令结束时间超过 8 点
|
- 程序需在 **8:00 后运行**,确保最后一条船指令结束时间超过 8 点
|
||||||
- 飞书 Token 自动刷新,提前 30 分钟续期
|
- 飞书 Token 自动刷新,提前 30 分钟续期
|
||||||
|
- Metabase 无原生 Python SDK,使用 REST API
|
||||||
|
- Confluence 和 Jira 需要配置相应 Token 才能启用完整功能
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
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"),
|
"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(
|
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)
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
from feishu.manager import FeishuScheduleManager
|
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:
|
class ShiftReportGenerator:
|
||||||
@@ -45,6 +49,22 @@ class ShiftReportGenerator:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.feishu_manager = FeishuScheduleManager()
|
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:
|
def get_shift_time_range(self, report_date: datetime, shift_type: str) -> tuple:
|
||||||
"""
|
"""
|
||||||
@@ -103,7 +123,7 @@ class ShiftReportGenerator:
|
|||||||
return schedule.get("night_shift", "")
|
return schedule.get("night_shift", "")
|
||||||
|
|
||||||
def get_vessels_in_time_range(
|
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]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取指定时间范围内作业的所有船舶列表
|
获取指定时间范围内作业的所有船舶列表
|
||||||
@@ -161,6 +181,29 @@ ORDER BY vesselVisitID
|
|||||||
|
|
||||||
col_idx = {col["name"]: i for i, col in enumerate(cols)}
|
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 = []
|
vessels = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
vessel_id = row[col_idx.get("vesselVisitID", 0)]
|
vessel_id = row[col_idx.get("vesselVisitID", 0)]
|
||||||
@@ -169,26 +212,163 @@ ORDER BY vesselVisitID
|
|||||||
teu = row[col_idx.get("teu", 5)] or 0
|
teu = row[col_idx.get("teu", 5)] or 0
|
||||||
vehicles = row[col_idx.get("vehicles", 6)] 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_name = self._extract_vessel_name(vessel_id)
|
||||||
vessel_code = self._extract_vessel_code(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(
|
vessels.append(
|
||||||
{
|
{
|
||||||
"vessel_code": vessel_code,
|
"vessel_code": vessel_number_display,
|
||||||
"vessel_name": vessel_name,
|
"vessel_name": vessel_name,
|
||||||
"vehicles": vehicles,
|
"vehicles": vehicles,
|
||||||
"teu_20ft": cnt20,
|
"teu_20ft": cnt20,
|
||||||
"teu_40ft": cnt40,
|
"teu_40ft": cnt40,
|
||||||
"total_teu": teu,
|
"total_teu": teu,
|
||||||
"efficiency": self._calculate_efficiency(teu, vehicles),
|
"efficiency": efficiency,
|
||||||
# 故障和人工介入暂无数据接口,显示占位符
|
"failures": failures,
|
||||||
"failures": "--",
|
"failure_rate": failure_rate,
|
||||||
"failure_rate": "--",
|
"interventions": interventions,
|
||||||
"interventions": "--",
|
"intervention_rate": intervention_rate,
|
||||||
"intervention_rate": "--",
|
"vessel_start": vessel_start,
|
||||||
|
"vessel_end": vessel_end,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
vessels = self._merge_vessels_by_name(vessels)
|
||||||
|
|
||||||
return vessels
|
return vessels
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -221,6 +401,140 @@ ORDER BY vesselVisitID
|
|||||||
|
|
||||||
return vessel_visit_id
|
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:
|
def _calculate_efficiency(self, teu: int, vehicles: int) -> str:
|
||||||
"""
|
"""
|
||||||
计算效率(简化版本,后续可接入真实效率数据)
|
计算效率(简化版本,后续可接入真实效率数据)
|
||||||
@@ -277,6 +591,44 @@ ORDER BY vesselVisitID
|
|||||||
|
|
||||||
return response.json()
|
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:
|
def read_template(self, template_path: str) -> str:
|
||||||
"""读取模板文件"""
|
"""读取模板文件"""
|
||||||
try:
|
try:
|
||||||
@@ -353,7 +705,7 @@ ORDER BY vesselVisitID
|
|||||||
|
|
||||||
# 3. 获取船舶作业数据
|
# 3. 获取船舶作业数据
|
||||||
print("获取船舶作业数据...")
|
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. 读取并渲染模板
|
# 4. 读取并渲染模板
|
||||||
template_path = os.path.join(
|
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