Files
EP-Hub-Skill/.agents/skills/dingtalk-misc/scripts/attendance_report_monthly.py
T
2026-09-02 11:44:52 +08:00

759 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
考勤报表导出 — 月度汇总粒度
[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 输出就直接拼命令执行,会导致:
- 报表数据不全 / 列错位 / 人员遗漏
- 错误处理缺失,把环境错误当业务错误反馈给用户
- 输出格式不规范,用户体验差
按人按字段汇总,每人一行(如:迟到 5 次、加班 32 小时、出勤 21 天)。
聚合策略:
- 数值字段(看起来是 int/float)→ 求和
- 时长字段(字段名含"时长"且值为数字)→ 求和(保留单位语义)
- 字符串/枚举字段(如出勤状态)→ 计数(distinct value → count
- 日期字段 → 计数(去重日期 → 出勤天数)
- 复杂字段(dict/list)→ 拼接(最多 5 条)
用法:
python attendance_report_monthly.py \
--users userId1,userId2,... \
--start "2026-03-01 00:00:00" \
--end "2026-03-31 23:59:59" \
[--columns 1001,1002]
[--column-keywords "迟到次数,加班时长"]
[--out attendance_report_2026-03-01_2026-03-31_monthly.xlsx]
[--inspect]
"""
from __future__ import annotations
import argparse
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
import attendance_report_common as cmn
# 默认关注字段 — 与 SKILL.md「月度汇总预定义列集合」严格对齐(共 20 个)
# 字段名必须和 `dws attendance report columns` 返回的 name 精确匹配
DEFAULT_KEYWORDS = [
"出勤天数",
"休息天数",
"工作时长",
"迟到次数",
"迟到时长",
"严重迟到次数",
"严重迟到时长",
"旷工迟到次数",
"早退次数",
"早退时长",
"上班缺卡次数",
"下班缺卡次数",
"旷工天数",
"出差时长",
"外出时长",
"请假",
"加班-审批单统计",
"考勤结果",
]
# 每日维度字段 — 这些字段在月度汇总中不做聚合,而是按天展开成多列
DAILY_EXPAND_FIELDS = {"考勤结果"}
# 日历表指标 — sheet2"日历表"展示的 3 行指标
# 这 3 个字段会被 resolve_columns 强制追加到查询字段集中(即使用户的 --column-keywords 没包含),
# 否则日历表会是空的。
# 注意:这 3 个字段名必须和 dws attendance report columns 返回的 name 严格一致。
CALENDAR_METRICS: tuple[str, ...] = ("班次名称", "考勤结果", "工作时长")
# 请假字段 — 触发"按假期类型展开"的字段名
# 不参与 query-data 查询,单独走 query-leave 接口,按 4 类假期展开为多列
# 注意:钉钉接口实际返回的字段名可能是 "请假"、"请假分类"、"请假时长" 等,
# 凡以 "请假" 开头的都视为请假字段,统一替换为 4 列假期类型展开。
LEAVE_FIELD_NAME = "请假"
LEAVE_TYPES: tuple[str, ...] = ("事假", "调休", "病假", "年假")
def _is_leave_field(name: str) -> bool:
"""判断一个字段名是否属于"请假"系列(如 请假 / 请假分类 / 请假时长)。"""
return isinstance(name, str) and name.startswith(LEAVE_FIELD_NAME)
# 工作日期字段的候选 key(按优先级试探)
DATE_KEY_CANDIDATES = (
"workDate", "work_date", "userCheckDate", "checkDate",
"date", "day", "工作日期",
)
# ─────────────────────────────────────────────────────────────────────────────
# 参数解析
# ─────────────────────────────────────────────────────────────────────────────
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("--columns", default="",
help="字段 ID 列表,逗号分隔;与 --column-keywords 二选一")
p.add_argument("--column-keywords", default="",
help="字段名关键词,逗号分隔;不传则走默认字段集")
p.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
p.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
return p.parse_args()
# ─────────────────────────────────────────────────────────────────────────────
# 字段解析(与 detail 一致)
# ─────────────────────────────────────────────────────────────────────────────
def _ensure_calendar_metrics(
matched: list[dict],
all_cols: list[dict],
) -> list[dict]:
"""
确保 CALENDAR_METRICS 中的 3 个指标字段(班次名称/考勤结果/工作时长)
出现在最终查询字段集中(即使用户传入的 --column-keywords 没匹配到)。
日历表 sheet2 强依赖这 3 个字段,缺一不可。
"""
existing_names = {c["_column_name"] for c in matched}
name_to_col: dict[str, dict] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None and name:
name_to_col[str(name)] = {
"_column_id": str(cid),
"_column_name": str(name),
}
appended: list[str] = []
for metric_name in CALENDAR_METRICS:
if metric_name in existing_names:
continue
col = name_to_col.get(metric_name)
if col is None:
cmn.log(
f"[calendar] 警告:月历指标字段「{metric_name}」在"
f" report columns 中未找到,月历对应行可能为空"
)
continue
matched.append(col)
appended.append(metric_name)
if appended:
cmn.log(f"[calendar] 已强制追加月历指标字段:{appended}")
return matched
def resolve_columns(args: argparse.Namespace) -> list[dict]:
all_cols_payload = cmn.run_dws(["attendance", "report", "columns"])
all_cols = cmn.extract_records(all_cols_payload)
if args.columns.strip():
cids = [c.strip() for c in args.columns.split(",") if c.strip()]
id_to_name: dict[str, str] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None:
id_to_name[str(cid)] = str(name) if name else str(cid)
matched = [{"_column_id": cid, "_column_name": id_to_name.get(cid, cid)}
for cid in cids]
return _ensure_calendar_metrics(matched, all_cols)
keywords = (
[k.strip() for k in args.column_keywords.split(",") if k.strip()]
if args.column_keywords.strip()
else DEFAULT_KEYWORDS
)
cmn.log(f"[columns] 使用关键词匹配字段:{keywords}")
cmn.log(f"[columns] dws 返回 {len(all_cols)} 个字段")
matched = cmn.match_columns_by_keywords(all_cols, keywords)
if not matched:
raise RuntimeError(
f"未匹配到任何字段。可用字段示例:"
f"{[cmn._first_nonempty(c, ('name','columnName','title','label')) for c in all_cols[:10]]}"
)
cmn.log(f"[columns] 匹配到 {len(matched)} 个字段:{[c['_column_name'] for c in matched]}")
return _ensure_calendar_metrics(matched, all_cols)
# ─────────────────────────────────────────────────────────────────────────────
# 接口调用(与 detail 一致)
# ─────────────────────────────────────────────────────────────────────────────
def query_one_batch(
user_batch: list[str],
column_ids: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
column_id_to_name: dict[str, str] | None = None,
inspect: bool = False,
inspected_flag: list[bool] = None,
) -> list[dict]:
cmn.log(
f"[query] users={len(user_batch)} cols={len(column_ids)} "
f"slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "report", "query-data",
"--users", ",".join(user_batch),
"--columns", ",".join(column_ids),
"--start", date_slice.start_str,
"--end", date_slice.end_str,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as e:
stats.total_dws_calls += 1
stats.failed_calls += 1
if e.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法导出考勤报表。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from e
stats.add_warning(f"[query failed] {date_slice.label}: {e}")
return []
records = cmn.extract_records(payload)
# 展平 report query-data 返回的嵌套 values 结构
records = cmn.flatten_query_data_records(records, column_id_to_name)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "query-data (flattened)")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 日期提取(复用 daily 脚本的逻辑)
# ─────────────────────────────────────────────────────────────────────────────
def _normalize_date(raw: Any) -> str | None:
"""把任意形态的日期值归一化为 YYYY-MM-DD 字符串。"""
if raw is None:
return None
if isinstance(raw, (int, float)) and 1_000_000_000_000 <= raw <= 9_999_999_999_999:
try:
return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
if isinstance(raw, (int, float)) and 1_000_000_000 <= raw <= 9_999_999_999:
try:
return datetime.fromtimestamp(raw).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
s = str(raw).strip()
if not s:
return None
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
head = s[:10]
try:
datetime.strptime(head, cmn.DATE_FMT)
return head
except ValueError:
return None
return None
def _extract_work_date(record: dict, columns: list[dict]) -> str | None:
"""从一条记录里提取工作日期(YYYY-MM-DD 格式)。"""
candidates: list[Any] = []
for key in DATE_KEY_CANDIDATES:
if key in record and record[key] not in (None, ""):
candidates.append(record[key])
for col in columns:
if "日期" in col["_column_name"] or "date" in col["_column_name"].lower():
v = _value_for_column(record, col)
if v not in (None, ""):
candidates.append(v)
for raw in candidates:
date_str = _normalize_date(raw)
if date_str:
return date_str
return None
def _generate_date_columns(start: datetime, end: datetime) -> list[str]:
"""根据日期范围生成按天展开的列标签列表,格式为日号(如 '1', '2', ...)。"""
dates: list[str] = []
current = start.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end.replace(hour=0, minute=0, second=0, microsecond=0)
while current <= end_date:
dates.append(current.strftime(cmn.DATE_FMT))
current += timedelta(days=1)
return dates
# ─────────────────────────────────────────────────────────────────────────────
# 月度聚合
# ─────────────────────────────────────────────────────────────────────────────
def _value_for_column(record: dict, col: dict) -> Any:
"""从一条原始记录里取某个字段的值(命名顺位试探)。"""
cname, cid = col["_column_name"], col["_column_id"]
for key in (cname, cid, f"col_{cid}", f"column_{cid}"):
if key in record:
return record[key]
return None
def _try_number(value: Any) -> float | None:
"""尝试把 value 解析为数字;不能则返回 None。"""
if value is None or value == "":
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
s = value.strip()
try:
return float(s)
except ValueError:
return None
return None
def _user_id_of(record: dict) -> str | None:
uid = cmn._first_nonempty(record, ("userId", "userid", "user_id", "targetUserId"))
return str(uid) if uid is not None else None
def aggregate_monthly(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_name_map: dict[str, str],
) -> tuple[list[dict[str, Any]], dict[str, dict[str, dict[str, str]]]]:
"""
按 userId 分组聚合:
- 普通字段(数值/非数值):按原聚合策略处理
- DAILY_EXPAND_FIELDS 中的字段(如"考勤结果"):按 (userId, date) 存储,不聚合
返回:
- rows: 每人一行的聚合结果(不含按天展开字段)
- daily_data: {field_name: {userId: {date_str: value}}}
"""
# 识别哪些列需要按天展开
expand_col_names = {col["_column_name"] for col in columns
if col["_column_name"] in DAILY_EXPAND_FIELDS}
agg_columns = [col for col in columns if col["_column_name"] not in expand_col_names]
# 聚合累加器(仅普通字段)
agg: dict[str, dict[str, dict]] = defaultdict(
lambda: {col["_column_name"]: {"sum": 0.0, "count": 0, "non_numeric": set()}
for col in agg_columns}
)
# 按天展开数据:field_name → userId → date_str → value
daily_data: dict[str, dict[str, dict[str, str]]] = {
fname: defaultdict(dict) for fname in expand_col_names
}
for record in all_records:
uid = _user_id_of(record)
if uid is None:
continue
work_date = _extract_work_date(record, columns)
# 按天展开字段
for fname in expand_col_names:
matching_col = next((c for c in columns if c["_column_name"] == fname), None)
if matching_col and work_date:
raw = _value_for_column(record, matching_col)
if raw not in (None, ""):
daily_data[fname][uid][work_date] = str(raw)
# 普通字段聚合
for col in agg_columns:
cname = col["_column_name"]
raw = _value_for_column(record, col)
num = _try_number(raw)
if num is not None:
agg[uid][cname]["sum"] += num
agg[uid][cname]["count"] += 1
elif raw not in (None, ""):
agg[uid][cname]["non_numeric"].add(str(raw))
rows: list[dict[str, Any]] = []
for uid in user_ids:
row: dict[str, Any] = {
"userId": uid,
"userName": user_name_map.get(uid, uid),
}
bucket = agg.get(uid, {})
for col in agg_columns:
cname = col["_column_name"]
cell = bucket.get(cname)
if not cell or (cell["count"] == 0 and not cell["non_numeric"]):
row[cname] = ""
elif cell["count"] > 0 and not cell["non_numeric"]:
total = cell["sum"]
row[cname] = int(total) if total == int(total) else round(total, 2)
elif cell["count"] == 0 and cell["non_numeric"]:
vals = sorted(cell["non_numeric"])
preview = "/".join(vals[:5]) + ("…" if len(vals) > 5 else "")
row[cname] = f"{len(vals)} 种:{preview}"
else:
total = cell["sum"]
num_part = int(total) if total == int(total) else round(total, 2)
vals = sorted(cell["non_numeric"])
preview = "/".join(vals[:3])
row[cname] = f"{num_part}(另含非数值:{preview}"
rows.append(row)
return rows, daily_data
# ─────────────────────────────────────────────────────────────────────────────
# 日历表(sheet2)构建
# ─────────────────────────────────────────────────────────────────────────────
def _build_calendar_value_map(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
) -> dict[str, dict[str, dict[str, str]]]:
"""
从 all_records 中按 (uid, date, metric_name) 提取 CALENDAR_METRICS 的值。
返回: {uid: {date_str: {metric_name: value_str}}}
注:同一 (uid, date, metric) 若有多条记录,取最后一条非空值(query-data 同日同字段
通常只返回一条)。
"""
valid_user_ids = set(user_ids)
metric_cols: dict[str, dict] = {}
for col in columns:
if col["_column_name"] in CALENDAR_METRICS:
metric_cols[col["_column_name"]] = col
result: dict[str, dict[str, dict[str, str]]] = {}
for record in all_records:
uid = _user_id_of(record)
if uid is None or uid not in valid_user_ids:
continue
work_date = _extract_work_date(record, columns)
if not work_date:
continue
for metric_name, col in metric_cols.items():
raw = _value_for_column(record, col)
if raw in (None, ""):
continue
uid_bucket = result.setdefault(uid, {})
date_bucket = uid_bucket.setdefault(work_date, {})
date_bucket[metric_name] = str(raw)
return result
def build_calendar_sheet(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_info_map: dict[str, "cmn.UserInfo"],
group_name_map: dict[str, str],
start: datetime,
end: datetime,
) -> dict:
"""
构建日历表 sheet2 的描述 dict(供 write_excel_multi_sheets 使用)。
布局(参考钉钉考勤月历):
列:姓名 | 考勤组 | 部门 | 指标 | 1日 | 2日 | ... | N日
每个用户占 3 行(班次名称 / 考勤结果 / 工作时长)
基础列(前 3 列)做纵向 3 行合并
返回的 sheet dict 包含 merge_groups 配置,让 write_excel_multi_sheets
自动完成基础列合并。
"""
all_dates = _generate_date_columns(start, end)
# 表头:基础列 + 指标列 + 日期列
headers = ["姓名", "考勤组", "部门", "指标"] + [
f"{datetime.strptime(d, cmn.DATE_FMT).day}日" for d in all_dates
]
# 抽取每个 (uid, date, metric) 的值
value_map = _build_calendar_value_map(all_records, columns, user_ids)
rows: list[list[Any]] = []
merge_groups: list[tuple[int, int, int]] = []
attend_result_row_offsets: set[int] = set()
n_metrics = len(CALENDAR_METRICS)
for uid in user_ids:
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
base_cells = [info.name or uid, group_name, info.dept_name]
block_start = len(rows) # 当前用户首行的 row_offset
for metric_name in CALENDAR_METRICS:
row_cells: list[Any] = list(base_cells) + [metric_name]
for date_str in all_dates:
val = value_map.get(uid, {}).get(date_str, {}).get(metric_name, "")
row_cells.append(val)
if metric_name == "考勤结果":
attend_result_row_offsets.add(len(rows))
rows.append(row_cells)
block_end = len(rows) - 1 # 当前用户末行的 row_offset
if block_end > block_start:
# 基础列 = 前 3 列(姓名/考勤组/部门),需纵向合并
merge_groups.append((block_start, block_end, 3))
title = (
f"日历表 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"至 {end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
return {
"name": "日历表",
"headers": headers,
"rows": rows,
"title": title,
"subtitle": subtitle,
"merge_groups": merge_groups,
"attend_result_rows": attend_result_row_offsets or None,
}
# ─────────────────────────────────────────────────────────────────────────────
# 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 e:
cmn.error(str(e))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
try:
columns = resolve_columns(args)
except cmn.DwsCallError as e:
if e.is_permission_error:
cmn.error("权限错误:当前账号无管理员权限,无法获取考勤字段列表。")
return 2
cmn.error(f"获取字段列表失败:{e}")
return 1
except RuntimeError as e:
cmn.error(str(e))
return 1
column_ids = [c["_column_id"] for c in columns]
column_names = [c["_column_name"] for c in columns]
column_id_to_name = {c["_column_id"]: c["_column_name"] for c in columns}
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
user_name_map = {uid: info.name or uid for uid, info in user_info_map.items()}
user_batches = cmn.chunk_users(user_ids)
date_slices = cmn.slice_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 bi, batch in enumerate(user_batches, start=1):
for si, dslice in enumerate(date_slices, start=1):
cmn.log(f"[batch {bi}/{len(user_batches)}] [slice {si}/{len(date_slices)}]")
records = query_one_batch(
batch, column_ids, dslice, stats,
column_id_to_name=column_id_to_name,
inspect=args.inspect,
inspected_flag=inspected_flag,
)
all_records.extend(records)
if not all_records:
stats.add_warning("查询完成,但未得到任何记录")
# 从原始记录中提取每个用户的考勤组名称
group_name_map = cmn.extract_group_names_from_records(all_records, user_ids)
# 月度聚合(普通字段聚合 + 每日维度字段按天存储)
rows_dict, daily_data = aggregate_monthly(all_records, columns, user_ids, user_name_map)
# 请假数据特殊处理:通过 query-leave 单独查询,按 4 类假期月度求和
# 凡是 "请假" 开头的字段(请假 / 请假分类 / 请假时长 等)都视为请假列
leave_in_columns = any(_is_leave_field(name) for name in column_names)
leave_data: dict[str, dict[str, dict[str, float]]] = {}
if leave_in_columns:
try:
leave_data = cmn.query_leave_data(
user_ids, start, end,
leave_names=LEAVE_TYPES,
stats=stats,
)
except cmn.DwsCallError as e:
stats.add_warning(f"[leave] 查询请假数据失败:{e}")
# 生成日期范围内所有日期列表
all_dates = _generate_date_columns(start, end)
# 构建表头:基础列 + 普通聚合字段(剔除"请假*"系列和按天展开字段)+ 请假展开列 + 按天展开字段
base_headers = ["姓名", "考勤组", "部门"]
agg_column_names = [
name for name in column_names
if name not in DAILY_EXPAND_FIELDS and not _is_leave_field(name)
]
# 请假按假期类型展开(如 "请假-事假", "请假-调休", ...
leave_headers: list[str] = []
if leave_in_columns:
leave_headers = [f"{LEAVE_FIELD_NAME}-{lt}" for lt in LEAVE_TYPES]
# 按天展开的表头:字段名-日号(如 "考勤结果-1日", "考勤结果-2日", ...
expand_headers: list[str] = []
expand_date_map: list[tuple[str, str]] = [] # [(field_name, date_str), ...]
for fname in column_names:
if fname in DAILY_EXPAND_FIELDS:
for date_str in all_dates:
day_num = datetime.strptime(date_str, cmn.DATE_FMT).day
header_label = f"{fname}-{day_num}日"
expand_headers.append(header_label)
expand_date_map.append((fname, date_str))
headers = base_headers + agg_column_names + leave_headers + expand_headers
# 计算考勤结果列的 0-based 列索引集合(供 Excel 条件配色使用)
_expand_col_start = len(base_headers) + len(agg_column_names) + len(leave_headers)
attend_result_col_indices: set[int] = set()
for i, (fname, _date) in enumerate(expand_date_map):
if fname == "考勤结果":
attend_result_col_indices.add(_expand_col_start + i)
rows_2d = []
for row in rows_dict:
uid = row.get("userId", "")
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
base = [info.name or uid, group_name, info.dept_name]
agg_data = [row.get(h, "") for h in agg_column_names]
# 请假按假期类型聚合(月度求和)
leave_row: list[Any] = []
if leave_in_columns:
user_leave = leave_data.get(uid, {})
for lt in LEAVE_TYPES:
total = 0.0
for day_bucket in user_leave.values():
total += day_bucket.get(lt, 0.0)
if total == 0.0:
leave_row.append("")
elif total == int(total):
leave_row.append(int(total))
else:
leave_row.append(round(total, 2))
# 按天展开字段的数据
expand_data = []
for fname, date_str in expand_date_map:
value = daily_data.get(fname, {}).get(uid, {}).get(date_str, "")
expand_data.append(value)
rows_2d.append(base + agg_data + leave_row + expand_data)
out_name = args.out or cmn.build_output_filename(start, end, suffix="monthly")
title = (
f"月度汇总展示 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"至 {end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
# sheet1:月度汇总(每人一行)
summary_sheet = {
"name": "月度汇总",
"headers": headers,
"rows": rows_2d,
"title": title,
"subtitle": subtitle,
"attend_result_columns": attend_result_col_indices or None,
}
# sheet2:日历表(每人 3 行:班次名称 / 考勤结果 / 工作时长,按日期展开)
calendar_sheet = build_calendar_sheet(
all_records, columns, user_ids,
user_info_map, group_name_map,
start, end,
)
try:
cmn.write_excel_multi_sheets(out_name, [summary_sheet, calendar_sheet])
except (RuntimeError, ValueError) as e:
cmn.error(str(e))
return 1
cmn.print_summary(
granularity_label="月度汇总",
out_path=out_name,
user_count=len(user_ids),
column_names=column_names,
start=start,
end=end,
rows_count=len(rows_2d),
stats=stats,
extra_tail=(
"[提示] 数值字段已求和;"
"「考勤结果」按天展开为多列(每天一列显示当天考勤状态)。\n"
"[提示] 已附加第二个 sheet「日历表」:每人 3 行(班次名称/考勤结果/工作时长),"
"按日期横向展开,基础列(姓名/考勤组/部门)已纵向合并。"
),
)
return 0
if __name__ == "__main__":
sys.exit(main())