mirror of
https://devops.liangqichi.top/qichi.liang/Orbitin.git
synced 2026-02-10 07:41:29 +08:00
- 在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。
413 lines
14 KiB
Python
413 lines
14 KiB
Python
#!/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()
|