1726 lines
69 KiB
Python
1726 lines
69 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
考勤报表导出脚本 — 公共模块
|
||
|
||
[AI Agent 强制门禁] 本模块不可单独执行,且任何调用方脚本
|
||
(attendance_report_detail/monthly/daily.py)执行前都必须先阅读:
|
||
references/attendance-report.md
|
||
|
||
工作流细节、报表类型判断、人员获取、列选择、错误处理等约束
|
||
全部在 attendance-report.md,禁止凭本脚本源码或 --help 自行组装命令。
|
||
|
||
被 attendance_report_detail.py / attendance_report_monthly.py /
|
||
attendance_report_daily.py 三个粒度脚本共享。
|
||
|
||
职责:
|
||
1. dws CLI 调用(run_dws / run_dws_raw)
|
||
2. 接口分批 / 切片(chunk_users / slice_date_range)
|
||
3. dws 返回值通用解析(unwrap_result / extract_records)
|
||
4. 字段(columns)模糊匹配(match_columns_by_keywords)
|
||
5. userId → name 映射(resolve_user_names)
|
||
6. Excel 写入(write_excel)
|
||
7. 错误处理 / stderr 进度日志
|
||
|
||
约束:
|
||
- 不依赖 dws 命令的具体业务字段(除接口顶层 success/result/error)
|
||
- 业务字段解析全部由各粒度脚本负责
|
||
- 时间字段单位由各粒度脚本自行处理(attendance 接口多为毫秒时间戳)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Iterable
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 常量:dws 接口限制(来自 attendance.md)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
MAX_USERS_PER_BATCH = 5 # report query-data: --users 最多 5 人(降低单批人数避免超时)
|
||
MAX_DAYS_PER_SLICE = 32 # report query-data: --start 到 --end ≤ 32 天
|
||
DWS_TIMEOUT_SECONDS = 120 # 单次 dws 调用超时
|
||
DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||
DATE_FMT = "%Y-%m-%d"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# stderr 日志(脚本所有进度信息打 stderr,stdout 留给最终摘要)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def log(msg: str) -> None:
|
||
"""打印进度信息到 stderr,stdout 保留给最终摘要。"""
|
||
print(msg, file=sys.stderr, flush=True)
|
||
|
||
|
||
def warn(msg: str) -> None:
|
||
log(f"[WARN] {msg}")
|
||
|
||
|
||
def error(msg: str) -> None:
|
||
log(f"[ERROR] {msg}")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# dws 调用
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class DwsCallError(Exception):
|
||
"""dws 调用失败(含进程退出非零、超时、JSON 解析失败、业务 success=false)。"""
|
||
|
||
def __init__(self, message: str, *, is_permission_error: bool = False) -> None:
|
||
super().__init__(message)
|
||
self.is_permission_error = is_permission_error
|
||
|
||
|
||
def _looks_like_permission_error(text: str) -> bool:
|
||
"""启发式:判断错误文本是否属于权限/管理员问题。"""
|
||
if not text:
|
||
return False
|
||
lower = text.lower()
|
||
keywords = ("403", "permission", "denied", "unauthorized",
|
||
"forbidden", "无权限", "权限不足", "管理员")
|
||
return any(k in lower for k in keywords)
|
||
|
||
|
||
def run_dws(args: list[str]) -> Any:
|
||
"""
|
||
调用 `dws <args> --format json`,返回解析后的 JSON。
|
||
|
||
自动追加 `--format json`(如果调用方没传),并解开顶层 `success/result/error`:
|
||
- success=True → 返回 result 内容
|
||
- success=False → 抛 DwsCallError(含 is_permission_error 标记)
|
||
- 进程退出非零或解析失败 → 抛 DwsCallError
|
||
|
||
注意:本函数仅做"顶层解包",业务字段解析由调用方负责。
|
||
"""
|
||
if "--format" not in args:
|
||
args = args + ["--format", "json"]
|
||
|
||
cmd = ["dws"] + args
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=DWS_TIMEOUT_SECONDS,
|
||
)
|
||
except subprocess.TimeoutExpired as e:
|
||
raise DwsCallError(f"dws 调用超时({DWS_TIMEOUT_SECONDS}s):{' '.join(cmd)}") from e
|
||
except FileNotFoundError as e:
|
||
raise DwsCallError("未找到 dws 命令,请确认 dws CLI 已安装并在 PATH 中") from e
|
||
|
||
stdout = result.stdout or ""
|
||
stderr = result.stderr or ""
|
||
|
||
if result.returncode != 0:
|
||
is_perm = _looks_like_permission_error(stderr) or _looks_like_permission_error(stdout)
|
||
raise DwsCallError(
|
||
f"dws 调用失败(exit={result.returncode}): {stderr.strip() or stdout.strip()}",
|
||
is_permission_error=is_perm,
|
||
)
|
||
|
||
try:
|
||
data = json.loads(stdout)
|
||
except json.JSONDecodeError as e:
|
||
raise DwsCallError(f"dws 返回非 JSON:{stdout[:200]!r}") from e
|
||
|
||
return unwrap_result(data)
|
||
|
||
|
||
def unwrap_result(data: Any) -> Any:
|
||
"""
|
||
解开 dws 返回的顶层 `{success, result, error}` 包装。
|
||
|
||
success=True → 返回 result(可能是 dict / list / None)
|
||
success=False → 抛 DwsCallError
|
||
其他形态 → 原样返回(兼容部分接口直接返回数据)
|
||
"""
|
||
if not isinstance(data, dict):
|
||
return data
|
||
|
||
if "success" not in data:
|
||
# 不是标准包装,原样返回
|
||
return data
|
||
|
||
if data.get("success") is True:
|
||
return data.get("result")
|
||
|
||
# success = False
|
||
err = data.get("error") or {}
|
||
if isinstance(err, dict):
|
||
msg = err.get("message") or err.get("msg") or json.dumps(err, ensure_ascii=False)
|
||
else:
|
||
msg = str(err)
|
||
raise DwsCallError(
|
||
f"dws 业务失败:{msg}",
|
||
is_permission_error=_looks_like_permission_error(msg),
|
||
)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 通用记录提取(兼容多种数据嵌套形态)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def extract_records(payload: Any) -> list[dict]:
|
||
"""
|
||
从 dws 返回的 result 中提取"记录数组"。
|
||
|
||
兼容多种常见嵌套:
|
||
- 直接是 list[dict] → 原样返回
|
||
- {"data": [...]} → 取 data
|
||
- {"records": [...]} → 取 records
|
||
- {"list": [...]} → 取 list
|
||
- {"items": [...]} → 取 items
|
||
- {"result": [...]} (双层包装) → 递归一次
|
||
- 其他 dict 但只有一个值是 list → 取那个 list
|
||
- 其他形态 → 返回 [],并 warn
|
||
|
||
业务字段不在本函数关心范围内。
|
||
"""
|
||
if payload is None:
|
||
return []
|
||
if isinstance(payload, list):
|
||
return [item for item in payload if isinstance(item, dict)]
|
||
if isinstance(payload, dict):
|
||
for key in ("data", "records", "list", "items", "result"):
|
||
if key in payload and isinstance(payload[key], list):
|
||
return [item for item in payload[key] if isinstance(item, dict)]
|
||
# 兜底:dict 中只有一个 list 值
|
||
list_values = [v for v in payload.values() if isinstance(v, list)]
|
||
if len(list_values) == 1:
|
||
return [item for item in list_values[0] if isinstance(item, dict)]
|
||
warn(f"未能从返回中识别记录数组,顶层 keys={list(payload.keys())}")
|
||
return []
|
||
warn(f"未能识别返回类型:{type(payload).__name__}")
|
||
return []
|
||
|
||
|
||
def flatten_query_data_records(
|
||
records: list[dict],
|
||
column_id_to_name: dict[str, str] | None = None,
|
||
) -> list[dict]:
|
||
"""
|
||
展平 report query-data 返回的嵌套 values 结构。
|
||
|
||
接口原始格式:
|
||
{"userId":"xxx", "values":[{"termId":"173410778","value":"1"}, ...], "workDate":"2026-05-01"}
|
||
|
||
展平后:
|
||
{"userId":"xxx", "workDate":"2026-05-01", "173410778":"1", "节假日+出勤":"1", ...}
|
||
|
||
Args:
|
||
records: extract_records 返回的原始记录列表
|
||
column_id_to_name: 可选的 columnId → columnName 映射,展平时同时写入字段名 key
|
||
"""
|
||
flattened: list[dict] = []
|
||
for record in records:
|
||
values_list = record.get("values")
|
||
if not isinstance(values_list, list):
|
||
# 已经是平铺格式或无 values 字段,原样保留
|
||
flattened.append(record)
|
||
continue
|
||
flat: dict[str, Any] = {}
|
||
# 保留顶层非 values 字段(userId, workDate, corpId 等)
|
||
for k, v in record.items():
|
||
if k != "values":
|
||
flat[k] = v
|
||
# 展平 values 数组
|
||
for entry in values_list:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
term_id = str(entry.get("termId", entry.get("columnId", entry.get("id", ""))))
|
||
value = entry.get("value", entry.get("data", ""))
|
||
if term_id:
|
||
flat[term_id] = value
|
||
# 同时写入字段名 key(方便按名称取值)
|
||
if column_id_to_name and term_id in column_id_to_name:
|
||
flat[column_id_to_name[term_id]] = value
|
||
flattened.append(flat)
|
||
return flattened
|
||
|
||
|
||
def extract_group_names_from_records(
|
||
records: list[dict],
|
||
user_ids: list[str],
|
||
) -> dict[str, str]:
|
||
"""
|
||
获取每个用户的考勤组名称。
|
||
|
||
优先从 report query-data 原始记录中提取(如果接口返回了 groupName 字段),
|
||
否则回退到通过 `dws attendance group search` + `filtered-get --member`
|
||
获取所有考勤组的成员列表,反向映射 userId → 考勤组名称。
|
||
|
||
返回 {userId: groupName} 映射,未找到的用户映射为空字符串。
|
||
"""
|
||
group_map: dict[str, str] = {}
|
||
candidate_keys = ("groupName", "group_name", "attendanceGroupName",
|
||
"groupId", "group_id")
|
||
|
||
# 1) 先尝试从原始记录中提取
|
||
for record in records:
|
||
uid = _first_nonempty(record, ("userId", "userid", "user_id", "targetUserId"))
|
||
if uid is None:
|
||
continue
|
||
uid_str = str(uid)
|
||
if uid_str in group_map:
|
||
continue
|
||
name = _first_nonempty(record, candidate_keys)
|
||
if name is not None and str(name).strip():
|
||
group_map[uid_str] = str(name).strip()
|
||
|
||
# 2) 如果还有用户未匹配到考勤组,通过 group API 反向查找
|
||
missing_uids = {uid for uid in user_ids if uid not in group_map}
|
||
if missing_uids:
|
||
api_map = _resolve_group_names_via_api(missing_uids)
|
||
group_map.update(api_map)
|
||
|
||
# 兜底:未找到的用户填空字符串
|
||
for uid in user_ids:
|
||
if uid not in group_map:
|
||
group_map[uid] = ""
|
||
|
||
return group_map
|
||
|
||
|
||
def _resolve_group_names_via_api(target_uids: set[str]) -> dict[str, str]:
|
||
"""
|
||
通过 dws attendance group search + filtered-get --member
|
||
反向映射 userId → 考勤组名称。
|
||
|
||
流程:
|
||
1. group search 获取所有考勤组(id + name)
|
||
2. 对每个考勤组调用 filtered-get --member 获取成员 userId 列表
|
||
3. 将 target_uids 中的用户与考勤组成员做交集映射
|
||
"""
|
||
result_map: dict[str, str] = {}
|
||
if not target_uids:
|
||
return result_map
|
||
|
||
# 获取所有考勤组
|
||
try:
|
||
search_payload = run_dws([
|
||
"attendance", "group", "search",
|
||
"--limit", "200",
|
||
])
|
||
except DwsCallError as e:
|
||
log(f"[group] 获取考勤组列表失败:{e}")
|
||
return result_map
|
||
|
||
search_result = unwrap_result(search_payload)
|
||
groups: list[dict] = []
|
||
if isinstance(search_result, dict):
|
||
groups = search_result.get("items", [])
|
||
elif isinstance(search_result, list):
|
||
groups = search_result
|
||
|
||
if not groups:
|
||
log("[group] 未获取到任何考勤组")
|
||
return result_map
|
||
|
||
log(f"[group] 共 {len(groups)} 个考勤组,开始查询成员列表")
|
||
|
||
remaining = set(target_uids)
|
||
for group in groups:
|
||
if not remaining:
|
||
break
|
||
group_id = group.get("id")
|
||
group_name = group.get("name", "")
|
||
if not group_id:
|
||
continue
|
||
|
||
try:
|
||
detail_payload = run_dws([
|
||
"attendance", "group", "filtered-get",
|
||
"--group-id", str(group_id),
|
||
"--member",
|
||
])
|
||
except DwsCallError as e:
|
||
log(f"[group] filtered-get 失败 (group={group_name}): {e}")
|
||
continue
|
||
|
||
detail = unwrap_result(detail_payload)
|
||
if not isinstance(detail, dict):
|
||
continue
|
||
|
||
member_users = detail.get("memberUsers", [])
|
||
if not isinstance(member_users, list):
|
||
continue
|
||
|
||
for member_uid in member_users:
|
||
uid_str = str(member_uid)
|
||
if uid_str in remaining:
|
||
result_map[uid_str] = group_name
|
||
remaining.discard(uid_str)
|
||
|
||
if remaining:
|
||
log(f"[group] {len(remaining)} 个用户未匹配到考勤组")
|
||
|
||
return result_map
|
||
|
||
|
||
def dump_first_record_for_inspection(records: list[dict], label: str) -> None:
|
||
"""
|
||
第一次跑脚本时,把第一条记录打到 stderr,方便用户/开发者
|
||
看清真实字段结构后回来调优解析逻辑。
|
||
"""
|
||
if not records:
|
||
log(f"[inspect:{label}] 无记录")
|
||
return
|
||
sample = records[0]
|
||
log(f"[inspect:{label}] 首条记录字段示例(用于核对真实结构):")
|
||
log(json.dumps(sample, ensure_ascii=False, indent=2))
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 时间区间切片
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class DateSlice:
|
||
start: datetime # 含
|
||
end: datetime # 含
|
||
|
||
@property
|
||
def start_str(self) -> str:
|
||
return self.start.strftime(DATETIME_FMT)
|
||
|
||
@property
|
||
def end_str(self) -> str:
|
||
return self.end.strftime(DATETIME_FMT)
|
||
|
||
@property
|
||
def label(self) -> str:
|
||
return f"{self.start.strftime(DATE_FMT)}~{self.end.strftime(DATE_FMT)}"
|
||
|
||
|
||
def parse_datetime_arg(s: str, *, end_of_day: bool = False) -> datetime:
|
||
"""
|
||
解析用户输入的日期参数,支持:
|
||
- YYYY-MM-DD → 00:00:00 或 23:59:59(取决于 end_of_day)
|
||
- YYYY-MM-DD HH:mm:ss
|
||
"""
|
||
s = s.strip()
|
||
try:
|
||
return datetime.strptime(s, DATETIME_FMT)
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
d = datetime.strptime(s, DATE_FMT)
|
||
if end_of_day:
|
||
return d.replace(hour=23, minute=59, second=59)
|
||
return d
|
||
except ValueError as e:
|
||
raise ValueError(
|
||
f"无法解析日期 {s!r},请使用 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss"
|
||
) from e
|
||
|
||
|
||
def slice_date_range(
|
||
start: datetime,
|
||
end: datetime,
|
||
max_days: int = MAX_DAYS_PER_SLICE,
|
||
) -> list[DateSlice]:
|
||
"""
|
||
把 [start, end] 切成多个 ≤ max_days 天的小区间。
|
||
|
||
每片含起含止;最后一片可能短于 max_days。
|
||
"""
|
||
if end < start:
|
||
raise ValueError(f"结束时间 {end} 早于开始时间 {start}")
|
||
|
||
slices: list[DateSlice] = []
|
||
cur_start = start
|
||
while cur_start <= end:
|
||
# 这片的最晚结束时间(不超 max_days,且不超 end)
|
||
cur_end_limit = cur_start + timedelta(days=max_days - 1)
|
||
cur_end_limit = cur_end_limit.replace(hour=23, minute=59, second=59)
|
||
cur_end = min(cur_end_limit, end)
|
||
slices.append(DateSlice(start=cur_start, end=cur_end))
|
||
# 下一片从次日 00:00 开始
|
||
next_day = (cur_end + timedelta(seconds=1)).replace(
|
||
hour=0, minute=0, second=0, microsecond=0
|
||
)
|
||
cur_start = next_day
|
||
return slices
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 用户分批
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def chunk_users(users: list[str], size: int = MAX_USERS_PER_BATCH) -> list[list[str]]:
|
||
"""把 userId 列表切成每片 ≤ size 的小批。"""
|
||
if size <= 0:
|
||
raise ValueError(f"size 必须 > 0,得到 {size}")
|
||
return [users[i: i + size] for i in range(0, len(users), size)]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# columns 模糊匹配
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def match_columns_by_keywords(
|
||
all_columns: list[dict],
|
||
keywords: list[str],
|
||
*,
|
||
name_keys: tuple[str, ...] = ("name", "columnName", "title", "label"),
|
||
id_keys: tuple[str, ...] = ("id", "columnId", "code", "key"),
|
||
) -> list[dict]:
|
||
"""
|
||
在 report columns 返回的字段列表里,按关键词匹配目标字段。
|
||
|
||
匹配策略(按优先级):
|
||
1. 精确匹配:关键词 == 字段名(优先)
|
||
2. 子串匹配:关键词是字段名的子串(仅当精确匹配无结果时回退)
|
||
|
||
列名严格使用接口返回的原始字段名,不做任何修改。
|
||
"""
|
||
matched: list[dict] = []
|
||
matched_ids: set[str] = set()
|
||
hit_keywords: set[str] = set()
|
||
|
||
# 构建 name → (col_dict, cid_str) 索引
|
||
col_index: list[tuple[str, str, dict]] = []
|
||
for col in all_columns:
|
||
name = _first_nonempty(col, name_keys)
|
||
cid = _first_nonempty(col, id_keys)
|
||
if not name or cid is None:
|
||
continue
|
||
col_index.append((str(name), str(cid), col))
|
||
|
||
for kw in keywords:
|
||
kw_stripped = kw.strip()
|
||
if not kw_stripped:
|
||
continue
|
||
|
||
# 第一轮:精确匹配
|
||
exact_hit = False
|
||
for name, cid_str, col in col_index:
|
||
if name == kw_stripped and cid_str not in matched_ids:
|
||
enriched = dict(col)
|
||
enriched["_column_id"] = cid_str
|
||
enriched["_column_name"] = name
|
||
matched.append(enriched)
|
||
matched_ids.add(cid_str)
|
||
hit_keywords.add(kw_stripped)
|
||
exact_hit = True
|
||
break
|
||
|
||
if exact_hit:
|
||
continue
|
||
|
||
# 第二轮:子串匹配(回退),只取第一个命中
|
||
kw_lower = kw_stripped.lower()
|
||
for name, cid_str, col in col_index:
|
||
if kw_lower in name.lower() and cid_str not in matched_ids:
|
||
enriched = dict(col)
|
||
enriched["_column_id"] = cid_str
|
||
enriched["_column_name"] = name
|
||
matched.append(enriched)
|
||
matched_ids.add(cid_str)
|
||
hit_keywords.add(kw_stripped)
|
||
break
|
||
|
||
missing = set(k.strip() for k in keywords if k.strip()) - hit_keywords
|
||
if missing:
|
||
warn(f"以下关键词未匹配到任何字段,已跳过:{sorted(missing)}")
|
||
return matched
|
||
|
||
|
||
def _first_nonempty(d: dict, keys: Iterable[str]) -> Any:
|
||
for k in keys:
|
||
if k in d and d[k] not in (None, ""):
|
||
return d[k]
|
||
return None
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# userId → name 映射
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def resolve_users_from_input(raw_ids: list[str]) -> list[str]:
|
||
"""
|
||
智能解析 --users 输入:自动区分部门ID和员工userId。
|
||
|
||
Wukong Agent 经常把部门ID当 userId 传入。本函数尝试对每个ID调用
|
||
`dws contact dept list-members` 获取成员列表:
|
||
- 如果成功且返回了员工,说明该ID是部门ID,展开为员工userId列表
|
||
- 如果失败或无结果,说明该ID本身就是userId,原样保留
|
||
|
||
最终返回去重后的 userId 列表。
|
||
"""
|
||
if not raw_ids:
|
||
return []
|
||
|
||
resolved: list[str] = []
|
||
seen: set[str] = set()
|
||
|
||
# 先尝试批量查部门成员(可能全是部门ID)
|
||
try:
|
||
result = run_dws([
|
||
"contact", "dept", "list-members",
|
||
"--ids", ",".join(raw_ids),
|
||
])
|
||
members = extract_records(result)
|
||
if members:
|
||
# 成功获取到成员 → 输入是部门ID
|
||
for member in members:
|
||
uid = _first_nonempty(member, ("userId", "userid", "id"))
|
||
if uid and str(uid) not in seen:
|
||
resolved.append(str(uid))
|
||
seen.add(str(uid))
|
||
if resolved:
|
||
log(f"[users] 检测到输入为部门ID,已展开为 {len(resolved)} 个员工userId")
|
||
return resolved
|
||
except DwsCallError:
|
||
pass
|
||
|
||
# 逐个ID尝试:可能混合了部门ID和userId
|
||
for raw_id in raw_ids:
|
||
if raw_id in seen:
|
||
continue
|
||
try:
|
||
result = run_dws([
|
||
"contact", "dept", "list-members",
|
||
"--ids", raw_id,
|
||
])
|
||
members = extract_records(result)
|
||
if members:
|
||
for member in members:
|
||
uid = _first_nonempty(member, ("userId", "userid", "id"))
|
||
if uid and str(uid) not in seen:
|
||
resolved.append(str(uid))
|
||
seen.add(str(uid))
|
||
log(f"[users] 部门ID {raw_id} 展开为 {len(members)} 个员工")
|
||
continue
|
||
except DwsCallError:
|
||
pass
|
||
# 不是部门ID,当作userId保留
|
||
if raw_id not in seen:
|
||
resolved.append(raw_id)
|
||
seen.add(raw_id)
|
||
|
||
return resolved
|
||
|
||
|
||
@dataclass
|
||
class UserInfo:
|
||
"""用户基础信息,用于报表的姓名/部门/工号/职位列。"""
|
||
name: str = ""
|
||
dept_name: str = ""
|
||
job_number: str = ""
|
||
title: str = ""
|
||
|
||
|
||
def _extract_title_from_labels(labels: list) -> str:
|
||
"""从 orgEmployeeModel.labels 数组中提取职务名称。"""
|
||
if not isinstance(labels, list):
|
||
return ""
|
||
for item in labels:
|
||
if isinstance(item, dict) and item.get("groupName") == "职务":
|
||
name = item.get("name", "")
|
||
if name:
|
||
return str(name)
|
||
return ""
|
||
|
||
|
||
def _parse_user_record(record: dict) -> tuple[str, UserInfo] | None:
|
||
"""
|
||
从 dws contact user get 返回的单条记录中解析用户信息。
|
||
|
||
接口返回结构为嵌套格式:
|
||
{"orgEmployeeModel": {"userId": "xxx", "orgUserName": "吾贤",
|
||
"depts": [{"deptName": "技术部"}],
|
||
"labels": [{"groupName": "职务", "name": "财务"}],
|
||
...}, "isAdmin": true}
|
||
|
||
也兼容扁平格式(其他接口可能返回):
|
||
{"userId": "xxx", "name": "吾贤", "deptName": "技术部", ...}
|
||
"""
|
||
# 优先从嵌套的 orgEmployeeModel 中提取
|
||
model = record.get("orgEmployeeModel")
|
||
if isinstance(model, dict):
|
||
uid = model.get("userId") or model.get("orgUserId")
|
||
if not uid:
|
||
return None
|
||
name = model.get("orgUserName") or model.get("name") or ""
|
||
depts = model.get("depts") or []
|
||
dept_name = depts[0].get("deptName", "") if depts and isinstance(depts[0], dict) else ""
|
||
# 工号:尝试多个候选字段
|
||
job_number = (model.get("jobNumber") or model.get("workNumber")
|
||
or model.get("empId") or "")
|
||
# 职位:优先 title/position,回退到 labels 中 groupName=="职务" 的条目
|
||
title = (model.get("title") or model.get("position")
|
||
or _extract_title_from_labels(model.get("labels", [])))
|
||
return str(uid), UserInfo(
|
||
name=str(name),
|
||
dept_name=str(dept_name),
|
||
job_number=str(job_number),
|
||
title=str(title),
|
||
)
|
||
|
||
# 回退:扁平格式
|
||
uid = _first_nonempty(record, ("userId", "userid", "id"))
|
||
if not uid:
|
||
return None
|
||
return str(uid), UserInfo(
|
||
name=str(_first_nonempty(record, ("name", "userName", "nick")) or ""),
|
||
dept_name=str(_first_nonempty(record, ("deptName", "dept_name", "department")) or ""),
|
||
job_number=str(_first_nonempty(record, ("jobNumber", "job_number", "workNumber")) or ""),
|
||
title=str(_first_nonempty(record, ("title", "position", "jobTitle")) or ""),
|
||
)
|
||
|
||
|
||
def resolve_user_info(user_ids: list[str]) -> dict[str, UserInfo]:
|
||
"""
|
||
批量获取用户的完整基础信息(姓名、部门、工号、职位)。
|
||
|
||
适配 dws contact user get 返回的嵌套 orgEmployeeModel 结构。
|
||
分批处理(每批20人),失败时不抛错。
|
||
"""
|
||
if not user_ids:
|
||
return {}
|
||
|
||
info_map: dict[str, UserInfo] = {}
|
||
|
||
for i in range(0, len(user_ids), 20):
|
||
batch = user_ids[i:i + 20]
|
||
try:
|
||
result = run_dws(["contact", "user", "get", "--ids", ",".join(batch)])
|
||
for record in extract_records(result):
|
||
parsed = _parse_user_record(record)
|
||
if parsed:
|
||
uid_str, info = parsed
|
||
info_map[uid_str] = info
|
||
except DwsCallError as e:
|
||
log(f"[user-info] 批量 get 失败(batch {i // 20 + 1}):{e}")
|
||
|
||
# 兜底:未解析到的用户填充 userId 作为姓名
|
||
for uid in user_ids:
|
||
if uid not in info_map:
|
||
info_map[uid] = UserInfo(name=uid)
|
||
|
||
return info_map
|
||
|
||
|
||
def resolve_user_names(user_ids: list[str]) -> dict[str, str]:
|
||
"""
|
||
给一组 userId 解析姓名映射(向后兼容接口)。
|
||
|
||
内部调用 resolve_user_info,只返回 {userId: name} 映射。
|
||
"""
|
||
info_map = resolve_user_info(user_ids)
|
||
return {uid: info.name or uid for uid, info in info_map.items()}
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Excel 写入
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Excel 样式常量与辅助函数
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
# 配色(参考钉钉考勤报表风格:青绿标题 + 浅黄表头 + 白色数据区)
|
||
_TITLE_FILL_COLOR = "D5EAEA" # 浅青绿 — 主标题背景
|
||
_TITLE_FONT_COLOR = "1F6E6E" # 深青 — 主标题字体
|
||
_SUBTITLE_FILL_COLOR = "DAEEF3" # 浅蓝 — 副标题(生成时间)背景
|
||
_SUBTITLE_FONT_COLOR = "31708F" # 深蓝 — 副标题字体
|
||
|
||
_HEADER_FILL_COLOR = "FFF2CC" # 浅黄 — 表头背景
|
||
_HEADER_FONT_COLOR = "333333" # 深灰近黑 — 表头字体
|
||
_DATA_FONT_COLOR = "333333" # 数据字体颜色
|
||
|
||
_BORDER_COLOR = "BFBFBF" # 浅灰 — 单元格边框
|
||
_FONT_NAME = "微软雅黑"
|
||
|
||
# 日历表人员交替配色(奇数人白底,偶数人浅灰蓝底,便于区分不同人员)
|
||
_CALENDAR_BAND_COLORS = ("FFFFFF", "EDF2F9")
|
||
|
||
# 考勤结果单元格条件配色(参考钉钉 previewStyleByValue 配置)
|
||
# 规则按优先级排列,首个匹配命中即停止;无 color 键表示不填充背景色。
|
||
import re as _re
|
||
|
||
_ATTEND_RESULT_STYLE_RULES: list[tuple["_re.Pattern[str]", str | None]] = [
|
||
# 白底(红字加粗由字体控制):周末 — POI index 9 WHITE (255,255,255)
|
||
(_re.compile(r".*(星期六|星期日|星期天)[\s\S]*"), "FFFFFF"),
|
||
# 浅黄(TAN):补卡审批通过/举证打卡/加班/外出/假/调休 — POI index 47 (255,204,153)
|
||
(_re.compile(r".*(补卡审批通过|举证打卡审批通过|加班|外出|假|调休)[\s\S]*"), "FFCC99"),
|
||
# 浅青(LIGHT_TURQUOISE):外勤/出差 — POI index 41 (204,255,255)
|
||
(_re.compile(r".*(外勤|出差)[\s\S]*"), "CCFFFF"),
|
||
# 浅黄(TAN):管理员改为正常 — POI index 47 (255,204,153)
|
||
(_re.compile(r"(?=.*管理员)(?=.*改为正常)^[\s\S]*$"), "FFCC99"),
|
||
# 水蓝(AQUA):旷工迟到(须在"旷工""迟到"之前匹配)— POI index 49 (51,204,204)
|
||
(_re.compile(r".*(旷工迟到)[\s\S]*"), "33CCCC"),
|
||
# 玫红(ROSE):旷工 — POI index 45 (255,153,204)
|
||
(_re.compile(r".*(旷工)[\s\S]*"), "FF99CC"),
|
||
# 淡蓝(PALE_BLUE):严重迟到(须在普通"迟到"之前匹配)— POI index 44 (153,204,255)
|
||
(_re.compile(r".*(严重迟到)[\s\S]*"), "99CCFF"),
|
||
# 浅绿(LIGHT_GREEN):迟到 — POI index 42 (204,255,204)
|
||
(_re.compile(r".*(迟到)[\s\S]*"), "CCFFCC"),
|
||
# 柠檬黄(LEMON_CHIFFON):早退 — POI index 26 (255,255,153)
|
||
(_re.compile(r".*(早退)[\s\S]*"), "FFFF99"),
|
||
# 珊瑚粉(CORAL):缺卡 — POI index 29 (255,128,128)
|
||
(_re.compile(r".*(缺卡)[\s\S]*"), "FF8080"),
|
||
# 无色:未排班/休息/正常 — 不填充
|
||
(_re.compile(r".*(未排班|休息|正常)[\s\S]*"), None),
|
||
]
|
||
|
||
|
||
def _match_attend_result_color(text: str) -> str | None:
|
||
"""
|
||
根据考勤结果文本匹配颜色(6 位 hex,不含 #),无匹配或匹配到"无色"规则时返回 None。
|
||
"""
|
||
if not text:
|
||
return None
|
||
for pattern, color in _ATTEND_RESULT_STYLE_RULES:
|
||
if pattern.fullmatch(text):
|
||
return color
|
||
return None
|
||
|
||
# 行高
|
||
_TITLE_ROW_HEIGHT = 30 # 主标题行高
|
||
_SUBTITLE_ROW_HEIGHT = 22 # 副标题行高
|
||
_HEADER_ROW_HEIGHT = 32 # 表头行高(更高,配合浅黄底色)
|
||
_DATA_ROW_HEIGHT = 22 # 数据行高
|
||
|
||
# 列宽估算:中文字符权重,宽度上下限
|
||
_CJK_CHAR_WEIGHT = 2.0
|
||
_ASCII_CHAR_WEIGHT = 1.1
|
||
_MIN_COL_WIDTH = 10
|
||
_MAX_COL_WIDTH = 40
|
||
|
||
|
||
def _is_cjk(ch: str) -> bool:
|
||
"""判断一个字符是否为中日韩字符(用于估算 Excel 列宽)。"""
|
||
if not ch:
|
||
return False
|
||
code = ord(ch)
|
||
return (
|
||
0x4E00 <= code <= 0x9FFF # CJK 统一汉字
|
||
or 0x3000 <= code <= 0x303F # CJK 符号和标点
|
||
or 0xFF00 <= code <= 0xFFEF # 全角字符
|
||
)
|
||
|
||
|
||
def _estimate_text_width(text: str) -> float:
|
||
"""估算字符串在 Excel 中显示所占的列宽(中文 ≈ 2,ASCII ≈ 1)。"""
|
||
if not text:
|
||
return 0.0
|
||
width = 0.0
|
||
for ch in text:
|
||
width += _CJK_CHAR_WEIGHT if _is_cjk(ch) else _ASCII_CHAR_WEIGHT
|
||
return width
|
||
|
||
|
||
def _is_numeric_value(value: Any) -> bool:
|
||
"""判断一个值是否为可对齐到右侧的数字(或纯数字字符串)。"""
|
||
if isinstance(value, bool):
|
||
return False
|
||
if isinstance(value, (int, float)):
|
||
return True
|
||
if isinstance(value, str):
|
||
s = value.strip()
|
||
if not s:
|
||
return False
|
||
try:
|
||
float(s)
|
||
return True
|
||
except ValueError:
|
||
return False
|
||
return False
|
||
|
||
|
||
def _build_styles():
|
||
"""构建 Excel 样式对象集合,避免每个单元格重复创建。"""
|
||
from openpyxl.styles import (
|
||
Alignment, Border, Font, PatternFill, Side,
|
||
)
|
||
|
||
thin_side = Side(border_style="thin", color=_BORDER_COLOR)
|
||
border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
|
||
|
||
return {
|
||
# 主标题("月度汇总展示 统计日期:xxx 至 xxx")
|
||
"title_font": Font(name=_FONT_NAME, bold=True, color=_TITLE_FONT_COLOR, size=14),
|
||
"title_fill": PatternFill(fill_type="solid", fgColor=_TITLE_FILL_COLOR),
|
||
"title_align": Alignment(horizontal="left", vertical="center", indent=1),
|
||
# 副标题("报表生成时间:xxx")
|
||
"subtitle_font": Font(name=_FONT_NAME, color=_SUBTITLE_FONT_COLOR, size=10),
|
||
"subtitle_fill": PatternFill(fill_type="solid", fgColor=_SUBTITLE_FILL_COLOR),
|
||
"subtitle_align": Alignment(horizontal="left", vertical="center", indent=1),
|
||
# 表头
|
||
"header_font": Font(name=_FONT_NAME, bold=True, color=_HEADER_FONT_COLOR, size=11),
|
||
"header_fill": PatternFill(fill_type="solid", fgColor=_HEADER_FILL_COLOR),
|
||
"header_align": Alignment(horizontal="center", vertical="center", wrap_text=True),
|
||
# 数据
|
||
"data_font": Font(name=_FONT_NAME, color=_DATA_FONT_COLOR, size=10),
|
||
"align_left": Alignment(horizontal="left", vertical="center", wrap_text=True),
|
||
"align_right": Alignment(horizontal="right", vertical="center", wrap_text=False),
|
||
"align_center": Alignment(horizontal="center", vertical="center", wrap_text=True),
|
||
"border": border,
|
||
# 日历表人员交替配色(按 merge_groups 块交替)
|
||
"band_fills": tuple(
|
||
PatternFill(fill_type="solid", fgColor=c)
|
||
for c in _CALENDAR_BAND_COLORS
|
||
),
|
||
}
|
||
|
||
|
||
def _apply_sheet_styles(
|
||
ws,
|
||
headers: list[str],
|
||
rows: list[list[Any]],
|
||
styles: dict,
|
||
*,
|
||
title: str | None = None,
|
||
subtitle: str | None = None,
|
||
freeze_first_col: bool = True,
|
||
merge_groups: list[tuple[int, int, int]] | None = None,
|
||
attend_result_columns: set[int] | None = None,
|
||
attend_result_rows: set[int] | None = None,
|
||
image_columns: list[str] | None = None,
|
||
image_size: tuple[int, int] = (80, 120),
|
||
) -> None:
|
||
"""
|
||
给一个已写入数据的 sheet 应用统一样式(钉钉风格)。
|
||
|
||
布局(自上而下):
|
||
[可选] 第 1 行:主标题(青绿底,跨所有列,写"xxx展示 统计日期:A 至 B")
|
||
[可选] 第 2 行:副标题(浅蓝底,跨所有列,写"报表生成时间:xxx")
|
||
表头行:浅黄底 + 加粗黑字 + 居中 + 加高
|
||
数据行:白底 + 居中 + 灰色细边框
|
||
|
||
参数:
|
||
title: 可选主标题文本(如"月度汇总展示 统计日期:2026-01-01 至 2026-01-31")
|
||
subtitle: 可选副标题文本(如"报表生成时间:2026-01-20 15:46")
|
||
freeze_first_col: 是否冻结首列(姓名)
|
||
attend_result_columns: 考勤结果列的 0-based 列索引集合(月度汇总场景)
|
||
attend_result_rows: 考勤结果行的 0-based 行偏移集合(日历表场景)
|
||
"""
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
n_cols = len(headers)
|
||
n_rows = len(rows)
|
||
last_col_letter = get_column_letter(n_cols) if n_cols >= 1 else "A"
|
||
|
||
# ── 标题区(如果有)── 占据 1~2 行,跨所有列 ───────────────────────
|
||
title_row_count = 0
|
||
if title:
|
||
title_row_count += 1
|
||
title_row = title_row_count
|
||
ws.cell(row=title_row, column=1, value=title)
|
||
if n_cols >= 2:
|
||
ws.merge_cells(
|
||
start_row=title_row, start_column=1,
|
||
end_row=title_row, end_column=n_cols,
|
||
)
|
||
cell = ws.cell(row=title_row, column=1)
|
||
cell.font = styles["title_font"]
|
||
cell.fill = styles["title_fill"]
|
||
cell.alignment = styles["title_align"]
|
||
ws.row_dimensions[title_row].height = _TITLE_ROW_HEIGHT
|
||
if subtitle:
|
||
title_row_count += 1
|
||
sub_row = title_row_count
|
||
ws.cell(row=sub_row, column=1, value=subtitle)
|
||
if n_cols >= 2:
|
||
ws.merge_cells(
|
||
start_row=sub_row, start_column=1,
|
||
end_row=sub_row, end_column=n_cols,
|
||
)
|
||
cell = ws.cell(row=sub_row, column=1)
|
||
cell.font = styles["subtitle_font"]
|
||
cell.fill = styles["subtitle_fill"]
|
||
cell.alignment = styles["subtitle_align"]
|
||
ws.row_dimensions[sub_row].height = _SUBTITLE_ROW_HEIGHT
|
||
|
||
header_row = title_row_count + 1
|
||
first_data_row = header_row + 1
|
||
|
||
# ── 表头样式 ─────────────────────────────────────────────────────────
|
||
for col_idx in range(1, n_cols + 1):
|
||
cell = ws.cell(row=header_row, column=col_idx)
|
||
cell.font = styles["header_font"]
|
||
cell.fill = styles["header_fill"]
|
||
cell.alignment = styles["header_align"]
|
||
cell.border = styles["border"]
|
||
ws.row_dimensions[header_row].height = _HEADER_ROW_HEIGHT
|
||
|
||
# ── 人员交替配色映射(仅在有 merge_groups 时生效)─────────────────
|
||
# 为每个数据行偏移预计算应使用的背景 fill(按人员块交替)
|
||
band_fill_map: dict[int, Any] = {}
|
||
if merge_groups:
|
||
band_fills = styles.get("band_fills", ())
|
||
if band_fills:
|
||
for group_idx, (start_off, end_off, _n_base) in enumerate(merge_groups):
|
||
fill = band_fills[group_idx % len(band_fills)]
|
||
for off in range(start_off, end_off + 1):
|
||
band_fill_map[off] = fill
|
||
|
||
# ── 考勤结果条件配色准备 ────────────────────────────────────────────
|
||
# attend_result_columns: 月度汇总场景,指定哪些列(0-based)是考勤结果列
|
||
# attend_result_rows: 日历表场景,指定哪些行偏移(0-based)是考勤结果行
|
||
# 两者都需要结合日期数据列(跳过基础信息列)来判断是否需要配色
|
||
from openpyxl.styles import PatternFill as _PF
|
||
_attend_fill_cache: dict[str, _PF] = {}
|
||
|
||
def _get_attend_fill(color_hex: str) -> _PF:
|
||
"""按颜色值缓存 PatternFill,避免重复创建。"""
|
||
if color_hex not in _attend_fill_cache:
|
||
_attend_fill_cache[color_hex] = _PF(fill_type="solid", fgColor=color_hex)
|
||
return _attend_fill_cache[color_hex]
|
||
|
||
_ar_cols = attend_result_columns or set()
|
||
_ar_rows = attend_result_rows or set()
|
||
|
||
# ── 数据行样式 ───────────────────────────────────────────────────────
|
||
for row_offset in range(n_rows):
|
||
excel_row = first_data_row + row_offset
|
||
row_fill = band_fill_map.get(row_offset)
|
||
is_attend_row = row_offset in _ar_rows
|
||
for col_idx in range(1, n_cols + 1):
|
||
cell = ws.cell(row=excel_row, column=col_idx)
|
||
cell.font = styles["data_font"]
|
||
cell.border = styles["border"]
|
||
|
||
# 背景色优先级:考勤结果条件配色 > 人员交替配色 > 默认无色
|
||
col_zero = col_idx - 1
|
||
value = rows[row_offset][col_zero] if col_zero < len(rows[row_offset]) else None
|
||
is_attend_cell = (col_zero in _ar_cols) or (is_attend_row and col_zero >= 4)
|
||
attend_color = None
|
||
if is_attend_cell and value not in (None, ""):
|
||
attend_color = _match_attend_result_color(str(value))
|
||
|
||
if attend_color:
|
||
cell.fill = _get_attend_fill(attend_color)
|
||
elif row_fill is not None:
|
||
cell.fill = row_fill
|
||
|
||
# 数据区统一居中(参考图风格),仅对长文本(>10 字符)的非首列左对齐避免拥挤
|
||
if _is_numeric_value(value):
|
||
cell.alignment = styles["align_right"]
|
||
else:
|
||
text = str(value) if value not in (None, "") else ""
|
||
if _estimate_text_width(text) > 14 and col_idx > 1:
|
||
cell.alignment = styles["align_left"]
|
||
else:
|
||
cell.alignment = styles["align_center"]
|
||
ws.row_dimensions[excel_row].height = _DATA_ROW_HEIGHT
|
||
|
||
# ── 列宽自适应 ───────────────────────────────────────────────────────
|
||
for col_idx in range(1, n_cols + 1):
|
||
header_text = str(headers[col_idx - 1])
|
||
max_width = _estimate_text_width(header_text) + 2
|
||
for row in rows:
|
||
if col_idx - 1 < len(row):
|
||
cell_value = row[col_idx - 1]
|
||
if cell_value is None or cell_value == "":
|
||
continue
|
||
w = _estimate_text_width(str(cell_value))
|
||
if w > max_width:
|
||
max_width = w
|
||
max_width = min(max(max_width + 1, _MIN_COL_WIDTH), _MAX_COL_WIDTH)
|
||
ws.column_dimensions[get_column_letter(col_idx)].width = max_width
|
||
|
||
# ── 冻结窗格 ─────────────────────────────────────────────────────────
|
||
# 冻结到首个数据行 + 第二列(保留标题/表头/姓名列常驻)
|
||
freeze_col_letter = "B" if (freeze_first_col and n_cols >= 2) else "A"
|
||
ws.freeze_panes = f"{freeze_col_letter}{first_data_row}"
|
||
|
||
# ── 自动筛选器(覆盖表头到最后一行数据)──────────────────────────
|
||
# 注意:合并单元格时不应用 auto_filter(会和合并冲突)
|
||
if n_cols >= 1 and n_rows >= 1 and not merge_groups:
|
||
ws.auto_filter.ref = (
|
||
f"A{header_row}:{last_col_letter}{first_data_row + n_rows - 1}"
|
||
)
|
||
|
||
# ── 单元格纵向合并(仅指定的基础列)──────────────────────────
|
||
# merge_groups: [(start_row_offset, end_row_offset, n_base_cols), ...]
|
||
# 其中 row_offset 是基于数据区第 0 行的偏移
|
||
if merge_groups:
|
||
for start_offset, end_offset, n_base_cols in merge_groups:
|
||
if end_offset <= start_offset:
|
||
continue
|
||
excel_start = first_data_row + start_offset
|
||
excel_end = first_data_row + end_offset
|
||
for col_idx in range(1, n_base_cols + 1):
|
||
# 取首行的值,合并后只保留首行内容
|
||
top_value = ws.cell(row=excel_start, column=col_idx).value
|
||
ws.merge_cells(
|
||
start_row=excel_start, start_column=col_idx,
|
||
end_row=excel_end, end_column=col_idx,
|
||
)
|
||
top_cell = ws.cell(row=excel_start, column=col_idx)
|
||
top_cell.value = top_value
|
||
top_cell.alignment = styles["align_center"]
|
||
top_cell.border = styles["border"]
|
||
|
||
# ── 图片嵌入(下载 URL 列指向的图片,转 PNG,嵌入对应单元格)────
|
||
if image_columns:
|
||
_embed_images_in_columns(
|
||
ws, headers, rows,
|
||
image_column_names=image_columns,
|
||
header_row=header_row,
|
||
first_data_row=first_data_row,
|
||
image_size=image_size,
|
||
)
|
||
|
||
|
||
def write_excel(
|
||
out_path: str,
|
||
headers: list[str],
|
||
rows: list[list[Any]],
|
||
*,
|
||
sheet_name: str = "考勤报表",
|
||
title: str | None = None,
|
||
subtitle: str | None = None,
|
||
image_columns: list[str] | None = None,
|
||
image_size: tuple[int, int] = (80, 120),
|
||
) -> None:
|
||
"""
|
||
用 openpyxl 写一个钉钉风格的美化版 Excel。
|
||
|
||
布局:
|
||
[可选] 第 1 行:主标题(青绿底色,跨所有列)
|
||
[可选] 第 2 行:副标题(浅蓝底色,跨所有列,常用于"报表生成时间")
|
||
表头行:浅黄底色 + 黑字加粗 + 居中 + 加高
|
||
数据行:白底 + 文本居中 / 数字右对齐 + 灰色细边框
|
||
|
||
交互:
|
||
- 冻结首列 + 表头(含标题区域)
|
||
- 自动筛选器(覆盖表头到最后一行)
|
||
- 列宽自适应(中文字符按 2 宽度估算)
|
||
|
||
参数:
|
||
title: 可选主标题(如"月度汇总展示 统计日期:2026-01-01 至 2026-01-31")
|
||
subtitle: 可选副标题(如"报表生成时间:2026-01-20 15:46")
|
||
"""
|
||
try:
|
||
from openpyxl import Workbook
|
||
except ImportError as e:
|
||
raise RuntimeError(
|
||
"缺少 openpyxl 依赖,请执行:pip install openpyxl"
|
||
) from e
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = sheet_name[:31] # openpyxl sheet 名 ≤ 31 字符
|
||
|
||
# 计算标题占用行数
|
||
title_row_count = (1 if title else 0) + (1 if subtitle else 0)
|
||
header_row = title_row_count + 1
|
||
first_data_row = header_row + 1
|
||
|
||
# 写入表头
|
||
for col_idx, header in enumerate(headers, start=1):
|
||
ws.cell(row=header_row, column=col_idx, value=header)
|
||
|
||
# 写入数据
|
||
for row_offset, row in enumerate(rows):
|
||
excel_row = first_data_row + row_offset
|
||
for col_idx, value in enumerate(row, start=1):
|
||
ws.cell(row=excel_row, column=col_idx, value=_excel_safe(value))
|
||
|
||
# 应用统一样式(含标题区)
|
||
styles = _build_styles()
|
||
_apply_sheet_styles(
|
||
ws, headers, rows, styles,
|
||
title=title, subtitle=subtitle,
|
||
image_columns=image_columns, image_size=image_size,
|
||
)
|
||
|
||
out_abs = os.path.abspath(out_path)
|
||
wb.save(out_abs)
|
||
|
||
|
||
def _excel_safe(value: Any) -> Any:
|
||
"""
|
||
把 dict / list 等复杂类型序列化为 JSON 字符串,避免 openpyxl 写入失败。
|
||
"""
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, (str, int, float, bool)):
|
||
return value
|
||
if isinstance(value, datetime):
|
||
return value.strftime(DATETIME_FMT)
|
||
try:
|
||
return json.dumps(value, ensure_ascii=False)
|
||
except (TypeError, ValueError):
|
||
return str(value)
|
||
|
||
|
||
def write_excel_multi_sheets(
|
||
out_path: str,
|
||
sheets: list[dict],
|
||
) -> None:
|
||
"""
|
||
用 openpyxl 写一个多 sheet 的 Excel 文件,每个 sheet 应用与 write_excel 相同的钉钉风格美化样式。
|
||
|
||
每个 sheet 用一个 dict 描述:
|
||
{
|
||
"name": str, # sheet 标题
|
||
"headers": list[str],
|
||
"rows": list[list[Any]],
|
||
"title": str | None, # 可选主标题(青绿底)
|
||
"subtitle": str | None, # 可选副标题(浅蓝底)
|
||
"merge_groups": list[tuple[int,int,int]] | None,
|
||
# 可选纵向合并配置(基础列)
|
||
# 每项: (start_row_offset, end_row_offset, n_base_cols)
|
||
"attend_result_columns": set[int] | None,
|
||
# 可选:考勤结果列的 0-based 列索引集合(月度汇总场景)
|
||
"attend_result_rows": set[int] | None,
|
||
# 可选:考勤结果行的 0-based 行偏移集合(日历表场景)
|
||
"image_columns": list[str] | None,
|
||
# 可选:哪些列名的 URL 要嵌入为图片
|
||
"image_size": tuple[int,int] | None,
|
||
# 可选:嵌入图片像素尺寸 (width, height),默认 (80,120)
|
||
}
|
||
"""
|
||
if not sheets:
|
||
raise ValueError("sheets 不能为空")
|
||
|
||
try:
|
||
from openpyxl import Workbook
|
||
except ImportError as e:
|
||
raise RuntimeError(
|
||
"缺少 openpyxl 依赖,请执行:pip install openpyxl"
|
||
) from e
|
||
|
||
wb = Workbook()
|
||
# 删除默认 sheet,由 sheets 描述完全决定
|
||
default_ws = wb.active
|
||
wb.remove(default_ws)
|
||
|
||
styles = _build_styles()
|
||
|
||
for sheet_def in sheets:
|
||
name = sheet_def.get("name") or "Sheet"
|
||
headers = sheet_def.get("headers") or []
|
||
rows = sheet_def.get("rows") or []
|
||
title = sheet_def.get("title")
|
||
subtitle = sheet_def.get("subtitle")
|
||
merge_groups = sheet_def.get("merge_groups")
|
||
attend_result_columns = sheet_def.get("attend_result_columns")
|
||
attend_result_rows = sheet_def.get("attend_result_rows")
|
||
image_columns = sheet_def.get("image_columns")
|
||
image_size = sheet_def.get("image_size") or (80, 120)
|
||
|
||
ws = wb.create_sheet(title=name[:31])
|
||
|
||
title_row_count = (1 if title else 0) + (1 if subtitle else 0)
|
||
header_row = title_row_count + 1
|
||
first_data_row = header_row + 1
|
||
|
||
# 写入表头
|
||
for col_idx, header in enumerate(headers, start=1):
|
||
ws.cell(row=header_row, column=col_idx, value=header)
|
||
|
||
# 写入数据
|
||
for row_offset, row in enumerate(rows):
|
||
excel_row = first_data_row + row_offset
|
||
for col_idx, value in enumerate(row, start=1):
|
||
ws.cell(row=excel_row, column=col_idx, value=_excel_safe(value))
|
||
|
||
# 应用统一样式
|
||
if headers:
|
||
_apply_sheet_styles(
|
||
ws, headers, rows, styles,
|
||
title=title, subtitle=subtitle,
|
||
merge_groups=merge_groups,
|
||
attend_result_columns=attend_result_columns,
|
||
attend_result_rows=attend_result_rows,
|
||
image_columns=image_columns,
|
||
image_size=image_size,
|
||
)
|
||
|
||
out_abs = os.path.abspath(out_path)
|
||
wb.save(out_abs)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 输出文件命名
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def build_output_filename(start: datetime, end: datetime, *, suffix: str = "") -> str:
|
||
"""
|
||
生成 attendance_report_<startDate>_<endDate>[_suffix].xlsx 形式的文件名,
|
||
落在当前工作目录。
|
||
"""
|
||
base = f"attendance_report_{start.strftime(DATE_FMT)}_{end.strftime(DATE_FMT)}"
|
||
if suffix:
|
||
base = f"{base}_{suffix}"
|
||
return f"{base}.xlsx"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 请假数据查询(query-leave)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
# 默认关注的 4 类假期
|
||
DEFAULT_LEAVE_NAMES: tuple[str, ...] = ("事假", "调休", "病假", "年假")
|
||
|
||
|
||
def _normalize_leave_date(raw: Any) -> str | None:
|
||
"""把 query-leave 返回的 date 字段(毫秒时间戳字符串/数字)归一化为 YYYY-MM-DD。"""
|
||
if raw is None or raw == "":
|
||
return None
|
||
try:
|
||
ts = int(str(raw).strip())
|
||
except (TypeError, ValueError):
|
||
# 也可能本来就是 YYYY-MM-DD
|
||
s = str(raw).strip()
|
||
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
|
||
return s[:10]
|
||
return None
|
||
# 毫秒级
|
||
if ts >= 1_000_000_000_000:
|
||
ts = ts // 1000
|
||
try:
|
||
return datetime.fromtimestamp(ts).strftime(DATE_FMT)
|
||
except (OSError, ValueError, OverflowError):
|
||
return None
|
||
|
||
|
||
def query_leave_data(
|
||
user_ids: list[str],
|
||
start: datetime,
|
||
end: datetime,
|
||
leave_names: Iterable[str] = DEFAULT_LEAVE_NAMES,
|
||
*,
|
||
stats: "CallStats | None" = None,
|
||
) -> dict[str, dict[str, dict[str, float]]]:
|
||
"""
|
||
分批分段调用 `dws attendance report query-leave`,聚合每个用户每天的假期数据。
|
||
|
||
返回结构:
|
||
{
|
||
userId: {
|
||
"YYYY-MM-DD": {
|
||
"事假": 1.0,
|
||
"调休": 0.5,
|
||
...
|
||
},
|
||
...
|
||
},
|
||
...
|
||
}
|
||
|
||
若同一 (userId, date, leaveName) 在多次返回中出现(理论不会),按 sum 累加。
|
||
分批规则与 query-data 一致:≤ MAX_USERS_PER_BATCH 人/次、≤ MAX_DAYS_PER_SLICE 天/次。
|
||
"""
|
||
result: dict[str, dict[str, dict[str, float]]] = {}
|
||
if not user_ids:
|
||
return result
|
||
|
||
leave_names_list = [n for n in leave_names if n]
|
||
if not leave_names_list:
|
||
return result
|
||
|
||
user_batches = chunk_users(user_ids)
|
||
date_slices = slice_date_range(start, end)
|
||
leave_arg = ",".join(leave_names_list)
|
||
|
||
log(
|
||
f"[leave] 查询 {len(user_ids)} 人 × {len(leave_names_list)} 类假期 × "
|
||
f"{len(date_slices)} 个时间片"
|
||
)
|
||
|
||
for bi, batch in enumerate(user_batches, start=1):
|
||
for si, dslice in enumerate(date_slices, start=1):
|
||
log(
|
||
f"[leave] [batch {bi}/{len(user_batches)}] "
|
||
f"[slice {si}/{len(date_slices)}] users={len(batch)} "
|
||
f"slice={dslice.label}"
|
||
)
|
||
try:
|
||
payload = run_dws([
|
||
"attendance", "report", "query-leave",
|
||
"--users", ",".join(batch),
|
||
"--leave-names", leave_arg,
|
||
"--start", dslice.start_str,
|
||
"--end", dslice.end_str,
|
||
])
|
||
if stats is not None:
|
||
stats.total_dws_calls += 1
|
||
except DwsCallError as e:
|
||
if stats is not None:
|
||
stats.total_dws_calls += 1
|
||
stats.failed_calls += 1
|
||
if e.is_permission_error:
|
||
error("权限错误:当前账号无管理员权限,无法查询请假数据。")
|
||
raise SystemExit(2) from e
|
||
if stats is not None:
|
||
stats.add_warning(f"[leave query failed] {dslice.label}: {e}")
|
||
else:
|
||
warn(f"[leave query failed] {dslice.label}: {e}")
|
||
continue
|
||
|
||
records = extract_records(payload)
|
||
for record in records:
|
||
uid = _first_nonempty(record, ("userId", "userid", "user_id"))
|
||
if uid is None:
|
||
continue
|
||
uid_str = str(uid)
|
||
leave_vals = record.get("leaveVals")
|
||
if not isinstance(leave_vals, list):
|
||
continue
|
||
user_bucket = result.setdefault(uid_str, {})
|
||
for entry in leave_vals:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
date_str = _normalize_leave_date(entry.get("date"))
|
||
if not date_str:
|
||
continue
|
||
leave_name = entry.get("leaveName") or entry.get("name")
|
||
if not leave_name:
|
||
continue
|
||
leave_name = str(leave_name)
|
||
raw_value = entry.get("value", entry.get("data", 0))
|
||
try:
|
||
num = float(str(raw_value).strip())
|
||
except (TypeError, ValueError):
|
||
continue
|
||
day_bucket = user_bucket.setdefault(date_str, {})
|
||
day_bucket[leave_name] = day_bucket.get(leave_name, 0.0) + num
|
||
|
||
return result
|
||
|
||
|
||
def build_vacation_filename(
|
||
start: datetime | None,
|
||
end: datetime | None,
|
||
*,
|
||
as_of: datetime | None = None,
|
||
) -> str:
|
||
"""
|
||
生成 vacation_export_<startDate>_<endDate>.xlsx;
|
||
无时间区间时退化为 vacation_export_<asOfDate>.xlsx(asOfDate 默认为今天)。
|
||
"""
|
||
if start is not None and end is not None:
|
||
return f"vacation_export_{start.strftime(DATE_FMT)}_{end.strftime(DATE_FMT)}.xlsx"
|
||
snapshot_date = (as_of or datetime.now()).strftime(DATE_FMT)
|
||
return f"vacation_export_{snapshot_date}.xlsx"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 通用报告骨架
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class CallStats:
|
||
"""记录一次脚本运行中的 dws 调用统计,用于最终摘要。"""
|
||
user_batches: int = 0
|
||
date_slices: int = 0
|
||
total_dws_calls: int = 0
|
||
failed_calls: int = 0
|
||
warnings: list[str] = field(default_factory=list)
|
||
|
||
def add_warning(self, msg: str) -> None:
|
||
self.warnings.append(msg)
|
||
warn(msg)
|
||
|
||
|
||
def print_summary(
|
||
*,
|
||
granularity_label: str,
|
||
out_path: str,
|
||
user_count: int,
|
||
column_names: list[str],
|
||
start: datetime,
|
||
end: datetime,
|
||
rows_count: int,
|
||
stats: CallStats,
|
||
extra_tail: str = "",
|
||
) -> None:
|
||
"""
|
||
把最终摘要打到 stdout,供调用方(Agent / 终端用户)查看。
|
||
格式与 SKILL.md 输出模板对齐。
|
||
"""
|
||
abs_path = os.path.abspath(out_path)
|
||
print("[完成] 考勤报表已导出")
|
||
print()
|
||
print(f"[文件] {abs_path}")
|
||
print(f"[粒度] {granularity_label}")
|
||
print(f"[用户] {user_count} 人(共 {stats.user_batches} 批)")
|
||
print(f"[时间] {start.strftime(DATE_FMT)} ~ {end.strftime(DATE_FMT)}"
|
||
f"(共 {stats.date_slices} 个时间片)")
|
||
print(f"[调用] 共调用 dws 接口:{stats.total_dws_calls} 次"
|
||
+ (f"(其中 {stats.failed_calls} 次失败)" if stats.failed_calls else ""))
|
||
print(f"[字段] {' / '.join(column_names) if column_names else '(默认)'}")
|
||
print(f"[行数] {rows_count} 行")
|
||
if stats.warnings:
|
||
print()
|
||
print("[警告]")
|
||
for w in stats.warnings[:10]:
|
||
print(f" - {w}")
|
||
if len(stats.warnings) > 10:
|
||
print(f" - ...(共 {len(stats.warnings)} 条警告)")
|
||
if extra_tail:
|
||
print()
|
||
print(extra_tail)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 图片下载 + Excel 嵌入(detail 报表的"打卡图片"列专用)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
# 全局缓存:URL → 本地 PNG 文件路径,避免同一张图重复下载/转换
|
||
_IMAGE_CACHE_DIR = os.path.join(
|
||
tempfile.gettempdir(), "dws_attendance_report_images"
|
||
)
|
||
_image_url_to_local: dict[str, str] = {}
|
||
# 下载/转换失败的 URL 黑名单,避免反复重试
|
||
_image_failed_urls: set[str] = set()
|
||
|
||
|
||
def _ensure_image_cache_dir() -> str:
|
||
"""确保图片缓存目录存在并返回路径。"""
|
||
os.makedirs(_IMAGE_CACHE_DIR, exist_ok=True)
|
||
return _IMAGE_CACHE_DIR
|
||
|
||
|
||
def _is_likely_url(value: Any) -> bool:
|
||
"""简单判断一个值是不是 http(s) URL。"""
|
||
if not isinstance(value, str):
|
||
return False
|
||
s = value.strip()
|
||
return s.startswith("http://") or s.startswith("https://")
|
||
|
||
|
||
def download_and_convert_image(
|
||
url: str,
|
||
*,
|
||
timeout: int = 10,
|
||
) -> str | None:
|
||
"""
|
||
下载图片 URL → PIL 转 PNG → 缓存到本地,返回本地 PNG 文件路径。
|
||
|
||
特性:
|
||
- 磁盘缓存(同一 URL 只下载一次)
|
||
- 支持 webp/jpg/jpeg/png 等格式(PIL 自动识别)
|
||
- 失败的 URL 加黑名单,避免反复重试
|
||
- 失败返回 None,调用方应保留原 URL 文本
|
||
|
||
依赖: requests + Pillow(PIL)
|
||
"""
|
||
if url in _image_url_to_local:
|
||
return _image_url_to_local[url]
|
||
if url in _image_failed_urls:
|
||
return None
|
||
|
||
cache_dir = _ensure_image_cache_dir()
|
||
url_hash = hashlib.md5(url.encode("utf-8")).hexdigest()
|
||
local_path = os.path.join(cache_dir, f"{url_hash}.png")
|
||
|
||
if os.path.exists(local_path) and os.path.getsize(local_path) > 0:
|
||
_image_url_to_local[url] = local_path
|
||
return local_path
|
||
|
||
try:
|
||
import requests
|
||
except ImportError:
|
||
warn("缺少 requests 依赖,无法下载图片,请执行: pip install requests")
|
||
_image_failed_urls.add(url)
|
||
return None
|
||
try:
|
||
from PIL import Image as PILImage
|
||
except ImportError:
|
||
warn("缺少 Pillow 依赖,无法转换图片格式,请执行: pip install Pillow")
|
||
_image_failed_urls.add(url)
|
||
return None
|
||
|
||
try:
|
||
resp = requests.get(url, timeout=timeout)
|
||
resp.raise_for_status()
|
||
raw_bytes = resp.content
|
||
if not raw_bytes:
|
||
raise ValueError("下载结果为空")
|
||
except Exception as e:
|
||
warn(f"[image] 下载失败: {url[:80]}... 原因: {e}")
|
||
_image_failed_urls.add(url)
|
||
return None
|
||
|
||
try:
|
||
from io import BytesIO
|
||
with PILImage.open(BytesIO(raw_bytes)) as img:
|
||
# webp 等可能是 RGBA / P 模式,统一转 RGB 再存 PNG
|
||
if img.mode not in ("RGB", "RGBA"):
|
||
img = img.convert("RGBA")
|
||
img.save(local_path, format="PNG")
|
||
except Exception as e:
|
||
warn(f"[image] 转换失败: {url[:80]}... 原因: {e}")
|
||
_image_failed_urls.add(url)
|
||
return None
|
||
|
||
_image_url_to_local[url] = local_path
|
||
return local_path
|
||
|
||
|
||
def _set_image_hyperlink(ws, excel_row: int, col_idx: int, url: str) -> None:
|
||
"""把单元格设为可点击的超链接,文案显示"打卡图片",避免直接暴露裸 URL。"""
|
||
from openpyxl.styles import Font
|
||
cell = ws.cell(row=excel_row, column=col_idx)
|
||
cell.value = "打卡图片"
|
||
cell.hyperlink = url
|
||
cell.font = Font(color="0563C1", underline="single")
|
||
|
||
|
||
def _replace_all_image_urls_with_hyperlinks(
|
||
ws, headers: list[str], rows: list[list[Any]],
|
||
image_column_names: list[str], first_data_row: int,
|
||
) -> None:
|
||
"""Pillow 不可用时的兜底:把所有图片列的 URL 替换为"打卡图片"超链接。"""
|
||
name_to_col_idx: dict[str, int] = {}
|
||
for i, h in enumerate(headers, start=1):
|
||
if h in image_column_names and h not in name_to_col_idx:
|
||
name_to_col_idx[h] = i
|
||
for row_offset, row in enumerate(rows):
|
||
excel_row = first_data_row + row_offset
|
||
for col_idx in name_to_col_idx.values():
|
||
if col_idx - 1 >= len(row):
|
||
continue
|
||
if _is_likely_url(row[col_idx - 1]):
|
||
_set_image_hyperlink(ws, excel_row, col_idx, str(row[col_idx - 1]).strip())
|
||
|
||
|
||
def _embed_images_in_columns(
|
||
ws,
|
||
headers: list[str],
|
||
rows: list[list[Any]],
|
||
*,
|
||
image_column_names: list[str],
|
||
header_row: int,
|
||
first_data_row: int,
|
||
image_size: tuple[int, int] = (80, 120),
|
||
) -> None:
|
||
"""
|
||
把指定列里的 URL 替换为嵌入的图片:
|
||
1. 找到 image_column_names 命中的列索引
|
||
2. 遍历每行该列的值,若是 http(s) URL 则下载 + 转 PNG + add_image
|
||
3. 同步调整列宽(与图片宽匹配)和行高(与图片高匹配)
|
||
4. 下载/转换失败时将 URL 替换为可点击的"打卡图片"超链接,避免暴露裸 URL
|
||
|
||
image_size: (width_px, height_px),控制嵌入图片尺寸,默认 80×120 像素
|
||
"""
|
||
if not image_column_names:
|
||
return
|
||
try:
|
||
from openpyxl.drawing.image import Image as OpenpyxlImage
|
||
from openpyxl.utils import get_column_letter
|
||
except ImportError as e:
|
||
warn(f"openpyxl 不完整,无法嵌入图片: {e}")
|
||
return
|
||
|
||
# openpyxl 的 Image 类内部依赖 Pillow(模块加载时检测),
|
||
# 如果 Pillow 不可用,OpenpyxlImage() 会抛出:
|
||
# ImportError: You must install Pillow to fetch image objects
|
||
# 这里提前检测,不可用时尝试自动安装,避免逐张图片重复报错。
|
||
from openpyxl.drawing.image import PILImage as _openpyxl_pil_check
|
||
if not _openpyxl_pil_check:
|
||
warn(
|
||
"[image] openpyxl 检测到 Pillow 未安装,尝试自动安装..."
|
||
)
|
||
import subprocess, sys
|
||
try:
|
||
subprocess.check_call(
|
||
[sys.executable, "-m", "pip", "install", "Pillow"],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||
timeout=60,
|
||
)
|
||
log("[image] Pillow 安装成功,重新加载 openpyxl.drawing.image...")
|
||
# 安装后需要重新加载模块,让 openpyxl 重新检测 Pillow
|
||
import importlib
|
||
import openpyxl.drawing.image as _img_mod
|
||
importlib.reload(_img_mod)
|
||
from openpyxl.drawing.image import Image as OpenpyxlImage # noqa: F811
|
||
from openpyxl.drawing.image import PILImage as _recheck
|
||
if not _recheck:
|
||
warn(
|
||
"[image] Pillow 安装后 openpyxl 仍无法检测到,"
|
||
"图片将显示为可点击链接。请手动执行: pip install Pillow"
|
||
)
|
||
_replace_all_image_urls_with_hyperlinks(
|
||
ws, headers, rows, image_column_names, first_data_row,
|
||
)
|
||
return
|
||
except Exception as install_err:
|
||
warn(
|
||
f"[image] Pillow 自动安装失败: {install_err}\n"
|
||
"图片将显示为可点击链接。请手动执行: pip install Pillow"
|
||
)
|
||
_replace_all_image_urls_with_hyperlinks(
|
||
ws, headers, rows, image_column_names, first_data_row,
|
||
)
|
||
return
|
||
|
||
# 找到目标列索引(1-based)
|
||
name_to_col_idx: dict[str, int] = {}
|
||
for i, h in enumerate(headers, start=1):
|
||
if h in image_column_names and h not in name_to_col_idx:
|
||
name_to_col_idx[h] = i
|
||
if not name_to_col_idx:
|
||
return
|
||
|
||
width_px, height_px = image_size
|
||
# Excel 列宽单位 ≈ 字符数,1 字符 ≈ 7px;行高单位为点,1 点 ≈ 1.33px
|
||
col_width = max(width_px / 7.0, 12.0)
|
||
row_height = max(height_px * 0.78, 60.0)
|
||
|
||
# 调列宽
|
||
for col_idx in name_to_col_idx.values():
|
||
ws.column_dimensions[get_column_letter(col_idx)].width = col_width
|
||
|
||
# 收集所有要嵌入的 (excel_row, col_idx, url),统一处理
|
||
embed_tasks: list[tuple[int, int, str]] = []
|
||
for row_offset, row in enumerate(rows):
|
||
excel_row = first_data_row + row_offset
|
||
row_has_image = False
|
||
for col_name, col_idx in name_to_col_idx.items():
|
||
if col_idx - 1 >= len(row):
|
||
continue
|
||
value = row[col_idx - 1]
|
||
if not _is_likely_url(value):
|
||
continue
|
||
url = str(value).strip()
|
||
embed_tasks.append((excel_row, col_idx, url))
|
||
row_has_image = True
|
||
if row_has_image:
|
||
ws.row_dimensions[excel_row].height = row_height
|
||
|
||
if not embed_tasks:
|
||
return
|
||
|
||
log(f"[image] 准备嵌入 {len(embed_tasks)} 张图片到 Excel...")
|
||
success_count = 0
|
||
failed_count = 0
|
||
|
||
for excel_row, col_idx, url in embed_tasks:
|
||
local_path = download_and_convert_image(url)
|
||
if not local_path:
|
||
_set_image_hyperlink(ws, excel_row, col_idx, url)
|
||
failed_count += 1
|
||
continue
|
||
try:
|
||
img = OpenpyxlImage(local_path)
|
||
img.width = width_px
|
||
img.height = height_px
|
||
anchor = f"{get_column_letter(col_idx)}{excel_row}"
|
||
# 清空原 URL 单元格内容(图片浮在格子上,但保留文本会重叠)
|
||
ws.cell(row=excel_row, column=col_idx, value="")
|
||
ws.add_image(img, anchor)
|
||
success_count += 1
|
||
except Exception as e:
|
||
warn(f"[image] 嵌入失败 ({excel_row},{col_idx}): {e}")
|
||
_set_image_hyperlink(ws, excel_row, col_idx, url)
|
||
failed_count += 1
|
||
|
||
log(f"[image] 嵌入完成: 成功 {success_count} 张,失败 {failed_count} 张")
|