#!/usr/bin/env python3 """ 考勤报表导出 — 签到记录粒度 [AI Agent 强制门禁] 调用本脚本前必须先阅读: references/attendance-report.md 本脚本是"签到报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md。 [严禁] 仅凭本脚本 docstring 或 --help 输出就直接拼命令执行。 导出签到报表:每条签到记录一行,包含签到详情(地点、经纬度、拜访客户、图片等)。 前置依赖: pip install openpyxl 用法: python attendance_report_checkin.py \ --users userId1,userId2,... \ --start "2026-04-01 00:00:00" \ --end "2026-04-07 23:59:59" \ [--out 签到报表_研发部_20260401_20260407.xlsx] [--inspect] """ from __future__ import annotations import argparse import json import subprocess import sys from datetime import datetime, timedelta from typing import Any # ── 前置依赖检查(在任何 dws 调用之前就检测,避免查完数据才报错)─────── _missing_deps: list[str] = [] try: import openpyxl as _openpyxl_check # noqa: F401 except ImportError: _missing_deps.append("openpyxl") try: import requests as _requests_check # noqa: F401 except ImportError: _missing_deps.append("requests") try: from PIL import Image as _pil_check # noqa: F401 except ImportError: _missing_deps.append("Pillow") if _missing_deps: print( f"[ERROR] 缺少以下依赖:{', '.join(_missing_deps)}\n" f" 请先安装:pip install {' '.join(_missing_deps)}\n" "安装后重新执行本脚本。\n" "(签到报表需要 openpyxl 生成 Excel、requests + Pillow 下载并嵌入签到图片)", file=sys.stderr, ) sys.exit(2) import attendance_report_common as cmn # ───────────────────────────────────────────────────────────────────────────── # 自动获取当前认证的 operator 信息 # ───────────────────────────────────────────────────────────────────────────── def _get_operator_context() -> tuple[str, str]: """ 从 `dws auth status --format json` 自动获取当前认证的 corp_id 和 user_id。 签到接口 (checkin records) 必须传 --operator-corp-id 和 --operator-staff-id, 这两个值来自 dws 的认证上下文(即 `dws auth status` 返回的 corp_id / user_id), 而非 `dws contact user get-self` 返回的长格式 userId。 Returns: (operator_corp_id, operator_staff_id) 元组 Raises: SystemExit: 未登录或无法获取认证信息时直接退出 """ try: result = subprocess.run( ["dws", "auth", "status", "--format", "json"], capture_output=True, text=True, timeout=30, ) except FileNotFoundError: cmn.error("未找到 dws 命令,请确认 dws CLI 已安装并在 PATH 中") sys.exit(2) except subprocess.TimeoutExpired: cmn.error("dws auth status 超时,请检查网络或重新登录(dws auth login)") sys.exit(2) if result.returncode != 0: cmn.error( "获取认证信息失败,请确保已执行 dws auth login 完成登录。\n" f" 错误详情:{(result.stderr or result.stdout or '').strip()}" ) sys.exit(2) try: auth_data = json.loads(result.stdout) except json.JSONDecodeError: cmn.error(f"dws auth status 返回非 JSON:{result.stdout[:200]!r}") sys.exit(2) corp_id = auth_data.get("corp_id") or auth_data.get("corpId") or "" user_id = auth_data.get("user_id") or auth_data.get("userId") or "" if not corp_id or not user_id: cmn.error( "无法从认证信息中提取 corp_id / user_id,请重新登录:\n" " dws auth login\n" f" 当前返回:{json.dumps(auth_data, ensure_ascii=False)[:300]}" ) sys.exit(2) cmn.log(f"[auth] 已获取 operator 信息:corp_id={corp_id}, user_id={user_id}") return str(corp_id), str(user_id) # 签到接口限制:开始到结束最多 7 天 MAX_DAYS_PER_CHECKIN_SLICE = 7 # 签到接口限制:每次最多查 100 人(与 check record 一致) MAX_USERS_PER_CHECKIN_BATCH = 50 # 最多支持 9 张图片列 MAX_IMAGE_COLUMNS = 9 # 报表表头(与用户要求严格对齐) REPORT_HEADERS = [ "姓名", "部门", "完整部门", "日期", "时间", "经度", "纬度", "地点", "详细地址", "拜访客户", "客户部门名称", "工作内容", "手机标识", ] + [f"图片{i}" for i in range(1, MAX_IMAGE_COLUMNS + 1)] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "导出签到报表 — 签到记录粒度。" "[强制] AI Agent 必须先读 references/attendance-report.md 再调用本脚本。" ), ) parser.add_argument("--users", required=True, help="userId 列表,逗号分隔(必填)") parser.add_argument("--start", required=True, help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)') parser.add_argument("--end", required=True, help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)') parser.add_argument("--out", default="", help="输出 xlsx 文件名;不传则按规范自动生成") parser.add_argument("--inspect", action="store_true", help="首次跑时打印首条记录原始结构(用于核对真实字段)") return parser.parse_args() # ───────────────────────────────────────────────────────────────────────────── # 签到接口时间切片(7 天一段) # ───────────────────────────────────────────────────────────────────────────── def slice_checkin_date_range( start: datetime, end: datetime, ) -> list[cmn.DateSlice]: """将日期范围按 7 天一段切片(签到接口限制开始到结束最多 7 天)。""" slices: list[cmn.DateSlice] = [] current = start while current <= end: slice_end = min(current + timedelta(days=MAX_DAYS_PER_CHECKIN_SLICE - 1), end) # 确保 slice_end 的时间部分是当天最后一秒 slice_end = slice_end.replace(hour=23, minute=59, second=59) if slice_end > end: slice_end = end slices.append(cmn.DateSlice( start=current, end=slice_end, )) current = slice_end.replace(hour=0, minute=0, second=0) + timedelta(days=1) return slices def chunk_checkin_users(user_ids: list[str]) -> list[list[str]]: """将用户列表按 MAX_USERS_PER_CHECKIN_BATCH 分批。""" batches: list[list[str]] = [] for i in range(0, len(user_ids), MAX_USERS_PER_CHECKIN_BATCH): batches.append(user_ids[i:i + MAX_USERS_PER_CHECKIN_BATCH]) return batches # ───────────────────────────────────────────────────────────────────────────── # 签到数据查询 # ───────────────────────────────────────────────────────────────────────────── def query_checkin_batch( user_batch: list[str], date_slice: cmn.DateSlice, operator_corp_id: str, operator_staff_id: str, stats: cmn.CallStats, *, inspect: bool = False, inspected_flag: list[bool] | None = None, ) -> list[dict]: """查询一批用户在一个时间片内的签到记录。""" cmn.log( f"[checkin] users={len(user_batch)} " f"slice={date_slice.label}" ) try: payload = cmn.run_dws([ "attendance", "checkin", "records", "--operator-corp-id", operator_corp_id, "--operator-staff-id", operator_staff_id, "--staff-ids", ",".join(user_batch), "--start", date_slice.start_str, "--end", date_slice.end_str, ]) stats.total_dws_calls += 1 except cmn.DwsCallError as exc: stats.total_dws_calls += 1 stats.failed_calls += 1 if exc.is_permission_error: cmn.error( "权限错误:当前账号无管理员权限,无法导出签到报表。\n" "请联系考勤管理员或换号重试。" ) raise SystemExit(2) from exc err_msg = str(exc) if "missing required flag" in err_msg.lower(): cmn.error( "签到接口调用失败:缺少必需参数。\n" "请确保已执行 dws auth login 完成登录,以便自动获取 operator 参数。\n" f"当前 operator: corp_id={operator_corp_id}, staff_id={operator_staff_id}\n" f"原始错误:{err_msg}" ) raise SystemExit(2) from exc stats.add_warning(f"[checkin failed] {date_slice.label}: {exc}") return [] records = cmn.extract_records(payload) if inspect and records and inspected_flag is not None and not inspected_flag[0]: cmn.dump_first_record_for_inspection(records, "checkin-records") inspected_flag[0] = True return records # ───────────────────────────────────────────────────────────────────────────── # 签到记录 → 报表行转换 # ───────────────────────────────────────────────────────────────────────────── def _format_timestamp(timestamp_value: Any) -> tuple[str, str]: """ 将签到时间戳转换为 (日期字符串, 时间字符串)。 签到接口的 timestamp 为毫秒时间戳。 """ if timestamp_value is None: return "", "" try: ts = float(timestamp_value) # 判断是毫秒还是秒级时间戳 if ts > 1_000_000_000_000: ts = ts / 1000 dt = datetime.fromtimestamp(ts) return dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M:%S") except (ValueError, TypeError, OSError, OverflowError): return str(timestamp_value), "" def transform_records_to_rows( records: list[dict], user_info_map: dict[str, cmn.UserInfo], ) -> list[list[Any]]: """将签到原始记录转换为报表行(与 REPORT_HEADERS 对齐)。""" rows: list[list[Any]] = [] for record in records: uid = cmn._first_nonempty(record, ("userId", "userid", "user_id")) uid_str = str(uid) if uid is not None else "" info = user_info_map.get(uid_str, cmn.UserInfo(name=uid_str)) # 姓名:优先用 resolve_user_info 的结果,回退到接口返回的 name name = info.name or record.get("name", uid_str) dept_name = info.dept_name # 完整部门:暂用 dept_name(如需更完整的路径可后续扩展) full_dept = dept_name # 日期与时间 date_str, time_str = _format_timestamp(record.get("timestamp")) # 经纬度 longitude = record.get("longitude", "") latitude = record.get("latitude", "") # 地点 place = record.get("place", "") detail_place = record.get("detailPlace", "") # 拜访客户 & 客户部门名称 customers = record.get("customers", "") # 签到接口暂无客户部门名称字段,预留空值 customer_dept = "" # 工作内容(备注) remark = record.get("remark", "") # 手机标识 mobile_id = record.get("mobileId", "") # 图片列(最多 9 张) image_list = record.get("imageList") or [] if isinstance(image_list, str): # 兼容接口可能返回逗号分隔的字符串 image_list = [img.strip() for img in image_list.split(",") if img.strip()] image_cells = [] for i in range(MAX_IMAGE_COLUMNS): if i < len(image_list): image_cells.append(image_list[i]) else: image_cells.append("") row = [ name, dept_name, full_dept, date_str, time_str, longitude, latitude, place, detail_place, customers, customer_dept, remark, mobile_id, ] + image_cells rows.append(row) return rows # ───────────────────────────────────────────────────────────────────────────── # main # ───────────────────────────────────────────────────────────────────────────── def main() -> int: args = parse_args() raw_ids = [u.strip() for u in args.users.split(",") if u.strip()] if not raw_ids: cmn.error("--users 不能为空") return 2 # 自动识别部门ID并展开为员工userId user_ids = cmn.resolve_users_from_input(raw_ids) if not user_ids: cmn.error("未能解析出任何有效的员工userId") return 2 cmn.log(f"[users] 最终用户列表:{len(user_ids)} 人") try: start = cmn.parse_datetime_arg(args.start, end_of_day=False) end = cmn.parse_datetime_arg(args.end, end_of_day=True) except ValueError as exc: cmn.error(str(exc)) return 2 if end < start: cmn.error(f"--end ({end}) 早于 --start ({start})") return 2 # 获取当前认证的 operator 信息(签到接口必需) operator_corp_id, operator_staff_id = _get_operator_context() # 获取用户基础信息(姓名、部门) cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息") user_info_map = cmn.resolve_user_info(user_ids) # 分批分段查询签到记录 user_batches = chunk_checkin_users(user_ids) date_slices = slice_checkin_date_range(start, end) stats = cmn.CallStats( user_batches=len(user_batches), date_slices=len(date_slices), ) cmn.log( f"[plan] 共 {len(user_batches)} 批 × {len(date_slices)} 个时间片 " f"= {len(user_batches) * len(date_slices)} 次接口调用" ) inspected_flag = [False] all_records: list[dict] = [] for batch_idx, batch in enumerate(user_batches, start=1): for slice_idx, date_slice in enumerate(date_slices, start=1): cmn.log( f"[batch {batch_idx}/{len(user_batches)}] " f"[slice {slice_idx}/{len(date_slices)}]" ) records = query_checkin_batch( batch, date_slice, operator_corp_id, operator_staff_id, stats, inspect=args.inspect, inspected_flag=inspected_flag, ) all_records.extend(records) if not all_records: stats.add_warning("查询完成,但未得到任何签到记录") # 转换为报表行 rows = transform_records_to_rows(all_records, user_info_map) # 按日期时间排序(日期列索引=3,时间列索引=4) rows.sort(key=lambda r: (r[3] or "", r[4] or "")) # 生成 Excel out_name = args.out or cmn.build_output_filename(start, end, suffix="checkin") title = ( f"签到报表 统计日期:{start.strftime(cmn.DATE_FMT)} " f"至 {end.strftime(cmn.DATE_FMT)}" ) subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}" # 图片列名列表(图片1~图片9),让 write_excel_multi_sheets 自动将 URL 嵌入为缩略图 image_column_names = [f"图片{i}" for i in range(1, MAX_IMAGE_COLUMNS + 1)] checkin_sheet = { "name": "签到记录", "headers": REPORT_HEADERS, "rows": rows, "title": title, "subtitle": subtitle, "image_columns": image_column_names, "image_size": (60, 60), } try: cmn.write_excel_multi_sheets(out_name, [checkin_sheet]) except (RuntimeError, ValueError) as exc: cmn.error(str(exc)) return 1 cmn.print_summary( granularity_label="签到报表", out_path=out_name, user_count=len(user_ids), column_names=[h for h in REPORT_HEADERS], start=start, end=end, rows_count=len(rows), stats=stats, ) return 0 if __name__ == "__main__": sys.exit(main())