Files
Orbitin/src/feishu_v2.py
qichi.liang dc2a55bbf4 feat: 添加飞书表格模块支持排班人员信息获取
- 新增 src/feishu_v2.py: 飞书表格API客户端,支持数据库存储和2026年全年排班表
- 新增 src/schedule_database.py: 排班信息数据库模块,用于缓存排班数据
- 新增 docs/feishu_data_flow.md: 飞书数据流文档
- 新增 plans/feishu_scheduling_plan.md: 飞书排班表模块设计文档
- 更新 src/report.py: 使用新的飞书模块获取排班人员信息
- 更新 src/gui.py: 启动时自动获取新数据,添加auto_fetch_data方法
- 更新 .env.example: 添加飞书配置示例
- 更新 AGENTS.md: 更新项目文档
- 更新 main.py: 集成飞书模块

功能特性:
1. 支持从飞书表格获取排班人员信息
2. 支持2025年月度表格和2026年全年排班表
3. 使用SQLite数据库缓存,减少API调用
4. 自动检测表格更新
5. GUI启动时自动获取最新数据
6. 日报中正确显示次日班次人员信息
2025-12-31 00:03:34 +08:00

643 lines
23 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
"""
飞书表格 API 客户端模块 v2
支持数据库存储和2026年全年排班表
"""
import requests
import json
import os
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import logging
import hashlib
from src.schedule_database import ScheduleDatabase
logger = logging.getLogger(__name__)
class FeishuSheetsClient:
"""飞书表格 API 客户端"""
def __init__(self, base_url: str, token: str, spreadsheet_token: str):
"""
初始化客户端
参数:
base_url: 飞书 API 基础URL
token: Bearer 认证令牌
spreadsheet_token: 表格 token
"""
self.base_url = base_url.rstrip('/')
self.spreadsheet_token = spreadsheet_token
self.headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
def get_sheets_info(self) -> List[Dict]:
"""
获取所有表格信息sheet_id 和 title
返回:
表格信息列表 [{'sheet_id': 'xxx', 'title': 'xxx'}, ...]
"""
url = f'{self.base_url}/spreadsheets/{self.spreadsheet_token}/sheets/query'
try:
response = requests.get(url, headers=self.headers, timeout=30)
response.raise_for_status()
data = response.json()
if data.get('code') != 0:
logger.error(f"飞书API错误: {data.get('msg')}")
return []
sheets = data.get('data', {}).get('sheets', [])
result = []
for sheet in sheets:
result.append({
'sheet_id': sheet.get('sheet_id'),
'title': sheet.get('title')
})
logger.info(f"获取到 {len(result)} 个表格")
return result
except requests.exceptions.RequestException as e:
logger.error(f"获取表格信息失败: {e}")
return []
except Exception as e:
logger.error(f"解析表格信息失败: {e}")
return []
def get_sheet_data(self, sheet_id: str, range_: str = 'A:AF') -> Dict:
"""
获取指定表格的数据
参数:
sheet_id: 表格ID
range_: 数据范围,默认 A:AF (31列)
返回:
飞书API返回的原始数据包含revision版本号
"""
# 注意:获取表格数据使用 v2 API而不是 v3
url = f'{self.base_url.replace("/v3", "/v2")}/spreadsheets/{self.spreadsheet_token}/values/{sheet_id}!{range_}'
params = {
'valueRenderOption': 'ToString',
'dateTimeRenderOption': 'FormattedString'
}
try:
response = requests.get(url, headers=self.headers, params=params, timeout=30)
response.raise_for_status()
data = response.json()
if data.get('code') != 0:
logger.error(f"飞书API错误: {data.get('msg')}")
return {}
return data.get('data', {})
except requests.exceptions.RequestException as e:
logger.error(f"获取表格数据失败: {e}")
return {}
except Exception as e:
logger.error(f"解析表格数据失败: {e}")
return {}
class ScheduleDataParser:
"""排班数据解析器支持2026年全年排班表"""
@staticmethod
def _parse_chinese_date(date_str: str) -> Optional[str]:
"""
解析中文日期格式
参数:
date_str: 中文日期,如 "12月30日""12/30""12月1日""1月1日"
返回:
标准化日期字符串 "M月D日" (不补零)
"""
if not date_str:
return None
# 如果是 "12/30" 格式
if '/' in date_str:
try:
month, day = date_str.split('/')
# 移除可能的空格和前导零
month = month.strip().lstrip('0')
day = day.strip().lstrip('0')
return f"{int(month)}{int(day)}"
except:
return None
# 如果是 "12月30日" 或 "1月1日" 格式
if '' in date_str and '' in date_str:
# 移除前导零,如 "01月01日" -> "1月1日"
parts = date_str.split('')
if len(parts) == 2:
month_part = parts[0].lstrip('0')
day_part = parts[1].rstrip('').lstrip('0')
return f"{month_part}{day_part}"
return date_str
# 如果是 "12月1日" 格式(已经包含"日"字)
if '' in date_str:
# 检查是否已经有"日"字
if '' not in date_str:
return f"{date_str}"
return date_str
return None
@staticmethod
def _find_date_column_index(headers: List[str], target_date: str) -> Optional[int]:
"""
在表头中查找目标日期对应的列索引
参数:
headers: 表头行 ["姓名", "12月1日", "12月2日", ...]
target_date: 目标日期 "12月30日"
返回:
列索引从0开始未找到返回None
"""
if not headers or not target_date:
return None
# 标准化目标日期
target_std = ScheduleDataParser._parse_chinese_date(target_date)
if not target_std:
return None
# 遍历表头查找匹配的日期
for i, header in enumerate(headers):
header_std = ScheduleDataParser._parse_chinese_date(header)
if header_std == target_std:
return i
return None
def parse_monthly_sheet(self, values: List[List[str]], target_date: str) -> Dict:
"""
解析月度表格数据如12月表格
参数:
values: 飞书表格返回的二维数组
target_date: 目标日期(格式: "12月30日""12/30"
返回:
排班信息字典
"""
if not values or len(values) < 2:
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 第一行是表头
headers = values[0]
date_column_index = self._find_date_column_index(headers, target_date)
if date_column_index is None:
logger.warning(f"未找到日期列: {target_date}")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 收集白班和夜班人员
day_shift_names = []
night_shift_names = []
# 从第二行开始是人员数据
for row in values[1:]:
if len(row) <= date_column_index:
continue
name = row[0] if row else ''
shift = row[date_column_index] if date_column_index < len(row) else ''
if not name or not shift:
continue
if shift == '':
day_shift_names.append(name)
elif shift == '':
night_shift_names.append(name)
# 格式化输出
day_shift_str = ''.join(day_shift_names) if day_shift_names else ''
night_shift_str = ''.join(night_shift_names) if night_shift_names else ''
return {
'day_shift': day_shift_str,
'night_shift': night_shift_str,
'day_shift_list': day_shift_names,
'night_shift_list': night_shift_names
}
def parse_yearly_sheet(self, values: List[List[str]], target_date: str) -> Dict:
"""
解析年度表格数据如2026年排班表
参数:
values: 飞书表格返回的二维数组
target_date: 目标日期(格式: "12月30日""12/30"
返回:
排班信息字典
"""
if not values:
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 查找目标月份的数据块
target_month = target_date.split('')[0] if '' in target_date else ''
if not target_month:
logger.warning(f"无法从 {target_date} 提取月份")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 在年度表格中查找对应的月份块
current_block_start = -1
current_month = ''
for i, row in enumerate(values):
if not row:
continue
first_cell = str(row[0]) if row else ''
# 检查是否是月份标题行,如 "福州港1月排班表"
if '排班表' in first_cell and '' in first_cell:
# 提取月份数字
for char in first_cell:
if char.isdigit():
month_str = ''
j = first_cell.index(char)
while j < len(first_cell) and first_cell[j].isdigit():
month_str += first_cell[j]
j += 1
if month_str:
current_month = month_str
current_block_start = i
break
# 如果找到目标月份,检查下一行是否是表头行
if current_month == target_month and i == current_block_start + 1:
# 当前行是表头行
headers = row
date_column_index = self._find_date_column_index(headers, target_date)
if date_column_index is None:
logger.warning(f"在年度表格中未找到日期列: {target_date}")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 收集人员数据(从表头行的下一行开始)
day_shift_names = []
night_shift_names = []
for j in range(i + 1, len(values)):
person_row = values[j]
if not person_row:
# 遇到空行,继续检查下一行
continue
# 检查是否是下一个月份块的开始
if person_row[0] and isinstance(person_row[0], str) and '排班表' in person_row[0] and '' in person_row[0]:
break
# 跳过星期行(第一列为空的行)
if not person_row[0]:
continue
if len(person_row) <= date_column_index:
continue
name = person_row[0] if person_row else ''
shift = person_row[date_column_index] if date_column_index < len(person_row) else ''
if not name or not shift:
continue
if shift == '':
day_shift_names.append(name)
elif shift == '':
night_shift_names.append(name)
# 格式化输出
day_shift_str = ''.join(day_shift_names) if day_shift_names else ''
night_shift_str = ''.join(night_shift_names) if night_shift_names else ''
return {
'day_shift': day_shift_str,
'night_shift': night_shift_str,
'day_shift_list': day_shift_names,
'night_shift_list': night_shift_names
}
logger.warning(f"在年度表格中未找到 {target_month}月 的数据块")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
def parse(self, values: List[List[str]], target_date: str, sheet_title: str = '') -> Dict:
"""
解析排班数据,自动判断表格类型
参数:
values: 飞书表格返回的二维数组
target_date: 目标日期(格式: "12月30日""12/30"
sheet_title: 表格标题,用于判断表格类型
返回:
排班信息字典
"""
# 根据表格标题判断表格类型
if '' in sheet_title and '排班表' in sheet_title:
# 年度表格
logger.info(f"使用年度表格解析器: {sheet_title}")
return self.parse_yearly_sheet(values, target_date)
else:
# 月度表格
logger.info(f"使用月度表格解析器: {sheet_title}")
return self.parse_monthly_sheet(values, target_date)
class FeishuScheduleManagerV2:
"""飞书排班管理器 v2使用数据库存储"""
def __init__(self, base_url: str = None, token: str = None,
spreadsheet_token: str = None):
"""
初始化管理器
参数:
base_url: 飞书API基础URL从环境变量读取
token: 飞书API令牌从环境变量读取
spreadsheet_token: 表格token从环境变量读取
"""
# 从环境变量读取配置
self.base_url = base_url or os.getenv('FEISHU_BASE_URL', 'https://open.feishu.cn/open-apis/sheets/v3')
self.token = token or os.getenv('FEISHU_TOKEN', '')
self.spreadsheet_token = spreadsheet_token or os.getenv('FEISHU_SPREADSHEET_TOKEN', '')
if not self.token or not self.spreadsheet_token:
logger.warning("飞书配置不完整,请检查环境变量")
self.client = FeishuSheetsClient(self.base_url, self.token, self.spreadsheet_token)
self.parser = ScheduleDataParser()
self.db = ScheduleDatabase()
def _select_sheet_for_date(self, sheets: List[Dict], target_year_month: str) -> Optional[Dict]:
"""
为指定日期选择最合适的表格
参数:
sheets: 表格列表
target_year_month: 目标年月,格式 "2025-12"
返回:
选中的表格信息未找到返回None
"""
if not sheets:
return None
# 提取年份和月份
year = target_year_month[:4]
month = target_year_month[5:7]
# 对于2026年优先使用年度表格
if year == '2026':
# 查找年度表格,如 "2026年排班表"
year_name = f"{year}"
for sheet in sheets:
title = sheet.get('title', '')
if year_name in title and '排班表' in title:
logger.info(f"找到2026年年度表格: {title}")
return sheet
# 优先查找月份表格,如 "12月"
month_name = f"{int(month)}"
for sheet in sheets:
title = sheet.get('title', '')
if month_name in title:
logger.info(f"找到月份表格: {title}")
return sheet
# 查找年度表格,如 "2026年排班表"
year_name = f"{year}"
for sheet in sheets:
title = sheet.get('title', '')
if year_name in title and '排班表' in title:
logger.info(f"找到年度表格: {title}")
return sheet
# 如果没有找到匹配的表格,使用第一个表格
logger.warning(f"未找到 {target_year_month} 的匹配表格,使用第一个表格: {sheets[0]['title']}")
return sheets[0]
def get_schedule_for_date(self, date_str: str) -> Dict:
"""
获取指定日期的排班信息
参数:
date_str: 日期字符串,格式 "2025-12-30"
返回:
排班信息字典
"""
try:
# 解析日期
dt = datetime.strptime(date_str, '%Y-%m-%d')
# 生成两种格式的日期字符串,用于匹配不同表格
target_date_mm_dd = dt.strftime('%m/%d') # "01/01" 用于月度表格
target_date_chinese = f"{dt.month}{dt.day}" # "1月1日" 用于年度表格
target_year_month = dt.strftime('%Y-%m') # "2025-12"
logger.info(f"获取 {date_str} 的排班信息 (格式: {target_date_mm_dd}/{target_date_chinese})")
# 1. 首先尝试从数据库获取
cached_schedule = self.db.get_schedule(date_str)
if cached_schedule:
logger.info(f"从数据库获取 {date_str} 的排班信息")
return {
'day_shift': cached_schedule['day_shift'],
'night_shift': cached_schedule['night_shift'],
'day_shift_list': cached_schedule['day_shift_list'],
'night_shift_list': cached_schedule['night_shift_list']
}
# 2. 数据库中没有,需要从飞书获取
logger.info(f"数据库中没有 {date_str} 的排班信息,从飞书获取")
# 获取表格信息
sheets = self.client.get_sheets_info()
if not sheets:
logger.error("未获取到表格信息")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 选择最合适的表格
selected_sheet = self._select_sheet_for_date(sheets, target_year_month)
if not selected_sheet:
logger.error("未找到合适的表格")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
sheet_id = selected_sheet['sheet_id']
sheet_title = selected_sheet['title']
# 3. 获取表格数据
sheet_data = self.client.get_sheet_data(sheet_id)
if not sheet_data:
logger.error("未获取到表格数据")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
values = sheet_data.get('valueRange', {}).get('values', [])
revision = sheet_data.get('revision', 0)
if not values:
logger.error("表格数据为空")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 4. 检查表格是否有更新
need_update = self.db.check_sheet_update(
sheet_id, sheet_title, revision, {'values': values}
)
if not need_update and cached_schedule:
# 表格无更新,且数据库中有缓存,直接返回
logger.info(f"表格无更新,使用数据库缓存")
return {
'day_shift': cached_schedule['day_shift'],
'night_shift': cached_schedule['night_shift'],
'day_shift_list': cached_schedule['day_shift_list'],
'night_shift_list': cached_schedule['night_shift_list']
}
# 5. 解析数据 - 根据表格类型选择合适的日期格式
# 如果是年度表格使用中文日期格式否则使用mm/dd格式
if '' in sheet_title and '排班表' in sheet_title:
target_date = target_date_chinese # "1月1日"
else:
target_date = target_date_mm_dd # "01/01"
logger.info(f"使用日期格式: {target_date} 解析表格: {sheet_title}")
result = self.parser.parse(values, target_date, sheet_title)
# 6. 保存到数据库
if result['day_shift'] or result['night_shift']:
self.db.save_schedule(date_str, result, sheet_id, sheet_title)
logger.info(f"已保存 {date_str} 的排班信息到数据库")
return result
except Exception as e:
logger.error(f"获取排班信息失败: {e}")
# 降级处理:返回空值
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
def get_schedule_for_today(self) -> Dict:
"""获取今天的排班信息"""
today = datetime.now().strftime('%Y-%m-%d')
return self.get_schedule_for_date(today)
def get_schedule_for_tomorrow(self) -> Dict:
"""获取明天的排班信息"""
tomorrow = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
return self.get_schedule_for_date(tomorrow)
def refresh_all_schedules(self, days: int = 30):
"""
刷新未来指定天数的排班信息
参数:
days: 刷新未来多少天的排班信息
"""
logger.info(f"开始刷新未来 {days} 天的排班信息")
today = datetime.now()
for i in range(days):
date = (today + timedelta(days=i)).strftime('%Y-%m-%d')
logger.info(f"刷新 {date} 的排班信息...")
self.get_schedule_for_date(date)
logger.info(f"排班信息刷新完成")
if __name__ == '__main__':
# 测试代码
import sys
# 设置日志
logging.basicConfig(level=logging.INFO)
# 从环境变量读取配置
manager = FeishuScheduleManagerV2()
if len(sys.argv) > 1:
date_str = sys.argv[1]
else:
date_str = datetime.now().strftime('%Y-%m-%d')
print(f"获取 {date_str} 的排班信息...")
schedule = manager.get_schedule_for_date(date_str)
print(f"白班人员: {schedule['day_shift']}")
print(f"夜班人员: {schedule['night_shift']}")
print(f"白班列表: {schedule['day_shift_list']}")
print(f"夜班列表: {schedule['night_shift_list']}")