first commit

This commit is contained in:
2026-09-02 11:44:52 +08:00
commit 0c8fa2653e
309 changed files with 57278 additions and 0 deletions
@@ -0,0 +1,498 @@
#!/usr/bin/env python3
"""
考勤排班导入脚本
[AI Agent 强制门禁] 本脚本执行前必须先阅读:
references/attendance-schedule.md
排班工作流、参数校验、班次校验、回显确认等约束全部在
attendance-schedule.md,禁止凭本脚本源码或 --help 自行组装命令。
职责:
1. 二次校验考勤组类型(必须为 TURN 排班制)
2. 二次校验班次 ID 在可用班次列表中
3. 回显排班内容表格,等待用户确认
4. 调用 dws attendance schedule import 执行排班
5. 输出执行结果摘要
用法:
python attendance_schedule_import.py \
--group-id 123456 \
--schedules '[{"userId":"u001","workDate":"2026-05-19","classId":789,"isRest":"N"}]' \
--confirm
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from typing import Any
# 复用公共模块
from attendance_report_common import (
run_dws,
DwsCallError,
extract_records,
resolve_user_names,
log,
warn,
error,
)
DATE_FMT = "%Y-%m-%d"
DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
# ─────────────────────────────────────────────────────────────────────────────
# 考勤组校验
# ─────────────────────────────────────────────────────────────────────────────
def _unwrap_group_vo(result: dict) -> dict:
"""从 group get 返回结构中提取 groupVOtype/name/classIds 等字段所在层)。
group get 返回结构:{groupVO: {type, name, classIds, ...}, ...}
filtered-get 返回结构可能直接是扁平的 {type, name, memberUsers, ...}
"""
if not isinstance(result, dict):
return result
group_vo = result.get("groupVO")
if isinstance(group_vo, dict) and group_vo.get("type"):
return group_vo
# 如果顶层已经有 type 字段,说明是扁平结构,直接返回
if result.get("type"):
return result
# 兜底:尝试从所有 dict 类型的值中找包含 type 字段的
for value in result.values():
if isinstance(value, dict) and value.get("type"):
return value
return result
def validate_group_is_turn(group_id: int) -> dict:
"""校验考勤组存在且类型为 TURN(排班制),返回考勤组信息(groupVO 层级)。"""
log(f"🔍 校验考勤组 {group_id} ...")
# 优先用 group get 获取完整信息(含绑定班次列表)
try:
result = run_dws([
"attendance", "group", "get",
"--group-id", str(group_id),
])
except DwsCallError:
# 降级使用 filtered-get
try:
result = run_dws([
"attendance", "group", "filtered-get",
"--group-id", str(group_id),
])
except DwsCallError as exc:
error(f"查询考勤组失败: {exc}")
raise SystemExit(1) from exc
if not result or not isinstance(result, dict):
error(f"考勤组 {group_id} 不存在或返回数据异常")
raise SystemExit(1)
# 关键:从 groupVO 中提取 type/name 等字段
group_vo = _unwrap_group_vo(result)
group_type = group_vo.get("type", "")
group_name = group_vo.get("name", f"ID:{group_id}")
if not group_type:
# 调试输出,帮助排查结构
log(f"[debug] group get 返回顶层 keys: {list(result.keys())}")
error(f"未能从考勤组 {group_id} 返回数据中识别出类型字段")
raise SystemExit(1)
if group_type != "TURN":
type_label = {"FIXED": "固定班制", "NONE": "自由工时"}.get(group_type, group_type)
error(f"考勤组「{group_name}」类型为 {type_label},不是排班制(TURN),无法执行排班操作")
raise SystemExit(1)
log(f"✅ 考勤组「{group_name}」确认为排班制")
return group_vo
# ─────────────────────────────────────────────────────────────────────────────
# 班次校验
# ─────────────────────────────────────────────────────────────────────────────
def extract_group_bound_classes(group_info: dict) -> set[int]:
"""从考勤组详情中提取绑定的班次 ID 集合。
兼容多种字段结构:
- classIds: [int] — 班次 ID 数组
- classes / selectedClass: [dict] — 班次对象数组 (含 id/classId)
- shiftVOList: [dict] — 排班制特有,含 shiftSetting.shiftId
- classNameIdMap: {name: id} — 名称到 ID 映射
"""
def _extract_from_obj(obj: dict) -> set[int]:
"""从单个 dict 层级中提取班次 ID。"""
ids: set[int] = set()
# 方式1: classIds / shiftIds 数组(最常见)
for key in ("classIds", "shiftIds", "classIdList"):
ids_list = obj.get(key)
if isinstance(ids_list, list):
for item in ids_list:
try:
ids.add(int(item))
except (ValueError, TypeError):
pass
# 方式2: classes / selectedClass 对象数组
for key in ("classes", "selectedClass"):
classes = obj.get(key)
if isinstance(classes, list):
for item in classes:
if isinstance(item, dict):
class_id = item.get("id") or item.get("classId")
if class_id is not None:
ids.add(int(class_id))
elif isinstance(item, (int, str)):
try:
ids.add(int(item))
except (ValueError, TypeError):
pass
# 方式3: shiftVOList — 排班制考勤组特有字段
shift_vo_list = obj.get("shiftVOList")
if isinstance(shift_vo_list, list):
for shift_vo in shift_vo_list:
if not isinstance(shift_vo, dict):
continue
# shiftSetting.shiftId
shift_setting = shift_vo.get("shiftSetting")
if isinstance(shift_setting, dict):
shift_id = shift_setting.get("shiftId") or shift_setting.get("classId")
if shift_id is not None:
ids.add(int(shift_id))
# 直接在 shiftVO 层级的 id/shiftId/classId
for id_key in ("id", "shiftId", "classId"):
val = shift_vo.get(id_key)
if val is not None:
try:
ids.add(int(val))
except (ValueError, TypeError):
pass
# 方式4: classNameIdMap {name: id}
class_map = obj.get("classNameIdMap")
if isinstance(class_map, dict):
for _, class_id in class_map.items():
try:
ids.add(int(class_id))
except (ValueError, TypeError):
pass
return ids
# 优先从 groupVO 提取(group get 返回结构),兼容顶层扁平结构
bound_ids: set[int] = set()
group_vo = group_info.get("groupVO")
if isinstance(group_vo, dict):
bound_ids.update(_extract_from_obj(group_vo))
# 同时从顶层提取(兼容 filtered-get 或已解包的结构)
bound_ids.update(_extract_from_obj(group_info))
return bound_ids
def fetch_all_classes() -> dict[int, str]:
"""获取全局所有班次,返回 {classId: className},用于 ID→名称映射。"""
log("🔍 获取班次名称映射 ...")
all_classes: dict[int, str] = {}
page_index = 1
page_size = 200
while True:
try:
result = run_dws([
"attendance", "class", "search",
"--page-index", str(page_index),
"--page-size", str(page_size),
])
except DwsCallError as exc:
error(f"查询班次列表失败: {exc}")
raise SystemExit(1) from exc
records = extract_records(result) if result else []
if not records:
break
for record in records:
class_id = record.get("id") or record.get("classId")
class_name = record.get("name") or record.get("className") or str(class_id)
if class_id is not None:
all_classes[int(class_id)] = class_name
if len(records) < page_size:
break
page_index += 1
log(f"✅ 获取到 {len(all_classes)} 个班次名称")
return all_classes
def validate_class_ids(
schedules: list[dict],
group_bound_class_ids: set[int],
all_classes: dict[int, str],
group_name: str,
) -> None:
"""校验排班记录中的 classId 都在该考勤组绑定的班次中。
如果考勤组未提取到绑定班次列表(可能是接口字段差异),
则降级为全局班次校验并输出警告。
"""
# 如果两个来源都无法获取到班次信息,跳过校验(排班导入接口本身有服务端校验)
no_bound = len(group_bound_class_ids) == 0
no_global = len(all_classes) == 0
if no_bound and no_global:
warn(f"无法获取考勤组绑定班次和全局班次列表,跳过班次校验(将依赖服务端校验)")
return
use_global_fallback = no_bound
if use_global_fallback:
warn(f"未能从考勤组「{group_name}」详情中提取绑定班次列表,降级为全局班次校验")
check_set = set(all_classes.keys())
else:
check_set = group_bound_class_ids
invalid_class_ids: set[int] = set()
for schedule in schedules:
is_rest = str(schedule.get("isRest", "N")).upper()
if is_rest == "Y":
continue
class_id = int(schedule.get("classId", 0))
if class_id != 0 and class_id not in check_set:
invalid_class_ids.add(class_id)
if invalid_class_ids:
invalid_names = [all_classes.get(cid, f"ID:{cid}") for cid in sorted(invalid_class_ids)]
if use_global_fallback:
error(f"以下班次不在可用班次列表中: {', '.join(invalid_names)}")
else:
error(f"以下班次不属于考勤组「{group_name}」: {', '.join(invalid_names)}")
log(f"{group_name}」可用班次:")
available_ids = check_set if not use_global_fallback else set(all_classes.keys())
for cid in sorted(available_ids):
cname = all_classes.get(cid, f"ID:{cid}")
log(f" - {cname} (ID: {cid})")
raise SystemExit(1)
# ─────────────────────────────────────────────────────────────────────────────
# 日期格式标准化
# ─────────────────────────────────────────────────────────────────────────────
def normalize_work_date(work_date: Any) -> str:
"""将 workDate 统一转换为 yyyy-MM-dd HH:mm:ss 格式。"""
if isinstance(work_date, (int, float)):
timestamp = work_date / 1000 if work_date > 1e12 else work_date
return datetime.fromtimestamp(timestamp).strftime(DATETIME_FMT)
date_str = str(work_date).strip()
for fmt in (DATETIME_FMT, DATE_FMT):
try:
parsed = datetime.strptime(date_str, fmt)
return parsed.strftime(DATETIME_FMT)
except ValueError:
continue
raise ValueError(f"无法解析日期格式: {work_date!r},请使用 YYYY-MM-DD 格式")
# ─────────────────────────────────────────────────────────────────────────────
# 回显排班内容
# ─────────────────────────────────────────────────────────────────────────────
def print_schedule_preview(
group_name: str,
group_id: int,
schedules: list[dict],
available_classes: dict[int, str],
user_names: dict[str, str],
) -> None:
"""向 stdout 打印排班预览表格供用户确认。"""
print("\n📋 排班确认")
print(f"\n考勤组: {group_name} (ID: {group_id})")
dates = sorted({s.get("workDate", "")[:10] for s in schedules})
if dates:
print(f"排班日期: {dates[0]} ~ {dates[-1]}")
print(f"\n{'员工姓名':<12} {'日期':<14} {'班次':<16} {'是否排休':<8}")
print("-" * 54)
for schedule in sorted(schedules, key=lambda s: (s.get("userId", ""), s.get("workDate", ""))):
user_id = schedule.get("userId", "")
user_name = user_names.get(user_id, user_id)
work_date = str(schedule.get("workDate", ""))[:10]
class_id = int(schedule.get("classId", 0))
is_rest = str(schedule.get("isRest", "N")).upper()
if is_rest == "Y":
class_display = "休息"
rest_display = ""
else:
class_display = available_classes.get(class_id, f"未知班次(ID:{class_id})")
rest_display = ""
print(f"{user_name:<12} {work_date:<14} {class_display:<16} {rest_display:<8}")
print(f"\n{len(schedules)} 条排班记录")
# ─────────────────────────────────────────────────────────────────────────────
# 执行排班
# ─────────────────────────────────────────────────────────────────────────────
def execute_schedule_import(group_id: int, schedules: list[dict]) -> None:
"""调用 dws attendance schedule import 执行排班。"""
log(f"🚀 正在执行排班导入 ({len(schedules)} 条记录) ...")
schedules_json = json.dumps(schedules, ensure_ascii=False)
try:
result = run_dws([
"attendance", "schedule", "import",
"--groupId", str(group_id),
"--scheduleVOS", schedules_json,
"--yes",
])
except DwsCallError as exc:
error(f"排班导入失败: {exc}")
if exc.is_permission_error:
error("提示: 当前账号可能不是考勤管理员,请确认权限")
raise SystemExit(1) from exc
log("✅ 排班导入完成")
return result
# ─────────────────────────────────────────────────────────────────────────────
# 主流程
# ─────────────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="考勤排班导入(含校验、回显、执行)",
epilog="执行前必须阅读 attendance-schedule.md",
)
parser.add_argument(
"--group-id", required=True, type=int,
help="考勤组 ID(必填,必须为排班制考勤组)",
)
parser.add_argument(
"--schedules", required=True,
help="排班记录 JSON 数组(必填),每条记录包含 userId/workDate/classId/isRest",
)
parser.add_argument(
"--confirm", action="store_true",
help="用户已确认排班内容(必填,表示用户已在 Agent 回显中确认)",
)
parser.add_argument(
"--dry-run", action="store_true",
help="仅校验和回显,不实际执行排班",
)
args = parser.parse_args()
# ── 解析排班记录 JSON ──
try:
schedules: list[dict] = json.loads(args.schedules)
except json.JSONDecodeError as exc:
error(f"--schedules JSON 格式错误: {exc}")
raise SystemExit(1) from exc
if not isinstance(schedules, list) or len(schedules) == 0:
error("--schedules 必须是非空 JSON 数组")
raise SystemExit(1)
# ── 校验必填字段 ──
required_fields = ("userId", "workDate", "classId", "isRest")
for idx, schedule in enumerate(schedules):
for field_name in required_fields:
if field_name not in schedule:
error(f"schedule[{idx}] 缺少必填字段: {field_name}")
raise SystemExit(1)
# ── 标准化日期格式 ──
for idx, schedule in enumerate(schedules):
try:
schedule["workDate"] = normalize_work_date(schedule["workDate"])
except ValueError as exc:
error(f"schedule[{idx}] 日期格式错误: {exc}")
raise SystemExit(1) from exc
# ── 阶段 1: 校验考勤组(必须为 TURN 排班制) ──
group_info = validate_group_is_turn(args.group_id)
group_name = group_info.get("name", f"ID:{args.group_id}")
# ── 阶段 2: 解析员工姓名 ──
user_ids = list({s["userId"] for s in schedules})
user_names = resolve_user_names(user_ids)
# ── 阶段 3: 校验班次(必须属于该考勤组) ──
group_bound_class_ids = extract_group_bound_classes(group_info)
all_classes = fetch_all_classes()
if group_bound_class_ids:
log(f"📋 考勤组「{group_name}」绑定了 {len(group_bound_class_ids)} 个班次:")
for cid in sorted(group_bound_class_ids):
cname = all_classes.get(cid, f"ID:{cid}")
log(f" - {cname} (ID: {cid})")
validate_class_ids(schedules, group_bound_class_ids, all_classes, group_name)
log("✅ 班次校验通过")
# ── 阶段 4: 回显排班内容 ──
print_schedule_preview(group_name, args.group_id, schedules, all_classes, user_names)
if args.dry_run:
print("\n[dry-run] 仅校验和回显,未实际执行排班")
return
if not args.confirm:
print("\n⚠️ 未传入 --confirm 参数,排班未执行")
print("请在 Agent 回显确认后,添加 --confirm 参数重新执行")
return
# ── 阶段 5: 执行排班 ──
execute_schedule_import(args.group_id, schedules)
# ── 阶段 6: 输出摘要 ──
print(f"\n✅ 排班导入成功!")
print(f" 考勤组: {group_name}")
print(f" 排班人数: {len(user_ids)}")
print(f" 排班记录: {len(schedules)}")
dates = sorted({s.get('workDate', '')[:10] for s in schedules})
if dates:
print(f" 日期范围: {dates[0]} ~ {dates[-1]}")
# 展示所有排班明细
print(f"\n{'员工姓名':<12} {'日期':<14} {'班次':<16} {'是否排休':<8}")
print("-" * 54)
for schedule in sorted(schedules, key=lambda s: (s.get("userId", ""), s.get("workDate", ""))):
uid = schedule.get("userId", "")
uname = user_names.get(uid, uid)
wdate = str(schedule.get("workDate", ""))[:10]
cid = int(schedule.get("classId", 0))
is_rest = str(schedule.get("isRest", "N")).upper()
if is_rest == "Y":
class_display = "休息"
rest_display = ""
else:
class_display = all_classes.get(cid, f"未知班次(ID:{cid})")
rest_display = ""
print(f"{uname:<12} {wdate:<14} {class_display:<16} {rest_display:<8}")
if __name__ == "__main__":
main()