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. 日报中正确显示次日班次人员信息
This commit is contained in:
2025-12-31 00:03:34 +08:00
parent 272d0156bb
commit dc2a55bbf4
10 changed files with 1908 additions and 13 deletions

View File

@@ -2,3 +2,8 @@
CONFLUENCE_BASE_URL=https://confluence.westwell-lab.com/rest/api
CONFLUENCE_TOKEN=your-token-here
CONFLUENCE_CONTENT_ID=155764524
# 飞书表格配置
FEISHU_BASE_URL=https://open.feishu.cn/open-apis/sheets/v3
FEISHU_TOKEN=your-feishu-api-token
FEISHU_SPREADSHEET_TOKEN=EgNPssi2ghZ7BLtGiTxcIBUmnVh

View File

@@ -22,7 +22,10 @@ OrbitIn/
├── debug/ # 调试输出目录
│ └── layout_output_*.txt # 带时间戳的调试文件
├── data/ # 数据目录
── daily_logs.db # SQLite3 数据库
── daily_logs.db # SQLite3 数据库
│ └── schedule_cache.json # 排班数据缓存
├── plans/ # 设计文档目录
│ └── feishu_scheduling_plan.md # 飞书排班表模块设计
└── src/ # 代码模块
├── __init__.py
├── confluence.py # Confluence API 客户端
@@ -30,7 +33,8 @@ OrbitIn/
├── parser.py # 日志解析器
├── database.py # SQLite3 数据库操作
├── report.py # 日报生成器
── gui.py # GUI 图形界面
── gui.py # GUI 图形界面
└── feishu.py # 飞书表格 API 客户端(新增)
```
## 核心模块
@@ -69,6 +73,7 @@ OrbitIn/
- `generate_report(date)` - 生成日报
- `print_report(date)` - 打印日报
- `get_shift_personnel(date)` - 获取班次人员(从飞书排班表获取)
### OrbitInGUI (src/gui.py:22)
@@ -76,6 +81,12 @@ OrbitIn/
- 支持获取数据、生成日报、添加未统计数据
- 日报内容可复制
### FeishuScheduleManager (src/feishu.py:150)
- `get_schedule_for_date(date)` - 获取指定日期的排班信息
- `get_schedule_for_today()` - 获取今天的排班信息
- `get_schedule_for_tomorrow()` - 获取明天的排班信息
## 文本格式约定
- 列表前缀:`•` 用于 `ul`,数字+点用于 `ol`
@@ -113,12 +124,18 @@ python3 src/gui.py
## 配置
`.env` 文件中配置 Confluence 连接信息:
`.env` 文件中配置连接信息:
```bash
# Confluence 配置
CONFLUENCE_BASE_URL=https://confluence.westwell-lab.com/rest/api
CONFLUENCE_TOKEN=your-api-token
CONFLUENCE_CONTENT_ID=155764524
# 飞书表格配置(用于获取排班人员信息)
FEISHU_BASE_URL=https://open.feishu.cn/open-apis/sheets/v3
FEISHU_TOKEN=your-feishu-api-token
FEISHU_SPREADSHEET_TOKEN=EgNPssi2ghZ7BLtGiTxcIBUmnVh
```
## 测试模式

179
docs/feishu_data_flow.md Normal file
View File

@@ -0,0 +1,179 @@
# 飞书数据获取流程
## 整体流程
```mermaid
flowchart TD
A[开始: 生成日报] --> B[调用 get_shift_personnel]
B --> C[创建 FeishuScheduleManager]
C --> D[调用 get_schedule_for_date]
D --> E[解析日期: 2025-12-30 → 12/30]
E --> F[检查缓存: data/schedule_cache.json]
F --> G{缓存是否存在?}
G -->|是| H[直接返回缓存数据]
G -->|否| I[调用 API 获取数据]
I --> J[调用 get_sheets_info]
J --> K[GET /spreadsheets/{token}/sheets/query]
K --> L[返回表格列表: 8月, 9月, 10月, 11月, 12月, 2026年...]
L --> M[根据月份选择表格: 12月 → sheet_id='zcYLIk']
M --> N[调用 get_sheet_data]
N --> O[GET /spreadsheets/{token}/values/zcYLIk!A:AF]
O --> P[返回表格数据: 姓名, 12月1日, 12月2日...]
P --> Q[调用 ScheduleDataParser.parse]
Q --> R[解析日期列: 查找12月30日对应的列索引]
R --> S[筛选班次人员: 白班='白', 夜班='夜']
S --> T[返回结果: 白班人员列表, 夜班人员列表]
T --> U[保存到缓存]
U --> V[返回给日报模块]
V --> W[填充到日报中]
```
## API调用详情
### 1. 获取表格列表
**请求**:
```
GET https://open.feishu.cn/open-apis/sheets/v3/spreadsheets/EgNPssi2ghZ7BLtGiTxcIBUmnVh/sheets/query
Authorization: Bearer u-dbctiP9qx1wF.wfoMV2ZHGkh1DNl14oriM8aZMI0026k
```
**响应**:
```json
{
"code": 0,
"data": {
"sheets": [
{"sheet_id": "904236", "title": "8月"},
{"sheet_id": "ATgwLm", "title": "9月"},
{"sheet_id": "2ml4B0", "title": "10月"},
{"sheet_id": "y5xv1D", "title": "11月"},
{"sheet_id": "zcYLIk", "title": "12月"},
{"sheet_id": "R35cIj", "title": "2026年排班表"},
{"sheet_id": "wMXHQg", "title": "12月副本"}
]
}
}
```
### 2. 获取表格数据
**请求**:
```
GET https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/EgNPssi2ghZ7BLtGiTxcIBUmnVh/values/zcYLIk!A:AF
Authorization: Bearer u-dbctiP9qx1wF.wfoMV2ZHGkh1DNl14oriM8aZMI0026k
params: {
valueRenderOption: "ToString",
dateTimeRenderOption: "FormattedString"
}
```
**响应**:
```json
{
"code": 0,
"data": {
"valueRange": {
"range": "zcYLIk!A1:AF11",
"values": [
["姓名", "12月1日", "12月2日", "12月3日", "12月4日", ...],
["张勤", "白", "白", "白", "白", ...],
["刘炜彬", "白", null, "夜", "夜", ...],
["杨俊豪", "白", "白", "白", "白", ...],
["梁启迟", "夜", "夜", "夜", "夜", ...],
...
]
}
}
}
```
## 数据解析流程
### 1. 查找日期列索引
```python
# 查找 "12月30日" 在表头中的位置
headers = ["姓名", "12月1日", "12月2日", ..., "12月30日", ...]
target = "12/30" # 从 "2025-12-30" 转换而来
# 遍历表头找到匹配的日期
for i, header in enumerate(headers):
if header == "12月30日":
column_index = i
break
# 结果: column_index = 31 (第32列)
```
### 2. 筛选班次人员
```python
# 遍历所有人员行
for row in values[1:]: # 跳过表头
name = row[0] # 姓名
shift = row[31] # 12月30日的班次
if shift == "白":
day_shift_list.append(name)
elif shift == "夜":
night_shift_list.append(name)
# 结果
# day_shift_list = ["张勤", "杨俊豪", "冯栋", "汪钦良"]
# night_shift_list = ["刘炜彬", "梁启迟"]
```
### 3. 生成日报输出
```python
day_shift_str = "、".join(day_shift_list) # "张勤、杨俊豪、冯栋、汪钦良"
night_shift_str = "、".join(night_shift_list) # "刘炜彬、梁启迟"
# 日报中的格式
lines.append(f"12/31 白班人员:{day_shift_str}")
lines.append(f"12/31 夜班人员:{night_shift_str}")
```
## 缓存机制
```mermaid
flowchart LR
A[首次请求] --> B[调用API]
B --> C[保存缓存: data/schedule_cache.json]
C --> D{"1小时内再次请求"}
D -->|是| E[直接读取缓存]
D -->|否| F[重新调用API]
```
缓存文件格式:
```json
{
"last_update": "2025-12-30T15:00:00",
"data": {
"2025-12-12/30": {
"day_shift": "张勤、杨俊豪、冯栋、汪钦良",
"night_shift": "刘炜彬、梁启迟",
"day_shift_list": ["张勤", "杨俊豪", "冯栋", "汪钦良"],
"night_shift_list": ["刘炜彬", "梁启迟"]
}
}
}
```
## 关键代码位置
| 功能 | 文件 | 行号 |
|------|------|------|
| 飞书API客户端 | [`src/feishu.py`](src/feishu.py:10) | 10 |
| 获取表格列表 | [`src/feishu.py`](src/feishu.py:28) | 28 |
| 获取表格数据 | [`src/feishu.py`](src/feishu.py:42) | 42 |
| 数据解析器 | [`src/feishu.py`](src/feishu.py:58) | 58 |
| 缓存管理 | [`src/feishu.py`](src/feishu.py:150) | 150 |
| 主管理器 | [`src/feishu.py`](src/feishu.py:190) | 190 |
| 日报集成 | [`src/report.py`](src/report.py:98) | 98 |

View File

@@ -23,6 +23,11 @@ CONF_BASE_URL = os.getenv('CONFLUENCE_BASE_URL')
CONF_TOKEN = os.getenv('CONFLUENCE_TOKEN')
CONF_CONTENT_ID = os.getenv('CONFLUENCE_CONTENT_ID')
# 飞书配置(可选)
FEISHU_BASE_URL = os.getenv('FEISHU_BASE_URL')
FEISHU_TOKEN = os.getenv('FEISHU_TOKEN')
FEISHU_SPREADSHEET_TOKEN = os.getenv('FEISHU_SPREADSHEET_TOKEN')
DEBUG_DIR = 'debug'

View File

@@ -0,0 +1,131 @@
# 飞书排班表模块实施计划
## 概述
添加飞书表格模块,用于获取码头作业人员的排班信息,并将人员姓名填充到日报中。
## 数据结构
### 飞书排班表格式
```
列A: 姓名
列B-列AF: 12月1日-12月31日的班次白/夜/null
```
### 日报表填充逻辑
- 根据日报中的日期(如 12/30对应排班表中的日期列
- 筛选出当天的白班人员名单和夜班人员名单
- 格式:`人员1、人员2、人员3...`
## 实现步骤
### 1. 创建 `src/feishu.py` 模块
#### FeishuSheetsClient 类
```python
class FeishuSheetsClient:
"""飞书表格 API 客户端"""
def __init__(self, base_url: str, token: str, spreadsheet_token: str):
# 初始化配置
def get_sheets_info(self) -> List[Dict]:
"""获取所有表格信息sheet_id 和 title"""
# GET /spreadsheets/{spreadsheet_token}/sheets/query
def get_sheet_data(self, sheet_id: str, range_: str = 'A:AF') -> Dict:
"""获取指定表格的数据"""
# GET /spreadsheets/{spreadsheet_token}/values/{sheet_id}?range={range_}
```
#### ScheduleDataParser 类
```python
class ScheduleDataParser:
"""排班数据解析器"""
def parse(self, values: List[List[str]], target_date: str) -> Dict:
"""
解析排班数据,获取指定日期的班次人员
参数:
values: 飞书表格返回的二维数组
target_date: 目标日期(格式: 12月30日 或 12/30
返回:
{
'day_shift': '张勤、刘炜彬、杨俊豪',
'night_shift': '梁启迟、江唯、汪钦良'
}
"""
```
### 2. 修改 `src/report.py`
`DailyReportGenerator` 类中:
```python
def get_shift_personnel(self, date: str) -> Dict:
"""获取班次人员(从飞书排班表获取)"""
# 使用 FeishuSheetsClient 获取排班数据
# 使用 ScheduleDataParser 解析人员名单
# 返回 day_shift 和 night_shift
```
### 3. 更新 `.env.example`
```bash
# 飞书表格配置
FEISHU_BASE_URL=https://open.feishu.cn/open-apis/sheets/v3
FEISHU_TOKEN=your-feishu-api-token
FEISHU_SPREADSHEET_TOKEN=EgNPssi2ghZ7BLtGiTxcIBUmnVh
```
### 4. 添加缓存机制
为减少 API 调用,实现简单的缓存:
- 缓存有效期1小时
- 缓存位置:`data/schedule_cache.json`
- 缓存格式:
```json
{
"last_update": "2025-12-30T10:00:00",
"data": {...}
}
```
## 技术决策
### 日期匹配逻辑
1. **日报日期到排班日期的映射**
- 日报中的 `12/30` 对应排班表中的 `12月30日`
- 班次人员需要显示在对应日期的下一班(白班→夜班→次日白班)
2. **sheet_id 选择策略**
- 2025年每月一张表需要根据年月选择对应的 sheet
- 2026年12个月整合在一块使用单一 sheet
### 错误处理
- API 调用失败时降级使用默认配置(如返回空的值班手机)
- 缓存过期时重新获取
- 日期匹配失败时返回空列表
## 文件变更
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/feishu.py` | 新建 | 飞书表格客户端模块 |
| `src/report.py` | 修改 | 集成人员信息获取 |
| `.env.example` | 修改 | 添加飞书配置 |
| `main.py` | 修改 | 添加飞书相关配置加载 |
## 测试用例
1. **正常流程**:获取 12 月 30 日的班次人员
2. **边界情况**:跨月日期匹配
3. **错误处理**API 调用失败时的降级策略
4. **缓存测试**:验证缓存生效和过期逻辑

