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