453 lines
17 KiB
Python
453 lines
17 KiB
Python
#!/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())
|