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

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

View File

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