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