#!/usr/bin/env python3 """ 考勤报表导出 — 明细粒度(打卡记录) 通过 `dws attendance check result` + `dws attendance check record` 查询打卡数据,每条打卡记录输出一行,不做聚合。 [AI Agent 强制门禁] 调用本脚本前必须先阅读: references/attendance-report.md 本脚本仅是"考勤报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md, 包含但不限于: - 阶段 0:报表类型判断(默认月度汇总,明细需用户明确说"明细/原始记录/每条打卡") - 阶段 1:人员列表获取(aisearch person / contact dept list-members) - 阶段 2:列选择(明细报表列固定,不支持 --column-keywords) - 阶段 3:调用本脚本 - 阶段 4:结果回传给用户的标准格式 - 错误处理(403 权限、HSF_ILLEGALPARAMS、空数据等) [严禁] 仅凭本脚本 docstring 或 --help 输出就直接拼命令执行,会导致: - 用户本来要"汇总"被给成"明细"(粒度错误) - 报表数据不全 / 人员遗漏 - 错误处理缺失,把环境错误当业务错误反馈给用户 与月度汇总/每日统计不同,明细报表: - 不使用 report columns / report query-data - 列固定(基础信息 + 打卡字段),不支持自定义列选择 - 分批限制:≤100 人/次(check result),时间跨度 ≤1 个月 用法: python attendance_report_detail.py \ --users userId1,userId2,... \ --start "2026-03-01" \ --end "2026-03-31" \ [--out attendance_report_2026-03-01_2026-03-31_detail.xlsx] [--inspect] # 首次跑时打印首条记录原始结构 约束: - 仅管理员可用,否则 dws 接口返回 403 - --users 超过 100 人 → 自动按每批 100 人分批 - --start 到 --end 超过 31 天 → 自动按月切片 """ from __future__ import annotations import argparse import json import sys from datetime import datetime from typing import Any import attendance_report_common as cmn # ───────────────────────────────────────────────────────────────────────────── # 接口限制(check result / check record) # ───────────────────────────────────────────────────────────────────────────── CHECK_MAX_USERS_PER_BATCH = 100 # check result: --users 最多 100 人 CHECK_MAX_DAYS_PER_SLICE = 31 # check result/record: 跨度 ≤ 1 个月 CHECK_RESULT_PAGE_SIZE = 1000 # check result: --limit 最大值 # ───────────────────────────────────────────────────────────────────────────── # 固定表头(与 SKILL.md 明细预定义列集合对齐) # ───────────────────────────────────────────────────────────────────────────── # 基础信息列 BASE_HEADERS = ["姓名", "考勤组", "部门"] # 打卡字段列(以打卡流水为主,关联 check result 的考勤时间和打卡结果) # 对应 Diamond 配置中 termId 8-20 的列定义 CHECK_HEADERS = [ "考勤日期", "考勤时间", "打卡时间", "打卡结果", "打卡地址", "打卡备注", "异常打卡原因", "打卡图片1", "打卡图片2", "打卡设备", "管理员修改备注", "管理员修改备注图片1", "管理员修改备注图片2", "管理员修改备注图片3", ] ALL_HEADERS = BASE_HEADERS + CHECK_HEADERS # ───────────────────────────────────────────────────────────────────────────── # 参数解析 # ───────────────────────────────────────────────────────────────────────────── def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description=( "导出考勤报表 — 明细粒度(打卡记录)。" "[强制] AI Agent 必须先读 references/attendance-report.md 再调用本脚本," "禁止凭 --help 或脚本路径自行拼命令。" ), ) p.add_argument("--users", required=True, help="userId 列表,逗号分隔(必填)") p.add_argument("--start", required=True, help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)') p.add_argument("--end", required=True, help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)') p.add_argument("--out", default="", help="输出 xlsx 文件名;不传则按规范自动生成") p.add_argument("--inspect", action="store_true", help="首次跑时打印首条记录原始结构(用于核对真实字段)") p.add_argument("--no-images", action="store_true", help="不在 Excel 中嵌入打卡图片(默认会下载 URL 并嵌入为缩略图," "图片多时较慢;加此参数仅保留 URL 文本)") p.add_argument("--image-size", default="80x120", help="嵌入图片像素尺寸 WxH,默认 80x120") return p.parse_args() # 含图片 URL 的列名(与 CHECK_HEADERS 中的中文名严格一致) IMAGE_COLUMN_NAMES = [ "打卡图片1", "打卡图片2", "管理员修改备注图片1", "管理员修改备注图片2", "管理员修改备注图片3", ] def _parse_image_size(spec: str) -> tuple[int, int]: """解析 --image-size 参数,格式 WxH。失败时回退到默认 (80, 120)。""" try: parts = spec.lower().replace(" ", "").split("x") w, h = int(parts[0]), int(parts[1]) if w > 0 and h > 0: return (w, h) except (ValueError, IndexError): pass cmn.warn(f"--image-size 格式无效: {spec!r},使用默认 80x120") return (80, 120) # ───────────────────────────────────────────────────────────────────────────── # check result 查询(打卡结果,含分页) # ───────────────────────────────────────────────────────────────────────────── def query_check_results( user_batch: list[str], date_slice: cmn.DateSlice, stats: cmn.CallStats, *, inspect: bool = False, inspected_flag: list[bool] | None = None, ) -> list[dict]: """ 对一批 users × 一个时间片调用 `dws attendance check result`。 自动分页:每次最多 1000 条,返回满 1000 条时递增 offset 继续拉取。 """ from_date = date_slice.start.strftime(cmn.DATE_FMT) to_date = date_slice.end.strftime(cmn.DATE_FMT) all_records: list[dict] = [] offset = 0 while True: cmn.log( f"[check-result] users={len(user_batch)} " f"slice={date_slice.label} offset={offset}" ) try: payload = cmn.run_dws([ "attendance", "check", "result", "--users", ",".join(user_batch), "--from", from_date, "--to", to_date, "--offset", str(offset), "--limit", str(CHECK_RESULT_PAGE_SIZE), ]) 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( "权限错误:当前账号无管理员权限,无法查询打卡结果。" "请联系考勤管理员或换号重试。" ) raise SystemExit(2) from exc stats.add_warning(f"[check-result failed] {date_slice.label} offset={offset}: {exc}") break 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, "check-result") inspected_flag[0] = True all_records.extend(records) # 未满一页 → 无需翻页 if len(records) < CHECK_RESULT_PAGE_SIZE: break offset += CHECK_RESULT_PAGE_SIZE return all_records # ───────────────────────────────────────────────────────────────────────────── # check record 查询(打卡流水) # ───────────────────────────────────────────────────────────────────────────── def query_check_records( user_batch: list[str], date_slice: cmn.DateSlice, stats: cmn.CallStats, *, inspect: bool = False, inspected_flag: list[bool] | None = None, ) -> list[dict]: """对一批 users × 一个时间片调用 `dws attendance check record`。""" from_date = date_slice.start.strftime(cmn.DATE_FMT) to_date = date_slice.end.strftime(cmn.DATE_FMT) cmn.log( f"[check-record] users={len(user_batch)} slice={date_slice.label}" ) try: payload = cmn.run_dws([ "attendance", "check", "record", "--users", ",".join(user_batch), "--from", from_date, "--to", to_date, ]) 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( "权限错误:当前账号无管理员权限,无法查询打卡流水。" "请联系考勤管理员或换号重试。" ) raise SystemExit(2) from exc stats.add_warning(f"[check-record 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, "check-record") inspected_flag[0] = True return records # ───────────────────────────────────────────────────────────────────────────── # 值提取工具 # ───────────────────────────────────────────────────────────────────────────── def _humanize_timestamp(value: Any) -> str: """把毫秒/秒级时间戳转成可读字符串;非时间戳原样返回。""" if value is None: return "" if isinstance(value, (int, float)): # 13 位毫秒时间戳 if 1_000_000_000_000 <= value <= 9_999_999_999_999: try: return datetime.fromtimestamp(value / 1000).strftime(cmn.DATETIME_FMT) except (OSError, ValueError, OverflowError): return str(value) # 10 位秒级时间戳 if 1_000_000_000 <= value <= 9_999_999_999: try: return datetime.fromtimestamp(value).strftime(cmn.DATETIME_FMT) except (OSError, ValueError, OverflowError): return str(value) return str(value) if value != "" else "" def _extract_field(record: dict, candidate_keys: tuple[str, ...]) -> Any: """从 record 中按候选 key 顺序取第一个非空值。""" return cmn._first_nonempty(record, candidate_keys) def _extract_date_str(record: dict) -> str: """从 check result 记录中提取考勤日期(YYYY-MM-DD)。""" raw = _extract_field(record, ( "workDate", "work_date", "checkDate", "userCheckDate", "date", "day", )) if raw is None: return "" # 毫秒时间戳 if isinstance(raw, (int, float)) and raw > 1_000_000_000_000: try: return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT) except (OSError, ValueError, OverflowError): return str(raw) s = str(raw).strip() # 已经是 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss → 取前 10 位 if len(s) >= 10 and s[4] == "-" and s[7] == "-": return s[:10] return s def _extract_time_str(record: dict, candidate_keys: tuple[str, ...]) -> str: """从记录中提取时间字段,毫秒时间戳自动转 HH:mm:ss。""" raw = _extract_field(record, candidate_keys) if raw is None: return "" if isinstance(raw, (int, float)) and raw > 1_000_000_000_000: try: return datetime.fromtimestamp(raw / 1000).strftime("%H:%M:%S") except (OSError, ValueError, OverflowError): return str(raw) if isinstance(raw, (int, float)) and raw > 1_000_000_000: try: return datetime.fromtimestamp(raw).strftime("%H:%M:%S") except (OSError, ValueError, OverflowError): return str(raw) return str(raw) # ───────────────────────────────────────────────────────────────────────────── # 字段翻译 / 提取工具函数(与 Java DataProvider 实现对齐) # ───────────────────────────────────────────────────────────────────────────── # 打卡结果映射(对应 CheckResultUtil.java 的 getCheckResultStr 逻辑) _CHECK_RESULT_MAP: dict[str, str] = { "Normal": "正常", "Late": "迟到", "Early": "早退", "NotSigned": "未打卡", "SeriousLate": "严重迟到", "Absenteeism": "旷工迟到", "LeaveEarly": "早退", } # 打卡设备 / 来源类型映射(对应 SourceType 枚举 + UserDeviceOriginData.java) _SOURCE_TYPE_MAP: dict[str, str] = { "ATM": "考勤机", "BEACON": "蓝牙", "DING_ATM": "钉钉考勤机", "USER": "手机打卡", "BOSS": "管理员", "SYSTEM": "系统", "CARD": "门禁", "SELF_SERVICE": "自助补卡", } # 异常打卡原因中文描述(对应 SecurityConfigureUtil DEFAULT_CHEAT_LIST) _CHEAT_REASON_MAP: dict[str, str] = { "LocationNotMatch": "定位异常", "WifiNotMatch": "WIFI异常", "MockLocation": "模拟定位", "FaceNotMatch": "人脸比对失败", "DeviceNotMatch": "设备异常", "OutsideRange": "不在打卡范围", "NoBluetooth": "蓝牙未开启", "BluetoothNotMatch": "蓝牙不匹配", } def _translate_check_result(raw_result: str) -> str: """ 把接口返回的英文打卡结果翻译成中文,与 CheckResultUtil.getCheckResultStr 对齐。 未命中翻译表时原样返回。 """ if not raw_result: return "" return _CHECK_RESULT_MAP.get(raw_result, raw_result) def _translate_source_type(raw_source: str) -> str: """ 把接口返回的 sourceType 枚举值翻译成中文,与 UserDeviceOriginData 对齐。 未命中翻译表时原样返回。 """ if not raw_source: return "" return _SOURCE_TYPE_MAP.get(raw_source, raw_source) def _extract_location(record: dict) -> str: """ 拼接打卡地址:地点名称 + 详细地址,与 UserLocationOriginData 对齐。 Java 逻辑: locationResult.getSpaceName() → 地点名称 locationResult.getDetailAddr() → 详细地址(含省市区+街道) 两者均有时拼接,只有一个时单独返回。 """ space_name = str(_extract_field(record, ( "spaceName", "space_name", "locationName", "location_name", )) or "").strip() detail_addr = str(_extract_field(record, ( "detailAddr", "detail_addr", "detailAddress", "address", "userAddress", )) or "").strip() if space_name and detail_addr: return f"{space_name} {detail_addr}" return space_name or detail_addr def _extract_exception_reason(record: dict) -> str: """ 提取并翻译异常打卡原因,与 CheckExceptionReasonOriginData 对齐。 Java 逻辑: 取 features.getInvalidRecordMsg()(逗号分隔的错误码列表) 逐个从 DEFAULT_CHEAT_LIST 查中文描述后再拼接返回。 """ raw = str(_extract_field(record, ( "invalidRecordMsg", "invalid_record_msg", "outsideRemark", "outside_remark", "exceptionReason", )) or "").strip() if not raw: return "" # 逗号分隔的多个错误码,逐个翻译后重新拼接 codes = [c.strip() for c in raw.split(",") if c.strip()] translated = [_CHEAT_REASON_MAP.get(code, code) for code in codes] return ",".join(translated) def _extract_photo_url(record: dict, candidate_keys: tuple[str, ...]) -> str: """从 record 或其 features 嵌套结构中提取图片 URL。""" raw = _extract_field(record, candidate_keys) if raw is None: return "" return str(raw).strip() def _extract_remark_photo(record: dict) -> str: """ 打卡图片1(备注/外勤打卡照片)。 dws check record 的真实返回字段(实测验证): - 顶层 photoUrl:外勤/拍照打卡的主图片 URL - 顶层 outsideAttachment:外勤打卡的附件(可能含多张图片) - 顶层 remarkPhotos:备注图片数组(旧字段,部分版本) Java 侧 RemarkPhotoOriginData 对应 features.getRemarkPhotos(), 但 dws CLI 实际把图片字段提到了顶层,需直接读顶层字段。 """ # 1) 兼容数组形式的 remarkPhotos(早期版本) remark_photos = record.get("remarkPhotos") or record.get("remark_photos") if isinstance(remark_photos, list) and remark_photos: return str(remark_photos[0]).strip() if isinstance(remark_photos, str) and remark_photos.strip(): parts = [p.strip() for p in remark_photos.split(",") if p.strip()] return parts[0] if parts else "" # 2) dws CLI 当前实际返回的字段(顶层) # photoUrl 优先,其次 outsideAttachment,再次旧候选名 photo = _extract_photo_url(record, ( "photoUrl", "photo_url", "outsideAttachment", "outside_attachment", "remarkPhoto", "remark_photo", "userImage", "user_image", "imageUrl", "image_url", )) if photo: # outsideAttachment 可能是逗号分隔多张,取第一张 if "," in photo: first = photo.split(",")[0].strip() if first: return first return photo # 3) 兜底:从 features 嵌套 JSON 里翻 return _extract_photo_from_features(record, ( "photoUrl", "remarkPhoto", "remarkPhotos", "outsideAttachment", "userImage", "imageUrl", )) def _extract_face_check_photo(record: dict) -> str: """ 打卡图片2(人脸识别照片)。 Java 侧 FaceCheckPhotoOriginData 对应 features.getFacePhoto()。 dws CLI 中人脸图未稳定暴露在顶层,优先读 features 嵌套字段。 """ # 1) 顶层候选 face = _extract_photo_url(record, ( "facePhoto", "face_photo", "faceCheckPhoto", "face_check_photo", "faceImage", "face_image", "faceUrl", "face_url", )) if face: return face # 2) features 嵌套兜底 return _extract_photo_from_features(record, ( "facePhoto", "faceCheckPhoto", "faceImage", "faceUrl", )) def _extract_photo_from_features( record: dict, candidate_keys: tuple[str, ...], ) -> str: """ 从 record['features'](JSON 字符串或 dict)中提取图片 URL。 候选 key 命中 features 中第一个非空值则返回。 """ feat = record.get("features") if isinstance(feat, str): feat_str = feat.strip() if not feat_str or feat_str[0] not in "{[": return "" try: feat = json.loads(feat_str) except (ValueError, TypeError): return "" if not isinstance(feat, dict): return "" for key in candidate_keys: val = feat.get(key) if val in (None, "", [], {}): continue if isinstance(val, list) and val: return str(val[0]).strip() s = str(val).strip() if "," in s: return s.split(",")[0].strip() return s return "" def _extract_boss_remark(record: dict) -> str: """ 管理员修改备注,与 BossCheckRemarkOriginData 对齐。 Java 逻辑:features.getBossRemark()。 """ return str(_extract_field(record, ( "bossRemark", "boss_remark", "approveRemark", "approve_remark", "adminModifyRemark", "admin_modify_remark", )) or "").strip() def _extract_boss_photo(record: dict, photo_index: int) -> str: """ 管理员修改备注图片(1/2/3),与 BossCheckPhoto1/2/3OriginData 对齐。 Java 逻辑:features.getBossPhotos(),按 index 取对应张。 photo_index: 0-based 索引(0=图片1, 1=图片2, 2=图片3) """ boss_photos = record.get("bossPhotos") or record.get("boss_photos") if isinstance(boss_photos, list): if photo_index < len(boss_photos): return str(boss_photos[photo_index]).strip() return "" if isinstance(boss_photos, str) and boss_photos.strip(): parts = [p.strip() for p in boss_photos.split(",") if p.strip()] return parts[photo_index] if photo_index < len(parts) else "" # 降级:尝试独立字段 val = _extract_field(record, ( f"bossPhoto{photo_index + 1}", f"boss_photo_{photo_index + 1}", )) return str(val).strip() if val else "" # ───────────────────────────────────────────────────────────────────────────── # 关联合并 check result + check record → 明细行 # ───────────────────────────────────────────────────────────────────────────── def _build_result_index( check_results: list[dict], ) -> dict[tuple[str, str], list[dict]]: """ 把 check result 按 (userId, 打卡时间 YYYY-MM-DD HH:mm:ss) 建索引, 用于关联打卡流水获取考勤时间和打卡结果。 """ index: dict[tuple[str, str], list[dict]] = {} for rec in check_results: uid = str(_extract_field(rec, ("userId", "userid", "user_id")) or "") raw_time = _extract_field(rec, ( "userCheckTime", "user_check_time", "checkTime", "baseCheckTime", )) time_key = _humanize_timestamp(raw_time) if raw_time else "_unknown" key = (uid, time_key) index.setdefault(key, []).append(rec) return index def build_record_rows( check_records: list[dict], check_results: list[dict], user_info_map: dict[str, cmn.UserInfo], group_name_map: dict[str, str], ) -> list[dict[str, str]]: """ 以 check record(打卡流水)为主表构建明细行。 每条打卡流水记录输出一行,只展示有实际打卡的记录。 通过打卡时间关联 check result 获取"考勤时间"和"打卡结果"。 列顺序与 Diamond 配置 termId 8-20 对齐,各字段逻辑与 Java DataProvider 一致。 返回每行一个 dict,key 与 ALL_HEADERS 对齐。 """ result_index = _build_result_index(check_results) rows: list[dict[str, str]] = [] for record in check_records: uid = str(_extract_field(record, ("userId", "userid", "user_id")) or "") info = user_info_map.get(uid, cmn.UserInfo(name=uid)) # ── 打卡时间(实际打卡时间,OriginUserCheckTimePlug)──────────────── actual_time_raw = _extract_field(record, ( "userCheckTime", "user_check_time", "checkTime", )) actual_time = _humanize_timestamp(actual_time_raw) # ── 关联 check result 获取"考勤时间"和"打卡结果" ────────────────── time_key = actual_time if actual_time else "_unknown" matched_results = result_index.get((uid, time_key), []) result_rec = matched_results[0] if matched_results else {} # 考勤时间 = 班次规定的应打卡时间(OriginPlanCheckTimePlug) plan_time_raw = _extract_field(result_rec, ( "planCheckTime", "plan_check_time", "baseCheckTime", )) if result_rec else None plan_time = _humanize_timestamp(plan_time_raw) if plan_time_raw else "" # 打卡结果(OriginUserCheckResultPlug):英文枚举 → 中文 raw_check_result = str(_extract_field(result_rec, ( "checkResult", "check_result", "timeResult", "result", )) or "") if result_rec else "" check_result_str = _translate_check_result(raw_check_result) # ── 打卡设备(OriginUserDevicePlug):sourceType 枚举 → 中文 ──────── raw_source_type = str(_extract_field(record, ( "sourceType", "source_type", "deviceType", "device_type", )) or "") device_str = _translate_source_type(raw_source_type) row: dict[str, str] = { # 基础信息 "姓名": info.name or uid, "考勤组": group_name_map.get(uid, ""), "部门": info.dept_name, # termId=8 考勤时间(OriginPlanCheckTimePlug) "考勤日期": _extract_date_str(record), "考勤时间": plan_time, # termId=9 打卡时间(OriginUserCheckTimePlug) "打卡时间": actual_time, # termId=10 打卡结果(OriginUserCheckResultPlug) "打卡结果": check_result_str, # termId=11 打卡地址(OriginUserLocationPlug) # Java 逻辑:spaceName + detailAddr 拼接 "打卡地址": _extract_location(record), # termId=12 打卡备注(OriginUserRemarkPlug) # Java 逻辑:features.getRemark() "打卡备注": str(_extract_field(record, ( "remark", "userRemark", "user_remark", )) or "").strip(), # termId=13 异常打卡原因(OriginCheckExceptionReasonPlug) # Java 逻辑:features.getInvalidRecordMsg() → 翻译错误码 "异常打卡原因": _extract_exception_reason(record), # termId=14 打卡图片1(OriginRemarkPhotoPlug) # Java 逻辑:features.getRemarkPhotos()[0] "打卡图片1": _extract_remark_photo(record), # termId=15 打卡图片2(OriginFaceCheckPhotoPlug) # Java 逻辑:features.getFacePhoto() "打卡图片2": _extract_face_check_photo(record), # termId=16 打卡设备(OriginUserDevicePlug) # Java 逻辑:SourceType 枚举 → 中文 "打卡设备": device_str, # termId=17 管理员修改备注(OriginBossCheckRemarkPlug) # Java 逻辑:features.getBossRemark() "管理员修改备注": _extract_boss_remark(record), # termId=18/19/20 管理员修改备注图片1/2/3(OriginBossCheckPhoto1/2/3Plug) # Java 逻辑:features.getBossPhotos()[0/1/2] "管理员修改备注图片1": _extract_boss_photo(record, 0), "管理员修改备注图片2": _extract_boss_photo(record, 1), "管理员修改备注图片3": _extract_boss_photo(record, 2), } rows.append(row) return rows # ───────────────────────────────────────────────────────────────────────────── # main # ───────────────────────────────────────────────────────────────────────────── def main() -> int: args = parse_args() # 1. 解析参数 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 # 2. 解析 userId → 用户信息(使用 resolve_user_info,已适配 labels 职位提取) cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息") user_info_map = cmn.resolve_user_info(user_ids) # 3. 切批 + 切片(明细用 100 人/批、31 天/片) user_batches = cmn.chunk_users(user_ids, size=CHECK_MAX_USERS_PER_BATCH) date_slices = cmn.slice_date_range(start, end, max_days=CHECK_MAX_DAYS_PER_SLICE) stats = cmn.CallStats( user_batches=len(user_batches), date_slices=len(date_slices), ) cmn.log(f"[plan] 共 {len(user_batches)} 批 × {len(date_slices)} 个时间片") # 4. 拉数据:check record(打卡流水)+ check result(用于关联考勤时间和打卡结果) inspected_result_flag = [False] inspected_record_flag = [False] all_check_results: list[dict] = [] all_check_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)}]") results = query_check_results( batch, date_slice, stats, inspect=args.inspect, inspected_flag=inspected_result_flag, ) all_check_results.extend(results) records = query_check_records( batch, date_slice, stats, inspect=args.inspect, inspected_flag=inspected_record_flag, ) all_check_records.extend(records) cmn.log(f"[data] check result: {len(all_check_results)} 条, " f"check record: {len(all_check_records)} 条") if not all_check_records: stats.add_warning("查询完成,但未得到任何打卡流水记录") # 5. 获取考勤组信息(通过 group API 反向映射 userId → 考勤组名称) group_name_map = cmn.extract_group_names_from_records(all_check_records, user_ids) # 6. 构建明细行(以 check record 为主表,关联 check result 获取考勤时间和打卡结果) detail_rows = build_record_rows( all_check_records, all_check_results, user_info_map, group_name_map, ) # 7. 写 Excel rows_2d = [[row.get(h, "") for h in ALL_HEADERS] for row in detail_rows] out_name = args.out or cmn.build_output_filename(start, end, suffix="detail") title = ( f"考勤明细展示 统计日期:{start.strftime(cmn.DATE_FMT)} " f"至 {end.strftime(cmn.DATE_FMT)}" ) subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}" # 图片嵌入参数:默认开启,--no-images 关闭 image_columns = None if args.no_images else IMAGE_COLUMN_NAMES image_size = _parse_image_size(args.image_size) try: cmn.write_excel( out_name, ALL_HEADERS, rows_2d, sheet_name="考勤明细", title=title, subtitle=subtitle, image_columns=image_columns, image_size=image_size, ) except RuntimeError as exc: cmn.error(str(exc)) return 1 # 8. 摘要 cmn.print_summary( granularity_label="明细(打卡流水)", out_path=out_name, user_count=len(user_ids), column_names=CHECK_HEADERS, start=start, end=end, rows_count=len(rows_2d), stats=stats, ) return 0 if __name__ == "__main__": sys.exit(main())