Files
2026-09-02 11:44:52 +08:00

618 lines
22 KiB
Python
Raw Permalink 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
"""
假期余额 Excel 导出脚本。
[AI Agent 强制门禁] 调用本脚本前必须先阅读:
references/attendance-vacation.md
本脚本负责:
1. 通过 dws attendance vacation types 获取假期规则列表,用于确定列顺序
2. 通过 dws attendance vacation balance 查询所有假期规则余额
3. 通过 dws contact user get 解析姓名、部门等基础信息
4. 生成横向宽表 Excel:每人一行,假期规则为动态列
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from typing import Any
import attendance_report_common as cmn
MAX_USERS_PER_BALANCE_BATCH = 20
BASE_HEADERS = ["姓名", "部门", "入职时间", "首次工作时间"]
USER_ID_KEYS = (
"userId", "userid", "targetUserId", "targetUserID", "staffId", "staffID",
"employeeId", "empId", "dingUserId",
)
LEAVE_CODE_KEYS = (
"leaveCode", "leaveTypeCode", "quotaCode", "vacationCode", "bizType",
"bizCode", "code", "id",
)
LEAVE_NAME_KEYS = (
"leaveName", "leaveTypeName", "quotaName", "vacationName", "name",
"title", "ruleName",
)
BALANCE_KEYS = (
"balance", "balanceQuota", "remain", "remainQuota", "remainDuration",
"restQuota", "availableBalance", "availableQuota", "quotaNumPerDay",
"quotaNumPerHour", "quotaNum", "quota", "value", "leaveBalance",
"leftQuota", "leftBalance",
)
MESSAGE_KEYS = ("message", "msg", "reason", "errorMessage", "errorMsg")
SOURCE_KEYS = ("source", "leaveSource", "ruleSource", "dataSource")
UNIT_KEYS = (
"leaveViewUnit", "viewUnit", "displayUnit", "unit", "quotaUnit",
"durationUnit", "timeUnit", "balanceUnit", "leaveUnit",
)
UNIT_LABELS = {
"day": "天",
"days": "天",
"percent_day": "天",
"hour": "小时",
"hours": "小时",
"minute": "分钟",
"minutes": "分钟",
}
ENTRY_TIME_KEYS = (
"entryTime", "entryDate", "hireDate", "joinDate", "employmentDate", "入职时间",
)
FIRST_WORK_TIME_KEYS = (
"firstWorkTime", "firstWorkingTime", "firstWorkDate", "首次工作时间",
)
UNLIMITED_KEYS = (
"unlimited", "isUnlimited", "unLimit", "unlimitedBalance", "notLimit",
)
NOT_APPLICABLE_KEYS = (
"notApplicable", "notApply", "isNotApplicable", "invalid", "disable", "disabled",
)
VISIBLE_KEYS = ("visible", "visiable", "visibility", "isVisible", "isVisiable")
NO_BALANCE_MESSAGES = ("假期类型没有余额", "没有余额", "未设置假期余额")
NOT_APPLICABLE_MESSAGES = (
"员工未设置首次参加工作时间",
"未设置首次参加工作时间",
"员工未设置入职时间",
"未设置入职时间",
)
EXTERNAL_SOURCE = "external"
EXTERNAL_BALANCE_UNAVAILABLE_MESSAGE = "外部规则暂无余额,需通过接口初始化更新余额"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"导出假期余额 Excel。AI Agent 必须先读 "
"references/attendance-vacation.md 再调用本脚本。"
),
)
parser.add_argument("--users", required=True, help="userId 或 deptId 列表,逗号分隔")
parser.add_argument("--leave-keywords", default="", help="按假期名称关键词筛选列,逗号分隔;默认导出全部")
parser.add_argument("--out", default="", help="输出 xlsx 文件名;不传则自动生成")
parser.add_argument("--inspect", action="store_true", help="打印首条假期类型和余额原始结构到 stderr")
return parser.parse_args()
def first_nonempty(record: dict[str, Any], keys: tuple[str, ...]) -> Any:
for key in keys:
if key in record and record[key] not in (None, ""):
return record[key]
return None
def recursively_collect_dicts(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
records: list[dict[str, Any]] = []
for item in payload:
records.extend(recursively_collect_dicts(item))
return records
if isinstance(payload, dict):
if looks_like_business_record(payload):
return [payload]
direct_records = cmn.extract_records(payload)
if direct_records:
return direct_records
records = []
for value in payload.values():
records.extend(recursively_collect_dicts(value))
return records
return []
def looks_like_business_record(record: dict[str, Any]) -> bool:
candidate_key_groups = (
USER_ID_KEYS,
LEAVE_CODE_KEYS,
LEAVE_NAME_KEYS,
BALANCE_KEYS,
ENTRY_TIME_KEYS,
FIRST_WORK_TIME_KEYS,
)
return any(first_nonempty(record, keys) is not None for keys in candidate_key_groups)
def is_truthy_flag(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in {"true", "1", "y", "yes", "是", "visible"}
return False
def is_falsey_flag(value: Any) -> bool:
if isinstance(value, bool):
return not value
if isinstance(value, (int, float)):
return value == 0
if isinstance(value, str):
return value.strip().lower() in {"false", "0", "n", "no", "否", "invisible", "not_visible"}
return False
def is_no_balance_message(message: Any) -> bool:
return any(keyword in str(message) for keyword in NO_BALANCE_MESSAGES)
def is_not_applicable_message(message: Any) -> bool:
return any(keyword in str(message) for keyword in NOT_APPLICABLE_MESSAGES)
def is_external_leave_type(leave_type: dict[str, str]) -> bool:
return leave_type.get("source", "").strip().lower() == EXTERNAL_SOURCE
def normalize_leave_unit(value: Any) -> str:
if value in (None, ""):
return ""
unit = str(value).strip()
if not unit:
return ""
return UNIT_LABELS.get(unit.lower(), unit)
def format_date(value: Any) -> str:
if value in (None, ""):
return "未设置"
if isinstance(value, (int, float)):
timestamp = value / 1000 if value > 10_000_000_000 else value
try:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
except (OverflowError, OSError, ValueError):
return str(value)
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return "未设置"
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(stripped[:19], fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return stripped[:10] if len(stripped) >= 10 else stripped
return str(value)
def format_balance_value(record: dict[str, Any]) -> Any:
visible = first_nonempty(record, VISIBLE_KEYS)
if visible is not None and is_falsey_flag(visible):
return "不适用"
message = first_nonempty(record, MESSAGE_KEYS)
if message and is_no_balance_message(message):
return "不限制余额"
if message and is_not_applicable_message(message):
return "不适用"
if "hideQuota" in record and is_truthy_flag(record["hideQuota"]):
return "不适用"
for key in UNLIMITED_KEYS:
if key in record and is_truthy_flag(record[key]):
return "不限制余额"
for key in NOT_APPLICABLE_KEYS:
if key in record and is_truthy_flag(record[key]):
return "不适用"
value = first_nonempty(record, BALANCE_KEYS)
if value in (None, ""):
status = first_nonempty(record, ("status", "state", "balanceStatus", *MESSAGE_KEYS))
return status or "不适用"
if isinstance(value, str):
stripped = value.strip()
if stripped in {"UNLIMITED", "Unlimited", "不限", "不限制"}:
return "不限制余额"
if stripped in {"N/A", "NA", "NOT_APPLICABLE", "不适用"}:
return "不适用"
try:
value = float(stripped)
except ValueError:
return stripped
if isinstance(value, (int, float)):
rounded = round(float(value), 2)
return int(rounded) if rounded == int(rounded) else rounded
return value
def normalize_leave_types(payload: Any) -> list[dict[str, str]]:
raw_records = recursively_collect_dicts(payload)
leave_types: list[dict[str, str]] = []
seen: set[str] = set()
for record in raw_records:
code = first_nonempty(record, LEAVE_CODE_KEYS)
name = first_nonempty(record, LEAVE_NAME_KEYS)
if not code and not name:
continue
stable_key = str(code or name)
if stable_key in seen:
continue
seen.add(stable_key)
unit = normalize_leave_unit(first_nonempty(record, UNIT_KEYS))
source = first_nonempty(record, SOURCE_KEYS)
leave_types.append({
"code": str(code or name),
"name": str(name or code),
"unit": unit,
"source": str(source or ""),
})
return leave_types
def normalize_balance_records(payload: Any) -> list[dict[str, Any]]:
raw_records = recursively_collect_dicts(payload)
return [record for record in raw_records if first_nonempty(record, USER_ID_KEYS) or first_nonempty(record, LEAVE_CODE_KEYS) or first_nonempty(record, LEAVE_NAME_KEYS)]
def query_leave_types(inspect: bool) -> list[dict[str, str]]:
payload = cmn.run_dws(["attendance", "vacation", "types"])
if inspect:
records = recursively_collect_dicts(payload)
cmn.log("[inspect] vacation types first record:\n" + json.dumps(records[:1], ensure_ascii=False, indent=2))
leave_types = normalize_leave_types(payload)
cmn.log(f"[types] 获取到 {len(leave_types)} 个假期规则")
return leave_types
def extract_message(payload: Any) -> str:
if isinstance(payload, dict):
message = first_nonempty(payload, MESSAGE_KEYS)
if message:
return str(message)
for value in payload.values():
nested_message = extract_message(value)
if nested_message:
return nested_message
if isinstance(payload, list):
for item in payload:
nested_message = extract_message(item)
if nested_message:
return nested_message
return ""
def enrich_balance_record(record: dict[str, Any], leave_type: dict[str, str]) -> dict[str, Any]:
enriched = dict(record)
enriched.setdefault("leaveCode", leave_type["code"])
enriched.setdefault("leaveName", leave_type["name"])
if leave_type.get("unit"):
enriched.setdefault("unit", leave_type["unit"])
if leave_type.get("source"):
enriched.setdefault("source", leave_type["source"])
return enriched
def build_message_balance_records(
batch: list[str],
leave_type: dict[str, str],
message: str,
) -> list[dict[str, Any]]:
if not message:
return []
return [
{
"userId": user_id,
"leaveCode": leave_type["code"],
"leaveName": leave_type["name"],
"unit": leave_type.get("unit") or "",
"source": leave_type.get("source") or "",
"message": message,
}
for user_id in batch
]
def query_balance_payload(batch: list[str], leave_code: str) -> Any:
return cmn.run_dws([
"attendance", "vacation", "balance",
"--users", ",".join(batch),
"--leave-code", leave_code,
])
def normalize_query_records(
payload: Any,
batch: list[str],
leave_type: dict[str, str],
) -> list[dict[str, Any]]:
records = [
enrich_balance_record(record, leave_type)
for record in normalize_balance_records(payload)
]
if records:
return records
return build_message_balance_records(batch, leave_type, extract_message(payload))
def query_single_user_after_batch_error(
user_id: str,
leave_type: dict[str, str],
batch_error: cmn.DwsCallError,
) -> list[dict[str, Any]]:
leave_code = leave_type["code"]
try:
payload = query_balance_payload([user_id], leave_code)
except cmn.DwsCallError as error:
if is_external_leave_type(leave_type) and not error.is_permission_error:
return build_message_balance_records(
[user_id],
leave_type,
EXTERNAL_BALANCE_UNAVAILABLE_MESSAGE,
)
if is_no_balance_message(error) or is_not_applicable_message(error):
return build_message_balance_records([user_id], leave_type, str(error))
raise
records = normalize_query_records(payload, [user_id], leave_type)
if records:
return records
return build_message_balance_records([user_id], leave_type, str(batch_error))
def query_balance_records(
user_ids: list[str],
leave_types: list[dict[str, str]],
inspect: bool,
) -> list[dict[str, Any]]:
all_records: list[dict[str, Any]] = []
for leave_index, leave_type in enumerate(leave_types, start=1):
leave_code = leave_type["code"]
cmn.log(f"[balance] 查询假期规则 {leave_index}/{len(leave_types)}{leave_type['name']}({leave_code})")
for batch_index, batch in enumerate(cmn.chunk_users(user_ids, MAX_USERS_PER_BALANCE_BATCH), start=1):
cmn.log(f"[balance] 查询第 {batch_index} 批,{len(batch)} 人")
try:
payload = query_balance_payload(batch, leave_code)
except cmn.DwsCallError as error:
if is_external_leave_type(leave_type) and not error.is_permission_error:
cmn.warn(
f"[balance] 外部假期规则 {leave_type['name']}({leave_code}) 查询失败,"
"按外部规则暂无余额处理"
)
records = build_message_balance_records(
batch,
leave_type,
EXTERNAL_BALANCE_UNAVAILABLE_MESSAGE,
)
all_records.extend(records)
continue
if is_no_balance_message(error):
cmn.warn(
f"[balance] 假期规则 {leave_type['name']}({leave_code}) 没有余额,"
"按不限制余额处理"
)
records = build_message_balance_records(batch, leave_type, str(error))
all_records.extend(records)
continue
if is_not_applicable_message(error):
cmn.warn(
f"[balance] 假期规则 {leave_type['name']}({leave_code}) 依赖员工时间字段,"
"改为逐个员工查询并将缺失配置的员工标为不适用"
)
for user_id in batch:
all_records.extend(query_single_user_after_batch_error(user_id, leave_type, error))
continue
raise
records = normalize_query_records(payload, batch, leave_type)
if inspect and leave_index == 1 and batch_index == 1:
cmn.log("[inspect] vacation balance first record:\n" + json.dumps(records[:1], ensure_ascii=False, indent=2))
all_records.extend(records)
cmn.log(f"[balance] 获取到 {len(all_records)} 条余额记录")
return all_records
def extract_user_id(record: dict[str, Any], fallback_users: list[str]) -> str:
user_id = first_nonempty(record, USER_ID_KEYS)
if user_id:
return str(user_id)
if len(fallback_users) == 1:
return fallback_users[0]
return ""
def build_leave_columns(
leave_types: list[dict[str, str]],
balance_records: list[dict[str, Any]],
keywords: list[str],
) -> list[dict[str, str]]:
columns: list[dict[str, str]] = []
seen: set[str] = set()
for leave_type in leave_types:
code = leave_type["code"]
name = leave_type["name"]
if keywords and not any(keyword in name for keyword in keywords):
continue
seen.add(code)
columns.append(leave_type)
for record in balance_records:
code = first_nonempty(record, LEAVE_CODE_KEYS)
name = first_nonempty(record, LEAVE_NAME_KEYS)
if not code and not name:
continue
code_str = str(code or name)
name_str = str(name or code)
if code_str in seen:
continue
if keywords and not any(keyword in name_str for keyword in keywords):
continue
seen.add(code_str)
unit = normalize_leave_unit(first_nonempty(record, UNIT_KEYS))
source = first_nonempty(record, SOURCE_KEYS)
columns.append({"code": code_str, "name": name_str, "unit": unit, "source": str(source or "")})
return columns
def build_balance_index(
user_ids: list[str],
balance_records: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
balance_index: dict[str, dict[str, Any]] = {user_id: {} for user_id in user_ids}
for record in balance_records:
user_id = extract_user_id(record, user_ids)
code = first_nonempty(record, LEAVE_CODE_KEYS)
name = first_nonempty(record, LEAVE_NAME_KEYS)
if not user_id or (not code and not name):
continue
value = format_balance_value(record)
if code:
balance_index.setdefault(user_id, {})[str(code)] = value
if name:
balance_index.setdefault(user_id, {})[str(name)] = value
return balance_index
def extract_user_extra(record: dict[str, Any]) -> dict[str, str]:
return {
"entry_time": format_date(first_nonempty(record, ENTRY_TIME_KEYS)),
"first_work_time": format_date(first_nonempty(record, FIRST_WORK_TIME_KEYS)),
}
def build_user_extra_index(
user_ids: list[str],
balance_records: list[dict[str, Any]],
) -> dict[str, dict[str, str]]:
result = {
user_id: {"entry_time": "未设置", "first_work_time": "未设置"}
for user_id in user_ids
}
for record in balance_records:
user_id = extract_user_id(record, user_ids)
if not user_id:
continue
extra = extract_user_extra(record)
current = result.setdefault(user_id, {"entry_time": "未设置", "first_work_time": "未设置"})
if current["entry_time"] == "未设置" and extra["entry_time"] != "未设置":
current["entry_time"] = extra["entry_time"]
if current["first_work_time"] == "未设置" and extra["first_work_time"] != "未设置":
current["first_work_time"] = extra["first_work_time"]
return result
def build_headers(leave_columns: list[dict[str, str]]) -> list[str]:
headers = BASE_HEADERS.copy()
for leave_column in leave_columns:
name = leave_column["name"]
unit = leave_column.get("unit") or ""
headers.append(f"{name}({unit})" if unit else name)
return headers
def build_rows(
user_ids: list[str],
leave_columns: list[dict[str, str]],
balance_index: dict[str, dict[str, Any]],
user_extra_index: dict[str, dict[str, str]],
user_info_map: dict[str, cmn.UserInfo],
) -> list[list[Any]]:
rows: list[list[Any]] = []
for user_id in user_ids:
user_info = user_info_map.get(user_id, cmn.UserInfo(name=user_id))
user_extra = user_extra_index.get(user_id, {})
user_balances = balance_index.get(user_id, {})
row: list[Any] = [
user_info.name or user_id,
user_info.dept_name,
user_extra.get("entry_time") or "未设置",
user_extra.get("first_work_time") or "未设置",
]
for leave_column in leave_columns:
row.append(
user_balances.get(leave_column["code"], user_balances.get(leave_column["name"], "不适用"))
)
rows.append(row)
return rows
def main() -> int:
args = parse_args()
raw_ids = [user_id.strip() for user_id in args.users.split(",") if user_id.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
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)} 人")
keywords = [keyword.strip() for keyword in args.leave_keywords.split(",") if keyword.strip()]
try:
leave_types = query_leave_types(args.inspect)
balance_records = query_balance_records(user_ids, leave_types, args.inspect)
except cmn.DwsCallError as error:
if error.is_permission_error:
cmn.error("权限错误:当前账号无权查询目标员工假期余额,请确认管理员或管理范围权限。")
return 2
cmn.error(f"查询假期余额失败:{error}")
return 1
leave_columns = build_leave_columns(leave_types, balance_records, keywords)
if not leave_columns:
cmn.error("未匹配到任何假期规则列,请检查假期规则或 --leave-keywords 参数。")
return 1
user_info_map = cmn.resolve_user_info(user_ids)
balance_index = build_balance_index(user_ids, balance_records)
user_extra_index = build_user_extra_index(user_ids, balance_records)
headers = build_headers(leave_columns)
rows = build_rows(user_ids, leave_columns, balance_index, user_extra_index, user_info_map)
out_name = args.out or f"attendance_vacation_balance_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
title = "假期余额列表"
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')};员工数:{len(user_ids)};假期规则数:{len(leave_columns)}"
try:
cmn.write_excel(
out_name,
headers,
rows,
sheet_name="假期余额",
title=title,
subtitle=subtitle,
)
except RuntimeError as error:
cmn.error(str(error))
return 1
print("✅ 假期余额 Excel 导出完成")
print(f"- 输出文件:{os.path.abspath(out_name)}")
print(f"- 员工数量:{len(user_ids)}")
print(f"- 假期规则列数:{len(leave_columns)}")
if keywords:
print(f"- 假期筛选关键词:{','.join(keywords)}")
print("- 说明:每名员工一行,假期规则横向展开;未设置假期余额显示“不限制余额”,hideQuota=true 显示“不适用”,余额为 0 时显示 0。")
return 0
if __name__ == "__main__":
sys.exit(main())