first commit
This commit is contained in:
@@ -0,0 +1,809 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user