Files
Orbitin/src/report.py
qichi.liang 9b19015156 feat: 添加飞书tenant_access_token自动获取功能
- 在FeishuSheetsClient中添加_get_tenant_access_token()方法
- 实现token自动缓存和刷新机制(提前30分钟刷新)
- 更新配置类支持FEISHU_APP_ID和FEISHU_APP_SECRET
- 从.env中移除FEISHU_TOKEN,完全使用应用凭证
- 更新report.py和gui.py支持新的配置检查逻辑
- 更新FeishuScheduleManager配置检查逻辑
- 更新文档和示例文件说明新的配置方式

系统现在支持两种认证方式:
1. 推荐:使用应用凭证(FEISHU_APP_ID + FEISHU_APP_SECRET)
2. 备选:使用手动token(FEISHU_TOKEN)

所有功能测试通过,系统能自动获取、缓存和刷新token。
2025-12-31 06:03:51 +08:00

413 lines
14 KiB
Python
Raw 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.

#!/usr/bin/env python3
"""
日报生成模块
更新依赖,使用新的配置和数据库模块
"""
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
import logging
from src.config import config
from src.logging_config import get_logger
from src.database.daily_logs import DailyLogsDatabase
from src.feishu.manager import FeishuScheduleManager
logger = get_logger(__name__)
class ReportGeneratorError(Exception):
"""日报生成错误"""
pass
class DailyReportGenerator:
"""每日作业报告生成器"""
def __init__(self, db_path: Optional[str] = None):
"""
初始化日报生成器
参数:
db_path: 数据库文件路径如果为None则使用配置
"""
self.db = DailyLogsDatabase(db_path)
logger.info("日报生成器初始化完成")
def get_latest_date(self) -> str:
"""
获取数据库中最新的日期
返回:
最新日期字符串,格式 "YYYY-MM-DD"
"""
try:
logs = self.db.query_all(limit=1)
if logs:
return logs[0]['date']
return datetime.now().strftime('%Y-%m-%d')
except Exception as e:
logger.error(f"获取最新日期失败: {e}")
return datetime.now().strftime('%Y-%m-%d')
def get_daily_data(self, date: str) -> Dict[str, Any]:
"""
获取指定日期的数据
参数:
date: 日期字符串,格式 "YYYY-MM-DD"
返回:
每日数据字典
"""
try:
logs = self.db.query_by_date(date)
# 按船名汇总TEU和尺寸箱量
ships: Dict[str, Dict[str, Any]] = {}
for log in logs:
ship = log['ship_name']
if ship not in ships:
ships[ship] = {
'teu': 0,
'twenty_feet': 0,
'forty_feet': 0
}
if log.get('teu'):
ships[ship]['teu'] += log['teu']
if log.get('twenty_feet'):
ships[ship]['twenty_feet'] += log['twenty_feet']
if log.get('forty_feet'):
ships[ship]['forty_feet'] += log['forty_feet']
total_teu = sum(ship_data['teu'] for ship_data in ships.values())
return {
'date': date,
'ships': ships,
'total_teu': total_teu,
'ship_count': len(ships)
}
except Exception as e:
logger.error(f"获取每日数据失败: {date}, 错误: {e}")
return {
'date': date,
'ships': {},
'total_teu': 0,
'ship_count': 0
}
def get_monthly_stats(self, date: str) -> Dict[str, Any]:
"""
获取月度统计(截止到指定日期)
参数:
date: 日期字符串,格式 "YYYY-MM-DD"
返回:
月度统计字典
"""
try:
year_month = date[:7] # YYYY-MM
target_date = datetime.strptime(date, '%Y-%m-%d').date()
logs = self.db.query_all(limit=10000)
# 只统计当月且在指定日期之前的数据
monthly_logs = [
log for log in logs
if log['date'].startswith(year_month)
and datetime.strptime(log['date'], '%Y-%m-%d').date() <= target_date
]
# 按日期汇总
daily_totals: Dict[str, int] = {}
for log in monthly_logs:
d = log['date']
if d not in daily_totals:
daily_totals[d] = 0
if log.get('teu'):
daily_totals[d] += log['teu']
# 计算当月天数(已过的天数)
current_date = datetime.strptime(date, '%Y-%m-%d')
if current_date.day == config.FIRST_DAY_OF_MONTH_SPECIAL:
days_passed = 1
else:
days_passed = current_date.day
# 获取未统计数据
unaccounted = self.db.get_unaccounted(year_month)
planned = days_passed * config.DAILY_TARGET_TEU
actual = sum(daily_totals.values()) + unaccounted
completion = round(actual / planned * 100, 2) if planned > 0 else 0
return {
'year_month': year_month,
'days_passed': days_passed,
'planned': planned,
'actual': actual,
'unaccounted': unaccounted,
'completion': completion,
'daily_totals': daily_totals
}
except Exception as e:
logger.error(f"获取月度统计失败: {date}, 错误: {e}")
return {
'year_month': date[:7],
'days_passed': 0,
'planned': 0,
'actual': 0,
'unaccounted': 0,
'completion': 0,
'daily_totals': {}
}
def get_shift_personnel(self, date: str) -> Dict[str, str]:
"""
获取班次人员(从飞书排班表获取)
注意:日报中显示的是次日的班次人员,所以需要获取 date+1 的排班
例如:生成 12/29 的日报,显示的是 12/30 的人员
参数:
date: 日期字符串,格式 "YYYY-MM-DD"
返回:
班次人员字典
"""
try:
# 检查飞书配置支持应用凭证和手动token两种方式
has_feishu_config = bool(config.FEISHU_SPREADSHEET_TOKEN) and (
bool(config.FEISHU_APP_ID and config.FEISHU_APP_SECRET) or
bool(config.FEISHU_TOKEN)
)
if not has_feishu_config:
logger.warning("飞书配置不完整,跳过排班信息获取")
logger.warning("需要配置 FEISHU_SPREADSHEET_TOKEN 和 (FEISHU_APP_ID+FEISHU_APP_SECRET 或 FEISHU_TOKEN)")
return self._empty_personnel()
# 初始化飞书排班管理器
manager = FeishuScheduleManager()
# 计算次日日期(日报中显示的是次日班次)
parsed_date = datetime.strptime(date, '%Y-%m-%d')
tomorrow = (parsed_date + timedelta(days=1)).strftime('%Y-%m-%d')
logger.info(f"获取 {date} 日报的班次人员,对应排班表日期: {tomorrow}")
# 获取次日的排班信息
schedule = manager.get_schedule_for_date(tomorrow)
# 如果从飞书获取到数据,使用飞书数据
if schedule.get('day_shift') or schedule.get('night_shift'):
return {
'day_shift': schedule.get('day_shift', ''),
'night_shift': schedule.get('night_shift', ''),
'duty_phone': config.DUTY_PHONE
}
# 如果飞书数据为空,返回空值
logger.warning(f"无法从飞书获取 {tomorrow} 的排班信息")
return self._empty_personnel()
except Exception as e:
logger.error(f"获取排班信息失败: {e}")
# 降级处理:返回空值
return self._empty_personnel()
def generate_report(self, date: Optional[str] = None) -> str:
"""
生成日报
参数:
date: 日期字符串,格式 "YYYY-MM-DD"如果为None则使用最新日期
返回:
日报文本
异常:
ReportGeneratorError: 生成失败
"""
try:
if not date:
date = self.get_latest_date()
# 验证日期格式
try:
parsed = datetime.strptime(date, '%Y-%m-%d')
display_date = parsed.strftime('%m/%d')
query_date = parsed.strftime('%Y-%m-%d')
except ValueError as e:
error_msg = f"日期格式无效: {date}, 错误: {e}"
logger.error(error_msg)
raise ReportGeneratorError(error_msg) from e
# 获取数据
daily_data = self.get_daily_data(query_date)
monthly_data = self.get_monthly_stats(query_date)
personnel = self.get_shift_personnel(query_date)
# 生成日报
lines: List[str] = []
lines.append(f"日期:{display_date}")
lines.append("")
# 船次信息
if daily_data['ships']:
ship_lines: List[str] = []
for ship, ship_data in sorted(daily_data['ships'].items(), key=lambda x: -x[1]['teu']):
ship_lines.append(f"船名:{ship}")
teu = ship_data['teu']
twenty_feet = ship_data.get('twenty_feet', 0)
forty_feet = ship_data.get('forty_feet', 0)
# 构建尺寸箱量字符串
size_parts = []
if twenty_feet > 0:
size_parts.append(f"20尺*{twenty_feet}")
if forty_feet > 0:
size_parts.append(f"40尺*{forty_feet}")
if size_parts:
size_str = " ".join(size_parts)
ship_lines.append(f"作业量:{teu}TEU{size_str}")
else:
ship_lines.append(f"作业量:{teu}TEU")
lines.extend(ship_lines)
lines.append("")
# 当日实际作业量
lines.append(f"当日实际作业量:{daily_data['total_teu']}TEU")
# 月度统计
lines.append(f"当月计划作业量:{monthly_data['planned']}TEU (用天数*{config.DAILY_TARGET_TEU}TEU)")
lines.append(f"当月实际作业量:{monthly_data['actual']}TEU")
lines.append(f"当月完成比例:{monthly_data['completion']}%")
lines.append("")
# 人员信息
day_personnel = personnel['day_shift']
night_personnel = personnel['night_shift']
duty_phone = personnel['duty_phone']
# 班次日期使用次日
next_day = (parsed + timedelta(days=1)).strftime('%m/%d')
lines.append(f"{next_day} 白班人员:{day_personnel}")
lines.append(f"{next_day} 夜班人员:{night_personnel}")
lines.append(f"24小时值班手机{duty_phone}")
report = "\n".join(lines)
logger.info(f"日报生成完成: {date}")
return report
except ReportGeneratorError:
raise
except Exception as e:
error_msg = f"生成日报失败: {e}"
logger.error(error_msg)
raise ReportGeneratorError(error_msg) from e
def print_report(self, date: Optional[str] = None) -> str:
"""
打印日报
参数:
date: 日期字符串,格式 "YYYY-MM-DD"如果为None则使用最新日期
返回:
日报文本
"""
try:
report = self.generate_report(date)
print(report)
return report
except ReportGeneratorError as e:
print(f"生成日报失败: {e}")
return ""
def save_report_to_file(self, date: Optional[str] = None, filepath: Optional[str] = None) -> bool:
"""
保存日报到文件
参数:
date: 日期字符串如果为None则使用最新日期
filepath: 文件路径如果为None则使用默认路径
返回:
是否成功
"""
try:
report = self.generate_report(date)
if filepath is None:
# 使用默认路径
import os
report_dir = "reports"
os.makedirs(report_dir, exist_ok=True)
if date is None:
date = self.get_latest_date()
filename = f"daily_report_{date}.txt"
filepath = os.path.join(report_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(report)
logger.info(f"日报已保存到文件: {filepath}")
return True
except Exception as e:
logger.error(f"保存日报到文件失败: {e}")
return False
def _empty_personnel(self) -> Dict[str, str]:
"""返回空的人员信息"""
return {
'day_shift': '',
'night_shift': '',
'duty_phone': config.DUTY_PHONE
}
def close(self):
"""关闭数据库连接"""
self.db.close()
if __name__ == '__main__':
# 测试代码
import sys
# 设置日志
logging.basicConfig(level=logging.INFO)
generator = DailyReportGenerator()
try:
# 测试获取最新日期
latest_date = generator.get_latest_date()
print(f"最新日期: {latest_date}")
# 测试生成日报
report = generator.generate_report(latest_date)
print(f"\n日报内容:\n{report}")
# 测试保存到文件
success = generator.save_report_to_file(latest_date)
print(f"\n保存到文件: {'成功' if success else '失败'}")
except ReportGeneratorError as e:
print(f"日报生成错误: {e}")
sys.exit(1)
except Exception as e:
print(f"未知错误: {e}")
sys.exit(1)
finally:
generator.close()