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,452 @@
#!/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())