Files
gloria/metabase/vessel_operations.py
qichi.liang 00d2218c6d feat: 初始化福州港日报管理系统
- 添加日报生成功能 (report_generator.py)
- 添加 GUI 界面 (daily_report_gui.py)
- 添加班次交接报告功能 (shift_report.py)
- 集成飞书 API 获取排班信息
- 集成 Metabase 查询作业数据
- 生成 AGENTS.md 文档
2026-03-03 02:07:34 +08:00

400 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
船舶作业数据查询模块
用于从 Metabase 查询船舶作业相关的箱量统计和效率指标数据。
以船舶为单位获取作业情况。
安装依赖:
pip install requests python-dotenv
环境变量配置(推荐):
MATEBASE_USERNAME=your_username
MATEBASE_PASSWORD=your_password
基本用法:
>>> from vessel_operations import VesselOperationsClient
>>> client = VesselOperationsClient()
>>> data = client.get_vessel_operations("260209-华晟67_X")
>>> print(data['cnt20']) # 44
"""
import requests
from typing import Optional, Dict, Any, List
import os
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
class MetabaseAPIError(Exception):
"""Metabase API 调用异常"""
pass
class MetabaseAuthError(MetabaseAPIError):
"""Metabase 认证异常"""
pass
class MetabaseQueryError(MetabaseAPIError):
"""Metabase 查询异常"""
pass
class VesselOperationsClient:
"""
船舶数据查询客户端
用于从 Metabase 查询船舶相关数据,包括:
- 箱量统计20尺、40尺、总箱数、TEU
- 无人集卡效率指标cycle/h
Args:
base_url: Metabase 服务地址
username: 用户名(默认从环境变量 MATEBASE_USERNAME 读取)
password: 密码(默认从环境变量 MATEBASE_PASSWORD 读取)
Example:
>>> client = VesselOperationsClient()
>>> data = client.get_vessel_operations("260209-华晟67_X")
>>> print(f"20尺箱量: {data['cnt20']}")
"""
# Metabase Card ID 映射表
_CARD_IDS = {
"overview": 57, # 总览 - 箱量统计
"efficiency_normal": 64, # 无人集卡效率指标
"efficiency_filtered": 70, # 无人集卡效率指标-剔除异常
"work_info": 74, # 船舶作业指令时间AT_WorkInfo
}
def __init__(
self,
base_url: str = "http://10.80.0.11:30001",
username: Optional[str] = None,
password: Optional[str] = None,
):
self.base_url = base_url.rstrip("/")
self.session_token: Optional[str] = None
# 优先使用传入的参数,否则从环境变量读取
self.username = username or os.getenv("MATEBASE_USERNAME")
self.password = password or os.getenv("MATEBASE_PASSWORD")
if not self.username or not self.password:
raise MetabaseAuthError(
"未提供 Metabase 用户名或密码。"
"请通过参数传入,或设置环境变量 "
"MATEBASE_USERNAME 和 MATEBASE_PASSWORD"
)
def _authenticate(self) -> None:
"""认证并获取 session token"""
auth_url = f"{self.base_url}/api/session"
payload = {"username": self.username, "password": self.password}
try:
response = requests.post(auth_url, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
self.session_token = data.get("id")
except requests.exceptions.RequestException as e:
raise MetabaseAuthError(f"Metabase 认证失败: {e}")
def _ensure_authenticated(self) -> None:
"""确保已认证"""
if not self.session_token:
self._authenticate()
def _query_card(self, card_id: int, parameters: List[Dict]) -> Dict:
"""
查询指定 Card
Args:
card_id: Metabase Card ID
parameters: 查询参数列表
Returns:
API 响应数据(字典)
Raises:
MetabaseQueryError: 查询失败
"""
self._ensure_authenticated()
url = f"{self.base_url}/api/card/{card_id}/query"
headers = {
"X-Metabase-Session": self.session_token,
"Content-Type": "application/json",
}
payload = {"parameters": parameters}
try:
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise MetabaseQueryError(f"查询 Metabase Card {card_id} 失败: {e}")
def _extract_row_data(self, response: Dict) -> Optional[Dict]:
"""
从 API 响应中提取第一行数据
Args:
response: API 响应字典
Returns:
列名到值的映射字典,如果没有数据则返回 None
"""
rows = response.get("data", {}).get("rows", [])
cols = response.get("data", {}).get("cols", [])
if not rows:
return None
row_data = rows[0]
result = {}
for i, col in enumerate(cols):
col_name = col.get("name", f"col_{i}")
result[col_name] = row_data[i] if i < len(row_data) else None
return result
def _extract_work_time_range(self, response: Dict) -> Dict[str, Optional[str]]:
"""
从作业指令数据中提取第一条指令开始时间和最后一条指令结束时间
Args:
response: API 响应字典
Returns:
包含 start_time 和 end_time 的字典,如果没有数据则返回 None
"""
rows = response.get("data", {}).get("rows", [])
cols = response.get("data", {}).get("cols", [])
if not rows:
return {"start_time": None, "end_time": None}
# 构建列名到索引的映射
col_to_idx = {}
for i, col in enumerate(cols):
col_name = col.get("name", f"col_{i}")
col_to_idx[col_name] = i
# 假设有 start_time 和 end_time 列,找到第一条开始时间和最后一条结束时间
start_time_idx = col_to_idx.get("start_time")
end_time_idx = col_to_idx.get("end_time")
if start_time_idx is None or end_time_idx is None:
return {"start_time": None, "end_time": None}
# 获取第一条指令的开始时间
first_start_time = (
rows[0][start_time_idx] if len(rows[0]) > start_time_idx else None
)
# 获取最后一条指令的结束时间
last_end_time = rows[-1][end_time_idx] if len(rows[-1]) > end_time_idx else None
# 处理时间格式,只保留到秒(去掉毫秒)
if first_start_time and isinstance(first_start_time, str):
# 去掉小数点及后面的部分
first_start_time = first_start_time.split(".")[0]
if last_end_time and isinstance(last_end_time, str):
last_end_time = last_end_time.split(".")[0]
return {"start_time": first_start_time, "end_time": last_end_time}
def get_vessel_operations(self, vessel_visit_id: str) -> Dict[str, Any]:
"""
获取船舶统计数据
查询指定船舶的箱量统计和效率指标数据。
Args:
vessel_visit_id: 船舶访问ID格式如 "260209-华晟67_X"
Returns:
包含以下字段的字典:
- vessel_visit_id: 船舶访问ID
- cnt20: 20尺箱量
- cnt40: 40尺箱量
- cntAll: 总箱数
- teu: TEU数
- cycle_h_normal: 无人集卡效率指标 (cycle/h)
- cycle_h_filtered: 无人集卡效率指标-剔除异常 (cycle/h)
- start_time: 第一条指令开始时间
- end_time: 最后一条指令结束时间
Raises:
MetabaseAPIError: API 调用失败
MetabaseAuthError: 认证失败
MetabaseQueryError: 查询失败
Example:
>>> client = VesselOperationsClient()
>>> data = client.get_vessel_operations("260209-华晟67_X")
>>> print(f"20尺箱量: {data['cnt20']}") # 44
>>> print(f"效率指标: {data['cycle_h_normal']}") # 2.35
>>> print(f"作业开始: {data['start_time']}") # 2026-02-01 08:00:00
>>> print(f"作业结束: {data['end_time']}") # 2026-02-01 18:00:00
"""
# 查询总览数据(箱量统计)
vessel_param = {
"type": "string/=",
"target": ["variable", ["template-tag", "vesselVisitID"]],
"value": vessel_visit_id,
}
overview_response = self._query_card(self._CARD_IDS["overview"], [vessel_param])
overview = self._extract_row_data(overview_response) or {}
# 查询效率指标数据
efficiency_param = {
"type": "string/=",
"target": ["variable", ["template-tag", "vesselVisitId"]],
"value": vessel_visit_id,
}
# Card 64: 无人集卡效率指标
normal_response = self._query_card(
self._CARD_IDS["efficiency_normal"], [efficiency_param]
)
normal_data = self._extract_row_data(normal_response) or {}
cycle_h_normal = normal_data.get("cycle/h")
# Card 70: 无人集卡效率指标-剔除异常
filtered_response = self._query_card(
self._CARD_IDS["efficiency_filtered"], [efficiency_param]
)
filtered_data = self._extract_row_data(filtered_response) or {}
cycle_h_filtered = filtered_data.get("cycle/h")
# Card 74: 船舶作业指令时间AT_WorkInfo
# 需要同时传递 vesselVisitID 和 vehicleId 两个参数
vehicle_param_all = {
"type": "string/=",
"target": ["variable", ["template-tag", "vehicleId"]],
"value": "ALL", # 使用 ALL 获取所有车辆的作业指令
}
work_info_response = self._query_card(
self._CARD_IDS["work_info"], [vessel_param, vehicle_param_all]
)
work_time_range = self._extract_work_time_range(work_info_response)
# 四舍五入到2位小数
cycle_h_normal_rounded = (
round(cycle_h_normal, 2) if cycle_h_normal is not None else None
)
cycle_h_filtered_rounded = (
round(cycle_h_filtered, 2) if cycle_h_filtered is not None else None
)
return {
"vessel_visit_id": vessel_visit_id,
"cnt20": overview.get("cnt20"),
"cnt40": overview.get("cnt40"),
"cntAll": overview.get("cntAll"),
"teu": overview.get("teu"),
"cycle_h_normal": cycle_h_normal_rounded,
"cycle_h_filtered": cycle_h_filtered_rounded,
"start_time": work_time_range.get("start_time"),
"end_time": work_time_range.get("end_time"),
}
# 工厂函数(推荐用于简单场景)
def create_vessel_operations_client(
base_url: str = "http://10.80.0.11:30001",
username: Optional[str] = None,
password: Optional[str] = None,
) -> VesselOperationsClient:
"""
创建船舶数据客户端
这是创建 VesselOperationsClient 实例的便捷工厂函数。
Args:
base_url: Metabase 服务地址
username: 用户名(默认从环境变量 MATEBASE_USERNAME 读取)
password: 密码(默认从环境变量 MATEBASE_PASSWORD 读取)
Returns:
VesselOperationsClient 实例
Example:
>>> from metabase_vessel_client import create_vessel_operations_client
>>> client = create_vessel_operations_client()
>>> data = client.get_vessel_operations("260209-华晟67_X")
"""
return VesselOperationsClient(
base_url=base_url,
username=username,
password=password,
)
# 向后兼容:保留旧函数名
def get_vessel_by_visit_id(vessel_visit_id: str) -> Dict[str, Any]:
"""
获取船舶统计数据(兼容旧版本)
注意:此函数使用默认配置(从环境变量读取账号密码),
在新项目中建议使用 VesselOperationsClient 类或 create_vessel_operations_client() 函数。
Args:
vessel_visit_id: 船舶访问ID
Returns:
船舶统计数据字典
"""
client = create_vessel_operations_client()
return client.get_vessel_operations(vessel_visit_id)
# 导出公共接口
__all__ = [
"VesselOperationsClient",
"create_vessel_operations_client",
"get_vessel_by_visit_id",
"MetabaseAPIError",
"MetabaseAuthError",
"MetabaseQueryError",
]
# 命令行入口点
if __name__ == "__main__":
import sys
import json
# 解析命令行参数
if len(sys.argv) < 2:
print("用法: python3 vessel_operations.py <船舶号>")
print("示例: python3 vessel_operations.py 260209-德盛6")
sys.exit(1)
vessel_visit_id = sys.argv[1]
try:
# 创建客户端并查询数据
client = VesselOperationsClient()
data = client.get_vessel_operations(vessel_visit_id)
# 输出 JSON 格式的结果
print(json.dumps(data, indent=2, ensure_ascii=False))
except MetabaseAuthError as e:
print(f"认证失败: {e}", file=sys.stderr)
sys.exit(1)
except MetabaseQueryError as e:
print(f"查询失败: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)