467
src/feishu.py Normal file
View File

@@ -0,0 +1,467 @@
#!/usr/bin/env python3
"""
飞书表格 API 客户端模块
用于获取码头作业人员排班信息
"""
import requests
import json
import os
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import logging
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返回的原始数据
"""
# 注意:获取表格数据使用 v2 API而不是 v3
# 根据你提供的示例GET 'https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{token}/values/{sheet_id}!A:AF'
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:
"""排班数据解析器"""
@staticmethod
def _parse_chinese_date(date_str: str) -> Optional[str]:
"""
解析中文日期格式
参数:
date_str: 中文日期,如 "12月30日""12/30""12月1日"
返回:
标准化日期字符串 "MM月DD日"
"""
if not date_str:
return None
# 如果是 "12/30" 格式
if '/' in date_str:
try:
month, day = date_str.split('/')
# 移除可能的空格
month = month.strip()
day = day.strip()
return f"{int(month)}{int(day)}"
except:
return None
# 如果是 "12月30日" 格式
if '' in date_str and '' in date_str:
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(self, values: List[List[str]], target_date: str) -> Dict:
"""
解析排班数据,获取指定日期的班次人员
参数:
values: 飞书表格返回的二维数组
target_date: 目标日期(格式: "12月30日""12/30"
返回:
{
'day_shift': '张勤、刘炜彬、杨俊豪',
'night_shift': '梁启迟、江唯、汪钦良',
'day_shift_list': ['张勤', '刘炜彬', '杨俊豪'],
'night_shift_list': ['梁启迟', '江唯', '汪钦良']
}
"""
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
}
class ScheduleCache:
"""排班数据缓存"""
def __init__(self, cache_file: str = 'data/schedule_cache.json'):
self.cache_file = cache_file
self.cache_ttl = 3600 # 1小时
def load(self) -> Optional[Dict]:
"""加载缓存"""
try:
if not os.path.exists(self.cache_file):
return None
with open(self.cache_file, 'r', encoding='utf-8') as f:
cache_data = json.load(f)
# 检查缓存是否过期
last_update = cache_data.get('last_update')
if last_update:
last_time = datetime.fromisoformat(last_update)
if (datetime.now() - last_time).total_seconds() < self.cache_ttl:
return cache_data.get('data')
return None
except Exception as e:
logger.warning(f"加载缓存失败: {e}")
return None
def save(self, data: Dict):
"""保存缓存"""
try:
# 确保目录存在
os.makedirs(os.path.dirname(self.cache_file), exist_ok=True)
cache_data = {
'last_update': datetime.now().isoformat(),
'data': data
}
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
logger.info(f"缓存已保存到 {self.cache_file}")
except Exception as e:
logger.error(f"保存缓存失败: {e}")
class FeishuScheduleManager:
"""飞书排班管理器(主入口)"""
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.cache = ScheduleCache()
def get_schedule_for_date(self, date_str: str, use_cache: bool = True) -> Dict:
"""
获取指定日期的排班信息
参数:
date_str: 日期字符串,格式 "2025-12-30""12/30"
use_cache: 是否使用缓存
返回:
排班信息字典
"""
# 转换日期格式
try:
if '-' in date_str:
# "2025-12-30" -> "12/30"
dt = datetime.strptime(date_str, '%Y-%m-%d')
target_date = dt.strftime('%m/%d')
year_month = dt.strftime('%Y-%m')
month_name = dt.strftime('%m月') # "12月"
else:
# "12/30" -> "12/30"
target_date = date_str
# 假设当前年份
current_year = datetime.now().year
month = int(date_str.split('/')[0])
year_month = f"{current_year}-{month:02d}"
month_name = f"{month}"
except:
target_date = date_str
year_month = datetime.now().strftime('%Y-%m')
month_name = datetime.now().strftime('%m月')
# 尝试从缓存获取
if use_cache:
cached_data = self.cache.load()
cache_key = f"{year_month}_{target_date}"
if cached_data and cache_key in cached_data:
logger.info(f"从缓存获取 {cache_key} 的排班信息")
return cached_data[cache_key]
# 获取表格信息
sheets = self.client.get_sheets_info()
if not sheets:
logger.error("未获取到表格信息")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 根据月份选择对应的表格
sheet_id = None
sheet_title = None
# 优先查找月份表格,如 "12月"
for sheet in sheets:
title = sheet.get('title', '')
if month_name in title:
sheet_id = sheet['sheet_id']
sheet_title = title
logger.info(f"找到月份表格: {title} (ID: {sheet_id})")
break
# 如果没有找到月份表格,使用第一个表格
if not sheet_id and sheets:
sheet_id = sheets[0]['sheet_id']
sheet_title = sheets[0]['title']
logger.warning(f"未找到 {month_name} 表格,使用第一个表格: {sheet_title}")
if not sheet_id:
logger.error("未找到可用的表格")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 获取表格数据
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', [])
if not values:
logger.error("表格数据为空")
return {
'day_shift': '',
'night_shift': '',
'day_shift_list': [],
'night_shift_list': []
}
# 解析数据
result = self.parser.parse(values, target_date)
# 更新缓存
if use_cache:
cached_data = self.cache.load() or {}
cache_key = f"{year_month}_{target_date}"
cached_data[cache_key] = result
self.cache.save(cached_data)
return result
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)
if __name__ == '__main__':
# 测试代码
import sys
# 设置日志
logging.basicConfig(level=logging.INFO)
# 从环境变量读取配置
manager = FeishuScheduleManager()
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']}")

642
src/feishu_v2.py Normal file
View File

@@ -0,0 +1,642 @@
#!/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']}")

View File

@@ -187,8 +187,8 @@ class OrbitInGUI:
self.log_message("=" * 50)
self.log_message("按 Ctrl+Enter 快速获取数据")
# 默认显示今日日报
self.generate_today_report()
# 启动时自动获取新数据
self.root.after(100, self.auto_fetch_data)
def log_message(self, message, is_error=False):
"""输出日志消息"""
@@ -413,6 +413,93 @@ class OrbitInGUI:
self.log_message(f"错误: {e}", is_error=True)
self.set_status("错误")
def auto_fetch_data(self):
"""自动获取新数据GUI启动时调用"""
self.set_status("正在自动获取新数据...")
self.log_message("GUI启动开始自动获取新数据...")
try:
# 1. 检查飞书配置,如果配置完整则刷新排班信息
from dotenv import load_dotenv
load_dotenv()
feishu_token = os.getenv('FEISHU_TOKEN')
feishu_spreadsheet_token = os.getenv('FEISHU_SPREADSHEET_TOKEN')
if feishu_token and feishu_spreadsheet_token:
try:
self.log_message("正在刷新排班信息...")
from src.feishu_v2 import FeishuScheduleManagerV2
feishu_manager = FeishuScheduleManagerV2()
# 只刷新未来7天的排班减少API调用
feishu_manager.refresh_all_schedules(days=7)
self.log_message("排班信息刷新完成")
except Exception as e:
self.log_message(f"刷新排班信息时出错: {e}", is_error=True)
self.log_message("将继续处理其他任务...")
else:
self.log_message("飞书配置不完整,跳过排班信息刷新")
# 2. 尝试获取最新的作业数据
self.log_message("正在尝试获取最新作业数据...")
base_url = os.getenv('CONFLUENCE_BASE_URL')
token = os.getenv('CONFLUENCE_TOKEN')
content_id = os.getenv('CONFLUENCE_CONTENT_ID')
if base_url and token and content_id:
try:
# 获取 HTML
self.log_message("正在从 Confluence 获取 HTML...")
from src.confluence import ConfluenceClient
client = ConfluenceClient(base_url, token)
html = client.get_html(content_id)
if html:
self.log_message(f"获取成功,共 {len(html)} 字符")
# 提取文本
self.log_message("正在提取布局文本...")
from src.extractor import HTMLTextExtractor
extractor = HTMLTextExtractor()
layout_text = extractor.extract(html)
# 解析数据
self.log_message("正在解析日志数据...")
from src.parser import HandoverLogParser
parser = HandoverLogParser()
logs = parser.parse(layout_text)
if logs:
# 保存到数据库
self.log_message("正在保存到数据库...")
db = DailyLogsDatabase()
count = db.insert_many([log.to_dict() for log in logs])
db.close()
self.log_message(f"已保存 {count} 条新记录")
else:
self.log_message("未解析到新记录")
else:
self.log_message("未获取到 HTML 内容,跳过数据获取")
except Exception as e:
self.log_message(f"获取作业数据时出错: {e}", is_error=True)
else:
self.log_message("Confluence 配置不完整,跳过数据获取")
# 3. 显示今日日报
self.log_message("正在生成今日日报...")
self.generate_today_report()
self.set_status("就绪")
self.log_message("自动获取完成GUI已就绪")
except Exception as e:
self.log_message(f"自动获取过程中出现错误: {e}", is_error=True)
self.log_message("将继续显示GUI界面...")
self.set_status("就绪")
# 即使出错也显示今日日报
self.generate_today_report()
def show_stats(self):
"""显示数据库统计"""
self.set_status("正在统计...")

View File

@@ -6,11 +6,15 @@ from datetime import datetime, timedelta
from typing import Dict, List, Optional
import sys
import os
import logging
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.database import DailyLogsDatabase
from src.feishu_v2 import FeishuScheduleManagerV2 as FeishuScheduleManager
logger = logging.getLogger(__name__)
class DailyReportGenerator:
@@ -96,14 +100,49 @@ class DailyReportGenerator:
}
def get_shift_personnel(self, date: str) -> Dict:
"""获取班次人员(从日志文本中解析,需要配合 parser 使用)"""
# 目前数据库中没有人员信息,返回空
# 可以后续扩展添加人员追踪功能
return {
'day_shift': '',
'night_shift': '',
'duty_phone': '13107662315'
}
"""
获取班次人员(从飞书排班表获取)
注意:日报中显示的是次日的班次人员,所以需要获取 date+1 的排班
例如:生成 12/29 的日报,显示的是 12/30 的人员
"""
try:
# 初始化飞书排班管理器
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': '13107662315'
}
# 如果飞书数据为空,返回空值
logger.warning(f"无法从飞书获取 {tomorrow} 的排班信息")
return {
'day_shift': '',
'night_shift': '',
'duty_phone': '13107662315'
}
except Exception as e:
logger.error(f"获取排班信息失败: {e}")
# 降级处理:返回空值
return {
'day_shift': '',
'night_shift': '',
'duty_phone': '13107662315'
}
def generate_report(self, date: str = None) -> str:
"""生成日报"""

323
src/schedule_database.py Normal file
View File

@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""
排班人员数据库模块
"""
import sqlite3
import os
import json
from datetime import datetime
from typing import List, Dict, Optional, Tuple
import hashlib
class ScheduleDatabase:
"""排班人员数据库"""
def __init__(self, db_path: str = 'data/daily_logs.db'):
"""
初始化数据库
参数:
db_path: 数据库文件路径
"""
self.db_path = db_path
self._ensure_directory()
self.conn = self._connect()
self._init_schema()
def _ensure_directory(self):
"""确保数据目录存在"""
data_dir = os.path.dirname(self.db_path)
if data_dir and not os.path.exists(data_dir):
os.makedirs(data_dir)
def _connect(self) -> sqlite3.Connection:
"""连接数据库"""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_schema(self):
"""初始化表结构"""
cursor = self.conn.cursor()
# 创建排班人员表
cursor.execute('''
CREATE TABLE IF NOT EXISTS schedule_personnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
day_shift TEXT,
night_shift TEXT,
day_shift_list TEXT, -- JSON数组
night_shift_list TEXT, -- JSON数组
sheet_id TEXT,
sheet_title TEXT,
data_hash TEXT, -- 数据哈希,用于检测更新
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date)
)
''')
# 创建表格版本表(用于检测表格是否有更新)
cursor.execute('''
CREATE TABLE IF NOT EXISTS sheet_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id TEXT NOT NULL,
sheet_title TEXT NOT NULL,
revision INTEGER NOT NULL,
data_hash TEXT,
last_checked_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(sheet_id)
)
''')
# 索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_schedule_date ON schedule_personnel(date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_schedule_sheet ON schedule_personnel(sheet_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sheet_versions ON sheet_versions(sheet_id)')
self.conn.commit()
def _calculate_hash(self, data: Dict) -> str:
"""计算数据哈希值"""
data_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
return hashlib.md5(data_str.encode('utf-8')).hexdigest()
def check_sheet_update(self, sheet_id: str, sheet_title: str, revision: int, data: Dict) -> bool:
"""
检查表格是否有更新
参数:
sheet_id: 表格ID
sheet_title: 表格标题
revision: 表格版本号
data: 表格数据
返回:
True: 有更新,需要重新获取
False: 无更新,可以使用缓存
"""
cursor = self.conn.cursor()
# 查询当前版本
cursor.execute(
'SELECT revision, data_hash FROM sheet_versions WHERE sheet_id = ?',
(sheet_id,)
)
result = cursor.fetchone()
if not result:
# 第一次获取,记录版本
data_hash = self._calculate_hash(data)
cursor.execute('''
INSERT INTO sheet_versions (sheet_id, sheet_title, revision, data_hash, last_checked_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (sheet_id, sheet_title, revision, data_hash))
self.conn.commit()
return True
# 检查版本号或数据是否有变化
old_revision = result['revision']
old_hash = result['data_hash']
new_hash = self._calculate_hash(data)
if old_revision != revision or old_hash != new_hash:
# 有更新,更新版本信息
cursor.execute('''
UPDATE sheet_versions
SET revision = ?, data_hash = ?, last_checked_at = CURRENT_TIMESTAMP
WHERE sheet_id = ?
''', (revision, new_hash, sheet_id))
self.conn.commit()
return True
# 无更新,更新检查时间
cursor.execute('''
UPDATE sheet_versions
SET last_checked_at = CURRENT_TIMESTAMP
WHERE sheet_id = ?
''', (sheet_id,))
self.conn.commit()
return False
def save_schedule(self, date: str, schedule_data: Dict, sheet_id: str = None, sheet_title: str = None) -> bool:
"""
保存排班信息到数据库
参数:
date: 日期 (YYYY-MM-DD)
schedule_data: 排班数据
sheet_id: 表格ID
sheet_title: 表格标题
返回:
是否成功
"""
try:
cursor = self.conn.cursor()
# 准备数据
day_shift = schedule_data.get('day_shift', '')
night_shift = schedule_data.get('night_shift', '')
day_shift_list = json.dumps(schedule_data.get('day_shift_list', []), ensure_ascii=False)
night_shift_list = json.dumps(schedule_data.get('night_shift_list', []), ensure_ascii=False)
data_hash = self._calculate_hash(schedule_data)
# 使用 INSERT OR REPLACE 来更新已存在的记录
cursor.execute('''
INSERT OR REPLACE INTO schedule_personnel
(date, day_shift, night_shift, day_shift_list, night_shift_list,
sheet_id, sheet_title, data_hash, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (
date, day_shift, night_shift, day_shift_list, night_shift_list,
sheet_id, sheet_title, data_hash
))
self.conn.commit()
return True
except sqlite3.Error as e:
print(f"数据库错误: {e}")
return False
def get_schedule(self, date: str) -> Optional[Dict]:
"""
获取指定日期的排班信息
参数:
date: 日期 (YYYY-MM-DD)
返回:
排班信息字典未找到返回None
"""
cursor = self.conn.cursor()
cursor.execute(
'SELECT * FROM schedule_personnel WHERE date = ?',
(date,)
)
result = cursor.fetchone()
if not result:
return None
# 解析JSON数组
day_shift_list = json.loads(result['day_shift_list']) if result['day_shift_list'] else []
night_shift_list = json.loads(result['night_shift_list']) if result['night_shift_list'] else []
return {
'date': result['date'],
'day_shift': result['day_shift'],
'night_shift': result['night_shift'],
'day_shift_list': day_shift_list,
'night_shift_list': night_shift_list,
'sheet_id': result['sheet_id'],
'sheet_title': result['sheet_title'],
'updated_at': result['updated_at']
}
def get_schedule_by_range(self, start_date: str, end_date: str) -> List[Dict]:
"""
获取日期范围内的排班信息
参数:
start_date: 开始日期 (YYYY-MM-DD)
end_date: 结束日期 (YYYY-MM-DD)
返回:
排班信息列表
"""
cursor = self.conn.cursor()
cursor.execute('''
SELECT * FROM schedule_personnel
WHERE date >= ? AND date <= ?
ORDER BY date
''', (start_date, end_date))
results = []
for row in cursor.fetchall():
day_shift_list = json.loads(row['day_shift_list']) if row['day_shift_list'] else []
night_shift_list = json.loads(row['night_shift_list']) if row['night_shift_list'] else []
results.append({
'date': row['date'],
'day_shift': row['day_shift'],
'night_shift': row['night_shift'],
'day_shift_list': day_shift_list,
'night_shift_list': night_shift_list,
'sheet_id': row['sheet_id'],
'sheet_title': row['sheet_title'],
'updated_at': row['updated_at']
})
return results
def delete_old_schedules(self, before_date: str) -> int:
"""
删除指定日期之前的排班记录
参数:
before_date: 日期 (YYYY-MM-DD)
返回:
删除的记录数
"""
cursor = self.conn.cursor()
cursor.execute(
'DELETE FROM schedule_personnel WHERE date < ?',
(before_date,)
)
deleted_count = cursor.rowcount
self.conn.commit()
return deleted_count
def get_stats(self) -> Dict:
"""获取统计信息"""
cursor = self.conn.cursor()
cursor.execute('SELECT COUNT(*) FROM schedule_personnel')
total = cursor.fetchone()[0]
cursor.execute('SELECT MIN(date), MAX(date) FROM schedule_personnel')
date_range = cursor.fetchone()
cursor.execute('SELECT COUNT(DISTINCT sheet_id) FROM schedule_personnel')
sheet_count = cursor.fetchone()[0]
return {
'total': total,
'date_range': {'start': date_range[0], 'end': date_range[1]},
'sheet_count': sheet_count
}
def close(self):
"""关闭连接"""
if self.conn:
self.conn.close()
if __name__ == '__main__':
# 测试代码
db = ScheduleDatabase()
# 测试保存
test_schedule = {
'day_shift': '张勤、杨俊豪',
'night_shift': '刘炜彬、梁启迟',
'day_shift_list': ['张勤', '杨俊豪'],
'night_shift_list': ['刘炜彬', '梁启迟']
}
success = db.save_schedule('2025-12-31', test_schedule, 'zcYLIk', '12月')
print(f"保存成功: {success}")
# 测试获取
schedule = db.get_schedule('2025-12-31')
print(f"获取结果: {schedule}")
# 测试统计
stats = db.get_stats()
print(f"统计: {stats}")
db.close()