first commit

This commit is contained in:
2026-09-02 11:44:52 +08:00
commit 0c8fa2653e
309 changed files with 57278 additions and 0 deletions
@@ -0,0 +1,344 @@
#!/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()