Files
EP-Hub-Skill/.agents/skills/dingtalk-misc/scripts/attendance_schedule_export.py
T
2026-09-02 11:44:52 +08:00

345 lines
13 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
"""
考勤排班查询导出脚本
[AI Agent 强制门禁] 本脚本执行前必须先阅读:
references/attendance-schedule.md
职责:
1. 分批查询排班记录(支持大量用户自动分批)
2. 将 classId 转为班次名称
3. 将 userId 转为员工姓名
4. 输出日历表格式的排班表 Excel(行=员工,列=日期,单元格=班次名称)
用法:
python attendance_schedule_export.py \
--users userId1,userId2,userId3 \
--start 2026-05-19 --end 2026-05-23
python attendance_schedule_export.py \
--users userId1,userId2 \
--start 2026-05-01 --end 2026-05-31 \
--output my_schedule.xlsx
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime, timedelta
from typing import Any
from attendance_report_common import (
DATE_FMT,
DATETIME_FMT,
DwsCallError,
chunk_users,
error,
extract_records,
log,
parse_datetime_arg,
resolve_user_names,
run_dws,
warn,
write_excel,
)
# schedule get 接口每批最多用户数(保守值,避免超时)
SCHEDULE_BATCH_SIZE = 20
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
# ─────────────────────────────────────────────────────────────────────────────
# 排班数据查询(分批)
# ─────────────────────────────────────────────────────────────────────────────
def fetch_schedule_batch(
user_ids: list[str],
start_date: str,
end_date: str,
) -> list[dict]:
"""调用 dws attendance schedule get 查询一批用户的排班记录。"""
users_str = ",".join(user_ids)
try:
result = run_dws([
"attendance", "schedule", "get",
"--users", users_str,
"--start", start_date,
"--end", end_date,
])
except DwsCallError as exc:
error(f"查询排班失败 (users={len(user_ids)}, {start_date}~{end_date}): {exc}")
return []
return extract_records(result) if result else []
def fetch_all_schedules(
user_ids: list[str],
start_date: str,
end_date: str,
) -> list[dict]:
"""分批查询所有用户的排班记录,自动处理用户数超限。"""
all_records: list[dict] = []
batches = chunk_users(user_ids, SCHEDULE_BATCH_SIZE)
total = len(batches)
log(f"📋 共 {len(user_ids)} 人,分 {total} 批查询排班 ({start_date} ~ {end_date})")
for idx, batch in enumerate(batches, start=1):
if total > 1:
log(f" 批次 {idx}/{total}: {len(batch)} 人")
records = fetch_schedule_batch(batch, start_date, end_date)
all_records.extend(records)
log(f"✅ 查询完成,共 {len(all_records)} 条排班记录")
return all_records
# ─────────────────────────────────────────────────────────────────────────────
# 班次名称映射
# ─────────────────────────────────────────────────────────────────────────────
def build_class_name_map(records: list[dict]) -> dict[int, str]:
"""从排班记录中提取 classId → className 映射。
优先使用记录自带的 className;缺失时回退 class search 补全。
"""
class_map: dict[int, str] = {}
missing_ids: set[int] = set()
for record in records:
raw_id = record.get("classId") or record.get("class_id")
raw_name = record.get("className") or record.get("class_name")
if raw_id is None:
continue
cid = int(raw_id)
if raw_name and str(raw_name).strip():
class_map[cid] = str(raw_name).strip()
elif cid != 0 and cid not in class_map:
missing_ids.add(cid)
if missing_ids:
log(f"🔍 {len(missing_ids)} 个班次缺名称,从 class search 补全 ...")
try:
result = run_dws(["attendance", "class", "search", "--page-size", "200"])
for cls in (extract_records(result) if result else []):
cid_raw = cls.get("id") or cls.get("classId")
cname = cls.get("name") or cls.get("className")
if cid_raw is not None and cname:
class_map[int(cid_raw)] = str(cname).strip()
except DwsCallError as exc:
warn(f"class search 失败,部分班次将显示为 ID: {exc}")
return class_map
# ─────────────────────────────────────────────────────────────────────────────
# 日期工具
# ─────────────────────────────────────────────────────────────────────────────
def normalize_work_date(raw: Any) -> str:
"""将排班记录中的 workDate 标准化为 YYYY-MM-DD。"""
if raw is None:
return ""
if isinstance(raw, (int, float)):
ts = raw / 1000 if raw > 1e12 else raw
try:
return datetime.fromtimestamp(ts).strftime(DATE_FMT)
except (OSError, ValueError, OverflowError):
return ""
s = str(raw).strip()
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return s[:10]
return s
def generate_date_range(start: datetime, end: datetime) -> list[str]:
"""生成 start 到 end 之间的所有日期字符串列表。"""
dates: list[str] = []
current = start
while current <= end:
dates.append(current.strftime(DATE_FMT))
current += timedelta(days=1)
return dates
# ─────────────────────────────────────────────────────────────────────────────
# 构建排班表(日历表格式)
# ─────────────────────────────────────────────────────────────────────────────
def build_schedule_table(
records: list[dict],
user_ids: list[str],
user_names: dict[str, str],
class_map: dict[int, str],
date_range: list[str],
) -> tuple[list[str], list[list[str]]]:
"""构建日历表格式的排班表。
Returns:
(headers, rows)
headers = ["员工姓名", "05-19\n周一", "05-20\n周二", ...]
rows = [["张三", "早班", "早班", "休息", ...], ...]
"""
# 构建 (userId, date) → 班次显示文本
schedule_lookup: dict[tuple[str, str], str] = {}
for record in records:
uid = str(record.get("userId") or record.get("userid") or "")
work_date = normalize_work_date(record.get("workDate") or record.get("work_date"))
if not uid or not work_date:
continue
is_rest = str(record.get("isRest") or record.get("is_rest") or "N").upper()
raw_cid = record.get("classId") or record.get("class_id") or 0
raw_cname = record.get("className") or record.get("class_name") or ""
if is_rest == "Y":
display = "休息"
elif raw_cname and str(raw_cname).strip():
display = str(raw_cname).strip()
else:
cid = int(raw_cid) if raw_cid else 0
if cid in class_map:
display = class_map[cid]
elif cid == 0:
display = "休息"
else:
display = f"班次{cid}"
schedule_lookup[(uid, work_date)] = display
# 表头
headers = ["员工姓名"]
for date_str in date_range:
dt = datetime.strptime(date_str, DATE_FMT)
weekday = WEEKDAY_NAMES[dt.weekday()]
headers.append(f"{date_str[5:]}\n{weekday}")
# 数据行
rows: list[list[str]] = []
for uid in user_ids:
name = user_names.get(uid, uid)
row = [name]
for date_str in date_range:
row.append(schedule_lookup.get((uid, date_str), ""))
rows.append(row)
return headers, rows
# ─────────────────────────────────────────────────────────────────────────────
# 摘要输出
# ─────────────────────────────────────────────────────────────────────────────
def print_summary(
rows: list[list[str]],
date_range: list[str],
out_path: str,
record_count: int,
) -> None:
"""输出排班查询摘要到 stdout。"""
out_abs = os.path.abspath(out_path)
print(f"\n✅ 排班表导出成功!")
print(f" 文件: {out_abs}")
print(f" 人数: {len(rows)}")
print(f" 日期: {date_range[0]} ~ {date_range[-1]} ({len(date_range)} 天)")
print(f" 记录: {record_count} 条")
# 预览前 10 人 × 前 7 天
preview_rows = min(len(rows), 10)
preview_cols = min(len(date_range), 7)
if preview_rows > 0:
print(f"\n排班预览(前 {preview_rows}×{preview_cols} 天):")
header_line = f"{'姓名':<10}" + "".join(
f"{d[5:]:<8}" for d in date_range[:preview_cols]
)
print(header_line)
print("-" * len(header_line))
for row in rows[:preview_rows]:
line = f"{row[0]:<10}" + "".join(
f"{cell:<8}" for cell in row[1:preview_cols + 1]
)
print(line)
if len(date_range) > preview_cols:
print(f" ... 共 {len(date_range)} 天,完整数据见 Excel")
if len(rows) > preview_rows:
print(f" ... 共 {len(rows)} 人,完整数据见 Excel")
def main() -> None:
parser = argparse.ArgumentParser(
description="考勤排班查询导出(排班表格式)",
epilog="执行前必须阅读 attendance-schedule.md",
)
parser.add_argument("--users", required=True, help="userId 列表,逗号分隔(必填)")
parser.add_argument("--start", required=True, help="开始日期 YYYY-MM-DD(必填)")
parser.add_argument("--end", required=True, help="结束日期 YYYY-MM-DD(必填)")
parser.add_argument("--output", default="", help="输出文件路径(可选)")
args = parser.parse_args()
# ── 解析参数 ──
user_ids = [uid.strip() for uid in args.users.split(",") if uid.strip()]
if not user_ids:
error("--users 不能为空")
raise SystemExit(1)
try:
start_dt = parse_datetime_arg(args.start)
end_dt = parse_datetime_arg(args.end, end_of_day=True)
except ValueError as exc:
error(str(exc))
raise SystemExit(1) from exc
start_date = start_dt.strftime(DATE_FMT)
end_date = end_dt.strftime(DATE_FMT)
if end_dt < start_dt:
error(f"结束日期 {end_date} 早于开始日期 {start_date}")
raise SystemExit(1)
output_path = args.output or f"attendance_schedule_{start_date}_{end_date}.xlsx"
log(f"🗓️ 排班查询: {len(user_ids)} 人, {start_date} ~ {end_date}")
# ── 阶段 1: 查询排班记录(分批) ──
records = fetch_all_schedules(user_ids, start_date, end_date)
if not records:
print(f"⚠️ 未查询到排班记录 ({start_date} ~ {end_date})")
return
# ── 阶段 2: 构建班次名称映射 ──
class_map = build_class_name_map(records)
# ── 阶段 3: 解析员工姓名 ──
user_names = resolve_user_names(user_ids)
# ── 阶段 4: 生成日期范围 & 构建排班表 ──
date_range = generate_date_range(start_dt, end_dt)
headers, rows = build_schedule_table(
records, user_ids, user_names, class_map, date_range,
)
# ── 阶段 5: 输出 Excel ──
title = f"排班表 {start_date}{end_date}"
subtitle = f"生成时间:{datetime.now().strftime(DATETIME_FMT)}{len(rows)} 人"
write_excel(
output_path,
headers,
rows,
sheet_name="排班表",
title=title,
subtitle=subtitle,
)
log(f"📄 Excel 已保存: {os.path.abspath(output_path)}")
# ── 阶段 6: 输出摘要 ──
print_summary(rows, date_range, output_path, len(records))
if __name__ == "__main__":
main()