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,138 @@
#!/usr/bin/env python3
"""
创建 AI 应用并自动轮询等待完成
用法:
python aiapp_create_and_poll.py \
--prompt "创建一个仓库管理应用"
python aiapp_create_and_poll.py \
--prompt "生成客户管理 CRM" \
--skills skill1,skill2 \
--interval 30 \
--timeout 600
python aiapp_create_and_poll.py --dry-run --prompt "test"
"""
import sys
import json
import subprocess
import argparse
import time
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def main():
parser = argparse.ArgumentParser(
description='创建 AI 应用并轮询等待完成'
)
parser.add_argument(
'--prompt', required=True, help='应用描述'
)
parser.add_argument(
'--skills', default='', help='技能 ID 列表'
)
parser.add_argument(
'--interval', type=int, default=30,
help='轮询间隔秒 (默认 30)',
)
parser.add_argument(
'--timeout', type=int, default=600,
help='最大等待秒 (默认 600)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
print(f'🚀 创建 AI 应用...')
print(f' Prompt: {args.prompt}')
cmd_args = [
'aiapp', 'create',
'--prompt', args.prompt,
'--format', 'json',
]
if args.skills:
cmd_args.extend(['--skills', args.skills])
create_data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
run_dws([
'aiapp', 'query',
'--task-id', '<TASK_ID>',
'--format', 'json',
], dry_run=True)
return
if not create_data:
sys.exit(1)
task_id = create_data.get('taskId') or create_data.get('id', '')
thread_id = create_data.get('threadId', '')
print(f" ✓ 任务已创建")
print(f" taskId: {task_id}")
print(f" threadId: {thread_id}")
print(f'\n⏳ 轮询等待 (间隔 {args.interval}s, '
f'超时 {args.timeout}s)...')
elapsed = 0
while elapsed < args.timeout:
time.sleep(args.interval)
elapsed += args.interval
query_data = run_dws([
'aiapp', 'query',
'--task-id', task_id,
'--format', 'json',
])
if not query_data:
print(f" [{elapsed}s] ⚠ 查询失败,继续等待...")
continue
status = (query_data.get('status')
or query_data.get('state', 'unknown'))
progress = query_data.get('progress', {})
step = ''
if isinstance(progress, dict):
step = progress.get('currentStep', '')
if status == 'succeeded':
print(f" [{elapsed}s] ✅ 应用创建成功!")
if thread_id:
print(f" threadId: {thread_id}")
return
elif status == 'failed':
print(f" [{elapsed}s] ❌ 创建失败")
sys.exit(1)
else:
info = f" [{elapsed}s] ⏳ {status}"
if step:
info += f" ({step})"
print(info)
print(f"\n⏰ 超时 ({args.timeout}s),任务可能仍在运行")
print(f" 可手动查询: dws aiapp query --task-id {task_id}")
if __name__ == '__main__':
main()
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
查看我今天/本周/指定日期的考勤记录(自动获取 userId)
用法:
python attendance_my_record.py # 今天
python attendance_my_record.py today # 今天
python attendance_my_record.py 2026-03-10 # 指定日期
python attendance_my_record.py --dry-run # 仅显示命令
"""
import sys
import json
import subprocess
import re
from datetime import datetime
from typing import List, Any, Optional
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_my_user_id(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'contact', 'user', 'get-self', '--format', 'json',
], dry_run=dry_run)
if dry_run:
return '<MY_USER_ID>'
if not data or not isinstance(data, dict):
return None
return data.get('userId') or data.get('userid')
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
date_str = args[0] if args else 'today'
if date_str == 'today':
date_str = datetime.now().strftime('%Y-%m-%d')
elif not DATE_PATTERN.match(date_str):
print(__doc__)
sys.exit(1)
print('🔍 获取当前用户信息...')
user_id = get_my_user_id(dry_run=dry_run)
if not user_id and not dry_run:
print('错误:无法获取当前用户 ID')
sys.exit(1)
print(f'📊 查询 {date_str} 考勤记录...\n')
data = run_dws([
'attendance', 'record', 'get',
'--user', user_id or '<MY_USER_ID>',
'--date', date_str,
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return
if not data:
print('未查到考勤记录')
return
print(f"📋 考勤记录 ({date_str})")
print('=' * 40)
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
"""
考勤报表导出 — 签到记录粒度
[AI Agent 强制门禁] 调用本脚本前必须先阅读:
references/attendance-report.md
本脚本是"签到报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md。
[严禁] 仅凭本脚本 docstring 或 --help 输出就直接拼命令执行。
导出签到报表:每条签到记录一行,包含签到详情(地点、经纬度、拜访客户、图片等)。
前置依赖:
pip install openpyxl
用法:
python attendance_report_checkin.py \
--users userId1,userId2,... \
--start "2026-04-01 00:00:00" \
--end "2026-04-07 23:59:59" \
[--out 签到报表_研发部_20260401_20260407.xlsx]
[--inspect]
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from datetime import datetime, timedelta
from typing import Any
# ── 前置依赖检查(在任何 dws 调用之前就检测,避免查完数据才报错)───────
_missing_deps: list[str] = []
try:
import openpyxl as _openpyxl_check # noqa: F401
except ImportError:
_missing_deps.append("openpyxl")
try:
import requests as _requests_check # noqa: F401
except ImportError:
_missing_deps.append("requests")
try:
from PIL import Image as _pil_check # noqa: F401
except ImportError:
_missing_deps.append("Pillow")
if _missing_deps:
print(
f"[ERROR] 缺少以下依赖:{', '.join(_missing_deps)}\n"
f" 请先安装:pip install {' '.join(_missing_deps)}\n"
"安装后重新执行本脚本。\n"
"(签到报表需要 openpyxl 生成 Excel、requests + Pillow 下载并嵌入签到图片)",
file=sys.stderr,
)
sys.exit(2)
import attendance_report_common as cmn
# ─────────────────────────────────────────────────────────────────────────────
# 自动获取当前认证的 operator 信息
# ─────────────────────────────────────────────────────────────────────────────
def _get_operator_context() -> tuple[str, str]:
"""
从 `dws auth status --format json` 自动获取当前认证的 corp_id 和 user_id。
签到接口 (checkin records) 必须传 --operator-corp-id 和 --operator-staff-id
这两个值来自 dws 的认证上下文(即 `dws auth status` 返回的 corp_id / user_id),
而非 `dws contact user get-self` 返回的长格式 userId。
Returns:
(operator_corp_id, operator_staff_id) 元组
Raises:
SystemExit: 未登录或无法获取认证信息时直接退出
"""
try:
result = subprocess.run(
["dws", "auth", "status", "--format", "json"],
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError:
cmn.error("未找到 dws 命令,请确认 dws CLI 已安装并在 PATH 中")
sys.exit(2)
except subprocess.TimeoutExpired:
cmn.error("dws auth status 超时,请检查网络或重新登录(dws auth login")
sys.exit(2)
if result.returncode != 0:
cmn.error(
"获取认证信息失败,请确保已执行 dws auth login 完成登录。\n"
f" 错误详情:{(result.stderr or result.stdout or '').strip()}"
)
sys.exit(2)
try:
auth_data = json.loads(result.stdout)
except json.JSONDecodeError:
cmn.error(f"dws auth status 返回非 JSON{result.stdout[:200]!r}")
sys.exit(2)
corp_id = auth_data.get("corp_id") or auth_data.get("corpId") or ""
user_id = auth_data.get("user_id") or auth_data.get("userId") or ""
if not corp_id or not user_id:
cmn.error(
"无法从认证信息中提取 corp_id / user_id,请重新登录:\n"
" dws auth login\n"
f" 当前返回:{json.dumps(auth_data, ensure_ascii=False)[:300]}"
)
sys.exit(2)
cmn.log(f"[auth] 已获取 operator 信息:corp_id={corp_id}, user_id={user_id}")
return str(corp_id), str(user_id)
# 签到接口限制:开始到结束最多 7 天
MAX_DAYS_PER_CHECKIN_SLICE = 7
# 签到接口限制:每次最多查 100 人(与 check record 一致)
MAX_USERS_PER_CHECKIN_BATCH = 50
# 最多支持 9 张图片列
MAX_IMAGE_COLUMNS = 9
# 报表表头(与用户要求严格对齐)
REPORT_HEADERS = [
"姓名", "部门", "完整部门",
"日期", "时间",
"经度", "纬度", "地点", "详细地址",
"拜访客户", "客户部门名称", "工作内容",
"手机标识",
] + [f"图片{i}" for i in range(1, MAX_IMAGE_COLUMNS + 1)]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"导出签到报表 — 签到记录粒度。"
"[强制] AI Agent 必须先读 references/attendance-report.md 再调用本脚本。"
),
)
parser.add_argument("--users", required=True,
help="userId 列表,逗号分隔(必填)")
parser.add_argument("--start", required=True,
help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
parser.add_argument("--end", required=True,
help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
parser.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
parser.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
return parser.parse_args()
# ─────────────────────────────────────────────────────────────────────────────
# 签到接口时间切片(7 天一段)
# ─────────────────────────────────────────────────────────────────────────────
def slice_checkin_date_range(
start: datetime, end: datetime,
) -> list[cmn.DateSlice]:
"""将日期范围按 7 天一段切片(签到接口限制开始到结束最多 7 天)。"""
slices: list[cmn.DateSlice] = []
current = start
while current <= end:
slice_end = min(current + timedelta(days=MAX_DAYS_PER_CHECKIN_SLICE - 1), end)
# 确保 slice_end 的时间部分是当天最后一秒
slice_end = slice_end.replace(hour=23, minute=59, second=59)
if slice_end > end:
slice_end = end
slices.append(cmn.DateSlice(
start=current,
end=slice_end,
))
current = slice_end.replace(hour=0, minute=0, second=0) + timedelta(days=1)
return slices
def chunk_checkin_users(user_ids: list[str]) -> list[list[str]]:
"""将用户列表按 MAX_USERS_PER_CHECKIN_BATCH 分批。"""
batches: list[list[str]] = []
for i in range(0, len(user_ids), MAX_USERS_PER_CHECKIN_BATCH):
batches.append(user_ids[i:i + MAX_USERS_PER_CHECKIN_BATCH])
return batches
# ─────────────────────────────────────────────────────────────────────────────
# 签到数据查询
# ─────────────────────────────────────────────────────────────────────────────
def query_checkin_batch(
user_batch: list[str],
date_slice: cmn.DateSlice,
operator_corp_id: str,
operator_staff_id: str,
stats: cmn.CallStats,
*,
inspect: bool = False,
inspected_flag: list[bool] | None = None,
) -> list[dict]:
"""查询一批用户在一个时间片内的签到记录。"""
cmn.log(
f"[checkin] users={len(user_batch)} "
f"slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "checkin", "records",
"--operator-corp-id", operator_corp_id,
"--operator-staff-id", operator_staff_id,
"--staff-ids", ",".join(user_batch),
"--start", date_slice.start_str,
"--end", date_slice.end_str,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as exc:
stats.total_dws_calls += 1
stats.failed_calls += 1
if exc.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法导出签到报表。\n"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from exc
err_msg = str(exc)
if "missing required flag" in err_msg.lower():
cmn.error(
"签到接口调用失败:缺少必需参数。\n"
"请确保已执行 dws auth login 完成登录,以便自动获取 operator 参数。\n"
f"当前 operator: corp_id={operator_corp_id}, staff_id={operator_staff_id}\n"
f"原始错误:{err_msg}"
)
raise SystemExit(2) from exc
stats.add_warning(f"[checkin failed] {date_slice.label}: {exc}")
return []
records = cmn.extract_records(payload)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "checkin-records")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 签到记录 → 报表行转换
# ─────────────────────────────────────────────────────────────────────────────
def _format_timestamp(timestamp_value: Any) -> tuple[str, str]:
"""
将签到时间戳转换为 (日期字符串, 时间字符串)。
签到接口的 timestamp 为毫秒时间戳。
"""
if timestamp_value is None:
return "", ""
try:
ts = float(timestamp_value)
# 判断是毫秒还是秒级时间戳
if ts > 1_000_000_000_000:
ts = ts / 1000
dt = datetime.fromtimestamp(ts)
return dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M:%S")
except (ValueError, TypeError, OSError, OverflowError):
return str(timestamp_value), ""
def transform_records_to_rows(
records: list[dict],
user_info_map: dict[str, cmn.UserInfo],
) -> list[list[Any]]:
"""将签到原始记录转换为报表行(与 REPORT_HEADERS 对齐)。"""
rows: list[list[Any]] = []
for record in records:
uid = cmn._first_nonempty(record, ("userId", "userid", "user_id"))
uid_str = str(uid) if uid is not None else ""
info = user_info_map.get(uid_str, cmn.UserInfo(name=uid_str))
# 姓名:优先用 resolve_user_info 的结果,回退到接口返回的 name
name = info.name or record.get("name", uid_str)
dept_name = info.dept_name
# 完整部门:暂用 dept_name(如需更完整的路径可后续扩展)
full_dept = dept_name
# 日期与时间
date_str, time_str = _format_timestamp(record.get("timestamp"))
# 经纬度
longitude = record.get("longitude", "")
latitude = record.get("latitude", "")
# 地点
place = record.get("place", "")
detail_place = record.get("detailPlace", "")
# 拜访客户 & 客户部门名称
customers = record.get("customers", "")
# 签到接口暂无客户部门名称字段,预留空值
customer_dept = ""
# 工作内容(备注)
remark = record.get("remark", "")
# 手机标识
mobile_id = record.get("mobileId", "")
# 图片列(最多 9 张)
image_list = record.get("imageList") or []
if isinstance(image_list, str):
# 兼容接口可能返回逗号分隔的字符串
image_list = [img.strip() for img in image_list.split(",") if img.strip()]
image_cells = []
for i in range(MAX_IMAGE_COLUMNS):
if i < len(image_list):
image_cells.append(image_list[i])
else:
image_cells.append("")
row = [
name, dept_name, full_dept,
date_str, time_str,
longitude, latitude, place, detail_place,
customers, customer_dept, remark,
mobile_id,
] + image_cells
rows.append(row)
return rows
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
raw_ids = [u.strip() for u in args.users.split(",") if u.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
# 自动识别部门ID并展开为员工userId
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效的员工userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)}")
try:
start = cmn.parse_datetime_arg(args.start, end_of_day=False)
end = cmn.parse_datetime_arg(args.end, end_of_day=True)
except ValueError as exc:
cmn.error(str(exc))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
# 获取当前认证的 operator 信息(签到接口必需)
operator_corp_id, operator_staff_id = _get_operator_context()
# 获取用户基础信息(姓名、部门)
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
# 分批分段查询签到记录
user_batches = chunk_checkin_users(user_ids)
date_slices = slice_checkin_date_range(start, end)
stats = cmn.CallStats(
user_batches=len(user_batches),
date_slices=len(date_slices),
)
cmn.log(
f"[plan] 共 {len(user_batches)}× {len(date_slices)} 个时间片 "
f"= {len(user_batches) * len(date_slices)} 次接口调用"
)
inspected_flag = [False]
all_records: list[dict] = []
for batch_idx, batch in enumerate(user_batches, start=1):
for slice_idx, date_slice in enumerate(date_slices, start=1):
cmn.log(
f"[batch {batch_idx}/{len(user_batches)}] "
f"[slice {slice_idx}/{len(date_slices)}]"
)
records = query_checkin_batch(
batch, date_slice,
operator_corp_id, operator_staff_id,
stats,
inspect=args.inspect,
inspected_flag=inspected_flag,
)
all_records.extend(records)
if not all_records:
stats.add_warning("查询完成,但未得到任何签到记录")
# 转换为报表行
rows = transform_records_to_rows(all_records, user_info_map)
# 按日期时间排序(日期列索引=3,时间列索引=4)
rows.sort(key=lambda r: (r[3] or "", r[4] or ""))
# 生成 Excel
out_name = args.out or cmn.build_output_filename(start, end, suffix="checkin")
title = (
f"签到报表 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"{end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
# 图片列名列表(图片1~图片9),让 write_excel_multi_sheets 自动将 URL 嵌入为缩略图
image_column_names = [f"图片{i}" for i in range(1, MAX_IMAGE_COLUMNS + 1)]
checkin_sheet = {
"name": "签到记录",
"headers": REPORT_HEADERS,
"rows": rows,
"title": title,
"subtitle": subtitle,
"image_columns": image_column_names,
"image_size": (60, 60),
}
try:
cmn.write_excel_multi_sheets(out_name, [checkin_sheet])
except (RuntimeError, ValueError) as exc:
cmn.error(str(exc))
return 1
cmn.print_summary(
granularity_label="签到报表",
out_path=out_name,
user_count=len(user_ids),
column_names=[h for h in REPORT_HEADERS],
start=start,
end=end,
rows_count=len(rows),
stats=stats,
)
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,558 @@
#!/usr/bin/env python3
"""
考勤报表导出 — 每日统计粒度
⛔ 【AI Agent 强制门禁】调用本脚本前必须先阅读:
references/attendance-report.md
本脚本仅是"考勤报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md
包含但不限于:
- 阶段 0:报表类型判断(默认月度汇总)
- 阶段 1:人员列表获取(aisearch person / contact dept list-members
- 阶段 2:列选择(是否传 --column-keywords
- 阶段 3:调用本脚本
- 阶段 4:结果回传给用户的标准格式
- 错误处理(403 权限、HSF_ILLEGALPARAMS、空数据等)
❌ 严禁仅凭本脚本 docstring 或 --help 输出就直接拼命令执行,会导致:
- 报表数据不全 / 列错位 / 人员遗漏
- 错误处理缺失,把环境错误当业务错误反馈给用户
- 输出格式不规范,用户体验差
按 (userId, workDate) 分组,每人每天一行。
聚合策略:
- 通过启发式识别每条记录的"工作日期":依次尝试字段名
workDate / work_date / date / userCheckTime / day / 工作日期
- 同一 (userId, workDate) 下的多条记录按字段聚合:
* 数值字段 → sum
* 非数值字段 → 取首个非空值(因为同一天同一字段通常只有一个值)
- 缺少 workDate 的记录会归入 "_no_date",并 warn
用法:
python attendance_report_daily.py \
--users userId1,userId2,... \
--start "2026-03-01 00:00:00" \
--end "2026-03-31 23:59:59" \
[--columns 1001,1002]
[--column-keywords "工作日期,出勤状态,迟到时长"]
[--out attendance_report_2026-03-01_2026-03-31_daily.xlsx]
[--inspect]
"""
from __future__ import annotations
import argparse
import sys
from collections import defaultdict
from datetime import datetime
from typing import Any
import attendance_report_common as cmn
# 默认关注字段 — 与 SKILL.md「每日统计预定义列集合」严格对齐(共 33 个)
# 字段名必须和 `dws attendance report columns` 返回的 name 精确匹配
DEFAULT_KEYWORDS = [
"班次",
"上班1打卡时间",
"上班1打卡结果",
"下班1打卡时间",
"下班1打卡结果",
"上班2打卡时间",
"上班2打卡结果",
"下班2打卡时间",
"下班2打卡结果",
"上班3打卡时间",
"上班3打卡结果",
"下班3打卡时间",
"下班3打卡结果",
"关联的审批单",
"出勤天数",
"休息天数",
"工作时长",
"迟到次数",
"迟到时长",
"严重迟到次数",
"严重迟到时长",
"旷工迟到次数",
"早退次数",
"早退时长",
"上班缺卡次数",
"下班缺卡次数",
"旷工天数",
"出差时长",
"外出时长",
"请假",
"加班-审批单统计",
]
# 工作日期字段的候选 key(按优先级试探)
DATE_KEY_CANDIDATES = (
"workDate", "work_date", "userCheckDate", "checkDate",
"date", "day", "工作日期",
)
# 请假字段 — 触发"按假期类型展开"的字段名
# 不参与 query-data 查询,单独走 query-leave 接口,按 4 类假期展开为多列
# 注意:钉钉接口实际返回的字段名可能是 "请假"、"请假分类"、"请假时长" 等,
# 凡以 "请假" 开头的都视为请假字段,统一替换为 4 列假期类型展开。
LEAVE_FIELD_NAME = "请假"
LEAVE_TYPES: tuple[str, ...] = ("事假", "调休", "病假", "年假")
def _is_leave_field(name: str) -> bool:
"""判断一个字段名是否属于"请假"系列(如 请假 / 请假分类 / 请假时长)。"""
return isinstance(name, str) and name.startswith(LEAVE_FIELD_NAME)
# ─────────────────────────────────────────────────────────────────────────────
# 参数解析
# ─────────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=(
"导出考勤报表 — 每日统计粒度。"
"⛔ AI Agent 必须先读 references/attendance-report.md 再调用本脚本,"
"禁止凭 --help 或脚本路径自行拼命令。"
),
)
p.add_argument("--users", required=True,
help="userId 列表,逗号分隔(必填)")
p.add_argument("--start", required=True,
help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--end", required=True,
help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--columns", default="",
help="字段 ID 列表,逗号分隔;与 --column-keywords 二选一")
p.add_argument("--column-keywords", default="",
help="字段名关键词,逗号分隔;不传则走默认字段集")
p.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
p.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
return p.parse_args()
# ─────────────────────────────────────────────────────────────────────────────
# 字段解析(与 detail / monthly 一致)
# ─────────────────────────────────────────────────────────────────────────────
def resolve_columns(args: argparse.Namespace) -> list[dict]:
if args.columns.strip():
cids = [c.strip() for c in args.columns.split(",") if c.strip()]
all_cols_payload = cmn.run_dws(["attendance", "report", "columns"])
all_cols = cmn.extract_records(all_cols_payload)
id_to_name: dict[str, str] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None:
id_to_name[str(cid)] = str(name) if name else str(cid)
return [{"_column_id": cid, "_column_name": id_to_name.get(cid, cid)}
for cid in cids]
keywords = (
[k.strip() for k in args.column_keywords.split(",") if k.strip()]
if args.column_keywords.strip()
else DEFAULT_KEYWORDS
)
cmn.log(f"[columns] 使用关键词匹配字段:{keywords}")
all_cols_payload = cmn.run_dws(["attendance", "report", "columns"])
all_cols = cmn.extract_records(all_cols_payload)
cmn.log(f"[columns] dws 返回 {len(all_cols)} 个字段")
matched = cmn.match_columns_by_keywords(all_cols, keywords)
if not matched:
raise RuntimeError(
f"未匹配到任何字段。可用字段示例:"
f"{[cmn._first_nonempty(c, ('name','columnName','title','label')) for c in all_cols[:10]]}"
)
cmn.log(f"[columns] 匹配到 {len(matched)} 个字段:{[c['_column_name'] for c in matched]}")
return matched
# ─────────────────────────────────────────────────────────────────────────────
# 接口调用(与 detail / monthly 一致)
# ─────────────────────────────────────────────────────────────────────────────
def query_one_batch(
user_batch: list[str],
column_ids: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
column_id_to_name: dict[str, str] | None = None,
inspect: bool = False,
inspected_flag: list[bool] = None,
) -> list[dict]:
cmn.log(
f"[query] users={len(user_batch)} cols={len(column_ids)} "
f"slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "report", "query-data",
"--users", ",".join(user_batch),
"--columns", ",".join(column_ids),
"--start", date_slice.start_str,
"--end", date_slice.end_str,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as e:
stats.total_dws_calls += 1
stats.failed_calls += 1
if e.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法导出考勤报表。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from e
stats.add_warning(f"[query failed] {date_slice.label}: {e}")
return []
records = cmn.extract_records(payload)
# 展平 report query-data 返回的嵌套 values 结构
records = cmn.flatten_query_data_records(records, column_id_to_name)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "query-data (flattened)")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 每日聚合
# ─────────────────────────────────────────────────────────────────────────────
def _value_for_column(record: dict, col: dict) -> Any:
cname, cid = col["_column_name"], col["_column_id"]
for key in (cname, cid, f"col_{cid}", f"column_{cid}"):
if key in record:
return record[key]
return None
def _try_number(value: Any) -> float | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None
def _user_id_of(record: dict) -> str | None:
uid = cmn._first_nonempty(record, ("userId", "userid", "user_id", "targetUserId"))
return str(uid) if uid is not None else None
def _extract_work_date(record: dict, columns: list[dict]) -> str | None:
"""
从一条记录里提取"工作日期"YYYY-MM-DD 格式)。
试探顺序:
1. record 里的 DATE_KEY_CANDIDATES
2. columns 里 _column_name 含"日期"的字段
3. 13 位毫秒时间戳 → 转 YYYY-MM-DD
4. ISO 字符串 → 截前 10 位
都没找到返回 None。
"""
candidates: list[Any] = []
# 1) 直接 key
for key in DATE_KEY_CANDIDATES:
if key in record and record[key] not in (None, ""):
candidates.append(record[key])
# 2) 字段名含"日期"
for col in columns:
if "日期" in col["_column_name"] or "date" in col["_column_name"].lower():
v = _value_for_column(record, col)
if v not in (None, ""):
candidates.append(v)
for raw in candidates:
date_str = _normalize_date(raw)
if date_str:
return date_str
return None
def _normalize_date(raw: Any) -> str | None:
"""把任意形态的日期值归一化为 YYYY-MM-DD 字符串。"""
if raw is None:
return None
# 毫秒时间戳
if isinstance(raw, (int, float)) and 1_000_000_000_000 <= raw <= 9_999_999_999_999:
try:
return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
# 秒级时间戳
if isinstance(raw, (int, float)) and 1_000_000_000 <= raw <= 9_999_999_999:
try:
return datetime.fromtimestamp(raw).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
s = str(raw).strip()
if not s:
return None
# 已经是 YYYY-MM-DD
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
head = s[:10]
try:
datetime.strptime(head, cmn.DATE_FMT)
return head
except ValueError:
return None
return None
def aggregate_daily(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_name_map: dict[str, str],
stats: cmn.CallStats,
) -> list[dict[str, Any]]:
"""
按 (userId, workDate) 聚合:
- 数值字段:sum
- 非数值字段:取首个非空值(同一天同字段通常只有一个值)
返回每人每天一行的 dict 列表,按 userId、workDate 排序。
"""
# bucket: (userId, date) → column_name → {sum: float, count_num: int, first_nonnum: Any}
buckets: dict[tuple[str, str], dict[str, dict]] = defaultdict(
lambda: {col["_column_name"]: {"sum": 0.0, "count_num": 0, "first_nonnum": None}
for col in columns}
)
no_date_count = 0
for record in all_records:
uid = _user_id_of(record)
if uid is None:
continue
date_str = _extract_work_date(record, columns)
if date_str is None:
no_date_count += 1
date_str = "_no_date"
for col in columns:
cname = col["_column_name"]
raw = _value_for_column(record, col)
num = _try_number(raw)
cell = buckets[(uid, date_str)][cname]
if num is not None:
cell["sum"] += num
cell["count_num"] += 1
elif raw not in (None, "") and cell["first_nonnum"] is None:
cell["first_nonnum"] = raw
if no_date_count > 0:
stats.add_warning(
f"{no_date_count} 条记录无法识别工作日期,已归入 '_no_date'"
"请用 --inspect 查看真实字段名"
)
# 输出:按 (uid, date) 排序
rows: list[dict[str, Any]] = []
for (uid, date_str) in sorted(buckets.keys(), key=lambda x: (x[0], x[1])):
row: dict[str, Any] = {
"userId": uid,
"userName": user_name_map.get(uid, uid),
"workDate": date_str,
}
bucket = buckets[(uid, date_str)]
for col in columns:
cname = col["_column_name"]
cell = bucket[cname]
if cell["count_num"] > 0:
total = cell["sum"]
row[cname] = int(total) if total == int(total) else round(total, 2)
elif cell["first_nonnum"] is not None:
row[cname] = cell["first_nonnum"]
else:
row[cname] = ""
rows.append(row)
return rows
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
raw_ids = [u.strip() for u in args.users.split(",") if u.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
# 自动识别部门ID并展开为员工userId
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效的员工userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)}")
try:
start = cmn.parse_datetime_arg(args.start, end_of_day=False)
end = cmn.parse_datetime_arg(args.end, end_of_day=True)
except ValueError as e:
cmn.error(str(e))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
try:
columns = resolve_columns(args)
except cmn.DwsCallError as e:
if e.is_permission_error:
cmn.error("权限错误:当前账号无管理员权限,无法获取考勤字段列表。")
return 2
cmn.error(f"获取字段列表失败:{e}")
return 1
except RuntimeError as e:
cmn.error(str(e))
return 1
column_ids = [c["_column_id"] for c in columns]
column_names = [c["_column_name"] for c in columns]
column_id_to_name = {c["_column_id"]: c["_column_name"] for c in columns}
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
user_name_map = {uid: info.name or uid for uid, info in user_info_map.items()}
user_batches = cmn.chunk_users(user_ids)
date_slices = cmn.slice_date_range(start, end)
stats = cmn.CallStats(
user_batches=len(user_batches),
date_slices=len(date_slices),
)
cmn.log(
f"[plan] 共 {len(user_batches)}× {len(date_slices)} 个时间片 "
f"= {len(user_batches) * len(date_slices)} 次接口调用"
)
inspected_flag = [False]
all_records: list[dict] = []
for bi, batch in enumerate(user_batches, start=1):
for si, dslice in enumerate(date_slices, start=1):
cmn.log(f"[batch {bi}/{len(user_batches)}] [slice {si}/{len(date_slices)}]")
records = query_one_batch(
batch, column_ids, dslice, stats,
column_id_to_name=column_id_to_name,
inspect=args.inspect,
inspected_flag=inspected_flag,
)
all_records.extend(records)
if not all_records:
stats.add_warning("查询完成,但未得到任何记录")
# 从原始记录中提取每个用户的考勤组名称
group_name_map = cmn.extract_group_names_from_records(all_records, user_ids)
rows_dict = aggregate_daily(all_records, columns, user_ids, user_name_map, stats)
# 请假数据特殊处理:通过 query-leave 单独查询,按 4 类假期按天展开
# 凡是 "请假" 开头的字段(请假 / 请假分类 / 请假时长 等)都视为请假列
leave_in_columns = any(_is_leave_field(name) for name in column_names)
leave_data: dict[str, dict[str, dict[str, float]]] = {}
if leave_in_columns:
try:
leave_data = cmn.query_leave_data(
user_ids, start, end,
leave_names=LEAVE_TYPES,
stats=stats,
)
except cmn.DwsCallError as e:
stats.add_warning(f"[leave] 查询请假数据失败:{e}")
# 表头对齐 SKILL.md 每日统计预定义列集合:姓名 | 考勤组 | 部门 | 日期 | 考勤字段...
# 请假按假期类型展开为多列(如 "请假-事假", "请假-调休", ...),其余字段保持顺序
# 多个 "请假*" 字段(如 "请假分类" + "请假时长")只展开 1 次,避免重复
base_headers = ["姓名", "考勤组", "部门", "日期"]
data_headers: list[str] = []
leave_expanded = False
for cname in column_names:
if cname == "工作日期":
continue
if _is_leave_field(cname):
if not leave_expanded:
data_headers.extend(f"{LEAVE_FIELD_NAME}-{lt}" for lt in LEAVE_TYPES)
leave_expanded = True
continue
data_headers.append(cname)
headers = base_headers + data_headers
rows_2d = []
for row in rows_dict:
uid = row.get("userId", "")
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
work_date = row.get("workDate", "")
base = [info.name or uid, group_name, info.dept_name, work_date]
# 当天该用户的请假数据
day_leave = leave_data.get(uid, {}).get(work_date, {}) if leave_in_columns else {}
data: list[Any] = []
leave_filled = False
for cname in column_names:
if cname == "工作日期":
continue
if _is_leave_field(cname):
if not leave_filled:
for lt in LEAVE_TYPES:
val = day_leave.get(lt, 0.0)
if val == 0.0:
data.append("")
elif val == int(val):
data.append(int(val))
else:
data.append(round(val, 2))
leave_filled = True
continue
data.append(row.get(cname, ""))
rows_2d.append(base + data)
out_name = args.out or cmn.build_output_filename(start, end, suffix="daily")
title = (
f"每日统计展示 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"{end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
try:
cmn.write_excel(
out_name, headers, rows_2d,
sheet_name="每日统计",
title=title,
subtitle=subtitle,
)
except RuntimeError as e:
cmn.error(str(e))
return 1
cmn.print_summary(
granularity_label="每日统计",
out_path=out_name,
user_count=len(user_ids),
column_names=column_names,
start=start,
end=end,
rows_count=len(rows_2d),
stats=stats,
extra_tail="ℹ️ 同一 (用户, 日期) 下数值字段已求和、非数值字段取首个值。",
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,809 @@
#!/usr/bin/env python3
"""
考勤报表导出 — 明细粒度(打卡记录)
通过 `dws attendance check result` + `dws attendance check record`
查询打卡数据,每条打卡记录输出一行,不做聚合。
[AI Agent 强制门禁] 调用本脚本前必须先阅读:
references/attendance-report.md
本脚本仅是"考勤报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md
包含但不限于:
- 阶段 0:报表类型判断(默认月度汇总,明细需用户明确说"明细/原始记录/每条打卡"
- 阶段 1:人员列表获取(aisearch person / contact dept list-members
- 阶段 2:列选择(明细报表列固定,不支持 --column-keywords
- 阶段 3:调用本脚本
- 阶段 4:结果回传给用户的标准格式
- 错误处理(403 权限、HSF_ILLEGALPARAMS、空数据等)
[严禁] 仅凭本脚本 docstring 或 --help 输出就直接拼命令执行,会导致:
- 用户本来要"汇总"被给成"明细"(粒度错误)
- 报表数据不全 / 人员遗漏
- 错误处理缺失,把环境错误当业务错误反馈给用户
与月度汇总/每日统计不同,明细报表:
- 不使用 report columns / report query-data
- 列固定(基础信息 + 打卡字段),不支持自定义列选择
- 分批限制:≤100 人/次(check result),时间跨度 ≤1 个月
用法:
python attendance_report_detail.py \
--users userId1,userId2,... \
--start "2026-03-01" \
--end "2026-03-31" \
[--out attendance_report_2026-03-01_2026-03-31_detail.xlsx]
[--inspect] # 首次跑时打印首条记录原始结构
约束:
- 仅管理员可用,否则 dws 接口返回 403
- --users 超过 100 人 → 自动按每批 100 人分批
- --start 到 --end 超过 31 天 → 自动按月切片
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from typing import Any
import attendance_report_common as cmn
# ─────────────────────────────────────────────────────────────────────────────
# 接口限制(check result / check record
# ─────────────────────────────────────────────────────────────────────────────
CHECK_MAX_USERS_PER_BATCH = 100 # check result: --users 最多 100 人
CHECK_MAX_DAYS_PER_SLICE = 31 # check result/record: 跨度 ≤ 1 个月
CHECK_RESULT_PAGE_SIZE = 1000 # check result: --limit 最大值
# ─────────────────────────────────────────────────────────────────────────────
# 固定表头(与 SKILL.md 明细预定义列集合对齐)
# ─────────────────────────────────────────────────────────────────────────────
# 基础信息列
BASE_HEADERS = ["姓名", "考勤组", "部门"]
# 打卡字段列(以打卡流水为主,关联 check result 的考勤时间和打卡结果)
# 对应 Diamond 配置中 termId 8-20 的列定义
CHECK_HEADERS = [
"考勤日期", "考勤时间", "打卡时间", "打卡结果",
"打卡地址", "打卡备注", "异常打卡原因",
"打卡图片1", "打卡图片2", "打卡设备", "管理员修改备注",
"管理员修改备注图片1", "管理员修改备注图片2", "管理员修改备注图片3",
]
ALL_HEADERS = BASE_HEADERS + CHECK_HEADERS
# ─────────────────────────────────────────────────────────────────────────────
# 参数解析
# ─────────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=(
"导出考勤报表 — 明细粒度(打卡记录)。"
"[强制] AI Agent 必须先读 references/attendance-report.md 再调用本脚本,"
"禁止凭 --help 或脚本路径自行拼命令。"
),
)
p.add_argument("--users", required=True,
help="userId 列表,逗号分隔(必填)")
p.add_argument("--start", required=True,
help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--end", required=True,
help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
p.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
p.add_argument("--no-images", action="store_true",
help="不在 Excel 中嵌入打卡图片(默认会下载 URL 并嵌入为缩略图,"
"图片多时较慢;加此参数仅保留 URL 文本)")
p.add_argument("--image-size", default="80x120",
help="嵌入图片像素尺寸 WxH,默认 80x120")
return p.parse_args()
# 含图片 URL 的列名(与 CHECK_HEADERS 中的中文名严格一致)
IMAGE_COLUMN_NAMES = [
"打卡图片1", "打卡图片2",
"管理员修改备注图片1", "管理员修改备注图片2", "管理员修改备注图片3",
]
def _parse_image_size(spec: str) -> tuple[int, int]:
"""解析 --image-size 参数,格式 WxH。失败时回退到默认 (80, 120)。"""
try:
parts = spec.lower().replace(" ", "").split("x")
w, h = int(parts[0]), int(parts[1])
if w > 0 and h > 0:
return (w, h)
except (ValueError, IndexError):
pass
cmn.warn(f"--image-size 格式无效: {spec!r},使用默认 80x120")
return (80, 120)
# ─────────────────────────────────────────────────────────────────────────────
# check result 查询(打卡结果,含分页)
# ─────────────────────────────────────────────────────────────────────────────
def query_check_results(
user_batch: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
inspect: bool = False,
inspected_flag: list[bool] | None = None,
) -> list[dict]:
"""
对一批 users × 一个时间片调用 `dws attendance check result`。
自动分页:每次最多 1000 条,返回满 1000 条时递增 offset 继续拉取。
"""
from_date = date_slice.start.strftime(cmn.DATE_FMT)
to_date = date_slice.end.strftime(cmn.DATE_FMT)
all_records: list[dict] = []
offset = 0
while True:
cmn.log(
f"[check-result] users={len(user_batch)} "
f"slice={date_slice.label} offset={offset}"
)
try:
payload = cmn.run_dws([
"attendance", "check", "result",
"--users", ",".join(user_batch),
"--from", from_date,
"--to", to_date,
"--offset", str(offset),
"--limit", str(CHECK_RESULT_PAGE_SIZE),
])
stats.total_dws_calls += 1
except cmn.DwsCallError as exc:
stats.total_dws_calls += 1
stats.failed_calls += 1
if exc.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法查询打卡结果。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from exc
stats.add_warning(f"[check-result failed] {date_slice.label} offset={offset}: {exc}")
break
records = cmn.extract_records(payload)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "check-result")
inspected_flag[0] = True
all_records.extend(records)
# 未满一页 → 无需翻页
if len(records) < CHECK_RESULT_PAGE_SIZE:
break
offset += CHECK_RESULT_PAGE_SIZE
return all_records
# ─────────────────────────────────────────────────────────────────────────────
# check record 查询(打卡流水)
# ─────────────────────────────────────────────────────────────────────────────
def query_check_records(
user_batch: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
inspect: bool = False,
inspected_flag: list[bool] | None = None,
) -> list[dict]:
"""对一批 users × 一个时间片调用 `dws attendance check record`。"""
from_date = date_slice.start.strftime(cmn.DATE_FMT)
to_date = date_slice.end.strftime(cmn.DATE_FMT)
cmn.log(
f"[check-record] users={len(user_batch)} slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "check", "record",
"--users", ",".join(user_batch),
"--from", from_date,
"--to", to_date,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as exc:
stats.total_dws_calls += 1
stats.failed_calls += 1
if exc.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法查询打卡流水。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from exc
stats.add_warning(f"[check-record failed] {date_slice.label}: {exc}")
return []
records = cmn.extract_records(payload)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "check-record")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 值提取工具
# ─────────────────────────────────────────────────────────────────────────────
def _humanize_timestamp(value: Any) -> str:
"""把毫秒/秒级时间戳转成可读字符串;非时间戳原样返回。"""
if value is None:
return ""
if isinstance(value, (int, float)):
# 13 位毫秒时间戳
if 1_000_000_000_000 <= value <= 9_999_999_999_999:
try:
return datetime.fromtimestamp(value / 1000).strftime(cmn.DATETIME_FMT)
except (OSError, ValueError, OverflowError):
return str(value)
# 10 位秒级时间戳
if 1_000_000_000 <= value <= 9_999_999_999:
try:
return datetime.fromtimestamp(value).strftime(cmn.DATETIME_FMT)
except (OSError, ValueError, OverflowError):
return str(value)
return str(value) if value != "" else ""
def _extract_field(record: dict, candidate_keys: tuple[str, ...]) -> Any:
"""从 record 中按候选 key 顺序取第一个非空值。"""
return cmn._first_nonempty(record, candidate_keys)
def _extract_date_str(record: dict) -> str:
"""从 check result 记录中提取考勤日期(YYYY-MM-DD)。"""
raw = _extract_field(record, (
"workDate", "work_date", "checkDate", "userCheckDate", "date", "day",
))
if raw is None:
return ""
# 毫秒时间戳
if isinstance(raw, (int, float)) and raw > 1_000_000_000_000:
try:
return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return str(raw)
s = str(raw).strip()
# 已经是 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss → 取前 10 位
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return s[:10]
return s
def _extract_time_str(record: dict, candidate_keys: tuple[str, ...]) -> str:
"""从记录中提取时间字段,毫秒时间戳自动转 HH:mm:ss。"""
raw = _extract_field(record, candidate_keys)
if raw is None:
return ""
if isinstance(raw, (int, float)) and raw > 1_000_000_000_000:
try:
return datetime.fromtimestamp(raw / 1000).strftime("%H:%M:%S")
except (OSError, ValueError, OverflowError):
return str(raw)
if isinstance(raw, (int, float)) and raw > 1_000_000_000:
try:
return datetime.fromtimestamp(raw).strftime("%H:%M:%S")
except (OSError, ValueError, OverflowError):
return str(raw)
return str(raw)
# ─────────────────────────────────────────────────────────────────────────────
# 字段翻译 / 提取工具函数(与 Java DataProvider 实现对齐)
# ─────────────────────────────────────────────────────────────────────────────
# 打卡结果映射(对应 CheckResultUtil.java 的 getCheckResultStr 逻辑)
_CHECK_RESULT_MAP: dict[str, str] = {
"Normal": "正常",
"Late": "迟到",
"Early": "早退",
"NotSigned": "未打卡",
"SeriousLate": "严重迟到",
"Absenteeism": "旷工迟到",
"LeaveEarly": "早退",
}
# 打卡设备 / 来源类型映射(对应 SourceType 枚举 + UserDeviceOriginData.java
_SOURCE_TYPE_MAP: dict[str, str] = {
"ATM": "考勤机",
"BEACON": "蓝牙",
"DING_ATM": "钉钉考勤机",
"USER": "手机打卡",
"BOSS": "管理员",
"SYSTEM": "系统",
"CARD": "门禁",
"SELF_SERVICE": "自助补卡",
}
# 异常打卡原因中文描述(对应 SecurityConfigureUtil DEFAULT_CHEAT_LIST
_CHEAT_REASON_MAP: dict[str, str] = {
"LocationNotMatch": "定位异常",
"WifiNotMatch": "WIFI异常",
"MockLocation": "模拟定位",
"FaceNotMatch": "人脸比对失败",
"DeviceNotMatch": "设备异常",
"OutsideRange": "不在打卡范围",
"NoBluetooth": "蓝牙未开启",
"BluetoothNotMatch": "蓝牙不匹配",
}
def _translate_check_result(raw_result: str) -> str:
"""
把接口返回的英文打卡结果翻译成中文,与 CheckResultUtil.getCheckResultStr 对齐。
未命中翻译表时原样返回。
"""
if not raw_result:
return ""
return _CHECK_RESULT_MAP.get(raw_result, raw_result)
def _translate_source_type(raw_source: str) -> str:
"""
把接口返回的 sourceType 枚举值翻译成中文,与 UserDeviceOriginData 对齐。
未命中翻译表时原样返回。
"""
if not raw_source:
return ""
return _SOURCE_TYPE_MAP.get(raw_source, raw_source)
def _extract_location(record: dict) -> str:
"""
拼接打卡地址:地点名称 + 详细地址,与 UserLocationOriginData 对齐。
Java 逻辑:
locationResult.getSpaceName() → 地点名称
locationResult.getDetailAddr() → 详细地址(含省市区+街道)
两者均有时拼接,只有一个时单独返回。
"""
space_name = str(_extract_field(record, (
"spaceName", "space_name", "locationName", "location_name",
)) or "").strip()
detail_addr = str(_extract_field(record, (
"detailAddr", "detail_addr", "detailAddress", "address", "userAddress",
)) or "").strip()
if space_name and detail_addr:
return f"{space_name} {detail_addr}"
return space_name or detail_addr
def _extract_exception_reason(record: dict) -> str:
"""
提取并翻译异常打卡原因,与 CheckExceptionReasonOriginData 对齐。
Java 逻辑:
取 features.getInvalidRecordMsg()(逗号分隔的错误码列表)
逐个从 DEFAULT_CHEAT_LIST 查中文描述后再拼接返回。
"""
raw = str(_extract_field(record, (
"invalidRecordMsg", "invalid_record_msg",
"outsideRemark", "outside_remark",
"exceptionReason",
)) or "").strip()
if not raw:
return ""
# 逗号分隔的多个错误码,逐个翻译后重新拼接
codes = [c.strip() for c in raw.split(",") if c.strip()]
translated = [_CHEAT_REASON_MAP.get(code, code) for code in codes]
return ",".join(translated)
def _extract_photo_url(record: dict, candidate_keys: tuple[str, ...]) -> str:
"""从 record 或其 features 嵌套结构中提取图片 URL。"""
raw = _extract_field(record, candidate_keys)
if raw is None:
return ""
return str(raw).strip()
def _extract_remark_photo(record: dict) -> str:
"""
打卡图片1(备注/外勤打卡照片)。
dws check record 的真实返回字段(实测验证):
- 顶层 photoUrl:外勤/拍照打卡的主图片 URL
- 顶层 outsideAttachment:外勤打卡的附件(可能含多张图片)
- 顶层 remarkPhotos:备注图片数组(旧字段,部分版本)
Java 侧 RemarkPhotoOriginData 对应 features.getRemarkPhotos()
但 dws CLI 实际把图片字段提到了顶层,需直接读顶层字段。
"""
# 1) 兼容数组形式的 remarkPhotos(早期版本)
remark_photos = record.get("remarkPhotos") or record.get("remark_photos")
if isinstance(remark_photos, list) and remark_photos:
return str(remark_photos[0]).strip()
if isinstance(remark_photos, str) and remark_photos.strip():
parts = [p.strip() for p in remark_photos.split(",") if p.strip()]
return parts[0] if parts else ""
# 2) dws CLI 当前实际返回的字段(顶层)
# photoUrl 优先,其次 outsideAttachment,再次旧候选名
photo = _extract_photo_url(record, (
"photoUrl", "photo_url",
"outsideAttachment", "outside_attachment",
"remarkPhoto", "remark_photo",
"userImage", "user_image", "imageUrl", "image_url",
))
if photo:
# outsideAttachment 可能是逗号分隔多张,取第一张
if "," in photo:
first = photo.split(",")[0].strip()
if first:
return first
return photo
# 3) 兜底:从 features 嵌套 JSON 里翻
return _extract_photo_from_features(record, (
"photoUrl", "remarkPhoto", "remarkPhotos",
"outsideAttachment", "userImage", "imageUrl",
))
def _extract_face_check_photo(record: dict) -> str:
"""
打卡图片2(人脸识别照片)。
Java 侧 FaceCheckPhotoOriginData 对应 features.getFacePhoto()。
dws CLI 中人脸图未稳定暴露在顶层,优先读 features 嵌套字段。
"""
# 1) 顶层候选
face = _extract_photo_url(record, (
"facePhoto", "face_photo",
"faceCheckPhoto", "face_check_photo",
"faceImage", "face_image",
"faceUrl", "face_url",
))
if face:
return face
# 2) features 嵌套兜底
return _extract_photo_from_features(record, (
"facePhoto", "faceCheckPhoto", "faceImage", "faceUrl",
))
def _extract_photo_from_features(
record: dict,
candidate_keys: tuple[str, ...],
) -> str:
"""
从 record['features'](JSON 字符串或 dict)中提取图片 URL。
候选 key 命中 features 中第一个非空值则返回。
"""
feat = record.get("features")
if isinstance(feat, str):
feat_str = feat.strip()
if not feat_str or feat_str[0] not in "{[":
return ""
try:
feat = json.loads(feat_str)
except (ValueError, TypeError):
return ""
if not isinstance(feat, dict):
return ""
for key in candidate_keys:
val = feat.get(key)
if val in (None, "", [], {}):
continue
if isinstance(val, list) and val:
return str(val[0]).strip()
s = str(val).strip()
if "," in s:
return s.split(",")[0].strip()
return s
return ""
def _extract_boss_remark(record: dict) -> str:
"""
管理员修改备注,与 BossCheckRemarkOriginData 对齐。
Java 逻辑:features.getBossRemark()。
"""
return str(_extract_field(record, (
"bossRemark", "boss_remark",
"approveRemark", "approve_remark",
"adminModifyRemark", "admin_modify_remark",
)) or "").strip()
def _extract_boss_photo(record: dict, photo_index: int) -> str:
"""
管理员修改备注图片(1/2/3),与 BossCheckPhoto1/2/3OriginData 对齐。
Java 逻辑:features.getBossPhotos(),按 index 取对应张。
photo_index: 0-based 索引(0=图片1, 1=图片2, 2=图片3
"""
boss_photos = record.get("bossPhotos") or record.get("boss_photos")
if isinstance(boss_photos, list):
if photo_index < len(boss_photos):
return str(boss_photos[photo_index]).strip()
return ""
if isinstance(boss_photos, str) and boss_photos.strip():
parts = [p.strip() for p in boss_photos.split(",") if p.strip()]
return parts[photo_index] if photo_index < len(parts) else ""
# 降级:尝试独立字段
val = _extract_field(record, (
f"bossPhoto{photo_index + 1}", f"boss_photo_{photo_index + 1}",
))
return str(val).strip() if val else ""
# ─────────────────────────────────────────────────────────────────────────────
# 关联合并 check result + check record → 明细行
# ─────────────────────────────────────────────────────────────────────────────
def _build_result_index(
check_results: list[dict],
) -> dict[tuple[str, str], list[dict]]:
"""
把 check result 按 (userId, 打卡时间 YYYY-MM-DD HH:mm:ss) 建索引,
用于关联打卡流水获取考勤时间和打卡结果。
"""
index: dict[tuple[str, str], list[dict]] = {}
for rec in check_results:
uid = str(_extract_field(rec, ("userId", "userid", "user_id")) or "")
raw_time = _extract_field(rec, (
"userCheckTime", "user_check_time", "checkTime", "baseCheckTime",
))
time_key = _humanize_timestamp(raw_time) if raw_time else "_unknown"
key = (uid, time_key)
index.setdefault(key, []).append(rec)
return index
def build_record_rows(
check_records: list[dict],
check_results: list[dict],
user_info_map: dict[str, cmn.UserInfo],
group_name_map: dict[str, str],
) -> list[dict[str, str]]:
"""
以 check record(打卡流水)为主表构建明细行。
每条打卡流水记录输出一行,只展示有实际打卡的记录。
通过打卡时间关联 check result 获取"考勤时间""打卡结果"
列顺序与 Diamond 配置 termId 8-20 对齐,各字段逻辑与 Java DataProvider 一致。
返回每行一个 dictkey 与 ALL_HEADERS 对齐。
"""
result_index = _build_result_index(check_results)
rows: list[dict[str, str]] = []
for record in check_records:
uid = str(_extract_field(record, ("userId", "userid", "user_id")) or "")
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
# ── 打卡时间(实际打卡时间,OriginUserCheckTimePlug)────────────────
actual_time_raw = _extract_field(record, (
"userCheckTime", "user_check_time", "checkTime",
))
actual_time = _humanize_timestamp(actual_time_raw)
# ── 关联 check result 获取"考勤时间"和"打卡结果" ──────────────────
time_key = actual_time if actual_time else "_unknown"
matched_results = result_index.get((uid, time_key), [])
result_rec = matched_results[0] if matched_results else {}
# 考勤时间 = 班次规定的应打卡时间(OriginPlanCheckTimePlug
plan_time_raw = _extract_field(result_rec, (
"planCheckTime", "plan_check_time", "baseCheckTime",
)) if result_rec else None
plan_time = _humanize_timestamp(plan_time_raw) if plan_time_raw else ""
# 打卡结果(OriginUserCheckResultPlug):英文枚举 → 中文
raw_check_result = str(_extract_field(result_rec, (
"checkResult", "check_result", "timeResult", "result",
)) or "") if result_rec else ""
check_result_str = _translate_check_result(raw_check_result)
# ── 打卡设备(OriginUserDevicePlug):sourceType 枚举 → 中文 ────────
raw_source_type = str(_extract_field(record, (
"sourceType", "source_type", "deviceType", "device_type",
)) or "")
device_str = _translate_source_type(raw_source_type)
row: dict[str, str] = {
# 基础信息
"姓名": info.name or uid,
"考勤组": group_name_map.get(uid, ""),
"部门": info.dept_name,
# termId=8 考勤时间(OriginPlanCheckTimePlug
"考勤日期": _extract_date_str(record),
"考勤时间": plan_time,
# termId=9 打卡时间(OriginUserCheckTimePlug
"打卡时间": actual_time,
# termId=10 打卡结果(OriginUserCheckResultPlug
"打卡结果": check_result_str,
# termId=11 打卡地址(OriginUserLocationPlug
# Java 逻辑:spaceName + detailAddr 拼接
"打卡地址": _extract_location(record),
# termId=12 打卡备注(OriginUserRemarkPlug
# Java 逻辑:features.getRemark()
"打卡备注": str(_extract_field(record, (
"remark", "userRemark", "user_remark",
)) or "").strip(),
# termId=13 异常打卡原因(OriginCheckExceptionReasonPlug
# Java 逻辑:features.getInvalidRecordMsg() → 翻译错误码
"异常打卡原因": _extract_exception_reason(record),
# termId=14 打卡图片1OriginRemarkPhotoPlug
# Java 逻辑:features.getRemarkPhotos()[0]
"打卡图片1": _extract_remark_photo(record),
# termId=15 打卡图片2OriginFaceCheckPhotoPlug
# Java 逻辑:features.getFacePhoto()
"打卡图片2": _extract_face_check_photo(record),
# termId=16 打卡设备(OriginUserDevicePlug
# Java 逻辑:SourceType 枚举 → 中文
"打卡设备": device_str,
# termId=17 管理员修改备注(OriginBossCheckRemarkPlug
# Java 逻辑:features.getBossRemark()
"管理员修改备注": _extract_boss_remark(record),
# termId=18/19/20 管理员修改备注图片1/2/3OriginBossCheckPhoto1/2/3Plug
# Java 逻辑:features.getBossPhotos()[0/1/2]
"管理员修改备注图片1": _extract_boss_photo(record, 0),
"管理员修改备注图片2": _extract_boss_photo(record, 1),
"管理员修改备注图片3": _extract_boss_photo(record, 2),
}
rows.append(row)
return rows
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
# 1. 解析参数
raw_ids = [u.strip() for u in args.users.split(",") if u.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
# 自动识别部门ID并展开为员工userId
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效的员工userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)}")
try:
start = cmn.parse_datetime_arg(args.start, end_of_day=False)
end = cmn.parse_datetime_arg(args.end, end_of_day=True)
except ValueError as exc:
cmn.error(str(exc))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
# 2. 解析 userId → 用户信息(使用 resolve_user_info,已适配 labels 职位提取)
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
# 3. 切批 + 切片(明细用 100 人/批、31 天/片)
user_batches = cmn.chunk_users(user_ids, size=CHECK_MAX_USERS_PER_BATCH)
date_slices = cmn.slice_date_range(start, end, max_days=CHECK_MAX_DAYS_PER_SLICE)
stats = cmn.CallStats(
user_batches=len(user_batches),
date_slices=len(date_slices),
)
cmn.log(f"[plan] 共 {len(user_batches)}× {len(date_slices)} 个时间片")
# 4. 拉数据:check record(打卡流水)+ check result(用于关联考勤时间和打卡结果)
inspected_result_flag = [False]
inspected_record_flag = [False]
all_check_results: list[dict] = []
all_check_records: list[dict] = []
for batch_idx, batch in enumerate(user_batches, start=1):
for slice_idx, date_slice in enumerate(date_slices, start=1):
cmn.log(f"[batch {batch_idx}/{len(user_batches)}] "
f"[slice {slice_idx}/{len(date_slices)}]")
results = query_check_results(
batch, date_slice, stats,
inspect=args.inspect, inspected_flag=inspected_result_flag,
)
all_check_results.extend(results)
records = query_check_records(
batch, date_slice, stats,
inspect=args.inspect, inspected_flag=inspected_record_flag,
)
all_check_records.extend(records)
cmn.log(f"[data] check result: {len(all_check_results)} 条, "
f"check record: {len(all_check_records)}")
if not all_check_records:
stats.add_warning("查询完成,但未得到任何打卡流水记录")
# 5. 获取考勤组信息(通过 group API 反向映射 userId → 考勤组名称)
group_name_map = cmn.extract_group_names_from_records(all_check_records, user_ids)
# 6. 构建明细行(以 check record 为主表,关联 check result 获取考勤时间和打卡结果)
detail_rows = build_record_rows(
all_check_records, all_check_results, user_info_map, group_name_map,
)
# 7. 写 Excel
rows_2d = [[row.get(h, "") for h in ALL_HEADERS] for row in detail_rows]
out_name = args.out or cmn.build_output_filename(start, end, suffix="detail")
title = (
f"考勤明细展示 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"{end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
# 图片嵌入参数:默认开启,--no-images 关闭
image_columns = None if args.no_images else IMAGE_COLUMN_NAMES
image_size = _parse_image_size(args.image_size)
try:
cmn.write_excel(
out_name, ALL_HEADERS, rows_2d,
sheet_name="考勤明细",
title=title,
subtitle=subtitle,
image_columns=image_columns,
image_size=image_size,
)
except RuntimeError as exc:
cmn.error(str(exc))
return 1
# 8. 摘要
cmn.print_summary(
granularity_label="明细(打卡流水)",
out_path=out_name,
user_count=len(user_ids),
column_names=CHECK_HEADERS,
start=start,
end=end,
rows_count=len(rows_2d),
stats=stats,
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,758 @@
#!/usr/bin/env python3
"""
考勤报表导出 — 月度汇总粒度
[AI Agent 强制门禁] 调用本脚本前必须先阅读:
references/attendance-report.md
本脚本仅是"考勤报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md
包含但不限于:
- 阶段 0:报表类型判断(默认月度汇总)
- 阶段 1:人员列表获取(aisearch person / contact dept list-members
- 阶段 2:列选择(是否传 --column-keywords
- 阶段 3:调用本脚本
- 阶段 4:结果回传给用户的标准格式
- 错误处理(403 权限、HSF_ILLEGALPARAMS、空数据等)
[严禁] 仅凭本脚本 docstring 或 --help 输出就直接拼命令执行,会导致:
- 报表数据不全 / 列错位 / 人员遗漏
- 错误处理缺失,把环境错误当业务错误反馈给用户
- 输出格式不规范,用户体验差
按人按字段汇总,每人一行(如:迟到 5 次、加班 32 小时、出勤 21 天)。
聚合策略:
- 数值字段(看起来是 int/float)→ 求和
- 时长字段(字段名含"时长"且值为数字)→ 求和(保留单位语义)
- 字符串/枚举字段(如出勤状态)→ 计数(distinct value → count
- 日期字段 → 计数(去重日期 → 出勤天数)
- 复杂字段(dict/list)→ 拼接(最多 5 条)
用法:
python attendance_report_monthly.py \
--users userId1,userId2,... \
--start "2026-03-01 00:00:00" \
--end "2026-03-31 23:59:59" \
[--columns 1001,1002]
[--column-keywords "迟到次数,加班时长"]
[--out attendance_report_2026-03-01_2026-03-31_monthly.xlsx]
[--inspect]
"""
from __future__ import annotations
import argparse
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
import attendance_report_common as cmn
# 默认关注字段 — 与 SKILL.md「月度汇总预定义列集合」严格对齐(共 20 个)
# 字段名必须和 `dws attendance report columns` 返回的 name 精确匹配
DEFAULT_KEYWORDS = [
"出勤天数",
"休息天数",
"工作时长",
"迟到次数",
"迟到时长",
"严重迟到次数",
"严重迟到时长",
"旷工迟到次数",
"早退次数",
"早退时长",
"上班缺卡次数",
"下班缺卡次数",
"旷工天数",
"出差时长",
"外出时长",
"请假",
"加班-审批单统计",
"考勤结果",
]
# 每日维度字段 — 这些字段在月度汇总中不做聚合,而是按天展开成多列
DAILY_EXPAND_FIELDS = {"考勤结果"}
# 日历表指标 — sheet2"日历表"展示的 3 行指标
# 这 3 个字段会被 resolve_columns 强制追加到查询字段集中(即使用户的 --column-keywords 没包含),
# 否则日历表会是空的。
# 注意:这 3 个字段名必须和 dws attendance report columns 返回的 name 严格一致。
CALENDAR_METRICS: tuple[str, ...] = ("班次名称", "考勤结果", "工作时长")
# 请假字段 — 触发"按假期类型展开"的字段名
# 不参与 query-data 查询,单独走 query-leave 接口,按 4 类假期展开为多列
# 注意:钉钉接口实际返回的字段名可能是 "请假"、"请假分类"、"请假时长" 等,
# 凡以 "请假" 开头的都视为请假字段,统一替换为 4 列假期类型展开。
LEAVE_FIELD_NAME = "请假"
LEAVE_TYPES: tuple[str, ...] = ("事假", "调休", "病假", "年假")
def _is_leave_field(name: str) -> bool:
"""判断一个字段名是否属于"请假"系列(如 请假 / 请假分类 / 请假时长)。"""
return isinstance(name, str) and name.startswith(LEAVE_FIELD_NAME)
# 工作日期字段的候选 key(按优先级试探)
DATE_KEY_CANDIDATES = (
"workDate", "work_date", "userCheckDate", "checkDate",
"date", "day", "工作日期",
)
# ─────────────────────────────────────────────────────────────────────────────
# 参数解析
# ─────────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=(
"导出考勤报表 — 月度汇总粒度。"
"[强制] AI Agent 必须先读 references/attendance-report.md 再调用本脚本,"
"禁止凭 --help 或脚本路径自行拼命令。"
),
)
p.add_argument("--users", required=True,
help="userId 列表,逗号分隔(必填)")
p.add_argument("--start", required=True,
help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--end", required=True,
help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--columns", default="",
help="字段 ID 列表,逗号分隔;与 --column-keywords 二选一")
p.add_argument("--column-keywords", default="",
help="字段名关键词,逗号分隔;不传则走默认字段集")
p.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
p.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
return p.parse_args()
# ─────────────────────────────────────────────────────────────────────────────
# 字段解析(与 detail 一致)
# ─────────────────────────────────────────────────────────────────────────────
def _ensure_calendar_metrics(
matched: list[dict],
all_cols: list[dict],
) -> list[dict]:
"""
确保 CALENDAR_METRICS 中的 3 个指标字段(班次名称/考勤结果/工作时长)
出现在最终查询字段集中(即使用户传入的 --column-keywords 没匹配到)。
日历表 sheet2 强依赖这 3 个字段,缺一不可。
"""
existing_names = {c["_column_name"] for c in matched}
name_to_col: dict[str, dict] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None and name:
name_to_col[str(name)] = {
"_column_id": str(cid),
"_column_name": str(name),
}
appended: list[str] = []
for metric_name in CALENDAR_METRICS:
if metric_name in existing_names:
continue
col = name_to_col.get(metric_name)
if col is None:
cmn.log(
f"[calendar] 警告:月历指标字段「{metric_name}」在"
f" report columns 中未找到,月历对应行可能为空"
)
continue
matched.append(col)
appended.append(metric_name)
if appended:
cmn.log(f"[calendar] 已强制追加月历指标字段:{appended}")
return matched
def resolve_columns(args: argparse.Namespace) -> list[dict]:
all_cols_payload = cmn.run_dws(["attendance", "report", "columns"])
all_cols = cmn.extract_records(all_cols_payload)
if args.columns.strip():
cids = [c.strip() for c in args.columns.split(",") if c.strip()]
id_to_name: dict[str, str] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None:
id_to_name[str(cid)] = str(name) if name else str(cid)
matched = [{"_column_id": cid, "_column_name": id_to_name.get(cid, cid)}
for cid in cids]
return _ensure_calendar_metrics(matched, all_cols)
keywords = (
[k.strip() for k in args.column_keywords.split(",") if k.strip()]
if args.column_keywords.strip()
else DEFAULT_KEYWORDS
)
cmn.log(f"[columns] 使用关键词匹配字段:{keywords}")
cmn.log(f"[columns] dws 返回 {len(all_cols)} 个字段")
matched = cmn.match_columns_by_keywords(all_cols, keywords)
if not matched:
raise RuntimeError(
f"未匹配到任何字段。可用字段示例:"
f"{[cmn._first_nonempty(c, ('name','columnName','title','label')) for c in all_cols[:10]]}"
)
cmn.log(f"[columns] 匹配到 {len(matched)} 个字段:{[c['_column_name'] for c in matched]}")
return _ensure_calendar_metrics(matched, all_cols)
# ─────────────────────────────────────────────────────────────────────────────
# 接口调用(与 detail 一致)
# ─────────────────────────────────────────────────────────────────────────────
def query_one_batch(
user_batch: list[str],
column_ids: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
column_id_to_name: dict[str, str] | None = None,
inspect: bool = False,
inspected_flag: list[bool] = None,
) -> list[dict]:
cmn.log(
f"[query] users={len(user_batch)} cols={len(column_ids)} "
f"slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "report", "query-data",
"--users", ",".join(user_batch),
"--columns", ",".join(column_ids),
"--start", date_slice.start_str,
"--end", date_slice.end_str,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as e:
stats.total_dws_calls += 1
stats.failed_calls += 1
if e.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法导出考勤报表。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from e
stats.add_warning(f"[query failed] {date_slice.label}: {e}")
return []
records = cmn.extract_records(payload)
# 展平 report query-data 返回的嵌套 values 结构
records = cmn.flatten_query_data_records(records, column_id_to_name)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "query-data (flattened)")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 日期提取(复用 daily 脚本的逻辑)
# ─────────────────────────────────────────────────────────────────────────────
def _normalize_date(raw: Any) -> str | None:
"""把任意形态的日期值归一化为 YYYY-MM-DD 字符串。"""
if raw is None:
return None
if isinstance(raw, (int, float)) and 1_000_000_000_000 <= raw <= 9_999_999_999_999:
try:
return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
if isinstance(raw, (int, float)) and 1_000_000_000 <= raw <= 9_999_999_999:
try:
return datetime.fromtimestamp(raw).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
s = str(raw).strip()
if not s:
return None
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
head = s[:10]
try:
datetime.strptime(head, cmn.DATE_FMT)
return head
except ValueError:
return None
return None
def _extract_work_date(record: dict, columns: list[dict]) -> str | None:
"""从一条记录里提取工作日期(YYYY-MM-DD 格式)。"""
candidates: list[Any] = []
for key in DATE_KEY_CANDIDATES:
if key in record and record[key] not in (None, ""):
candidates.append(record[key])
for col in columns:
if "日期" in col["_column_name"] or "date" in col["_column_name"].lower():
v = _value_for_column(record, col)
if v not in (None, ""):
candidates.append(v)
for raw in candidates:
date_str = _normalize_date(raw)
if date_str:
return date_str
return None
def _generate_date_columns(start: datetime, end: datetime) -> list[str]:
"""根据日期范围生成按天展开的列标签列表,格式为日号(如 '1', '2', ...)。"""
dates: list[str] = []
current = start.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end.replace(hour=0, minute=0, second=0, microsecond=0)
while current <= end_date:
dates.append(current.strftime(cmn.DATE_FMT))
current += timedelta(days=1)
return dates
# ─────────────────────────────────────────────────────────────────────────────
# 月度聚合
# ─────────────────────────────────────────────────────────────────────────────
def _value_for_column(record: dict, col: dict) -> Any:
"""从一条原始记录里取某个字段的值(命名顺位试探)。"""
cname, cid = col["_column_name"], col["_column_id"]
for key in (cname, cid, f"col_{cid}", f"column_{cid}"):
if key in record:
return record[key]
return None
def _try_number(value: Any) -> float | None:
"""尝试把 value 解析为数字;不能则返回 None。"""
if value is None or value == "":
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
s = value.strip()
try:
return float(s)
except ValueError:
return None
return None
def _user_id_of(record: dict) -> str | None:
uid = cmn._first_nonempty(record, ("userId", "userid", "user_id", "targetUserId"))
return str(uid) if uid is not None else None
def aggregate_monthly(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_name_map: dict[str, str],
) -> tuple[list[dict[str, Any]], dict[str, dict[str, dict[str, str]]]]:
"""
按 userId 分组聚合:
- 普通字段(数值/非数值):按原聚合策略处理
- DAILY_EXPAND_FIELDS 中的字段(如"考勤结果"):按 (userId, date) 存储,不聚合
返回:
- rows: 每人一行的聚合结果(不含按天展开字段)
- daily_data: {field_name: {userId: {date_str: value}}}
"""
# 识别哪些列需要按天展开
expand_col_names = {col["_column_name"] for col in columns
if col["_column_name"] in DAILY_EXPAND_FIELDS}
agg_columns = [col for col in columns if col["_column_name"] not in expand_col_names]
# 聚合累加器(仅普通字段)
agg: dict[str, dict[str, dict]] = defaultdict(
lambda: {col["_column_name"]: {"sum": 0.0, "count": 0, "non_numeric": set()}
for col in agg_columns}
)
# 按天展开数据:field_name → userId → date_str → value
daily_data: dict[str, dict[str, dict[str, str]]] = {
fname: defaultdict(dict) for fname in expand_col_names
}
for record in all_records:
uid = _user_id_of(record)
if uid is None:
continue
work_date = _extract_work_date(record, columns)
# 按天展开字段
for fname in expand_col_names:
matching_col = next((c for c in columns if c["_column_name"] == fname), None)
if matching_col and work_date:
raw = _value_for_column(record, matching_col)
if raw not in (None, ""):
daily_data[fname][uid][work_date] = str(raw)
# 普通字段聚合
for col in agg_columns:
cname = col["_column_name"]
raw = _value_for_column(record, col)
num = _try_number(raw)
if num is not None:
agg[uid][cname]["sum"] += num
agg[uid][cname]["count"] += 1
elif raw not in (None, ""):
agg[uid][cname]["non_numeric"].add(str(raw))
rows: list[dict[str, Any]] = []
for uid in user_ids:
row: dict[str, Any] = {
"userId": uid,
"userName": user_name_map.get(uid, uid),
}
bucket = agg.get(uid, {})
for col in agg_columns:
cname = col["_column_name"]
cell = bucket.get(cname)
if not cell or (cell["count"] == 0 and not cell["non_numeric"]):
row[cname] = ""
elif cell["count"] > 0 and not cell["non_numeric"]:
total = cell["sum"]
row[cname] = int(total) if total == int(total) else round(total, 2)
elif cell["count"] == 0 and cell["non_numeric"]:
vals = sorted(cell["non_numeric"])
preview = "/".join(vals[:5]) + ("" if len(vals) > 5 else "")
row[cname] = f"{len(vals)} 种:{preview}"
else:
total = cell["sum"]
num_part = int(total) if total == int(total) else round(total, 2)
vals = sorted(cell["non_numeric"])
preview = "/".join(vals[:3])
row[cname] = f"{num_part}(另含非数值:{preview}"
rows.append(row)
return rows, daily_data
# ─────────────────────────────────────────────────────────────────────────────
# 日历表(sheet2)构建
# ─────────────────────────────────────────────────────────────────────────────
def _build_calendar_value_map(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
) -> dict[str, dict[str, dict[str, str]]]:
"""
从 all_records 中按 (uid, date, metric_name) 提取 CALENDAR_METRICS 的值。
返回: {uid: {date_str: {metric_name: value_str}}}
注:同一 (uid, date, metric) 若有多条记录,取最后一条非空值(query-data 同日同字段
通常只返回一条)。
"""
valid_user_ids = set(user_ids)
metric_cols: dict[str, dict] = {}
for col in columns:
if col["_column_name"] in CALENDAR_METRICS:
metric_cols[col["_column_name"]] = col
result: dict[str, dict[str, dict[str, str]]] = {}
for record in all_records:
uid = _user_id_of(record)
if uid is None or uid not in valid_user_ids:
continue
work_date = _extract_work_date(record, columns)
if not work_date:
continue
for metric_name, col in metric_cols.items():
raw = _value_for_column(record, col)
if raw in (None, ""):
continue
uid_bucket = result.setdefault(uid, {})
date_bucket = uid_bucket.setdefault(work_date, {})
date_bucket[metric_name] = str(raw)
return result
def build_calendar_sheet(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_info_map: dict[str, "cmn.UserInfo"],
group_name_map: dict[str, str],
start: datetime,
end: datetime,
) -> dict:
"""
构建日历表 sheet2 的描述 dict(供 write_excel_multi_sheets 使用)。
布局(参考钉钉考勤月历):
列:姓名 | 考勤组 | 部门 | 指标 | 1日 | 2日 | ... | N日
每个用户占 3 行(班次名称 / 考勤结果 / 工作时长)
基础列(前 3 列)做纵向 3 行合并
返回的 sheet dict 包含 merge_groups 配置,让 write_excel_multi_sheets
自动完成基础列合并。
"""
all_dates = _generate_date_columns(start, end)
# 表头:基础列 + 指标列 + 日期列
headers = ["姓名", "考勤组", "部门", "指标"] + [
f"{datetime.strptime(d, cmn.DATE_FMT).day}" for d in all_dates
]
# 抽取每个 (uid, date, metric) 的值
value_map = _build_calendar_value_map(all_records, columns, user_ids)
rows: list[list[Any]] = []
merge_groups: list[tuple[int, int, int]] = []
attend_result_row_offsets: set[int] = set()
n_metrics = len(CALENDAR_METRICS)
for uid in user_ids:
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
base_cells = [info.name or uid, group_name, info.dept_name]
block_start = len(rows) # 当前用户首行的 row_offset
for metric_name in CALENDAR_METRICS:
row_cells: list[Any] = list(base_cells) + [metric_name]
for date_str in all_dates:
val = value_map.get(uid, {}).get(date_str, {}).get(metric_name, "")
row_cells.append(val)
if metric_name == "考勤结果":
attend_result_row_offsets.add(len(rows))
rows.append(row_cells)
block_end = len(rows) - 1 # 当前用户末行的 row_offset
if block_end > block_start:
# 基础列 = 前 3 列(姓名/考勤组/部门),需纵向合并
merge_groups.append((block_start, block_end, 3))
title = (
f"日历表 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"{end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
return {
"name": "日历表",
"headers": headers,
"rows": rows,
"title": title,
"subtitle": subtitle,
"merge_groups": merge_groups,
"attend_result_rows": attend_result_row_offsets or None,
}
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
raw_ids = [u.strip() for u in args.users.split(",") if u.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
# 自动识别部门ID并展开为员工userId
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效的员工userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)}")
try:
start = cmn.parse_datetime_arg(args.start, end_of_day=False)
end = cmn.parse_datetime_arg(args.end, end_of_day=True)
except ValueError as e:
cmn.error(str(e))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
try:
columns = resolve_columns(args)
except cmn.DwsCallError as e:
if e.is_permission_error:
cmn.error("权限错误:当前账号无管理员权限,无法获取考勤字段列表。")
return 2
cmn.error(f"获取字段列表失败:{e}")
return 1
except RuntimeError as e:
cmn.error(str(e))
return 1
column_ids = [c["_column_id"] for c in columns]
column_names = [c["_column_name"] for c in columns]
column_id_to_name = {c["_column_id"]: c["_column_name"] for c in columns}
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
user_name_map = {uid: info.name or uid for uid, info in user_info_map.items()}
user_batches = cmn.chunk_users(user_ids)
date_slices = cmn.slice_date_range(start, end)
stats = cmn.CallStats(
user_batches=len(user_batches),
date_slices=len(date_slices),
)
cmn.log(
f"[plan] 共 {len(user_batches)}× {len(date_slices)} 个时间片 "
f"= {len(user_batches) * len(date_slices)} 次接口调用"
)
inspected_flag = [False]
all_records: list[dict] = []
for bi, batch in enumerate(user_batches, start=1):
for si, dslice in enumerate(date_slices, start=1):
cmn.log(f"[batch {bi}/{len(user_batches)}] [slice {si}/{len(date_slices)}]")
records = query_one_batch(
batch, column_ids, dslice, stats,
column_id_to_name=column_id_to_name,
inspect=args.inspect,
inspected_flag=inspected_flag,
)
all_records.extend(records)
if not all_records:
stats.add_warning("查询完成,但未得到任何记录")
# 从原始记录中提取每个用户的考勤组名称
group_name_map = cmn.extract_group_names_from_records(all_records, user_ids)
# 月度聚合(普通字段聚合 + 每日维度字段按天存储)
rows_dict, daily_data = aggregate_monthly(all_records, columns, user_ids, user_name_map)
# 请假数据特殊处理:通过 query-leave 单独查询,按 4 类假期月度求和
# 凡是 "请假" 开头的字段(请假 / 请假分类 / 请假时长 等)都视为请假列
leave_in_columns = any(_is_leave_field(name) for name in column_names)
leave_data: dict[str, dict[str, dict[str, float]]] = {}
if leave_in_columns:
try:
leave_data = cmn.query_leave_data(
user_ids, start, end,
leave_names=LEAVE_TYPES,
stats=stats,
)
except cmn.DwsCallError as e:
stats.add_warning(f"[leave] 查询请假数据失败:{e}")
# 生成日期范围内所有日期列表
all_dates = _generate_date_columns(start, end)
# 构建表头:基础列 + 普通聚合字段(剔除"请假*"系列和按天展开字段)+ 请假展开列 + 按天展开字段
base_headers = ["姓名", "考勤组", "部门"]
agg_column_names = [
name for name in column_names
if name not in DAILY_EXPAND_FIELDS and not _is_leave_field(name)
]
# 请假按假期类型展开(如 "请假-事假", "请假-调休", ...
leave_headers: list[str] = []
if leave_in_columns:
leave_headers = [f"{LEAVE_FIELD_NAME}-{lt}" for lt in LEAVE_TYPES]
# 按天展开的表头:字段名-日号(如 "考勤结果-1日", "考勤结果-2日", ...
expand_headers: list[str] = []
expand_date_map: list[tuple[str, str]] = [] # [(field_name, date_str), ...]
for fname in column_names:
if fname in DAILY_EXPAND_FIELDS:
for date_str in all_dates:
day_num = datetime.strptime(date_str, cmn.DATE_FMT).day
header_label = f"{fname}-{day_num}"
expand_headers.append(header_label)
expand_date_map.append((fname, date_str))
headers = base_headers + agg_column_names + leave_headers + expand_headers
# 计算考勤结果列的 0-based 列索引集合(供 Excel 条件配色使用)
_expand_col_start = len(base_headers) + len(agg_column_names) + len(leave_headers)
attend_result_col_indices: set[int] = set()
for i, (fname, _date) in enumerate(expand_date_map):
if fname == "考勤结果":
attend_result_col_indices.add(_expand_col_start + i)
rows_2d = []
for row in rows_dict:
uid = row.get("userId", "")
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
base = [info.name or uid, group_name, info.dept_name]
agg_data = [row.get(h, "") for h in agg_column_names]
# 请假按假期类型聚合(月度求和)
leave_row: list[Any] = []
if leave_in_columns:
user_leave = leave_data.get(uid, {})
for lt in LEAVE_TYPES:
total = 0.0
for day_bucket in user_leave.values():
total += day_bucket.get(lt, 0.0)
if total == 0.0:
leave_row.append("")
elif total == int(total):
leave_row.append(int(total))
else:
leave_row.append(round(total, 2))
# 按天展开字段的数据
expand_data = []
for fname, date_str in expand_date_map:
value = daily_data.get(fname, {}).get(uid, {}).get(date_str, "")
expand_data.append(value)
rows_2d.append(base + agg_data + leave_row + expand_data)
out_name = args.out or cmn.build_output_filename(start, end, suffix="monthly")
title = (
f"月度汇总展示 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"{end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
# sheet1:月度汇总(每人一行)
summary_sheet = {
"name": "月度汇总",
"headers": headers,
"rows": rows_2d,
"title": title,
"subtitle": subtitle,
"attend_result_columns": attend_result_col_indices or None,
}
# sheet2:日历表(每人 3 行:班次名称 / 考勤结果 / 工作时长,按日期展开)
calendar_sheet = build_calendar_sheet(
all_records, columns, user_ids,
user_info_map, group_name_map,
start, end,
)
try:
cmn.write_excel_multi_sheets(out_name, [summary_sheet, calendar_sheet])
except (RuntimeError, ValueError) as e:
cmn.error(str(e))
return 1
cmn.print_summary(
granularity_label="月度汇总",
out_path=out_name,
user_count=len(user_ids),
column_names=column_names,
start=start,
end=end,
rows_count=len(rows_2d),
stats=stats,
extra_tail=(
"[提示] 数值字段已求和;"
"「考勤结果」按天展开为多列(每天一列显示当天考勤状态)。\n"
"[提示] 已附加第二个 sheet「日历表」:每人 3 行(班次名称/考勤结果/工作时长),"
"按日期横向展开,基础列(姓名/考勤组/部门)已纵向合并。"
),
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,947 @@
#!/usr/bin/env python3
"""
考勤记录报表导出脚本 — 补卡/出差/外出/请假
属于考勤报表导出体系,和 attendance_report_detail.py / attendance_report_monthly.py 平级。
Agent 负责意图判断和人员获取,本脚本自包含:数据查询 → 解析 → Excel 生成。
数据链路:
1. dws attendance approve list --users <ids> --types <type> --start --end
→ 获取审批单摘要(含 originId = processInstanceId
2. dws oa approval detail --instance-id <originId>
→ 获取审批单完整表单字段(extValue / detailList
3. 解析 formValueVOS 中的 DDHolidayField / extValue → 按天拆分行
4. write_excel 输出
用法:
python attendance_report_record.py --type leave --users <userId1,userId2> --start 2026-04-01 --end 2026-04-30
python attendance_report_record.py --type trip --users <userId1,userId2> --start 2026-04-01 --end 2026-04-30
python attendance_report_record.py --type out --users <userId1> --start 2026-05-01 --end 2026-05-31
python attendance_report_record.py --type patch --users <userId1> --start 2026-05-01 --end 2026-05-31
支持类型: leave(请假), trip(出差), out(外出), patch(补卡)
"""
from __future__ import annotations
import argparse
import json
import sys
import os
from datetime import datetime
from typing import Any
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from attendance_report_common import (
run_dws,
write_excel,
resolve_user_names,
resolve_user_info,
UserInfo,
log,
warn,
error,
DwsCallError,
DATE_FMT,
)
# ─────────────────────────────────────────────────────────────────────────────
# 常量
# ─────────────────────────────────────────────────────────────────────────────
SUPPORTED_TYPES = ("leave", "trip", "out", "patch")
COLUMNS: dict[str, list[str]] = {
"leave": ["姓名", "考勤组", "部门", "工号", "职位", "假期类型", "请假时间",
"请假时长(小时)", "请假时长(天)", "关联审批单", "审批单状态"],
"trip": ["姓名", "考勤组", "部门", "工号", "职位", "出差时间",
"出差时长", "出差单位", "关联审批单", "审批单状态"],
"out": ["姓名", "考勤组", "部门", "工号", "职位", "外出申请时间",
"外出时长(小时)", "外出时长(天)", "关联审批单", "审批单状态"],
"patch": ["姓名", "考勤组", "部门", "工号", "职位", "考勤日期", "考勤时间",
"原打卡时间", "原考勤状态", "补卡时间", "补卡结果", "关联审批单", "审批单状态"],
}
SHEET_NAMES: dict[str, str] = {
"leave": "请假记录",
"trip": "出差记录",
"out": "外出记录",
"patch": "补卡记录",
}
STATUS_MAP: dict[str, dict[str, str]] = {
"COMPLETED": {"agree": "审批通过", "refuse": "已拒绝"},
"RUNNING": {"": "审批中"},
"TERMINATED": {"": "已撤销"},
}
APPROVE_LIST_BATCH_SIZE = 50 # attendance approve list 单次最多用户数
# 审批详情页 URL 模板
# 内层:aflow 审批详情页
_AFLOW_URL_TEMPLATE = (
"https://aflow.dingtalk.com/dingtalk/mobile/homepage.htm"
"?corpid={corp_id}&dd_share=false&showmenu=true&back=native"
"#/approval?procInstId={instance_id}"
)
# 外层:dingtalk schema 协议,在钉钉客户端侧边面板打开
_DINGTALK_SCHEMA_TEMPLATE = (
"dingtalk://dingtalkclient/action/openapp"
"?corpid={corp_id}&container_type=slide_panel&app_id=-4"
"&&redirect_url={encoded_url}"
)
class HyperlinkCell:
"""标记单元格为超链接:Excel 中显示 label 文本,点击跳转到 url。"""
__slots__ = ("label", "url")
def __init__(self, label: str, url: str):
self.label = label
self.url = url
def __str__(self) -> str:
return self.label
def build_approve_url(corp_id: str, instance_id: str) -> str:
"""
构建审批单跳转链接(dingtalk:// schema)。
结构:外层 dingtalk schema 打开钉钉侧边面板,内部 redirect 到 aflow 审批详情页。
"""
from urllib.parse import quote
inner_url = _AFLOW_URL_TEMPLATE.format(corp_id=corp_id, instance_id=instance_id)
encoded_url = quote(inner_url, safe="")
return _DINGTALK_SCHEMA_TEMPLATE.format(corp_id=corp_id, encoded_url=encoded_url)
def build_approve_cell(corp_id: str, instance_id: str, title: str = "") -> HyperlinkCell | str:
"""
构建"关联审批单"列的单元格值。
如果有 corp_id 和 instance_id,返回 HyperlinkCellExcel 中为可点击链接)。
否则返回纯文本。
"""
if not instance_id:
return ""
label = title or instance_id
if not corp_id:
return label
url = build_approve_url(corp_id, instance_id)
return HyperlinkCell(label=label, url=url)
# ─────────────────────────────────────────────────────────────────────────────
# 工具函数
# ─────────────────────────────────────────────────────────────────────────────
def normalize_am_pm(text: str) -> str:
"""将时间文本中的 AM/PM 替换为 上午/下午。"""
return text.replace(" PM", " 下午").replace(" AM", " 上午")
def format_status(status: str, result: str) -> str:
"""将 status + processInstanceResult 转为中文状态。"""
status_upper = (status or "").upper()
result_lower = (result or "").lower()
group = STATUS_MAP.get(status_upper, {})
return group.get(result_lower, group.get("", f"{status}/{result}"))
def ms_to_datetime(ms: int | float | None) -> datetime | None:
"""毫秒时间戳转 datetime。"""
if not ms:
return None
try:
return datetime.fromtimestamp(int(ms) / 1000)
except (OSError, ValueError, OverflowError):
return None
def ms_to_time_str(ms: int | float | None) -> str:
"""毫秒时间戳转 HH:MM。"""
dt = ms_to_datetime(ms)
return dt.strftime("%H:%M") if dt else ""
def ms_to_date_str(ms: int | float | None) -> str:
"""毫秒时间戳转 YYYY-MM-DD。"""
dt = ms_to_datetime(ms)
return dt.strftime(DATE_FMT) if dt else ""
def format_day_type(detail: dict) -> str:
"""从 detailList 单条判断日历类型。"""
day_type = detail.get("dayType", "")
is_rest = detail.get("isRest", False)
if day_type == "workDay" or (not is_rest and not day_type):
return "工作日"
if day_type == "restDay" or is_rest:
return "休息日"
if day_type == "holiday":
return "节假日"
return day_type or ("休息日" if is_rest else "工作日")
def format_class_time(detail: dict) -> str:
"""从 detailList 单条提取上下班时间。"""
class_info = detail.get("classInfo", {})
sections = class_info.get("sections", []) if class_info else []
if not sections:
return "未排班"
section = sections[0]
start_time = ms_to_time_str(section.get("startTime"))
end_time = ms_to_time_str(section.get("endTime"))
if start_time and end_time:
return f"{start_time} ~ {end_time}"
return "未排班"
# ─────────────────────────────────────────────────────────────────────────────
# 数据查询
# ─────────────────────────────────────────────────────────────────────────────
# 钉钉接口把"外出"和"出差"都归类到 tripbizType=2),out 类型查不到数据。
# 脚本通过 tagName 区分:tagName="出差" → triptagName="外出" → out。
_API_TYPE_MAP: dict[str, str] = {
"leave": "leave",
"trip": "trip",
"out": "trip", # 外出也用 trip 查询,再按 tagName 过滤
"patch": "patch",
}
_TAG_FILTER: dict[str, str | None] = {
"leave": None,
"trip": "出差",
"out": "外出",
"patch": None,
}
def fetch_approve_list(user_ids: list[str], record_type: str, start: str, end: str) -> list[dict]:
"""
分批调用 dws attendance approve list 获取审批单摘要。
返回列表中每条包含: userId, tagName, duration, durationUnit, beginTime, endTime, originId。
对于 out 类型,实际用 trip 查询接口,再按 tagName="外出" 过滤;
对于 trip 类型,按 tagName="出差" 过滤(排除外出记录)。
"""
api_type = _API_TYPE_MAP.get(record_type, record_type)
tag_filter = _TAG_FILTER.get(record_type)
all_records: list[dict] = []
for i in range(0, len(user_ids), APPROVE_LIST_BATCH_SIZE):
batch = user_ids[i:i + APPROVE_LIST_BATCH_SIZE]
users_str = ",".join(batch)
try:
result = run_dws([
"attendance", "approve", "list",
"--users", users_str,
"--types", api_type,
"--start", start,
"--end", end,
])
records: list[dict] = []
if isinstance(result, list):
records = result
elif isinstance(result, dict):
records = result.get("approveList", result.get("list", []))
if not isinstance(records, list):
records = []
# 按 tagName 过滤
if tag_filter:
records = [r for r in records if r.get("tagName") == tag_filter]
all_records.extend(records)
except DwsCallError as e:
warn(f"查询审批列表失败(batch {i // APPROVE_LIST_BATCH_SIZE + 1}): {e}")
return all_records
def fetch_detail(instance_id: str) -> dict | None:
"""调用 dws oa approval detail 获取审批单完整详情。"""
try:
result = run_dws([
"oa", "approval", "detail",
"--instance-id", instance_id,
])
return result if isinstance(result, dict) else None
except DwsCallError as e:
warn(f"获取审批详情失败({instance_id[:20]}...): {e}")
return None
# ─────────────────────────────────────────────────────────────────────────────
# 解析器
# ─────────────────────────────────────────────────────────────────────────────
def find_holiday_field(form_values: list[dict]) -> dict | None:
"""从 formValueVOS 中查找 DDHolidayField 组件。"""
for fv in form_values:
if fv.get("componentType") == "DDHolidayField":
return fv
return None
def parse_ext_value(field_data: dict) -> dict:
"""解析字段的 extValue JSON 字符串。"""
ext_str = field_data.get("extValue") or ""
if not ext_str:
return {}
try:
return json.loads(ext_str)
except (json.JSONDecodeError, TypeError):
return {}
def parse_leave_detail(detail: dict, name_map: dict[str, str], *,
user_info_map: dict[str, "UserInfo"] | None = None,
group_map: dict[str, str] | None = None,
corp_id: str = "",
) -> list[list[str]]:
"""解析请假审批单。"""
form_values = detail.get("formValueVOS", [])
user_id = detail.get("originatorUserid", "")
dept_name = detail.get("originatorDeptName", "")
instance_id = detail.get("processInstanceId", "")
status = format_status(detail.get("status", ""), detail.get("processInstanceResult", ""))
# 用户基础信息
info = (user_info_map or {}).get(user_id)
user_name = info.name if info else name_map.get(user_id, user_id)
dept = info.dept_name if info and info.dept_name else dept_name
job_number = info.job_number if info else ""
title = info.title if info else ""
group_name = (group_map or {}).get(user_id, "")
approve_cell = build_approve_cell(corp_id, instance_id, f"{user_name}提交的请假审批单")
holiday_field = find_holiday_field(form_values)
if not holiday_field:
return [[user_name, group_name, dept, job_number, title,
"", "", "", "", approve_cell, status]]
# value: ["开始时间","结束时间",天数,"单位","假期类型","请假类型"]
value_str = holiday_field.get("value", "")
leave_type = ""
leave_time = ""
try:
value_arr = json.loads(value_str)
if isinstance(value_arr, list) and len(value_arr) >= 2:
leave_time = normalize_am_pm(f"{value_arr[0]} ~ {value_arr[1]}")
if len(value_arr) > 4:
leave_type = str(value_arr[4])
except (json.JSONDecodeError, TypeError):
leave_time = normalize_am_pm(value_str)
ext = parse_ext_value(holiday_field)
duration_day = str(ext.get("durationInDay", ""))
duration_hour = str(ext.get("durationInHour", ""))
return [[user_name, group_name, dept, job_number, title,
leave_type, leave_time, duration_hour, duration_day,
approve_cell, status]]
def _extract_time_duration_from_fields(form_values: list[dict]) -> tuple[str, str, str, str]:
"""
从独立表单字段中提取时间范围和时长。
适用于外出/出差表单的非 DDHolidayField 结构:
- startTime (DDDateField) + finishTime (DDDateField) → 时间范围
- duration (NumberField) → extValue 中含 durationInDay / durationInHour
Returns: (time_range, duration_hour, duration_day, ext_from_duration)
"""
start_time = ""
end_time = ""
duration_hour = ""
duration_day = ""
for fv in form_values:
biz_alias = (fv.get("bizAlias") or "").lower()
name = (fv.get("name") or "").lower()
value = fv.get("value") or ""
# 开始时间
if biz_alias in ("starttime", "start_time") or "开始时间" in name:
if value and not start_time:
start_time = value
# 结束时间
if biz_alias in ("finishtime", "finish_time", "endtime", "end_time") or "结束时间" in name:
if value and not end_time:
end_time = value
# 时长字段 — extValue 中有 durationInDay / durationInHour
if biz_alias == "duration" or "时长" in name:
ext = parse_ext_value(fv)
if ext:
duration_day = str(ext.get("durationInDay", ""))
duration_hour = str(ext.get("durationInHour", ""))
time_range = ""
if start_time and end_time:
time_range = f"{start_time} ~ {end_time}"
elif start_time:
time_range = start_time
return time_range, duration_hour, duration_day
def parse_out_detail(detail: dict, name_map: dict[str, str], *,
user_info_map: dict[str, "UserInfo"] | None = None,
group_map: dict[str, str] | None = None,
corp_id: str = "",
) -> list[list[str]]:
"""解析外出审批单。兼容 DDHolidayField 和独立字段两种表单结构。"""
form_values = detail.get("formValueVOS", [])
user_id = detail.get("originatorUserid", "")
dept_name = detail.get("originatorDeptName", "")
instance_id = detail.get("processInstanceId", "")
status = format_status(detail.get("status", ""), detail.get("processInstanceResult", ""))
# 用户基础信息
info = (user_info_map or {}).get(user_id)
user_name = info.name if info else name_map.get(user_id, user_id)
dept = info.dept_name if info and info.dept_name else dept_name
job_number = info.job_number if info else ""
title = info.title if info else ""
group_name = (group_map or {}).get(user_id, "")
approve_cell = build_approve_cell(corp_id, instance_id, f"{user_name}提交的外出审批单")
# 优先尝试 DDHolidayField
holiday_field = find_holiday_field(form_values)
if holiday_field:
value_str = holiday_field.get("value", "")
time_range = ""
try:
value_arr = json.loads(value_str)
if isinstance(value_arr, list) and len(value_arr) >= 2:
time_range = normalize_am_pm(f"{value_arr[0]} ~ {value_arr[1]}")
except (json.JSONDecodeError, TypeError):
time_range = normalize_am_pm(value_str)
ext = parse_ext_value(holiday_field)
duration_day = str(ext.get("durationInDay", ""))
duration_hour = str(ext.get("durationInHour", ""))
else:
# 回退: 从独立字段提取
time_range, duration_hour, duration_day = _extract_time_duration_from_fields(form_values)
return [[user_name, group_name, dept, job_number, title,
time_range, duration_hour, duration_day,
approve_cell, status]]
def parse_trip_from_approve_record(
record: dict,
name_map: dict[str, str],
*,
user_info_map: dict[str, "UserInfo"] | None = None,
group_map: dict[str, str] | None = None,
corp_id: str = "",
) -> list[str]:
"""
直接从 attendance approve list 的记录中解析出差行。
不依赖 oa approval detail(该接口对出差单存在 saNode 类型冲突 bug),
仅使用 approve list 返回的 beginTime/endTime/duration/durationUnit/originId。
"""
user_id = record.get("userId", "")
info = (user_info_map or {}).get(user_id)
user_name = info.name if info else name_map.get(user_id, user_id)
dept = info.dept_name if info else ""
job_number = info.job_number if info else ""
title = info.title if info else ""
group_name = (group_map or {}).get(user_id, "")
begin_ms = record.get("beginTime")
end_ms = record.get("endTime")
begin_str = ms_to_date_str(begin_ms) if begin_ms else ""
end_str = ms_to_date_str(end_ms) if end_ms else ""
time_range = f"{begin_str} ~ {end_str}" if begin_str and end_str else begin_str or end_str
duration = record.get("duration", "")
duration_unit = record.get("durationUnit", "DAY")
unit_str = "" if duration_unit == "DAY" else "小时"
instance_id = record.get("originId", "")
effective_corp_id = corp_id or record.get("corpId", "")
approve_cell = build_approve_cell(effective_corp_id, instance_id, f"{user_name}提交的出差审批单")
# approve list 没有审批状态,有 gmtFinished 说明已完结,视为审批通过
status = "审批通过" if record.get("gmtFinished") else "审批中"
return [user_name, group_name, dept, job_number, title,
time_range, str(duration), unit_str, approve_cell, status]
def fetch_check_results(user_ids: list[str], start: str, end: str) -> dict[str, list[dict]]:
"""
批量查询打卡结果,返回 {userId: [records...]} 映射。
每条 record 含: workDate, timeResult, planCheckTime, userCheckTime 等。
"""
result_map: dict[str, list[dict]] = {}
batch_size = 50
for i in range(0, len(user_ids), batch_size):
batch = user_ids[i:i + batch_size]
try:
result = run_dws([
"attendance", "check", "result",
"--users", ",".join(batch),
"--start", start,
"--end", end,
])
records = []
if isinstance(result, list):
records = result
elif isinstance(result, dict):
records = result.get("result", result.get("list", []))
if not isinstance(records, list):
records = []
for rec in records:
uid = rec.get("userId", "")
if uid:
result_map.setdefault(uid, []).append(rec)
except DwsCallError as e:
warn(f"查询打卡结果失败(batch {i // batch_size + 1}): {e}")
return result_map
def fetch_user_group_map(user_ids: list[str]) -> dict[str, str]:
"""
查询考勤组列表并建立 userId → 考勤组名称映射。
流程:先 group search 拿到所有考勤组 ID+名称,
再对有成员的考勤组调用 filtered-get --member 获取成员列表。
"""
group_map: dict[str, str] = {}
user_id_set = set(user_ids)
try:
result = run_dws(["attendance", "group", "search"])
items: list[dict] = []
if isinstance(result, list):
items = result
elif isinstance(result, dict):
# 适配 {items: [...]} 或 {result: {items: [...]}}
inner = result.get("items", result.get("result", result))
if isinstance(inner, dict):
items = inner.get("items", [])
elif isinstance(inner, list):
items = inner
for g in items:
group_name = g.get("name", g.get("groupName", ""))
group_id = g.get("id", g.get("groupId", ""))
member_count = g.get("memberCount", 0)
if not group_id or not group_name or not member_count:
continue
# 调用 filtered-get 获取成员 userId 列表
try:
detail = run_dws([
"attendance", "group", "filtered-get",
"--group-id", str(group_id), "--member",
])
member_users: list[str] = []
if isinstance(detail, dict):
member_users = detail.get("memberUsers", [])
if not isinstance(member_users, list):
member_users = []
for uid in member_users:
uid_str = str(uid)
if uid_str in user_id_set:
group_map[uid_str] = group_name
except DwsCallError:
pass
except DwsCallError as e:
warn(f"查询考勤组失败: {e}")
return group_map
CHECK_TIME_RESULT_MAP = {
"Normal": "正常",
"Late": "迟到",
"Early": "早退",
"Absenteeism": "旷工",
"NotSigned": "未打卡",
"SeriousLate": "严重迟到",
}
def parse_patch_detail(detail: dict, name_map: dict[str, str], *,
user_info_map: dict[str, "UserInfo"] | None = None,
group_map: dict[str, str] | None = None,
check_result_map: dict[str, list[dict]] | None = None,
corp_id: str = "",
) -> list[list[str]]:
"""解析补卡审批单,输出完整列。"""
form_values = detail.get("formValueVOS", [])
user_id = detail.get("originatorUserid", "")
dept_name = detail.get("originatorDeptName", "")
instance_id = detail.get("processInstanceId", "")
status = format_status(detail.get("status", ""), detail.get("processInstanceResult", ""))
# 用户基础信息
info = (user_info_map or {}).get(user_id)
user_name = info.name if info else name_map.get(user_id, user_id)
dept = info.dept_name if info and info.dept_name else dept_name
job_number = info.job_number if info else ""
title = info.title if info else ""
group_name = (group_map or {}).get(user_id, "")
approve_cell = build_approve_cell(corp_id, instance_id, f"{user_name}提交的补卡审批单")
# 从表单解析补卡时间和原因
patch_time = ""
patch_reason = ""
work_date = ""
check_time_str = ""
ext_data: dict = {}
for fv in form_values:
comp_type = fv.get("componentType", "") or ""
biz_alias = (fv.get("bizAlias") or "").lower()
name = fv.get("name") or ""
value = fv.get("value") or ""
if comp_type == "DDDateField" or "checktime" in biz_alias or "补卡时间" in name:
if value and not patch_time:
patch_time = value
# 解析 extValue 获取考勤日期等
ext = parse_ext_value(fv)
if ext and not ext_data:
ext_data = ext
if "reason" in biz_alias or "原因" in name or "事由" in name or "理由" in name:
if value and not patch_reason:
patch_reason = value
# 从 extValue 提取考勤日期、考勤时间、原考勤状态
plan_tip = ""
plan_text = ""
if ext_data:
work_date_ms = ext_data.get("workDate")
if work_date_ms:
work_date = ms_to_date_str(work_date_ms)
plan_tip = ext_data.get("planTip", "")
plan_text = ext_data.get("planText", "")
# 从 planText / planTip 提取考勤时间(目标格式:YYYY-MM-DD HH:MM
# planText 格式: "2026-04-25,星期六,195固定班次,上班时间09:00"
# planTip 格式: "周六上班(04.25 09:00) 缺卡" / "周一上班(04.27 09:00) 缺卡"
import re
plan_time_hhmm = ""
# 优先从 planTip 提取(更可靠,含具体日期和时间)
# planTip 格式: "周三下班(03.05 01:00) 缺卡"
plan_date_from_tip = "" # MM.DD → 用于跨日场景
if plan_tip:
# 匹配 "(MM.DD HH:MM)" 格式
tip_match = re.search(r"\((\d{2})\.(\d{2})\s+(\d{2}:\d{2})\)", plan_tip)
if tip_match:
plan_date_from_tip = f"{tip_match.group(1)}-{tip_match.group(2)}" # "03-05"
plan_time_hhmm = tip_match.group(3)
# 回退:从 planText 中提取
if not plan_time_hhmm and plan_text:
# 匹配 "上班时间HH:MM" 或 "下班时间HH:MM" 或 "时间HH:MM"
time_match = re.search(r"时间(\d{2}:\d{2})", plan_text)
if time_match:
plan_time_hhmm = time_match.group(1)
# 最后回退:任意 HH:MM 格式
if not plan_time_hhmm:
for source in (plan_tip, plan_text):
if source:
fallback_match = re.search(r"(\d{2}:\d{2})", source)
if fallback_match:
plan_time_hhmm = fallback_match.group(1)
break
# 拼接考勤时间:优先使用 planTip 中解析的完整日期(处理跨日班次)
if plan_date_from_tip and plan_time_hhmm and work_date:
# 用 work_date 的年份 + planTip 中的 MM-DD + HH:MM
year = work_date[:4]
check_time_str = f"{year}-{plan_date_from_tip} {plan_time_hhmm}"
elif work_date and plan_time_hhmm:
check_time_str = f"{work_date} {plan_time_hhmm}"
elif plan_time_hhmm:
check_time_str = plan_time_hhmm
elif plan_text:
check_time_str = plan_text
elif plan_tip:
check_time_str = plan_tip
# 如果 work_date 为空,从 patch_time 中提取日期
if not work_date and patch_time:
work_date = patch_time[:10] if len(patch_time) >= 10 else ""
# 从 planTip / planText 提取原考勤状态
# planTip 格式: "周六上班(04.25 09:00) 缺卡" / "Thursday ( 04.23 ) Adjust"
# planText 格式: "2026-04-25,星期六,195固定班次,上班时间09:00" 或 "周一上班(04.27 09:00) 缺卡"
original_check_time = ""
original_status = ""
tip_status_map = {
"缺卡": "缺卡", "未打卡": "未打卡",
"迟到": "迟到", "早退": "早退",
"旷工": "旷工", "正常": "正常",
"NotSigned": "未打卡", "Adjust": "已调整",
}
# 优先从 planTip 提取,回退到 planText
for source in (plan_tip, plan_text):
if source:
for keyword, label in tip_status_map.items():
if keyword in source:
original_status = label
break
if original_status:
break
# 回退: 尝试从 check result 接口获取(如果有数据)
if check_result_map and user_id in check_result_map:
for rec in check_result_map[user_id]:
rec_date = rec.get("workDate", "")
if isinstance(rec_date, (int, float)):
rec_date = ms_to_date_str(rec_date)
if rec_date == work_date:
user_check_ms = rec.get("userCheckTime")
if user_check_ms:
dt = ms_to_datetime(user_check_ms)
original_check_time = dt.strftime("%Y-%m-%d %H:%M") if dt else ""
time_result = rec.get("timeResult", "")
if time_result:
original_status = CHECK_TIME_RESULT_MAP.get(time_result, time_result)
break
# 补卡结果:审批通过 → 补卡成功
patch_result = ""
if status == "审批通过":
patch_result = "补卡成功"
elif status == "已拒绝":
patch_result = "补卡失败"
elif status == "审批中":
patch_result = "待审批"
elif status == "已撤销":
patch_result = "已撤销"
return [[user_name, group_name, dept, job_number, title, work_date, check_time_str,
original_check_time, original_status, patch_time, patch_result,
approve_cell, status]]
PARSERS = {
"leave": parse_leave_detail,
"out": parse_out_detail,
"patch": parse_patch_detail,
}
# 需要额外用户信息(考勤组/工号/职位)的类型
_TYPES_NEED_USER_INFO = {"leave", "out", "patch", "trip"}
# ─────────────────────────────────────────────────────────────────────────────
# 主流程
# ─────────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="考勤记录报表导出(补卡/出差/外出/请假)",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--type", required=True, choices=SUPPORTED_TYPES,
help="记录类型: leave(请假)/trip(出差)/out(外出)/patch(补卡)")
parser.add_argument("--users", required=True,
help="用户 ID 列表,逗号分隔(由 Agent 从人员获取阶段提供)")
parser.add_argument("--start", required=True,
help="开始日期 YYYY-MM-DD")
parser.add_argument("--end", required=True,
help="结束日期 YYYY-MM-DD")
parser.add_argument("--out", default="",
help="输出文件路径(不传则自动生成)")
return parser.parse_args()
def main() -> None:
args = parse_args()
record_type: str = args.type
user_ids = [u.strip() for u in args.users.split(",") if u.strip()]
start_date: str = args.start
end_date: str = args.end
if not user_ids:
error("--users 不能为空")
sys.exit(1)
try:
datetime.strptime(start_date, DATE_FMT)
datetime.strptime(end_date, DATE_FMT)
except ValueError:
error("日期格式错误,请使用 YYYY-MM-DD")
sys.exit(1)
sheet_name = SHEET_NAMES[record_type]
log(f"开始导出{sheet_name}{len(user_ids)} 人,{start_date} ~ {end_date}")
# ── Step 1: 获取审批单列表 ──
log("步骤 1/4:查询审批单列表...")
approve_records = fetch_approve_list(user_ids, record_type, start_date, end_date)
log(f" 获取到 {len(approve_records)} 条审批记录")
if not approve_records:
log("未查询到任何记录")
print(f"{sheet_name}0 条记录,无需生成文件")
sys.exit(0)
# 从 approve list 记录中提取 corpId(用于构建审批单跳转链接)
corp_id = ""
for r in approve_records:
if r.get("corpId"):
corp_id = r["corpId"]
break
# ── Step 2: 去重提取 instanceId ──
instance_ids = list(dict.fromkeys(
r.get("originId", "") for r in approve_records if r.get("originId")
))
log(f"步骤 2/4:共 {len(instance_ids)} 个审批实例")
# ── Step 3: 解析用户信息 ──
log("步骤 3/4:解析用户信息...")
name_map = resolve_user_names(user_ids)
user_info_map: dict[str, UserInfo] | None = None
group_map: dict[str, str] | None = None
check_result_map: dict[str, list[dict]] | None = None
if record_type in _TYPES_NEED_USER_INFO:
log(" 获取用户完整信息(工号/职位)...")
user_info_map = resolve_user_info(user_ids)
log(" 查询考勤组映射...")
group_map = fetch_user_group_map(user_ids)
if record_type == "patch":
log(" 查询原打卡结果...")
check_result_map = fetch_check_results(user_ids, start_date, end_date)
all_rows: list[list[str]] = []
if record_type == "trip":
# 出差记录直接从 approve list 数据生成,不调用 oa approval detail
# oa approval detail 对出差单存在 saNode result 字段类型冲突 bug
log("步骤 4/4:从审批列表解析出差记录...")
for record in approve_records:
row = parse_trip_from_approve_record(
record, name_map,
user_info_map=user_info_map,
group_map=group_map,
corp_id=corp_id,
)
all_rows.append(row)
else:
log("步骤 4/4:查询审批详情并解析...")
for idx, instance_id in enumerate(instance_ids):
if (idx + 1) % 10 == 0:
log(f" 进度: {idx + 1}/{len(instance_ids)}")
detail = fetch_detail(instance_id)
if not detail:
continue
# 补充新发现的用户
originator = detail.get("originatorUserid", "")
if originator and originator not in name_map:
extra = resolve_user_names([originator])
name_map.update(extra)
if originator and user_info_map and originator not in user_info_map:
extra_info = resolve_user_info([originator])
user_info_map.update(extra_info)
if record_type == "patch":
rows = parse_patch_detail(
detail, name_map,
user_info_map=user_info_map,
group_map=group_map,
check_result_map=check_result_map,
corp_id=corp_id,
)
elif record_type == "leave":
rows = parse_leave_detail(
detail, name_map,
user_info_map=user_info_map,
group_map=group_map,
corp_id=corp_id,
)
elif record_type == "out":
rows = parse_out_detail(
detail, name_map,
user_info_map=user_info_map,
group_map=group_map,
corp_id=corp_id,
)
else:
rows = PARSERS[record_type](detail, name_map)
all_rows.extend(rows)
log(f" 解析完成,共 {len(all_rows)}")
if not all_rows:
log("无有效数据行")
print(f"{sheet_name}:解析后 0 行有效数据,无需生成文件")
sys.exit(0)
# ── 写入 Excel ──
out_path = args.out or f"attendance_report_record_{record_type}_{start_date}_{end_date}.xlsx"
headers = COLUMNS[record_type]
title = f"{sheet_name} 统计日期:{start_date}{end_date}"
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
# 将 HyperlinkCell 转为纯文本供 write_excel 写入,之后再补超链接
plain_rows = []
hyperlink_cells: list[tuple[int, int, str]] = [] # (row_offset, col_idx, url)
for row_offset, row in enumerate(all_rows):
plain_row = []
for col_idx, cell in enumerate(row):
if isinstance(cell, HyperlinkCell):
plain_row.append(cell.label)
hyperlink_cells.append((row_offset, col_idx, cell.url))
else:
plain_row.append(cell)
plain_rows.append(plain_row)
write_excel(
out_path,
headers,
plain_rows,
sheet_name=sheet_name,
title=title,
subtitle=subtitle,
)
# 补充超链接
if hyperlink_cells:
from openpyxl import load_workbook
from openpyxl.styles import Font
wb = load_workbook(out_path)
ws = wb.active
# 计算标题行偏移:title + subtitle + header
title_row_count = (1 if title else 0) + (1 if subtitle else 0)
first_data_row = title_row_count + 2 # +1 for header, +1 for 1-indexed
link_font = Font(color="0563C1", underline="single")
for row_offset, col_idx, url in hyperlink_cells:
cell = ws.cell(row=first_data_row + row_offset, column=col_idx + 1)
cell.hyperlink = url
cell.font = link_font
wb.save(out_path)
abs_path = os.path.abspath(out_path)
log(f"✅ 导出完成: {abs_path}")
print(f"{sheet_name}导出完成:{abs_path}{len(all_rows)} 行,{len(instance_ids)} 个审批单)")
if __name__ == "__main__":
main()
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""
考勤排班查询导出脚本
[AI Agent 强制门禁] 本脚本执行前必须先阅读:
references/attendance-schedule.md
职责:
1. 分批查询排班记录(支持大量用户自动分批)
2. 将 classId 转为班次名称
3. 将 userId 转为员工姓名
4. 输出日历表格式的排班表 Excel(行=员工,列=日期,单元格=班次名称)
用法:
python attendance_schedule_export.py \
--users userId1,userId2,userId3 \
--start 2026-05-19 --end 2026-05-23
python attendance_schedule_export.py \
--users userId1,userId2 \
--start 2026-05-01 --end 2026-05-31 \
--output my_schedule.xlsx
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime, timedelta
from typing import Any
from attendance_report_common import (
DATE_FMT,
DATETIME_FMT,
DwsCallError,
chunk_users,
error,
extract_records,
log,
parse_datetime_arg,
resolve_user_names,
run_dws,
warn,
write_excel,
)
# schedule get 接口每批最多用户数(保守值,避免超时)
SCHEDULE_BATCH_SIZE = 20
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
# ─────────────────────────────────────────────────────────────────────────────
# 排班数据查询(分批)
# ─────────────────────────────────────────────────────────────────────────────
def fetch_schedule_batch(
user_ids: list[str],
start_date: str,
end_date: str,
) -> list[dict]:
"""调用 dws attendance schedule get 查询一批用户的排班记录。"""
users_str = ",".join(user_ids)
try:
result = run_dws([
"attendance", "schedule", "get",
"--users", users_str,
"--start", start_date,
"--end", end_date,
])
except DwsCallError as exc:
error(f"查询排班失败 (users={len(user_ids)}, {start_date}~{end_date}): {exc}")
return []
return extract_records(result) if result else []
def fetch_all_schedules(
user_ids: list[str],
start_date: str,
end_date: str,
) -> list[dict]:
"""分批查询所有用户的排班记录,自动处理用户数超限。"""
all_records: list[dict] = []
batches = chunk_users(user_ids, SCHEDULE_BATCH_SIZE)
total = len(batches)
log(f"📋 共 {len(user_ids)} 人,分 {total} 批查询排班 ({start_date} ~ {end_date})")
for idx, batch in enumerate(batches, start=1):
if total > 1:
log(f" 批次 {idx}/{total}: {len(batch)}")
records = fetch_schedule_batch(batch, start_date, end_date)
all_records.extend(records)
log(f"✅ 查询完成,共 {len(all_records)} 条排班记录")
return all_records
# ─────────────────────────────────────────────────────────────────────────────
# 班次名称映射
# ─────────────────────────────────────────────────────────────────────────────
def build_class_name_map(records: list[dict]) -> dict[int, str]:
"""从排班记录中提取 classId → className 映射。
优先使用记录自带的 className;缺失时回退 class search 补全。
"""
class_map: dict[int, str] = {}
missing_ids: set[int] = set()
for record in records:
raw_id = record.get("classId") or record.get("class_id")
raw_name = record.get("className") or record.get("class_name")
if raw_id is None:
continue
cid = int(raw_id)
if raw_name and str(raw_name).strip():
class_map[cid] = str(raw_name).strip()
elif cid != 0 and cid not in class_map:
missing_ids.add(cid)
if missing_ids:
log(f"🔍 {len(missing_ids)} 个班次缺名称,从 class search 补全 ...")
try:
result = run_dws(["attendance", "class", "search", "--page-size", "200"])
for cls in (extract_records(result) if result else []):
cid_raw = cls.get("id") or cls.get("classId")
cname = cls.get("name") or cls.get("className")
if cid_raw is not None and cname:
class_map[int(cid_raw)] = str(cname).strip()
except DwsCallError as exc:
warn(f"class search 失败,部分班次将显示为 ID: {exc}")
return class_map
# ─────────────────────────────────────────────────────────────────────────────
# 日期工具
# ─────────────────────────────────────────────────────────────────────────────
def normalize_work_date(raw: Any) -> str:
"""将排班记录中的 workDate 标准化为 YYYY-MM-DD。"""
if raw is None:
return ""
if isinstance(raw, (int, float)):
ts = raw / 1000 if raw > 1e12 else raw
try:
return datetime.fromtimestamp(ts).strftime(DATE_FMT)
except (OSError, ValueError, OverflowError):
return ""
s = str(raw).strip()
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return s[:10]
return s
def generate_date_range(start: datetime, end: datetime) -> list[str]:
"""生成 start 到 end 之间的所有日期字符串列表。"""
dates: list[str] = []
current = start
while current <= end:
dates.append(current.strftime(DATE_FMT))
current += timedelta(days=1)
return dates
# ─────────────────────────────────────────────────────────────────────────────
# 构建排班表(日历表格式)
# ─────────────────────────────────────────────────────────────────────────────
def build_schedule_table(
records: list[dict],
user_ids: list[str],
user_names: dict[str, str],
class_map: dict[int, str],
date_range: list[str],
) -> tuple[list[str], list[list[str]]]:
"""构建日历表格式的排班表。
Returns:
(headers, rows)
headers = ["员工姓名", "05-19\n周一", "05-20\n周二", ...]
rows = [["张三", "早班", "早班", "休息", ...], ...]
"""
# 构建 (userId, date) → 班次显示文本
schedule_lookup: dict[tuple[str, str], str] = {}
for record in records:
uid = str(record.get("userId") or record.get("userid") or "")
work_date = normalize_work_date(record.get("workDate") or record.get("work_date"))
if not uid or not work_date:
continue
is_rest = str(record.get("isRest") or record.get("is_rest") or "N").upper()
raw_cid = record.get("classId") or record.get("class_id") or 0
raw_cname = record.get("className") or record.get("class_name") or ""
if is_rest == "Y":
display = "休息"
elif raw_cname and str(raw_cname).strip():
display = str(raw_cname).strip()
else:
cid = int(raw_cid) if raw_cid else 0
if cid in class_map:
display = class_map[cid]
elif cid == 0:
display = "休息"
else:
display = f"班次{cid}"
schedule_lookup[(uid, work_date)] = display
# 表头
headers = ["员工姓名"]
for date_str in date_range:
dt = datetime.strptime(date_str, DATE_FMT)
weekday = WEEKDAY_NAMES[dt.weekday()]
headers.append(f"{date_str[5:]}\n{weekday}")
# 数据行
rows: list[list[str]] = []
for uid in user_ids:
name = user_names.get(uid, uid)
row = [name]
for date_str in date_range:
row.append(schedule_lookup.get((uid, date_str), ""))
rows.append(row)
return headers, rows
# ─────────────────────────────────────────────────────────────────────────────
# 摘要输出
# ─────────────────────────────────────────────────────────────────────────────
def print_summary(
rows: list[list[str]],
date_range: list[str],
out_path: str,
record_count: int,
) -> None:
"""输出排班查询摘要到 stdout。"""
out_abs = os.path.abspath(out_path)
print(f"\n✅ 排班表导出成功!")
print(f" 文件: {out_abs}")
print(f" 人数: {len(rows)}")
print(f" 日期: {date_range[0]} ~ {date_range[-1]} ({len(date_range)} 天)")
print(f" 记录: {record_count}")
# 预览前 10 人 × 前 7 天
preview_rows = min(len(rows), 10)
preview_cols = min(len(date_range), 7)
if preview_rows > 0:
print(f"\n排班预览(前 {preview_rows}×{preview_cols} 天):")
header_line = f"{'姓名':<10}" + "".join(
f"{d[5:]:<8}" for d in date_range[:preview_cols]
)
print(header_line)
print("-" * len(header_line))
for row in rows[:preview_rows]:
line = f"{row[0]:<10}" + "".join(
f"{cell:<8}" for cell in row[1:preview_cols + 1]
)
print(line)
if len(date_range) > preview_cols:
print(f" ... 共 {len(date_range)} 天,完整数据见 Excel")
if len(rows) > preview_rows:
print(f" ... 共 {len(rows)} 人,完整数据见 Excel")
def main() -> None:
parser = argparse.ArgumentParser(
description="考勤排班查询导出(排班表格式)",
epilog="执行前必须阅读 attendance-schedule.md",
)
parser.add_argument("--users", required=True, help="userId 列表,逗号分隔(必填)")
parser.add_argument("--start", required=True, help="开始日期 YYYY-MM-DD(必填)")
parser.add_argument("--end", required=True, help="结束日期 YYYY-MM-DD(必填)")
parser.add_argument("--output", default="", help="输出文件路径(可选)")
args = parser.parse_args()
# ── 解析参数 ──
user_ids = [uid.strip() for uid in args.users.split(",") if uid.strip()]
if not user_ids:
error("--users 不能为空")
raise SystemExit(1)
try:
start_dt = parse_datetime_arg(args.start)
end_dt = parse_datetime_arg(args.end, end_of_day=True)
except ValueError as exc:
error(str(exc))
raise SystemExit(1) from exc
start_date = start_dt.strftime(DATE_FMT)
end_date = end_dt.strftime(DATE_FMT)
if end_dt < start_dt:
error(f"结束日期 {end_date} 早于开始日期 {start_date}")
raise SystemExit(1)
output_path = args.output or f"attendance_schedule_{start_date}_{end_date}.xlsx"
log(f"🗓️ 排班查询: {len(user_ids)} 人, {start_date} ~ {end_date}")
# ── 阶段 1: 查询排班记录(分批) ──
records = fetch_all_schedules(user_ids, start_date, end_date)
if not records:
print(f"⚠️ 未查询到排班记录 ({start_date} ~ {end_date})")
return
# ── 阶段 2: 构建班次名称映射 ──
class_map = build_class_name_map(records)
# ── 阶段 3: 解析员工姓名 ──
user_names = resolve_user_names(user_ids)
# ── 阶段 4: 生成日期范围 & 构建排班表 ──
date_range = generate_date_range(start_dt, end_dt)
headers, rows = build_schedule_table(
records, user_ids, user_names, class_map, date_range,
)
# ── 阶段 5: 输出 Excel ──
title = f"排班表 {start_date}{end_date}"
subtitle = f"生成时间:{datetime.now().strftime(DATETIME_FMT)}{len(rows)}"
write_excel(
output_path,
headers,
rows,
sheet_name="排班表",
title=title,
subtitle=subtitle,
)
log(f"📄 Excel 已保存: {os.path.abspath(output_path)}")
# ── 阶段 6: 输出摘要 ──
print_summary(rows, date_range, output_path, len(records))
if __name__ == "__main__":
main()
@@ -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()
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
查询团队成员本周排班和出勤统计
用法:
python attendance_team_shift.py --users userId1,userId2,userId3
python attendance_team_shift.py --users userId1,userId2 \
--from 2026-03-10 --to 2026-03-14
python attendance_team_shift.py --users userId1 --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_week_range():
today = datetime.now()
monday = today - timedelta(days=today.weekday())
friday = monday + timedelta(days=4)
return monday.strftime('%Y-%m-%d'), friday.strftime('%Y-%m-%d')
def main():
parser = argparse.ArgumentParser(
description='查询团队成员排班和出勤统计'
)
parser.add_argument(
'--users', required=True, help='用户 ID 列表,逗号分隔'
)
mon, fri = get_week_range()
parser.add_argument('--from', dest='from_date', default=mon)
parser.add_argument('--to', dest='to_date', default=fri)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
user_count = len(args.users.split(','))
if user_count > 50:
print('错误:最多查询 50 人')
sys.exit(1)
print(f"📊 团队排班查询 ({args.from_date} ~ {args.to_date})")
print(f" 人数: {user_count}")
print('=' * 50)
print('\n🔍 查询排班信息...')
data = run_dws([
'attendance', 'shift', 'list',
'--users', args.users,
'--start', args.from_date,
'--end', args.to_date,
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
if not data:
print('未查到排班信息')
return
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
@@ -0,0 +1,617 @@
#!/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())
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
查看指定日期现金日报
用法:
python finance_daily_cashflow.py # 今天
python finance_daily_cashflow.py --date 2026-03-10
python finance_daily_cashflow.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(
description='查看现金日报'
)
parser.add_argument(
'--date', default='', help='日期 YYYY-MM-DD (默认今天)'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
date_str = args.date or datetime.now().strftime('%Y-%m-%d')
print(f'💰 现金日报 ({date_str})\n')
data = run_dws([
'finance', 'journal', 'daily',
'--date', date_str,
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
if not data:
print('未查到现金日报')
return
print('=' * 50)
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
完整报销流程:搜索供应商 → 搜索类别 → 创建付款单
用法:
python finance_expense_flow.py \
--amount 5000 \
--supplier "华为" \
--category "差旅" \
--category-type expense
python finance_expense_flow.py --dry-run --amount 1000
"""
import sys
import json
import subprocess
import argparse
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def main():
parser = argparse.ArgumentParser(
description='完整报销流程'
)
parser.add_argument(
'--amount', required=True, help='报销金额'
)
parser.add_argument(
'--supplier', default='', help='供应商名称关键词'
)
parser.add_argument(
'--category', default='', help='费用类别关键词'
)
parser.add_argument(
'--category-type', default='expense',
choices=['income', 'expense'],
)
parser.add_argument('--tax', default='', help='税额')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
supplier_code = ''
category_code = ''
if args.supplier:
print(f'🔍 搜索供应商: {args.supplier}')
data = run_dws([
'finance', 'supplier', 'search',
'--query', args.supplier,
'--format', 'json',
], dry_run=args.dry_run)
if not args.dry_run and data:
if isinstance(data, list):
items = data
elif isinstance(data, dict):
inner = data.get('result', data)
items = inner if isinstance(inner, list) else []
else:
items = []
if items:
supplier_code = (items[0].get('code')
or items[0].get('supplierCode', ''))
name = items[0].get('name', '')
print(f" ✓ 找到: {name} ({supplier_code})")
else:
print(f" ⚠ 未找到供应商: {args.supplier}")
if args.category:
print(f'🔍 搜索费用类别: {args.category}')
data = run_dws([
'finance', 'category', 'search',
'--type', args.category_type,
'--query', args.category,
'--format', 'json',
], dry_run=args.dry_run)
if not args.dry_run and data:
if isinstance(data, list):
items = data
elif isinstance(data, dict):
inner = data.get('result', data)
items = inner if isinstance(inner, list) else []
else:
items = []
if items:
category_code = (items[0].get('code')
or items[0].get('categoryCode', ''))
name = items[0].get('name', '')
print(f" ✓ 找到: {name} ({category_code})")
else:
print(f" ⚠ 未找到类别: {args.category}")
print(f'\n💰 创建付款单 (金额: {args.amount})')
cmd_args = [
'finance', 'receipt', 'create',
'--amount', args.amount,
'--format', 'json',
]
if supplier_code:
cmd_args.extend(['--supplier-code', supplier_code])
if category_code:
cmd_args.extend(['--category-code', category_code])
if args.tax:
cmd_args.extend(['--tax', args.tax])
result = run_dws(cmd_args, dry_run=args.dry_run)
if result:
print(f" ✓ 付款单已创建")
else:
print(f" ✗ 创建失败")
sys.exit(1)
print('\n✅ 报销流程完成!')
if __name__ == '__main__':
main()
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
批量同意/拒绝待审批项(含安全确认)
用法:
python oa_batch_approve.py --action approve --days 7
python oa_batch_approve.py --action reject --remark "不符合要求"
python oa_batch_approve.py --action approve --instance-ids id1,id2
python oa_batch_approve.py --dry-run --action approve
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def to_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def main():
parser = argparse.ArgumentParser(
description='批量同意/拒绝审批'
)
parser.add_argument(
'--action', required=True,
choices=['approve', 'reject'], help='审批动作',
)
parser.add_argument(
'--remark', default='', help='审批意见'
)
parser.add_argument('--days', type=int, default=7)
parser.add_argument('--instance-ids', default='')
parser.add_argument(
'--yes', action='store_true', help='跳过确认'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
instance_ids: List[str] = []
if args.instance_ids:
instance_ids = [x.strip() for x in
args.instance_ids.split(',') if x.strip()]
else:
now = datetime.now()
start = now - timedelta(days=args.days)
data = run_dws([
'oa', 'approval', 'list-pending',
'--start', to_iso(start),
'--end', to_iso(now),
'--format', 'json',
], dry_run=args.dry_run)
if not args.dry_run and data:
if isinstance(data, list):
items = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
items = inner.get('processInstanceList',
inner.get('items', []))
elif isinstance(inner, list):
items = inner
else:
items = []
else:
items = []
instance_ids = [
item.get('processInstanceId') or item.get('id')
for item in items
if isinstance(item, dict)
and (item.get('processInstanceId') or item.get('id'))
]
if not instance_ids and not args.dry_run:
print('✅ 没有待处理的审批')
return
action_label = '同意' if args.action == 'approve' else '拒绝'
count = len(instance_ids) if instance_ids else '?'
print(f"\n⚠️ 即将 {action_label} {count} 条审批")
if not args.yes and not args.dry_run:
confirm = input('确认执行?(y/N): ').strip().lower()
if confirm != 'y':
print('已取消')
return
success, fail = 0, 0
for i, inst_id in enumerate(instance_ids or ['<INST_ID>'], 1):
tasks_data = run_dws([
'oa', 'approval', 'tasks',
'--instance-id', inst_id,
'--format', 'json',
], dry_run=args.dry_run)
task_id = None
if not args.dry_run and tasks_data:
if isinstance(tasks_data, list):
task_ids = tasks_data
elif isinstance(tasks_data, dict):
inner = tasks_data.get('result', tasks_data)
if isinstance(inner, dict):
task_ids = inner.get('tasks', inner.get('items', []))
elif isinstance(inner, list):
task_ids = inner
else:
task_ids = []
else:
task_ids = []
if task_ids:
task_id = (task_ids[0] if isinstance(task_ids[0], str)
else task_ids[0].get('taskId', ''))
cmd_args = [
'oa', 'approval', args.action,
'--instance-id', inst_id,
'--task-id', task_id or '<TASK_ID>',
'--format', 'json',
]
if args.remark:
cmd_args.extend(['--remark', args.remark])
result = run_dws(cmd_args, dry_run=args.dry_run)
if result:
print(f" ✓ [{i}/{count}] {inst_id}{action_label}")
success += 1
else:
print(f" ✗ [{i}/{count}] {inst_id}")
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
if __name__ == '__main__':
main()
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
查看待我审批列表 + 逐条显示详情(自动时间戳计算)
用法:
python oa_pending_review.py # 最近 7 天
python oa_pending_review.py --days 30 # 最近 30 天
python oa_pending_review.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def to_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def main():
parser = argparse.ArgumentParser(
description='查看待我审批列表'
)
parser.add_argument(
'--days', type=int, default=7, help='查询天数 (默认 7)'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
now = datetime.now()
start = now - timedelta(days=args.days)
print(f"📋 查询待审批 (最近 {args.days} 天)...")
data = run_dws([
'oa', 'approval', 'list-pending',
'--start', to_iso(start),
'--end', to_iso(now),
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
run_dws([
'oa', 'approval', 'detail',
'--instance-id', '<INSTANCE_ID>',
'--format', 'json',
], dry_run=True)
return
if not data:
print('未查到待审批')
return
if isinstance(data, list):
instances = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
instances = inner.get('processInstanceList',
inner.get('items', []))
elif isinstance(inner, list):
instances = inner
else:
instances = []
else:
instances = []
if not instances:
print('✅ 暂无待审批事项')
return
print(f"\n🔔 待审批列表 ({len(instances)} 条)")
print('=' * 50)
for i, inst in enumerate(instances, 1):
if not isinstance(inst, dict):
print(f"\n [{i}] {inst}")
continue
inst_id = (inst.get('processInstanceId')
or inst.get('id', ''))
title = inst.get('title') or inst.get('name', '无标题')
status = inst.get('status') or inst.get('result', '')
create_time = inst.get('createTime', '')
if isinstance(create_time, (int, float)):
create_time = datetime.fromtimestamp(
create_time / 1000
).strftime('%Y-%m-%d %H:%M')
print(f"\n [{i}] {title}")
print(f" 状态: {status} 创建: {create_time}")
print(f" ID: {inst_id}")
detail = run_dws([
'oa', 'approval', 'detail',
'--instance-id', inst_id,
'--format', 'json',
])
if detail and isinstance(detail, dict):
forms = detail.get('formComponentValues', [])
if forms:
print(f" --- 表单内容 ---")
for f in forms[:5]:
name = f.get('name', '')
value = f.get('value', '')
if value:
print(f" {name}: {value[:60]}")
if __name__ == '__main__':
main()
@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""有界分页列出今天或最近几天收到的日志摘要。
脚本只读取列表投影,不再为每条日志调用 ``entry get``。需要正文时,调用方应
从结果中选择明确的 reportId,再单独读取那一条,避免列表任务退化成 N+1。
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from datetime import datetime, timedelta
from typing import Any, NamedTuple
from zoneinfo import ZoneInfo
SHANGHAI = ZoneInfo("Asia/Shanghai")
PAGE_SIZE = 20
DEFAULT_MAX_PAGES = 10
HARD_MAX_PAGES = 10
MAX_REPORTS = PAGE_SIZE * HARD_MAX_PAGES
DEFAULT_DISPLAY_LIMIT = 20
PER_COMMAND_TIMEOUT_SECONDS = 60
TOTAL_TIMEOUT_SECONDS = 120
MAX_ERROR_DETAIL_CHARS = 4096
class ReportCommandError(RuntimeError):
"""DWS 执行或响应契约失败,不能降级成合法空结果。"""
class InboxScanResult(NamedTuple):
"""完整扫描证据与受展示上限约束的摘要。"""
total_count: int
visible_items: list[dict[str, Any]]
def query_window(days: int, now: datetime | None = None) -> tuple[datetime, datetime]:
"""冻结查询时间窗,并保证午夜调度也得到严格递增的范围。"""
current = now or datetime.now(SHANGHAI)
start = (current - timedelta(days=days - 1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = current.replace(microsecond=0)
if end <= start:
end = start + timedelta(seconds=1)
return start, end
def format_create_time(value: Any) -> str:
"""把服务端 epoch 毫秒转换为带时区的可读时间,未知形态如实保留。"""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return datetime.fromtimestamp(value / 1000, SHANGHAI).strftime(
"%Y-%m-%d %H:%M:%S %z"
)
return str(value or "")
def clip_detail(value: Any, limit: int = MAX_ERROR_DETAIL_CHARS) -> str:
"""把诊断压到固定上限,避免响应正文进入错误日志或模型上下文。"""
if isinstance(value, (dict, list)):
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
else:
text = str(value or "")
text = text.strip()
if len(text) <= limit:
return text
return text[: limit - 14] + "…[已截断]"
def process_error_detail(result: subprocess.CompletedProcess[str]) -> str:
"""优先保留结构化错误与 stderr;原始 stdout 仅做有界兜底。"""
parts: list[str] = []
try:
payload = json.loads(result.stdout)
except (json.JSONDecodeError, TypeError):
payload = None
if isinstance(payload, dict):
structured = payload.get("error") or payload.get("message")
if structured:
parts.append("error=" + clip_detail(structured, 2048))
stderr = clip_detail(result.stderr, 2048)
if stderr:
parts.append("stderr=" + stderr)
if not parts:
stdout = clip_detail(result.stdout, 2048)
if stdout:
parts.append("stdout=" + stdout)
return clip_detail("; ".join(parts)) or "无错误详情"
def run_dws(
args: list[str], *, dry_run: bool = False, timeout_seconds: float = 60
) -> Any | None:
cmd = ["dws", *args]
if dry_run:
print("[dry-run] " + " ".join(cmd))
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout_seconds
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ReportCommandError(f"DWS 执行失败: {exc}") from exc
if result.returncode != 0:
raise ReportCommandError(
f"DWS 返回非零状态 exit={result.returncode}: "
f"{process_error_detail(result)}"
)
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ReportCommandError(f"DWS 返回的不是合法 JSON: {exc}") from exc
def parse_inbox_page(
payload: Any, current_cursor: int
) -> tuple[list[dict[str, Any]], int | None]:
if not isinstance(payload, dict):
raise ReportCommandError(
f"收件箱响应应为对象,实际为 {type(payload).__name__}"
)
if payload.get("ok") is not True or payload.get("outcome") != "success":
error = payload.get("error")
raise ReportCommandError(
"收件箱调用未成功: " + clip_detail(error or payload)
)
data = payload.get("data")
if not isinstance(data, dict):
raise ReportCommandError("收件箱成功响应缺少 data 对象")
reports = data.get("reports")
if not isinstance(reports, list) or any(
not isinstance(item, dict) for item in reports
):
raise ReportCommandError("收件箱 data.reports 必须是对象数组")
count = data.get("count")
if (
not isinstance(count, int)
or isinstance(count, bool)
or count != len(reports)
):
raise ReportCommandError("收件箱 count 与 reports 数量不一致")
complete = data.get("complete")
if not isinstance(complete, bool):
raise ReportCommandError("收件箱响应缺少布尔 complete")
meta = payload.get("meta")
pagination = meta.get("pagination") if isinstance(meta, dict) else None
if not isinstance(pagination, dict):
raise ReportCommandError("收件箱响应缺少 meta.pagination")
exhausted = pagination.get("endpoint_exhausted")
if not isinstance(exhausted, bool) or exhausted != complete:
raise ReportCommandError("收件箱 data.complete 与分页终止证据冲突")
if exhausted:
return reports, None
raw_next = pagination.get("next_token")
try:
next_cursor = int(raw_next)
except (TypeError, ValueError) as exc:
raise ReportCommandError("收件箱续页缺少整数 next_token") from exc
if next_cursor <= current_cursor:
raise ReportCommandError("收件箱 continuation cursor 没有严格前进")
return reports, next_cursor
def scan_inbox(
start: datetime,
end: datetime,
max_pages: int,
*,
display_limit: int = DEFAULT_DISPLAY_LIMIT,
total_timeout_seconds: float = TOTAL_TIMEOUT_SECONDS,
) -> InboxScanResult:
if not 1 <= display_limit <= MAX_REPORTS:
raise ReportCommandError(
f"展示上限必须在 1..{MAX_REPORTS} 之间"
)
cursor = 0
total_count = 0
visible_items: list[dict[str, Any]] = []
seen: dict[str, Any] = {}
deadline = time.monotonic() + total_timeout_seconds
for _ in range(max_pages):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ReportCommandError(
f"收件箱分页超过总时限 {total_timeout_seconds:g}"
)
payload = run_dws([
"report", "+inbox-list",
"--start", start.isoformat(timespec="seconds"),
"--end", end.isoformat(timespec="seconds"),
"--cursor", str(cursor),
"--size", str(PAGE_SIZE),
"--format", "json",
], timeout_seconds=max(
0.1, min(PER_COMMAND_TIMEOUT_SECONDS, remaining)
))
page, next_cursor = parse_inbox_page(payload, cursor)
for item in page:
report_id = item.get("reportId")
if not isinstance(report_id, str) or not report_id.strip():
raise ReportCommandError("收件箱条目缺少稳定 reportId")
created = item.get("createTime")
if report_id in seen:
if seen[report_id] != created:
raise ReportCommandError(
f"收件箱重复 reportId 的 createTime 冲突: {report_id}"
)
continue
if total_count >= MAX_REPORTS:
raise ReportCommandError(
f"收件箱结果超过有界条数上限 {MAX_REPORTS}"
)
seen[report_id] = created
total_count += 1
if len(visible_items) < display_limit:
visible_items.append(item)
if next_cursor is None:
return InboxScanResult(total_count, visible_items)
cursor = next_cursor
raise ReportCommandError(
f"达到 --max-pages={max_pages} 时收件箱仍有后续页;"
"拒绝把部分结果伪装成完整列表"
)
def main() -> int:
parser = argparse.ArgumentParser(description="查看收到的日志摘要")
parser.add_argument(
"--days", type=int, default=1, help="查询天数(默认 1"
)
parser.add_argument(
"--max-pages",
type=int,
default=DEFAULT_MAX_PAGES,
help=f"最大分页数(默认 {DEFAULT_MAX_PAGES},范围 1..{HARD_MAX_PAGES}",
)
parser.add_argument(
"--display-limit",
type=int,
default=DEFAULT_DISPLAY_LIMIT,
help=f"最多展开的摘要数(默认 {DEFAULT_DISPLAY_LIMIT},范围 1..{MAX_REPORTS}",
)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if args.days < 1:
parser.error("--days must be >= 1")
if not 1 <= args.max_pages <= HARD_MAX_PAGES:
parser.error(
f"--max-pages must be between 1 and {HARD_MAX_PAGES}"
)
if not 1 <= args.display_limit <= MAX_REPORTS:
parser.error(
f"--display-limit must be between 1 and {MAX_REPORTS}"
)
# 以调用开始时刻冻结查询窗,避免分页过程中把未来新增条目插进结果集。
start, end = query_window(args.days)
label = "今天" if args.days == 1 else f"最近 {args.days}"
if args.dry_run:
run_dws([
"report", "+inbox-list",
"--start", start.isoformat(timespec="seconds"),
"--end", end.isoformat(timespec="seconds"),
"--cursor", "0",
"--size", str(PAGE_SIZE),
"--format", "json",
], dry_run=True)
return 0
scan = scan_inbox(
start, end, args.max_pages, display_limit=args.display_limit
)
if scan.total_count == 0:
print(f"{label}暂无收到的日志")
return 0
print(f"{label}收到的日志({scan.total_count} 条,已完成分页)")
for item in scan.visible_items:
creator = (
item.get("creatorName")
or item.get("creatorUserId")
or "未知创建人"
)
template = item.get("templateName") or "日志"
print(
f"- {template} | {creator} | "
f"{format_create_time(item.get('createTime'))} | {item['reportId']}"
)
if len(scan.visible_items) < scan.total_count:
print(
f"另有 {scan.total_count - len(scan.visible_items)} 条未展开;"
f"需要时用 --display-limit {min(scan.total_count, MAX_REPORTS)} 显示。"
)
print(
"需要正文时,请选择上面的明确 reportId "
"再执行 dws report entry get。"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except ReportCommandError as exc:
print(f"错误:{exc}", file=sys.stderr)
raise SystemExit(2) from exc
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""
宜搭自定义页面 schema 生成/修改(编排:get-schema → 编译 + 构建 → update-schema
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【调用前必读】references/yida-custom-page-codegen.md
JSX 入口签名 / Hooks 限制 / 行内样式 / 跨表单联动 5 种模式 / SEARCH/REPLACE
增量改写 / 常见坑速查表)。本脚本 --help 仅给出基本用法,**不要**只看
--help 就直接拼 JSX,几乎必踩坑。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
用法:
python yida_custom_page_update.py --app APP_X --form FORM-XXX --code-file page.jsx --yes
python yida_custom_page_update.py --app APP_X --form FORM-XXX --code 'import ...' --yes
python yida_custom_page_update.py --app APP_X --form FORM-XXX --show-current
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--show-current 模式:
只拉取现有 schema 并输出当前代码,不做修改。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
写入模式:
全量替换代码。脚本调用纯 Python 编译管线(JSX→createElement 转换 +
Hooks 兼容层 _customState/didMount),构建标准 Jsx 组件 schema,并保留
page_id 和已有 dataSource。**零第三方依赖**(仅需 Python 3.7+ 标准库,
无需 pip install、无需 Node.js)。空页面和已有代码的页面均可使用。
跨表单联动场景:JSX 内可通过 Yida.api.form.* 直接读写同应用内任意表单,
搭配 `yida_form_inspector.py --action fields-snippet` 取目标表的字段常量片段。
详见 references/yida-custom-page-codegen.md §9。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from yida_page_compiler import compile_jsx_to_schema # noqa: E402
from yida_page_schema import extract_source_code # noqa: E402
from yida_jsx_pipeline import field_check, lint_check # noqa: E402
MAX_CODE_FILE_SIZE = 1 * 1024 * 1024
MAX_INLINE_CODE = 200 * 1024
def _gather_allowed_roots() -> list[Path]:
"""收集所有允许的路径根目录。任一命中即放行,详见 _resolve_safe_path。
优先级(靠前的优先,仅影响报错提示顺序):
1. OPENYIDA_ALLOWED_ROOTS:显式多根,以 os.pathsep / ':' / ',' 分隔。
2. OPENCLAW_WORKSPACE:老环境变量,向后兼容。
3. 当前工作目录 cwd:兼容原行为。
4. 临时目录 tempdir:供脚本中转使用。
"""
roots: list[Path] = []
extra = os.environ.get("OPENYIDA_ALLOWED_ROOTS", "")
if extra:
seps = [os.pathsep, ":", ","]
parts: list[str] = [extra]
for sep in seps:
parts = [seg for chunk in parts for seg in chunk.split(sep)]
for part in parts:
part = part.strip()
if part:
roots.append(Path(part).expanduser().resolve())
legacy = os.environ.get("OPENCLAW_WORKSPACE")
if legacy:
roots.append(Path(legacy).expanduser().resolve())
roots.append(Path.cwd().resolve())
import tempfile as _tempfile
roots.append(Path(_tempfile.gettempdir()).resolve())
roots.append(Path("/tmp").resolve())
roots.append(Path("/private/tmp").resolve())
# 去重保序
seen: set[str] = set()
uniq: list[Path] = []
for r in roots:
s = str(r)
if s not in seen:
seen.add(s)
uniq.append(r)
return uniq
def _resolve_safe_path(path_str: str) -> Path:
target = Path(path_str).expanduser()
target = target.resolve() if target.is_absolute() else (Path.cwd() / target).resolve()
roots = _gather_allowed_roots()
for root in roots:
try:
target.relative_to(root)
return target
except ValueError:
continue
listing = "\n - ".join(str(r) for r in roots)
raise ValueError(
f"路径超出允许范围:{path_str}\n"
f"已尝试的允许根目录:\n - {listing}\n"
f"提示:设置 OPENYIDA_ALLOWED_ROOTS(允许多根,冒号/逗号分隔)或 OPENCLAW_WORKSPACE 扩展允许范围。"
)
def _run_dws(args: list[str], dry_run: bool = False) -> Any | None:
cmd = ["dws"] + args
if dry_run:
print(f" [dry-run] {' '.join(cmd)}")
return {"dry_run": True}
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print(" [FAIL] 找不到 'dws' 命令", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print(" [FAIL] dws 超时", file=sys.stderr)
return None
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
print(f" [FAIL] dws 失败 (exit {result.returncode}): {err}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f" [FAIL] 非 JSON: {e}\n 输出: {result.stdout[:300]}", file=sys.stderr)
return None
def _unwrap_content(data: Any) -> Any:
"""兼容 dws JSON 输出的常见包裹层。"""
current = data
for _ in range(4):
if not isinstance(current, dict):
return current
if "content" in current:
current = current["content"]
continue
if "data" in current and isinstance(current["data"], dict):
current = current["data"]
continue
return current
return current
def _extract_form_type(info: Any) -> str:
"""从 get-info 的不同返回形态中提取 formType/type。"""
candidates: list[Any] = []
current = info
for _ in range(4):
if not isinstance(current, dict):
break
candidates.append(current)
next_obj = None
for key in ("content", "data", "result"):
value = current.get(key)
if isinstance(value, dict):
next_obj = value
break
if next_obj is None:
break
current = next_obj
for item in candidates:
value = item.get("formType") or item.get("type") or item.get("pageType")
if isinstance(value, str) and value.strip():
return value.strip().lower()
return ""
def _check_display_target(app: str, form: str, force: bool = False) -> bool:
"""发布前确认目标是自定义展示页,避免覆盖普通表单/流程表单。"""
print("Step 0: 校验发布目标")
info = _run_dws(["yida", "design", "form", "get-info", "--app", app,
"--form", form, "--format", "json"])
form_type = _extract_form_type(info)
if form_type == "display":
print(" [OK] 目标类型 display")
return True
if force:
reason = form_type or "unknown"
print(f" [WARN] 目标类型为 {reason},已按 --force 跳过保护")
return True
if not info:
print(" [FAIL] 无法获取目标页面类型,已拒绝写入", file=sys.stderr)
elif form_type:
print(f" [FAIL] 目标 formType={form_type},不是 display 自定义页面,已拒绝写入",
file=sys.stderr)
else:
print(" [FAIL] get-info 返回中未找到 formType,已拒绝写入", file=sys.stderr)
print(" [HINT] 请确认 --form 是 display 页面;确认无误时可加 --force 显式绕过",
file=sys.stderr)
return False
def _load_code(args: argparse.Namespace) -> str:
if args.code_file:
safe = _resolve_safe_path(args.code_file)
if not safe.exists():
raise ValueError(f"文件不存在: {safe}")
if safe.stat().st_size > MAX_CODE_FILE_SIZE:
raise ValueError(f"文件过大 (限制 {MAX_CODE_FILE_SIZE:,} 字节)")
return safe.read_text(encoding="utf-8")
elif args.code:
if len(args.code.encode("utf-8")) > MAX_INLINE_CODE:
raise ValueError(f"--code 过长 (限制 {MAX_INLINE_CODE:,} 字节)")
return args.code
else:
raise ValueError("必须提供 --code-file 或 --code")
def _extract_existing_data_source(schema: dict) -> dict | None:
"""从已有 schema 中提取 Page 组件的 dataSource,用于 merge 保留用户自定义数据源。"""
try:
return schema["pages"][0]["componentsTree"][0].get("dataSource")
except (KeyError, IndexError, TypeError):
return None
def main() -> int:
ap = argparse.ArgumentParser(
description=(
"宜搭自定义页面 schema 生成/修改 "
"【调用前必读】references/yida-custom-page-codegen.md"
"JSX 写法 / Hooks 限制 / 跨表单联动 / 常见坑),不要只看 --help 就拼 JSX"
),
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
ap.add_argument("--app", required=True, help="应用编码 appType")
ap.add_argument("--form", required=True, help="页面 formUuid")
ap.add_argument("--code-file", help="新代码文件路径")
ap.add_argument("--code", help="新代码内联字符串")
ap.add_argument("--show-current", action="store_true", help="只输出当前代码不修改")
ap.add_argument("--yes", action="store_true", help="确认写入")
ap.add_argument("--dry-run", action="store_true", help="只编译不写入")
ap.add_argument("--skip-field-check", action="store_true",
help="跳过字段 ID 对账预检(不推荐)")
ap.add_argument("--skip-lint", action="store_true",
help="跳过 JSX 静态检查(30 条宜搭专属陷阱,不推荐)")
ap.add_argument("--force", action="store_true",
help="跳过发布目标 formType=display 保护(仅确认目标无误时使用)")
args = ap.parse_args()
if not args.show_current and not args.code_file and not args.code:
print("错误: 必须提供 --code-file / --code 或 --show-current", file=sys.stderr)
return 1
if not args.show_current and not args.dry_run:
if not _check_display_target(args.app, args.form, force=args.force):
return 1
# Step 1: 拉取现有 schema
print("Step 1: 获取现有 schema")
resp = _run_dws(["yida", "design", "form", "get-schema", "--app", args.app,
"--form", args.form, "--format", "json"], dry_run=args.dry_run)
if args.dry_run and not args.show_current:
try:
new_code = _load_code(args)
except ValueError as e:
print(f"错误: {e}", file=sys.stderr)
return 1
result = compile_jsx_to_schema(new_code, form_uuid=args.form)
if not result.get("ok"):
errors = result.get("errors", [])
err_msgs = "; ".join(e.get("message", "") for e in errors)
print(f" [FAIL] 编译失败: {err_msgs}", file=sys.stderr)
lint = result.get("lint", {})
if lint.get("warnings"):
for w in lint["warnings"]:
print(f" [WARN] {w.get('message', w)}", file=sys.stderr)
return 1
schema_json = result["schema"]
print(json.dumps({"ok": True, "dry_run": True, "formUuid": args.form,
"codeSize": len(new_code),
"schemaSize": len(schema_json)}, ensure_ascii=False, indent=2))
return 0
if not resp:
return 1
schema = resp
print(" [OK] 拿到 schema")
# --show-current 模式
if args.show_current:
try:
current_code = extract_source_code(schema)
except (ValueError, TypeError):
current_code = None
if current_code is None:
print(" [WARN] schema 中没有可提取的自定义页面代码")
print(json.dumps({"ok": False, "error": "not_a_custom_page"}, ensure_ascii=False))
return 1
print(json.dumps({"ok": True, "formUuid": args.form,
"codeSize": len(current_code),
"currentCode": current_code}, ensure_ascii=False, indent=2))
return 0
# 获取 page_id
page_id = args.form
pages = schema.get("pages", [])
if pages:
page_id = pages[0].get("id", args.form) or args.form
# 提取已有 dataSource(用于 merge
existing_ds = _extract_existing_data_source(schema)
# Step 2: 加载新代码、编译并构建 schema
try:
new_code = _load_code(args)
except ValueError as e:
print(f"错误: {e}", file=sys.stderr)
return 1
if not new_code.strip():
print("错误: 代码不能为空", file=sys.stderr)
return 1
try:
current_code = extract_source_code(schema)
except (ValueError, TypeError):
current_code = None
previous_size = len(current_code) if current_code else 0
print(f"Step 2: 编译 + 构建 schema (新代码 {len(new_code):,} 字节)")
result = compile_jsx_to_schema(new_code, form_uuid=page_id, existing_data_source=existing_ds)
if not result.get("ok"):
errors = result.get("errors", [])
err_msgs = "; ".join(e.get("message", "") for e in errors)
print(f" [FAIL] 编译失败: {err_msgs}", file=sys.stderr)
lint = result.get("lint", {})
if lint.get("warnings"):
for w in lint["warnings"]:
print(f" [WARN] {w.get('message', w)}", file=sys.stderr)
return 1
schema_json = result["schema"]
lint = result.get("lint", {})
if lint.get("warnings"):
for w in lint["warnings"]:
print(f" [WARN] lint: {w.get('message', w)}")
print(f" [OK] 编译成功, schema 大小: {len(schema_json):,} 字节")
# Step 2.5: 字段 ID 对账预检(避免发布后运行时才报 fieldId 不存在)
if not args.skip_field_check:
print("Step 2.5: 字段 ID 对账")
chk = field_check(new_code, args.app)
for w in chk.get("warnings", []):
print(f" [WARN] {w.get('message', w)}")
if not chk.get("ok"):
print(" [FAIL] 字段对账未通过,为避免发布后页面报错,拒绝写入:", file=sys.stderr)
for e in chk.get("errors", []):
print(f" - {e.get('message', e)}", file=sys.stderr)
print(" [HINT] 修复后重试;确认需要忽略可加 --skip-field-check(不推荐)", file=sys.stderr)
return 1
info = chk.get("info", {})
if info.get("skipped"):
print(f" [OK] 跳过({info['skipped']}")
else:
print(f" [OK] 已校验 {info.get('referencedFieldCount', 0)} 个字段引用,"
f"覆盖 {len(info.get('checkedForms', []))} 张表单")
# Step 2.7: JSX 静态检查(避免发布后运行时才报错)
if not args.skip_lint:
print("Step 2.7: JSX 静态检查")
lr = lint_check(new_code, filename=args.code_file or "page.jsx")
for w in lr.get("warnings", []):
print(f" [WARN] L{w['line']} [{w['rule']}] {w['message']}")
if not lr.get("ok"):
print(" [FAIL] JSX 静态检查未通过,为避免发布后页面报错,拒绝写入:", file=sys.stderr)
for e in lr.get("errors", []):
print(f" L{e['line']} [{e['rule']}] {e['message']}", file=sys.stderr)
print(" [HINT] 修复后重试;确认需要忽略可加 --skip-lint(不推荐)", file=sys.stderr)
print(" [HINT] 或在 JSX 中加 // dws-lint-disable-line [rule] 关闭单行检查", file=sys.stderr)
return 1
info = lr.get("info", {})
print(f" [OK] 检查通过(错误 {info.get('errorCount', 0)} / 警告 {info.get('warningCount', 0)}")
# Step 3: 写回
print("Step 3: 写入 schema")
resp = _run_dws(["yida", "design", "form", "update-schema", "--app", args.app,
"--form", args.form, "--form-type", "display",
"--content", schema_json, "--yes", "--format", "json"])
if not resp:
return 1
print(" [OK] 写入成功")
print(json.dumps({"ok": True, "formUuid": args.form, "codeSize": len(new_code),
"previousCodeSize": previous_size}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,526 @@
"""
宜搭表单 schema 构造 — 字段组件装配进 Page > FormContainer 骨架。
主入口:
build_form_schema(form_title, fields, form_uuid, corp_id, app_type)
apply_changes_to_schema(schema, changes) — 增量操作
"""
from __future__ import annotations
import copy
import json
import re
from typing import Any, Optional
from yida_schema_common import (
build_components_map,
collect_component_names,
DATA_SOURCE_FIT_COMPILED,
DATA_SOURCE_FIT_SOURCE,
generate_field_id,
i18n,
next_node_id,
normalize_field_type,
UTILS_LEGAO_BUILTIN,
UTILS_YIDA_PLUGIN,
)
from yida_form_fields import build_field_component
# ---------------------------------------------------------------------------
# 骨架常量
# ---------------------------------------------------------------------------
_FORM_ACTIONS = {
"module": {
"source": (
'/**\n* 尊敬的用户,你好:页面 JS 面板是高阶用法。\n*/\n\n'
'export function didMount() {\n'
' console.log(`「页面 JS」:当前页面地址 ${location.href}`);\n'
'}'
),
"compiled": (
'"use strict";\n\nexports.__esModule = true;\nexports.didMount = didMount;\n'
'function didMount() {\n'
' console.log("\\u300C\\u9875\\u9762 JS\\u300D\\uFF1A\\u5F53\\u524D\\u9875\\u9762\\u5730\\u5740 " + location.href);\n'
'}\n'
),
},
"type": "FUNCTION",
"list": [{"id": "didMount", "title": "didMount"}],
}
_CONSTRUCTOR_SOURCE = (
"function constructor() {\n"
"var module = { exports: {} };\n"
"var _this = this;\n"
"this.__initMethods__(module.exports, module);\n"
"Object.keys(module.exports).forEach(function(item) {\n"
" if(typeof module.exports[item] === 'function'){\n"
" _this[item] = module.exports[item];\n"
" }\n"
"});\n\n"
"}"
)
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def build_form_schema(
form_title: str,
fields: list[dict[str, Any]],
form_uuid: str = "",
corp_id: str = "",
app_type: str = "",
*,
label_align: str = "top",
) -> dict[str, Any]:
"""
构造完整的表单 schema。
Args:
form_title: 表单标题
fields: 字段定义数组(每项含 type + label + ...
form_uuid: 表单 UUID
corp_id: 企业 ID(流水号需要)
app_type: 应用编码
label_align: 标签对齐方式 top/left
"""
# 1. 构造所有字段节点
field_nodes: list[dict[str, Any]] = []
used_colors: set[str] = set()
for field in fields:
node, used_colors = build_field_component(
field,
used_colors=used_colors,
app_type=app_type,
form_uuid=form_uuid,
corp_id=corp_id,
)
field_nodes.append(node)
# 2. 后处理:解析 @label: 引用
_resolve_field_id_references(field_nodes)
# 3. 后处理:补全流水号 formula(此时 corp_id/app_type/form_uuid 确定)
_fill_serial_number_formulas(field_nodes, corp_id, app_type, form_uuid)
# 4. 收集 componentsMap
all_component_names = ["Page", "RootHeader", "RootContent", "RootFooter", "FormContainer"]
all_component_names.extend(collect_component_names(field_nodes))
components_map = build_components_map(all_component_names)
# 5. 拼装骨架
schema: dict[str, Any] = {
"schemaType": "superform",
"schemaVersion": "5.0",
"pages": [
{
"utils": [UTILS_LEGAO_BUILTIN, UTILS_YIDA_PLUGIN],
"componentsMap": components_map,
"componentsTree": [
{
"componentName": "Page",
"id": next_node_id(),
"props": {
"templateVersion": "1.0.0",
"pageStyle": {"backgroundColor": "#f2f3f5"},
"titleName": i18n(form_title),
"titleDesc": i18n(""),
"titleColor": "light",
"titleBg": "https://img.alicdn.com/imgextra/i2/O1CN0143ATPP1wIa9TrVvzN_!!6000000006285-2-tps-3360-400.png_.webp",
"backgroundColorCustom": "#f1f2f3",
"sizePc": "medium",
"labelAlignPc": label_align,
"labelWidthPc": "130px",
"labelWeightPc": "normal",
"contentMargin": "12",
"contentPadding": "20",
"contentBgColor": "white",
"showTitle": True,
"labelAlignMobile": "left",
"labelWidthMobile": "100px",
"labelWeightMobile": "bold",
"contentMarginMobile": "12",
"contentPaddingMobile": "0",
"contentBgColorMobile": "white",
"className": "page_m8o991i5",
},
"dataSource": {
"offline": [],
"globalConfig": {
"fit": {
"compiled": DATA_SOURCE_FIT_COMPILED,
"source": DATA_SOURCE_FIT_SOURCE,
"type": "js",
"error": {},
},
},
"online": [],
"list": [],
"sync": True,
},
"methods": {
"__initMethods__": {
"type": "js",
"source": "function (exports, module) { /*set actions code here*/ }",
"compiled": "function (exports, module) { /*set actions code here*/ }",
},
},
"lifeCycles": {
"componentDidMount": {
"id": "didMount",
"name": "didMount",
"params": {},
"type": "actionRef",
},
"componentWillUnmount": "",
"constructor": {
"type": "js",
"compiled": _CONSTRUCTOR_SOURCE,
"source": _CONSTRUCTOR_SOURCE,
},
},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
"children": [
{
"componentName": "RootHeader",
"id": next_node_id(),
"props": {},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
},
{
"componentName": "RootContent",
"id": next_node_id(),
"props": {},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
"children": [
{
"componentName": "FormContainer",
"id": next_node_id(),
"props": {
"columns": 1,
"labelAlign": label_align,
"submitText": i18n("提交", "Submit"),
"fieldId": generate_field_id("formContainer"),
"aiFormConfig": {
"systemPrompt": "",
"model": "qwen",
},
"beforeSubmit": False,
"afterSubmit": False,
},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
"children": field_nodes,
},
],
},
{
"componentName": "RootFooter",
"id": next_node_id(),
"props": {},
"hidden": False,
"title": "",
"isLocked": False,
"condition": True,
"conditionGroup": "",
},
],
"css": "body{background-color:#f2f3f5}",
},
],
"componentAlias": {"items": []},
"id": form_uuid or "xxxx",
"connectComponent": [],
},
],
"actions": copy.deepcopy(_FORM_ACTIONS),
"config": {"connectComponent": []},
}
return schema
# ---------------------------------------------------------------------------
# 增量修改
# ---------------------------------------------------------------------------
def apply_changes_to_schema(
schema: dict[str, Any],
changes: list[dict[str, Any]],
*,
corp_id: str = "",
app_type: str = "",
form_uuid: str = "",
) -> dict[str, Any]:
"""
在已有 schema 上执行增量 changesadd/update/delete)。
返回修改后的 schema(原地修改)。
"""
form_container = _find_form_container(schema)
if form_container is None:
raise ValueError("schema 中找不到 FormContainer 节点")
children: list[dict[str, Any]] = form_container.get("children", [])
used_colors = _collect_existing_colors(children)
for change in changes:
action = change.get("action", "")
if action == "add":
field_def = change.get("field", {})
node, used_colors = build_field_component(
field_def,
used_colors=used_colors,
app_type=app_type,
form_uuid=form_uuid,
corp_id=corp_id,
)
after_label = change.get("after")
before_label = change.get("before")
insert_idx = len(children)
if after_label:
idx = _find_field_index_by_label(children, after_label)
if idx is not None:
insert_idx = idx + 1
elif before_label:
idx = _find_field_index_by_label(children, before_label)
if idx is not None:
insert_idx = idx
children.insert(insert_idx, node)
elif action == "update":
label = change.get("label", "")
table_label = change.get("tableLabel")
patches = change.get("changes", {})
target_list = children
if table_label:
table_node = _find_field_by_label(children, table_label)
if table_node and table_node.get("componentName") == "TableField":
target_list = table_node.get("children", [])
target = _find_field_by_label(target_list, label)
if target:
_apply_field_patches(target, patches, used_colors)
elif action == "delete":
label = change.get("label", "")
table_label = change.get("tableLabel")
target_list = children
if table_label:
table_node = _find_field_by_label(children, table_label)
if table_node and table_node.get("componentName") == "TableField":
target_list = table_node.get("children", [])
idx = _find_field_index_by_label(target_list, label)
if idx is not None:
target_list.pop(idx)
# 后处理
all_fields = form_container.get("children", [])
_resolve_field_id_references(all_fields)
_fill_serial_number_formulas(all_fields, corp_id, app_type, form_uuid)
# 更新 componentsMap
all_names = ["Page", "RootHeader", "RootContent", "RootFooter", "FormContainer"]
all_names.extend(collect_component_names(all_fields))
schema["pages"][0]["componentsMap"] = build_components_map(all_names)
return schema
# ---------------------------------------------------------------------------
# 后处理
# ---------------------------------------------------------------------------
def _resolve_field_id_references(field_nodes: list[dict[str, Any]]) -> None:
"""解析 @label:字段名 引用为真实 fieldId。"""
label_to_field_id: dict[str, str] = {}
def _collect(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
props = node.get("props", {})
label_obj = props.get("label", {})
label_text = label_obj.get("zh_CN", "") if isinstance(label_obj, dict) else str(label_obj)
field_id = props.get("fieldId", "")
if label_text and field_id:
label_to_field_id[label_text] = field_id
for child in node.get("children", []):
_collect([child])
_collect(field_nodes)
def _resolve(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
props = node.get("props", {})
filling_rules = props.get("dataFillingRules", {})
if isinstance(filling_rules, dict):
main_rules = filling_rules.get("mainRules", [])
for rule in main_rules:
for key in ("source", "sourceFieldId", "target", "targetFieldId"):
val = rule.get(key, "")
if isinstance(val, str) and val.startswith("@label:"):
name = val[7:]
if name in label_to_field_id:
rule[key] = label_to_field_id[name]
for child in node.get("children", []):
_resolve([child])
_resolve(field_nodes)
def _fill_serial_number_formulas(
field_nodes: list[dict[str, Any]],
corp_id: str,
app_type: str,
form_uuid: str,
) -> None:
"""确保 SerialNumberField 的 formula 包含正确的 corp_id/app_type/form_uuid。"""
if not corp_id or not app_type or not form_uuid:
return
def _walk(nodes: list[dict[str, Any]]) -> None:
for node in nodes:
if node.get("componentName") == "SerialNumberField":
props = node.get("props", {})
field_id = props.get("fieldId", "")
serial_rule = props.get("serialNumberRule", [])
if serial_rule and field_id:
rule_json = json.dumps({"type": "custom", "value": serial_rule}).replace('"', '\\"')
props["formula"] = {
"expression": f'SERIALNUMBER("{corp_id}", "{app_type}", "{form_uuid}", "{field_id}", "{rule_json}")'
}
for child in node.get("children", []):
_walk([child])
_walk(field_nodes)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _find_form_container(schema: dict[str, Any]) -> Optional[dict[str, Any]]:
pages = schema.get("pages", [])
if not pages:
return None
tree = pages[0].get("componentsTree", [])
if not tree:
return None
def _search(node: dict[str, Any]) -> Optional[dict[str, Any]]:
if node.get("componentName") == "FormContainer":
return node
for child in node.get("children", []):
found = _search(child)
if found:
return found
return None
return _search(tree[0])
def _find_field_by_label(nodes: list[dict[str, Any]], label: str) -> Optional[dict[str, Any]]:
for node in nodes:
props = node.get("props", {})
label_obj = props.get("label", {})
label_text = label_obj.get("zh_CN", "") if isinstance(label_obj, dict) else str(label_obj)
if label_text == label:
return node
return None
def _find_field_index_by_label(nodes: list[dict[str, Any]], label: str) -> Optional[int]:
for idx, node in enumerate(nodes):
props = node.get("props", {})
label_obj = props.get("label", {})
label_text = label_obj.get("zh_CN", "") if isinstance(label_obj, dict) else str(label_obj)
if label_text == label:
return idx
return None
def _collect_existing_colors(nodes: list[dict[str, Any]]) -> set[str]:
colors: set[str] = set()
for node in nodes:
props = node.get("props", {})
for item in props.get("dataSource", []):
c = item.get("color")
if c:
colors.add(c)
for child in node.get("children", []):
colors.update(_collect_existing_colors([child]))
return colors
def _apply_field_patches(node: dict[str, Any], patches: dict[str, Any], used_colors: set[str]) -> None:
props = node.get("props", {})
if "label" in patches:
props["label"] = i18n(patches["label"])
if "required" in patches:
validation = props.get("validation", [])
has_req = any(v.get("type") == "required" for v in validation)
if patches["required"] and not has_req:
validation.append({"type": "required"})
props["validation"] = validation
elif not patches["required"] and has_req:
props["validation"] = [v for v in validation if v.get("type") != "required"]
if "behavior" in patches:
props["behavior"] = patches["behavior"]
if "options" in patches:
from yida_schema_common import build_option_data_source
component_name = node.get("componentName", "")
is_checkbox = component_name in ("CheckboxField", "MultiSelectField")
ds, _ = build_option_data_source(patches["options"], is_checkbox=is_checkbox, used_colors=used_colors)
props["dataSource"] = ds
props["isUseDataSourceColor"] = True
if "placeholder" in patches:
props["placeholder"] = i18n(patches["placeholder"])
if "suffix" in patches:
props["innerAfter"] = i18n(patches["suffix"])
if "prefix" in patches:
props["innerBefore"] = i18n(patches["prefix"])
if "format" in patches:
props["format"] = patches["format"]
if "multiple" in patches or "multi" in patches:
val = patches.get("multiple") or patches.get("multi")
props["multiple"] = val
if val:
props["mode"] = "multiple"
def is_empty_skeleton(schema: dict[str, Any]) -> bool:
"""判断 schema 是否是空骨架(无字段)。"""
fc = _find_form_container(schema)
if fc is None:
return True
children = fc.get("children", [])
return len(children) == 0
@@ -0,0 +1,315 @@
"""
宜搭表单字段构造 — 按 type 分发,每种字段类型产出一个 component dict。
支持 19 种字段类型 + Divider。被 yida_form_builder.py 调用。
"""
from __future__ import annotations
import json
from typing import Any, Optional
from yida_schema_common import (
generate_field_id,
i18n,
next_node_id,
normalize_field_type,
build_option_data_source,
OPTION_FIELD_TYPES,
SUPPORTED_FIELD_TYPES,
)
def build_field_component(
field: dict[str, Any],
*,
used_colors: Optional[set[str]] = None,
app_type: str = "",
form_uuid: str = "",
corp_id: str = "",
) -> tuple[dict[str, Any], set[str]]:
"""
根据字段定义 dict 构造一个组件节点。
返回 (component_node, updated_used_colors)。
"""
if used_colors is None:
used_colors = set()
raw_type = field.get("type", "")
component_name = normalize_field_type(raw_type)
label = field.get("label", "")
required = field.get("required", False)
field_id = generate_field_id(component_name)
node: dict[str, Any] = {
"componentName": component_name,
"id": next_node_id(),
"props": {
"label": i18n(label),
"fieldId": field_id,
"__category__": "form",
"behavior": field.get("behavior", "NORMAL"),
"visibility": ["PC", "MOBILE"],
"submittable": "DEFAULT",
},
}
props = node["props"]
# required
if required:
props["validation"] = [{"type": "required"}]
# placeholder
if field.get("placeholder"):
props["placeholder"] = i18n(field["placeholder"])
# defaultValue
if field.get("defaultValue") is not None:
props["defaultValue"] = field["defaultValue"]
# --- 按类型分发 ---
if component_name == "TextareaField":
_apply_textarea_props(props, field)
elif component_name == "NumberField":
_apply_number_props(props, field)
elif component_name == "RateField":
_apply_rate_props(props, field)
elif component_name in ("DateField", "CascadeDateField"):
_apply_date_props(props, field, component_name)
elif component_name in OPTION_FIELD_TYPES:
used_colors = _apply_option_props(props, field, component_name, used_colors)
elif component_name in ("EmployeeField", "DepartmentSelectField"):
_apply_people_props(props, field)
elif component_name == "CountrySelectField":
_apply_people_props(props, field)
elif component_name == "AddressField":
_apply_address_props(props, field)
elif component_name == "AttachmentField":
_apply_attachment_props(props, field)
elif component_name == "ImageField":
_apply_image_props(props, field)
elif component_name == "SerialNumberField":
_apply_serial_number_props(props, field, app_type, form_uuid, field_id, corp_id)
elif component_name == "TableField":
used_colors = _apply_table_props(node, field, used_colors, app_type, form_uuid, corp_id)
elif component_name == "AssociationFormField":
_apply_association_props(props, field, app_type)
elif component_name == "Divider":
_apply_divider_props(props, field, label)
return node, used_colors
# ---------------------------------------------------------------------------
# 各类型 props 应用
# ---------------------------------------------------------------------------
def _apply_textarea_props(props: dict, field: dict) -> None:
props["rows"] = field.get("rows", 4)
props["htmlType"] = "textarea"
def _apply_number_props(props: dict, field: dict) -> None:
fmt = field.get("format", "INT")
if fmt == "FLOAT":
props["precision"] = field.get("precision", 2)
props["format"] = "money_w4"
elif fmt == "PERCENT":
props["precision"] = field.get("precision", 2)
props["format"] = "percent"
else:
props["precision"] = 0
props["format"] = "integer"
if field.get("suffix"):
props["innerAfter"] = i18n(field["suffix"])
if field.get("prefix"):
props["innerBefore"] = i18n(field["prefix"])
if field.get("min") is not None:
props["min"] = field["min"]
if field.get("max") is not None:
props["max"] = field["max"]
def _apply_rate_props(props: dict, field: dict) -> None:
props["count"] = field.get("total", 5)
if field.get("allowHalf"):
props["allowHalf"] = True
def _apply_date_props(props: dict, field: dict, component_name: str) -> None:
fmt = field.get("format", "yyyy-MM-dd")
props["format"] = fmt
if "HH" in fmt:
props["showTime"] = True
def _apply_option_props(
props: dict,
field: dict,
component_name: str,
used_colors: set[str],
) -> set[str]:
options = field.get("options", [])
if not options:
return used_colors
is_checkbox = component_name in ("CheckboxField", "MultiSelectField")
props["isUseDataSourceColor"] = True
data_source, used_colors = build_option_data_source(
options, is_checkbox=is_checkbox, used_colors=used_colors
)
props["dataSource"] = data_source
if not is_checkbox and data_source:
props["value"] = data_source[0]["value"]
return used_colors
def _apply_people_props(props: dict, field: dict) -> None:
multi = field.get("multi") or field.get("multiple")
if multi:
props["multiple"] = True
props["mode"] = "multiple"
def _apply_address_props(props: dict, field: dict) -> None:
level = field.get("level", "ADDRESS")
props["addressType"] = level
def _apply_attachment_props(props: dict, field: dict) -> None:
props["autoUpload"] = True
props["maxFileSize"] = field.get("maxFileSize", 100)
if field.get("maxFiles"):
props["maxItems"] = field["maxFiles"]
if field.get("fileTypes"):
props["accept"] = field["fileTypes"]
def _apply_image_props(props: dict, field: dict) -> None:
props["autoUpload"] = True
if field.get("maxFiles"):
props["maxItems"] = field["maxFiles"]
def _apply_serial_number_props(
props: dict,
field: dict,
app_type: str,
form_uuid: str,
field_id: str,
corp_id: str,
) -> None:
# 流水号不允许 required
if "validation" in props:
props["validation"] = [v for v in props["validation"] if v.get("type") != "required"]
serial_rule = field.get("serialNumberRule") or [
{
"__hide_delete__": False,
"ruleType": "date",
"content": "",
"formField": "",
"dateFormat": "yyyyMMdd",
"timeZone": "+8",
"digitCount": 4,
"isFixed": True,
"isFixedTips": "",
"resetPeriod": "noClean",
"resetPeriodTips": "",
"initialValue": 1,
},
{
"__hide_delete__": True,
"ruleType": "autoCount",
"content": "",
"formField": "",
"dateFormat": "yyyyMMdd",
"timeZone": "+8",
"digitCount": "4",
"isFixed": True,
"isFixedTips": "",
"resetPeriod": "noClean",
"resetPeriodTips": "",
"initialValue": 1,
},
]
props["serialNumberRule"] = serial_rule
serial_rule_json = json.dumps({"type": "custom", "value": serial_rule}).replace('"', '\\"')
props["formula"] = {
"expression": f'SERIALNUMBER("{corp_id}", "{app_type}", "{form_uuid}", "{field_id}", "{serial_rule_json}")'
}
def _apply_table_props(
node: dict,
field: dict,
used_colors: set[str],
app_type: str,
form_uuid: str,
corp_id: str,
) -> set[str]:
props = node["props"]
props["layout"] = "TABLE"
props["mobileLayout"] = "TILED"
props["maxItems"] = field.get("maxItems", 50)
children_defs = field.get("children", [])
children_nodes: list[dict[str, Any]] = []
for child_field in children_defs:
child_node, used_colors = build_field_component(
child_field,
used_colors=used_colors,
app_type=app_type,
form_uuid=form_uuid,
corp_id=corp_id,
)
children_nodes.append(child_node)
node["children"] = children_nodes
return used_colors
def _apply_association_props(props: dict, field: dict, app_type: str) -> None:
source_app = field.get("sourceApp", app_type)
source_form = field.get("sourceForm", "")
display_field_code = field.get("displayFieldCode", "")
if source_form:
props["associationForm"] = {
"appType": source_app,
"formUuid": source_form,
"formType": "receipt",
"formTitle": "",
"mainFieldId": display_field_code,
"mainComponentName": "TextField",
"mainFieldLabel": "",
}
props["dataFilterRules"] = {"instanceFieldId": None}
filling_rules = field.get("dataFillingRules", [])
if filling_rules:
normalized: list[dict[str, Any]] = []
for rule in filling_rules:
src = rule.get("source", "")
tgt = rule.get("target", "")
normalized.append({
"source": src,
"sourceFieldId": src,
"sourceType": rule.get("sourceType", "form"),
"target": tgt,
"targetFieldId": tgt,
"targetType": rule.get("targetType", "form"),
})
props["dataFillingRules"] = {"mainRules": normalized}
def _apply_divider_props(props: dict, field: dict, label: str) -> None:
props["title"] = i18n(label)
props["type"] = field.get("dividerType", "multi-parallelograms-end")
props.pop("validation", None)
props["behavior"] = "NORMAL"
props.pop("__category__", None)
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""
yida_form_inspector.py — 跨表单元数据巡查工具
为「自定义页面联动其他表单」场景提供元数据采集能力,让 AI 在生成 JSX 前就能
拿到目标应用下所有表单的 formUuid + 字段 ID,避免硬编码错误。
【调用前必读】references/yida-custom-page-codegen.md §9
跨表单联动 5 种模式(只读聚合 / 提交其他表 / Master-Detail / 多表 Dashboard /
跨表搬运)。本脚本只负责取字段元数据,JSX 写法以 codegen.md 为准——不要
看到 --help 就直接拼 JSX。
用法:
# 列出应用下全部表单(含 formUuid / formType / title
python yida_form_inspector.py --action list-forms --app APP_X
# 查看单张表单的字段(fieldId / dataType / label
python yida_form_inspector.py --action fields --app APP_X --form FORM-XXX
# 一次性导出多张表单的字段汇总(推荐:跨表整合页面用)
python yida_form_inspector.py --action bundle --app APP_X --forms FORM-A,FORM-B,FORM-C --output ./forms.json
# 直接生成可粘贴到 JSX 的 FIELDS 常量代码
python yida_form_inspector.py --action fields-snippet --app APP_X --form FORM-XXX
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
def _run_dws(args):
cmd = ["dws"] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print("[FAIL] 找不到 'dws' 命令,请确认已安装并在 PATH 中", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print("[FAIL] dws 调用超时", file=sys.stderr)
return None
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
print(f"[FAIL] dws 执行失败 (exit {result.returncode}): {err}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
print(f"[FAIL] 输出非 JSON: {exc}\n{result.stdout[:300]}", file=sys.stderr)
return None
def _list_forms(app, form_types=None):
args = ["yida", "app", "list-forms", "--app", app, "--format", "json"]
if form_types:
args.extend(["--form-types", form_types])
data = _run_dws(args)
if data is None:
return None
if isinstance(data, dict):
for key in ("forms", "data", "items", "list"):
if isinstance(data.get(key), list):
return data[key]
return [data]
if isinstance(data, list):
return data
return []
_FIELD_ID_VALUE_RE = re.compile(r'\b[A-Za-z]+Field_[A-Za-z0-9]+\b')
_COMPONENT_LIST_KEYS = ("components", "fields", "data", "items", "result", "children", "list")
def _walk_values(obj):
if isinstance(obj, dict):
yield obj
for value in obj.values():
yield from _walk_values(value)
elif isinstance(obj, list):
for item in obj:
yield from _walk_values(item)
def _find_nested_value(obj, keys):
if isinstance(obj, dict):
for key in keys:
value = obj.get(key)
if value not in (None, ""):
return value
for value in obj.values():
found = _find_nested_value(value, keys)
if found not in (None, ""):
return found
elif isinstance(obj, list):
for item in obj:
found = _find_nested_value(item, keys)
if found not in (None, ""):
return found
return None
def _normalize_i18n_label(label):
if isinstance(label, str):
raw = label.strip()
if raw.startswith("{") and raw.endswith("}"):
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return label
if isinstance(parsed, dict):
return (parsed.get("zh_CN") or parsed.get("zh-CN")
or parsed.get("text") or parsed.get("pureEn_US")
or parsed.get("en_US") or label)
return label
if isinstance(label, dict):
return (label.get("zh_CN") or label.get("zh-CN") or label.get("text")
or label.get("pureEn_US") or label.get("en_US") or "")
return label or ""
def _find_field_id(obj):
value = _find_nested_value(obj, ("fieldId", "fieldCode", "field_id", "fieldKey"))
if isinstance(value, str):
match = _FIELD_ID_VALUE_RE.search(value)
return match.group(0) if match else value
key_value = _find_nested_value(obj, ("key", "name", "id"))
if isinstance(key_value, str):
match = _FIELD_ID_VALUE_RE.search(key_value)
if match:
return match.group(0)
text = json.dumps(obj, ensure_ascii=False) if isinstance(obj, (dict, list)) else str(obj)
match = _FIELD_ID_VALUE_RE.search(text)
return match.group(0) if match else None
def _extract_component_items(data):
if isinstance(data, dict):
for key in _COMPONENT_LIST_KEYS:
value = data.get(key)
if isinstance(value, list):
return value
if isinstance(value, dict):
nested = _extract_component_items(value)
if nested:
return nested
if isinstance(data, list):
return data
found = []
seen = set()
for item in _walk_values(data):
if not isinstance(item, dict):
continue
field_id = _find_field_id(item)
if field_id and field_id not in seen:
seen.add(field_id)
found.append(item)
return found
def _components(app, form):
data = _run_dws(["yida", "form", "components", "--app", app,
"--form", form, "--format", "json"])
if data is None:
return None
return _extract_component_items(data)
def _normalize_field(comp):
field_id = _find_field_id(comp)
label = (_find_nested_value(comp, ("label", "title", "text", "displayName", "nameCn"))
or "")
label = _normalize_i18n_label(label)
return {
"fieldId": field_id,
"label": label,
"dataType": _find_nested_value(comp, ("dataType", "valueType")) or "",
"componentName": _find_nested_value(comp, ("componentName", "type", "component")) or "",
}
def _camel_safe(text, fallback):
if not text:
text = fallback
cleaned = re.sub(r"[^A-Za-z0-9]+", " ", text).strip()
if not cleaned:
return fallback
parts = cleaned.split()
return parts[0].lower() + "".join(p.capitalize() for p in parts[1:])
def _action_list_forms(args):
forms = _list_forms(args.app, args.form_types)
if forms is None:
return 1
summary = []
for f in forms:
summary.append({
"formUuid": f.get("formUuid") or f.get("formId") or f.get("uuid"),
"title": f.get("title") or f.get("name"),
"formType": f.get("formType") or f.get("type"),
"appType": f.get("appType") or args.app,
})
print(json.dumps({"ok": True, "app": args.app, "count": len(summary),
"forms": summary}, ensure_ascii=False, indent=2))
return 0
def _action_fields(args):
if not args.form:
print("错误: --action fields 需要 --form", file=sys.stderr)
return 1
comps = _components(args.app, args.form)
if comps is None:
return 1
fields = [_normalize_field(c) for c in comps]
print(json.dumps({"ok": True, "app": args.app, "form": args.form,
"count": len(fields), "fields": fields},
ensure_ascii=False, indent=2))
return 0
def _action_fields_snippet(args):
if not args.form:
print("错误: --action fields-snippet 需要 --form", file=sys.stderr)
return 1
comps = _components(args.app, args.form)
if comps is None:
return 1
lines = ["// 由 yida_form_inspector.py 自动生成",
f"// 表单: {args.form}",
"var FIELDS = {"]
used = set()
for idx, comp in enumerate(comps):
f = _normalize_field(comp)
if not f["fieldId"]:
continue
var_name = _camel_safe(f["label"], f"field{idx}")
base = var_name
n = 2
while var_name in used:
var_name = f"{base}{n}"
n += 1
used.add(var_name)
comment = f" // {f['label']} ({f['dataType']})" if f["label"] else ""
lines.append(f" {var_name}: '{f['fieldId']}',{comment}")
lines.append("};")
print("\n".join(lines))
return 0
def _action_bundle(args):
if not args.forms:
print("错误: --action bundle 需要 --forms FORM-A,FORM-B,...", file=sys.stderr)
return 1
form_ids = [s.strip() for s in args.forms.split(",") if s.strip()]
bundle = {"ok": True, "app": args.app, "forms": {}}
for fid in form_ids:
comps = _components(args.app, fid)
if comps is None:
bundle["forms"][fid] = {"ok": False, "error": "fetch_failed"}
continue
bundle["forms"][fid] = {
"ok": True,
"fields": [_normalize_field(c) for c in comps],
}
out = json.dumps(bundle, ensure_ascii=False, indent=2)
if args.output:
Path(args.output).write_text(out, encoding="utf-8")
print(f"[OK] 已写入 {args.output}{len(form_ids)} 张表单)")
else:
print(out)
return 0
def main():
ap = argparse.ArgumentParser(
description=(
"跨表单元数据巡查 / FIELDS 常量生成 "
"【调用前必读】references/yida-custom-page-codegen.md §9"
"(跨表单联动 5 种模式),不要只看 --help 就拼 JSX"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
ap.add_argument("--action", required=True,
choices=["list-forms", "fields", "fields-snippet", "bundle"])
ap.add_argument("--app", required=True, help="应用编码 appType")
ap.add_argument("--form", help="单表 formUuidfields / fields-snippet 用)")
ap.add_argument("--forms", help="多表 formUuid 逗号分隔(bundle 用)")
ap.add_argument("--form-types",
help="list-forms 过滤:receipt / process / report / display 等")
ap.add_argument("--output", help="bundle 写入文件")
args = ap.parse_args()
handlers = {
"list-forms": _action_list_forms,
"fields": _action_fields,
"fields-snippet": _action_fields_snippet,
"bundle": _action_bundle,
}
return handlers[args.action](args)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
宜搭表单 schema 生成/修改(编排:get-schema → apply changes → update-schema
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
用法:
python yida_form_update.py --app APP_X --form FORM-XXX --changes-file fields.json --yes
python yida_form_update.py --app APP_X --form FORM-XXX --changes-json '[...]' --yes
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
changes.json 格式(非空数组,≤ 30 条):
[
{"action": "add", "field": {"type": "TextField", "label": "备注"}, "after": "请假事由"},
{"action": "update", "label": "天数", "changes": {"required": true, "suffix": ""}},
{"action": "delete", "label": "废弃字段"}
]
action 支持: add / update / delete
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
新建场景:CLI 先 `dws yida design form create` 拿到 formUuid
再调本脚本传全 add 的 changes → 自动走 build_form_schema 全量构建。
更新场景:传含 add/update/delete 的 changes → 增量修改既有 schema。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from yida_form_builder import apply_changes_to_schema, build_form_schema, is_empty_skeleton # noqa: E402
MAX_CHANGES = 30
MAX_CHANGES_FILE_SIZE = 512 * 1024
MAX_INLINE_JSON = 64 * 1024
def _resolve_safe_path(path_str: str) -> Path:
allowed_root = os.environ.get("OPENCLAW_WORKSPACE", os.getcwd())
allowed_root_p = Path(allowed_root).resolve()
target = Path(path_str).resolve() if Path(path_str).is_absolute() else (Path.cwd() / path_str).resolve()
try:
target.relative_to(allowed_root_p)
except ValueError:
raise ValueError(f"路径超出允许范围:{path_str}\n允许根目录:{allowed_root_p}")
return target
def _run_dws(args: list[str], dry_run: bool = False) -> Any | None:
cmd = ["dws"] + args
if dry_run:
print(f" [dry-run] {' '.join(cmd)}")
return {"dry_run": True}
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print(" ✗ 找不到 'dws' 命令", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print(" ✗ dws 超时", file=sys.stderr)
return None
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
print(f" ✗ dws 失败 (exit {result.returncode}): {err}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f" ✗ 非 JSON: {e}\n 输出: {result.stdout[:300]}", file=sys.stderr)
return None
def _extract_schema(resp: Any) -> dict[str, Any] | None:
if isinstance(resp, dict) and isinstance(resp.get("content"), dict):
return resp["content"]
if isinstance(resp, dict):
return resp
return None
def _load_changes(args: argparse.Namespace) -> list[dict]:
if args.changes_file:
safe = _resolve_safe_path(args.changes_file)
if not safe.exists():
raise ValueError(f"文件不存在: {safe}")
if safe.stat().st_size > MAX_CHANGES_FILE_SIZE:
raise ValueError(f"文件过大 (限制 {MAX_CHANGES_FILE_SIZE:,} 字节)")
with safe.open("r", encoding="utf-8") as f:
changes = json.load(f)
elif args.changes_json:
if len(args.changes_json.encode("utf-8")) > MAX_INLINE_JSON:
raise ValueError(f"--changes-json 过长 (限制 {MAX_INLINE_JSON:,} 字节)")
changes = json.loads(args.changes_json)
else:
raise ValueError("必须提供 --changes-file 或 --changes-json")
if not isinstance(changes, list) or not changes:
raise ValueError("changes 必须是非空数组")
if len(changes) > MAX_CHANGES:
raise ValueError(f"changes 过多 ({len(changes)} > {MAX_CHANGES})")
for i, c in enumerate(changes):
if not isinstance(c, dict):
raise ValueError(f"change #{i+1} 必须是对象")
if c.get("action") not in ("add", "update", "delete"):
raise ValueError(f"change #{i+1} action 无效: {c.get('action')}")
return changes
def main() -> int:
ap = argparse.ArgumentParser(description="宜搭表单 schema 生成/修改",
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
ap.add_argument("--app", required=True, help="应用编码 appType")
ap.add_argument("--form", required=True, help="表单 formUuid")
ap.add_argument("--changes-file", help="变更定义 JSON 文件路径")
ap.add_argument("--changes-json", help="变更定义 JSON 内联")
ap.add_argument("--corp-id", default="", help="企业 ID")
ap.add_argument("--yes", action="store_true", help="确认写入")
ap.add_argument("--dry-run", action="store_true", help="只生成不写入")
args = ap.parse_args()
try:
changes = _load_changes(args)
except ValueError as e:
print(f"错误: {e}", file=sys.stderr)
return 1
# Step 1: 拉取现有 schema
print("Step 1/3: 获取现有 schema")
resp = _run_dws(["yida", "design", "form", "get-schema", "--app", args.app,
"--form", args.form, "--format", "json"], dry_run=args.dry_run)
if args.dry_run:
print(" [dry-run] 跳过 get-schema")
print(json.dumps({"ok": True, "dry_run": True, "changeCount": len(changes)}, ensure_ascii=False))
return 0
if not resp:
return 1
schema = _extract_schema(resp)
if not schema:
print("错误: get-schema 返回结构异常,未找到 schema", file=sys.stderr)
return 1
print(" ✓ 拿到 schema")
# Step 2: 空骨架自愈
all_add = all(c.get("action") == "add" for c in changes)
if is_empty_skeleton(schema):
if not all_add:
print("错误: 表单是空骨架,但 changes 含 update/delete;空表单只能用全 add", file=sys.stderr)
return 1
print(" ⚠ 空骨架 + 全 add → 全量构建")
info = _run_dws(["yida", "design", "form", "get-info", "--app", args.app,
"--form", args.form, "--format", "json"])
title = (info.get("title", "") or "未命名") if info else "未命名"
fields = [c["field"] for c in changes]
schema = build_form_schema(form_title=title, fields=fields, form_uuid=args.form,
corp_id=args.corp_id, app_type=args.app)
else:
print(f"Step 2/3: 应用 {len(changes)} 条变更")
schema = apply_changes_to_schema(schema, changes, corp_id=args.corp_id,
app_type=args.app, form_uuid=args.form)
print(" ✓ 本地变更完成")
# Step 3: 写回
schema_json = json.dumps(schema, ensure_ascii=False, separators=(",", ":"))
print(f"Step 3/3: 写入 schema ({len(schema_json):,} 字节)")
resp = _run_dws(["yida", "design", "form", "update-schema", "--app", args.app,
"--form", args.form, "--content", schema_json, "--yes", "--format", "json"])
if not resp:
return 1
print(" ✓ 写入成功")
print(json.dumps({"ok": True, "formUuid": args.form, "changeCount": len(changes)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
#!/usr/bin/env python3
"""Generate stable Yida custom-page source from a small JSON spec.
The generator is intentionally pure Python and emits conservative Yida runtime
source: named exports plus React.createElement calls. This avoids the most
common failure modes in hand-written JSX: unsupported hooks, event binding
mistakes, computed object keys, and fragile JSX transforms.
Usage:
python yida_page_generate.py product-homepage --spec page.json --output page.jsx --compile
python yida_page_generate.py todo-mvc --output todo.jsx --title "团队待办"
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from yida_jsx_pipeline import lint_check # noqa: E402
from yida_page_compiler import compile_jsx_to_schema # noqa: E402
IR_VERSION = "1.0"
KNOWN_TEMPLATES = ("product-homepage", "todo-mvc")
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2)
def _safe_filename_stem(value: str, fallback: str) -> str:
text = re.sub(r"[^A-Za-z0-9_.-]+", "-", (value or "").strip()).strip(".-")
return text or fallback
def _read_spec(path: str | None) -> dict[str, Any]:
if not path:
return {}
spec_path = Path(path).expanduser().resolve()
if not spec_path.exists():
raise ValueError(f"spec 文件不存在: {spec_path}")
try:
data = json.loads(spec_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"spec 不是合法 JSON: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("spec 顶层必须是 JSON object")
return data
def _merge_cli_vars(spec: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
merged = dict(spec)
for key in ("title", "subtitle", "brand_name", "tagline"):
value = getattr(args, key, None)
if value:
merged[key] = value
if args.item:
merged["items"] = [{"title": item, "text": ""} for item in args.item]
return merged
def _list_of_dicts(value: Any, fallback: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(value, list):
return fallback
result: list[dict[str, Any]] = []
for item in value:
if isinstance(item, dict):
result.append(dict(item))
elif item is not None:
result.append({"title": str(item), "text": ""})
return result or fallback
def _normalize_product_homepage(raw: dict[str, Any]) -> dict[str, Any]:
features = _list_of_dicts(raw.get("features") or raw.get("items"), [
{"title": "统一入口", "text": "把常用流程、表单和报表聚合到一个页面。"},
{"title": "实时概览", "text": "用清晰的指标帮助团队快速判断当前状态。"},
{"title": "低风险交付", "text": "使用稳定模板生成,减少运行时白屏概率。"},
])
metrics = _list_of_dicts(raw.get("metrics"), [
{"value": "3", "label": "核心模块"},
{"value": "100%", "label": "纯 Python 生成"},
{"value": "0", "label": "外部依赖"},
])
actions = _list_of_dicts(raw.get("actions"), [
{"title": "查看能力", "target": "features"},
{"title": "查看指标", "target": "metrics"},
])
return {
"irVersion": IR_VERSION,
"template": "product-homepage",
"title": str(raw.get("title") or raw.get("brandName") or raw.get("brand_name") or "宜搭自定义页面"),
"subtitle": str(raw.get("subtitle") or raw.get("tagline") or "稳定生成、可检查、可发布的自定义页面骨架"),
"featuresTitle": str(raw.get("featuresTitle") or "核心能力"),
"metricsTitle": str(raw.get("metricsTitle") or "关键指标"),
"features": features[:8],
"metrics": metrics[:6],
"actions": actions[:4],
}
def _normalize_todo_mvc(raw: dict[str, Any]) -> dict[str, Any]:
todos = _list_of_dicts(raw.get("todos") or raw.get("items"), [
{"content": "确认页面需求", "done": True},
{"content": "生成稳定源码", "done": False},
{"content": "发布前 dry-run 校验", "done": False},
])
normalized_todos = []
for item in todos[:20]:
normalized_todos.append({
"content": str(item.get("content") or item.get("title") or "未命名任务"),
"done": bool(item.get("done")),
})
return {
"irVersion": IR_VERSION,
"template": "todo-mvc",
"title": str(raw.get("title") or "团队待办"),
"subtitle": str(raw.get("subtitle") or "验证状态、事件、列表渲染和本地交互的稳定模板"),
"placeholder": str(raw.get("placeholder") or "输入任务后点击添加"),
"todos": normalized_todos,
}
def normalize_spec(template: str, raw: dict[str, Any]) -> dict[str, Any]:
if template == "product-homepage":
return _normalize_product_homepage(raw)
if template == "todo-mvc":
return _normalize_todo_mvc(raw)
raise ValueError(f"未知模板: {template}")
def _common_runtime() -> str:
return """function h(type, props) {
var children = [];
for (var i = 2; i < arguments.length; i++) {
var child = arguments[i];
if (child === null || typeof child === 'undefined' || child === false) {
continue;
}
if (Array.isArray(child)) {
for (var j = 0; j < child.length; j++) {
if (child[j] !== null && typeof child[j] !== 'undefined' && child[j] !== false) {
children.push(child[j]);
}
}
} else {
children.push(child);
}
}
return React.createElement.apply(React, [type, props || null].concat(children));
}
function mergeStyle(base, extra) {
var out = {};
var key;
for (key in (base || {})) {
if (Object.prototype.hasOwnProperty.call(base, key)) {
out[key] = base[key];
}
}
for (key in (extra || {})) {
if (Object.prototype.hasOwnProperty.call(extra, key)) {
out[key] = extra[key];
}
}
return out;
}
var _customState = {};
export function getCustomState(key) {
if (key) {
return _customState[key];
}
var out = {};
var stateKey;
for (stateKey in _customState) {
if (Object.prototype.hasOwnProperty.call(_customState, stateKey)) {
out[stateKey] = _customState[stateKey];
}
}
return out;
}
export function setCustomState(newState) {
var data = newState || {};
Object.keys(data).forEach(function(key) {
_customState[key] = data[key];
});
this.forceUpdate();
}
export function forceUpdate() {
this.setState({ timestamp: new Date().getTime() });
}
export function didMount() {}
export function didUnmount() {}
"""
def render_product_homepage(ir: dict[str, Any]) -> str:
return f"""/* Generated by dws yida_page_generate.py. Edit the spec/manifest first when possible. */
var PAGE_SPEC = {_json(ir)};
{_common_runtime()}
export function scrollToSection(id) {{
if (!id || typeof document === 'undefined') {{
return;
}}
var node = document.getElementById(id);
if (node && node.scrollIntoView) {{
node.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
}}
export function renderJsx() {{
var self = this;
var features = [];
var metrics = [];
var actions = [];
var i;
var featureCardStyle = {{
padding: 18,
borderRadius: 8,
backgroundColor: '#ffffff',
border: '1px solid #e5e7eb',
boxShadow: '0 1px 4px rgba(15, 23, 42, 0.06)'
}};
var actionStyle = {{
height: 36,
padding: '0 14px',
borderRadius: 6,
border: '1px solid #2563eb',
backgroundColor: '#2563eb',
color: '#ffffff',
cursor: 'pointer'
}};
for (i = 0; i < PAGE_SPEC.features.length; i++) {{
features.push(h('div', {{ key: 'feature-' + i, style: featureCardStyle }},
h('div', {{ style: {{ fontSize: 16, fontWeight: 700, color: '#111827', marginBottom: 8 }} }}, PAGE_SPEC.features[i].title),
h('div', {{ style: {{ fontSize: 13, lineHeight: 1.7, color: '#4b5563' }} }}, PAGE_SPEC.features[i].text)
));
}}
for (i = 0; i < PAGE_SPEC.metrics.length; i++) {{
metrics.push(h('div', {{ key: 'metric-' + i, style: {{ minWidth: 120 }} }},
h('div', {{ style: {{ fontSize: 26, fontWeight: 800, color: '#111827' }} }}, PAGE_SPEC.metrics[i].value),
h('div', {{ style: {{ fontSize: 13, color: '#6b7280', marginTop: 4 }} }}, PAGE_SPEC.metrics[i].label)
));
}}
for (i = 0; i < PAGE_SPEC.actions.length; i++) {{
actions.push(h('button', {{
key: 'action-' + i,
type: 'button',
style: i === 0 ? actionStyle : mergeStyle(actionStyle, {{ backgroundColor: '#ffffff', color: '#2563eb' }}),
onClick: function(target) {{
return function(e) {{
if (e && e.preventDefault) {{
e.preventDefault();
}}
self.scrollToSection(target);
}};
}}(PAGE_SPEC.actions[i].target)
}}, PAGE_SPEC.actions[i].title));
}}
return h('div', {{ style: {{ minHeight: '100vh', padding: 24, backgroundColor: '#f3f4f6', color: '#111827', boxSizing: 'border-box' }} }},
h('div', {{ style: {{ display: 'none' }} }}, this.state && this.state.timestamp),
h('section', {{ style: {{ maxWidth: 1120, margin: '0 auto', padding: '34px 0 22px' }} }},
h('div', {{ style: {{ fontSize: 32, lineHeight: 1.25, fontWeight: 800, marginBottom: 12 }} }}, PAGE_SPEC.title),
h('div', {{ style: {{ maxWidth: 720, fontSize: 15, lineHeight: 1.8, color: '#4b5563', marginBottom: 20 }} }}, PAGE_SPEC.subtitle),
h('div', {{ style: {{ display: 'flex', flexWrap: 'wrap', gap: 10 }} }}, actions)
),
h('section', {{ id: 'metrics', style: {{ maxWidth: 1120, margin: '0 auto 18px', padding: 20, borderRadius: 8, backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }} }},
h('div', {{ style: {{ fontSize: 15, fontWeight: 700, marginBottom: 16 }} }}, PAGE_SPEC.metricsTitle),
h('div', {{ style: {{ display: 'flex', flexWrap: 'wrap', gap: 28 }} }}, metrics)
),
h('section', {{ id: 'features', style: {{ maxWidth: 1120, margin: '0 auto', padding: '10px 0 28px' }} }},
h('div', {{ style: {{ fontSize: 20, fontWeight: 800, margin: '10px 0 14px' }} }}, PAGE_SPEC.featuresTitle),
h('div', {{ style: {{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 14 }} }}, features)
)
);
}}
"""
def render_todo_mvc(ir: dict[str, Any]) -> str:
return f"""/* Generated by dws yida_page_generate.py. Edit the spec/manifest first when possible. */
var PAGE_SPEC = {_json(ir)};
{_common_runtime()}
_customState = {{
todos: PAGE_SPEC.todos || [],
draft: ''
}};
export function updateDraft(e) {{
_customState.draft = e && e.target ? e.target.value : '';
}}
export function addTodo(e) {{
if (e && e.preventDefault) {{
e.preventDefault();
}}
var text = String(_customState.draft || '').replace(/^\\s+|\\s+$/g, '');
if (!text) {{
if (this.utils && this.utils.toast) {{
this.utils.toast({{ title: '请输入待办内容', type: 'warning' }});
}}
return;
}}
var next = [];
for (var i = 0; i < _customState.todos.length; i++) {{
next.push(_customState.todos[i]);
}}
next.push({{ content: text, done: false }});
this.setCustomState({{ todos: next, draft: '' }});
}}
export function toggleTodo(index) {{
var next = [];
for (var i = 0; i < _customState.todos.length; i++) {{
var item = _customState.todos[i] || {{}};
next.push({{ content: item.content, done: i === index ? !item.done : !!item.done }});
}}
this.setCustomState({{ todos: next }});
}}
export function clearDone() {{
var next = [];
for (var i = 0; i < _customState.todos.length; i++) {{
if (!_customState.todos[i].done) {{
next.push(_customState.todos[i]);
}}
}}
this.setCustomState({{ todos: next }});
}}
export function renderJsx() {{
var self = this;
var todos = _customState.todos || [];
var rows = [];
var doneCount = 0;
var i;
for (i = 0; i < todos.length; i++) {{
if (todos[i].done) {{
doneCount += 1;
}}
rows.push(h('div', {{ key: 'todo-' + i, style: {{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 0', borderBottom: '1px solid #eef2f7' }} }},
h('button', {{
type: 'button',
style: {{ width: 26, height: 26, borderRadius: 6, border: '1px solid #cbd5e1', backgroundColor: todos[i].done ? '#16a34a' : '#ffffff', color: '#ffffff', cursor: 'pointer' }},
onClick: function(index) {{
return function(e) {{
if (e && e.preventDefault) {{
e.preventDefault();
}}
self.toggleTodo(index);
}};
}}(i)
}}, todos[i].done ? '' : ''),
h('span', {{ style: {{ flex: 1, color: todos[i].done ? '#94a3b8' : '#111827', textDecoration: todos[i].done ? 'line-through' : 'none' }} }}, todos[i].content)
));
}}
return h('div', {{ style: {{ minHeight: '100vh', padding: 24, backgroundColor: '#f8fafc', color: '#111827', boxSizing: 'border-box' }} }},
h('div', {{ style: {{ display: 'none' }} }}, this.state && this.state.timestamp),
h('section', {{ style: {{ maxWidth: 760, margin: '0 auto', padding: 22, borderRadius: 8, backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }} }},
h('div', {{ style: {{ fontSize: 26, fontWeight: 800, marginBottom: 8 }} }}, PAGE_SPEC.title),
h('div', {{ style: {{ fontSize: 14, color: '#64748b', marginBottom: 18 }} }}, PAGE_SPEC.subtitle),
h('div', {{ style: {{ display: 'flex', gap: 10, marginBottom: 12 }} }},
h('input', {{
key: 'draft-' + todos.length + '-' + String(_customState.draft || '').length,
defaultValue: _customState.draft || '',
placeholder: PAGE_SPEC.placeholder,
style: {{ flex: 1, height: 38, padding: '0 12px', borderRadius: 6, border: '1px solid #cbd5e1', outline: 'none' }},
onChange: function(e) {{ self.updateDraft(e); }},
onKeyDown: function(e) {{
if (e && e.key === 'Enter') {{
self.addTodo(e);
}}
}}
}}),
h('button', {{ type: 'button', style: {{ height: 38, padding: '0 14px', borderRadius: 6, border: '1px solid #2563eb', backgroundColor: '#2563eb', color: '#ffffff', cursor: 'pointer' }}, onClick: function(e) {{ self.addTodo(e); }} }}, '添加')
),
h('div', {{ style: {{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: '#64748b', margin: '8px 0 4px' }} }},
h('span', null, '' + todos.length + ''),
h('span', null, '已完成 ' + doneCount + '')
),
h('div', null, rows.length ? rows : h('div', {{ style: {{ padding: 26, textAlign: 'center', color: '#94a3b8' }} }}, '暂无待办')),
h('div', {{ style: {{ marginTop: 14, textAlign: 'right' }} }},
h('button', {{ type: 'button', style: {{ height: 34, padding: '0 12px', borderRadius: 6, border: '1px solid #cbd5e1', backgroundColor: '#ffffff', color: '#334155', cursor: 'pointer' }}, onClick: function(e) {{ if (e && e.preventDefault) {{ e.preventDefault(); }} self.clearDone(); }} }}, '清除已完成')
)
)
);
}}
"""
def render_source(ir: dict[str, Any]) -> str:
template = ir.get("template")
if template == "product-homepage":
return render_product_homepage(ir)
if template == "todo-mvc":
return render_todo_mvc(ir)
raise ValueError(f"未知模板: {template}")
def manifest_path_for(output: Path) -> Path:
suffix = "".join(output.suffixes)
stem = output.name[:-len(suffix)] if suffix else output.stem
return output.with_name(f"{stem}.dws-yida-page.json")
def write_generated(output: Path, source: str, ir: dict[str, Any]) -> Path:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(source, encoding="utf-8")
manifest_path = manifest_path_for(output)
manifest_path.write_text(_json(ir) + "\n", encoding="utf-8")
return manifest_path
def generate(args: argparse.Namespace) -> dict[str, Any]:
raw = _merge_cli_vars(_read_spec(args.spec), args)
template = args.template or raw.get("template") or "product-homepage"
if template not in KNOWN_TEMPLATES:
raise ValueError(f"未知模板: {template}; 可选: {', '.join(KNOWN_TEMPLATES)}")
ir = normalize_spec(template, raw)
output = Path(args.output or raw.get("output") or f"{_safe_filename_stem(ir.get('title', ''), template)}.jsx")
output = output.expanduser().resolve()
source = render_source(ir)
lint = lint_check(source, filename=str(output))
if not lint.get("ok"):
return {"ok": False, "stage": "lint", "errors": lint.get("errors", []), "warnings": lint.get("warnings", [])}
compile_result: dict[str, Any] | None = None
if args.compile:
compile_result = compile_jsx_to_schema(source, form_uuid=args.form or "FORM-GENERATED-PREVIEW")
if not compile_result.get("ok"):
return {
"ok": False,
"stage": "compile",
"errors": compile_result.get("errors", []),
"warnings": compile_result.get("lint", {}).get("warnings", []),
}
manifest = write_generated(output, source, ir)
result = {
"ok": True,
"template": template,
"output": str(output),
"manifest": str(manifest),
"lint": lint.get("info", {}),
}
if compile_result is not None:
result["compiledSize"] = len(compile_result.get("compiled_code", ""))
result["schemaSize"] = len(compile_result.get("schema", ""))
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="纯 Python 宜搭自定义页面稳定生成器")
parser.add_argument("template", nargs="?", choices=KNOWN_TEMPLATES, help="页面模板")
parser.add_argument("--spec", help="页面 JSON spec")
parser.add_argument("--output", help="输出源码路径,建议 pages/src/<name>.jsx")
parser.add_argument("--compile", action="store_true", help="生成前先通过纯 Python 编译校验")
parser.add_argument("--form", help="编译校验用 formUuid,默认 FORM-GENERATED-PREVIEW")
parser.add_argument("--title", help="页面标题")
parser.add_argument("--subtitle", help="页面副标题")
parser.add_argument("--brand-name", dest="brand_name", help="品牌名,等价于 title")
parser.add_argument("--tagline", help="标语,等价于 subtitle")
parser.add_argument("--item", action="append", help="快速添加 feature/todo 项,可重复")
parser.add_argument("--json", action="store_true", help="只输出 JSON 结果")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
result = generate(args)
except ValueError as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
if result.get("ok"):
print(f"[OK] 已生成: {result['output']}")
print(f"[OK] Manifest: {result['manifest']}")
if "compiledSize" in result:
print(f"[OK] 编译校验通过: compiledSize={result['compiledSize']} schemaSize={result['schemaSize']}")
else:
print(json.dumps(result, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
return 0 if result.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,519 @@
"""
yida_page_schema.py 宜搭自定义页面 schema 处理builder + extractor
公开 API:
Builder:
- build_schema_content(source_code, compiled_code, form_uuid, existing_data_source=None) -> str
- build_default_page_data_source(form_uuid) -> dict
- merge_page_data_source(existing_data_source, generated_data_source) -> dict
Extractor:
- extract_source_code(schema) -> str
- extract_compiled_code(schema) -> str
- inject_source_code(schema, new_source, new_compiled) -> str
支持三种 schema 格式
1. 标准格式actions.module.source / actions.module.compiled
2. YidaCodeCanvas 格式pages[0].componentsTree[*].props.code / runtimeCode
3. YidaAICanvas 格式 YidaCodeCanvas 布局
"""
import copy
import json
import random
import time
# region: schema_builder
# ---------------------------------------------------------------------------
# 宜搭页面 schema JSON 外壳构建
# ---------------------------------------------------------------------------
def _create_node_id_generator():
counter = [0]
def next_node_id():
counter[0] += 1
ts = _base36(int(time.time() * 1000))
return 'node_oc' + ts + _base36(counter[0])
return next_node_id
def _base36(n):
if n == 0:
return '0'
chars = '0123456789abcdefghijklmnopqrstuvwxyz'
result = []
while n > 0:
result.append(chars[n % 36])
n //= 36
return ''.join(reversed(result))
def _generate_suffix():
ts = _base36(int(time.time() * 1000))
rand = _base36(random.randint(0, 36 ** 6))
return ts + rand.ljust(6, '0')[:6]
def _get_global_data_source_fit_config():
fit_compiled = (
"'use strict';\n\nvar __preParser__ = function fit(response) {\n"
" var content = response.content !== undefined ? response.content : response;\n"
" var error = {\n"
" message: response.errorMsg || response.errors && response.errors[0] "
"&& response.errors[0].msg || response.content || "
"'远程数据源请求出错,success is false'\n"
" };\n var success = true;\n"
" if (response.success !== undefined) {\n success = response.success;\n"
" } else if (response.hasError !== undefined) {\n success = !response.hasError;\n"
" }\n return {\n content: content,\n success: success,\n error: error\n };\n};"
)
fit_source = (
"function fit(response) {\r\n"
" const content = (response.content !== undefined) ? response.content : response;\r\n"
" const error = {\r\n"
" message: response.errorMsg ||\r\n"
" (response.errors && response.errors[0] && response.errors[0].msg) ||\r\n"
" response.content || '远程数据源请求出错,success is false',\r\n"
" };\r\n"
" let success = true;\r\n"
" if (response.success !== undefined) {\r\n"
" success = response.success;\r\n"
" } else if (response.hasError !== undefined) {\r\n"
" success = !response.hasError;\r\n"
" }\r\n"
" return {\r\n"
" content,\r\n"
" success,\r\n"
" error,\r\n"
" };\r\n"
"}"
)
return {
'fit': {
'compiled': fit_compiled,
'source': fit_source,
'type': 'js',
'error': {},
},
}
def build_default_page_data_source(form_uuid):
url_params = {
'id': 'VCB660714833IBHEOXK376TA7XJH2AXUWR8MMW',
'name': 'urlParams',
'description': '当前页面地址的参数:如 aliwork.com/APP_XXX/workbench?id=1&name=宜搭,'
'可通过 this.state.urlParams.name 获取到宜搭',
'formUuid': form_uuid,
'protocal': 'URI',
'isReadonly': True,
}
timestamp = {
'id': '',
'name': 'timestamp',
'description': '',
'formUuid': form_uuid,
'protocal': 'VALUE',
'initialData': '',
}
return {
'offline': [],
'globalConfig': _get_global_data_source_fit_config(),
'online': [url_params, timestamp],
'list': [url_params, timestamp],
'sync': True,
}
def _is_built_in_page_data_source(item):
if not item or not isinstance(item, dict):
return False
return item.get('name') in ('urlParams', 'timestamp')
def _get_data_source_identity(item):
if not item or not isinstance(item, dict):
return ''
if _is_built_in_page_data_source(item):
return 'builtin:' + item['name']
if item.get('id'):
return 'id:' + item['id']
if item.get('name') and item.get('protocal'):
return 'name:' + item['name'] + '|protocal:' + item['protocal']
if item.get('name'):
return 'name:' + item['name']
return json.dumps(item, ensure_ascii=False, sort_keys=True)
def _merge_data_source_array(existing_items, generated_items):
merged = copy.deepcopy(existing_items) if isinstance(existing_items, list) else []
seen = set()
for item in merged:
identity = _get_data_source_identity(item)
if identity:
seen.add(identity)
for item in (generated_items if isinstance(generated_items, list) else []):
identity = _get_data_source_identity(item)
if not identity or identity not in seen:
merged.append(copy.deepcopy(item))
if identity:
seen.add(identity)
return merged
def merge_page_data_source(existing_data_source, generated_data_source):
if not existing_data_source or not isinstance(existing_data_source, dict):
return copy.deepcopy(generated_data_source)
existing = copy.deepcopy(existing_data_source)
generated = copy.deepcopy(generated_data_source) if generated_data_source else {}
merged = {**generated, **existing}
merged['offline'] = _merge_data_source_array(existing.get('offline'), generated.get('offline'))
merged['online'] = _merge_data_source_array(existing.get('online'), generated.get('online'))
merged['list'] = _merge_data_source_array(existing.get('list'), generated.get('list'))
merged['globalConfig'] = {
**(generated.get('globalConfig') or {}),
**(existing.get('globalConfig') or {}),
}
merged['sync'] = existing.get('sync') if existing.get('sync') is not None else generated.get('sync')
return merged
def build_schema_content(source_code, compiled_code, form_uuid, existing_data_source=None):
"""把 sourceCode + compiledCode 包装成完整的宜搭自定义页面 schema JSON 字符串。"""
next_node_id = _create_node_id_generator()
constructor_code = (
"function constructor() {\n"
"var module = { exports: {} };\n"
"var _this = this;\n"
"this.__initMethods__(module.exports, module);\n"
"Object.keys(module.exports).forEach(function(item) {\n"
" if(typeof module.exports[item] === 'function'){\n"
" _this[item] = module.exports[item];\n"
" }\n"
"});\n\n"
"}"
)
page_data_source = merge_page_data_source(
existing_data_source,
build_default_page_data_source(form_uuid),
)
schema = {
'schemaType': 'superform',
'schemaVersion': '5.0',
'pages': [
{
'utils': [
{
'name': 'legaoBuiltin',
'type': 'npm',
'content': {
'package': '@ali/vu-legao-builtin',
'version': '3.0.0',
'exportName': 'legaoBuiltin',
},
},
{
'name': 'yidaPlugin',
'type': 'npm',
'content': {
'package': '@ali/vu-yida-plugin',
'version': '1.1.0',
'exportName': 'yidaPlugin',
},
},
],
'componentsMap': [
{'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'RootHeader'},
{'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'Jsx'},
{'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'RootContent'},
{'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'RootFooter'},
{'package': '@ali/vc-deep-yida', 'version': '1.5.169', 'componentName': 'Page'},
],
'componentsTree': [
{
'componentName': 'Page',
'id': next_node_id(),
'props': {
'contentBgColor': 'white',
'pageStyle': {'backgroundColor': '#f2f3f5'},
'contentMargin': '0',
'contentPadding': '0',
'showTitle': False,
'contentPaddingMobile': '0',
'templateVersion': '1.0.0',
'contentMarginMobile': '0',
'className': 'page_' + _generate_suffix(),
'contentBgColorMobile': 'white',
},
'condition': True,
'css': (
'body{background-color:#f2f3f5}'
'.vc-page-yida-page{--yida-form-content-padding:0;'
'--yida-form-content-margin:0;--yida-layout-padding:0}'
'.vc-deep-container-entry.vc-rootcontent{padding:0!important;'
'margin-top:0!important;margin-right:0!important;'
'margin-bottom:0!important;margin-left:0!important}'
),
'methods': {
'__initMethods__': {
'type': 'js',
'source': 'function (exports, module) { /*set actions code here*/ }',
'compiled': 'function (exports, module) { /*set actions code here*/ }',
},
},
'dataSource': page_data_source,
'lifeCycles': {
'constructor': {
'type': 'js',
'compiled': constructor_code,
'source': constructor_code,
},
'componentWillUnmount': {
'name': 'didUnmount',
'id': 'didUnmount',
'type': 'actionRef',
'params': {},
},
'componentDidMount': {
'name': 'didMount',
'id': 'didMount',
'params': {},
'type': 'actionRef',
},
},
'hidden': False,
'title': '',
'isLocked': False,
'conditionGroup': '',
'children': [
{
'componentName': 'RootHeader',
'id': next_node_id(),
'props': {},
'condition': True,
'hidden': False,
'title': '',
'isLocked': False,
'conditionGroup': '',
},
{
'componentName': 'RootContent',
'id': next_node_id(),
'props': {},
'condition': True,
'hidden': False,
'title': '',
'isLocked': False,
'conditionGroup': '',
'children': [
{
'componentName': 'Jsx',
'id': next_node_id(),
'props': {
'render': {
'type': 'js',
'compiled': (
'function main(){\n \n "use strict";\n\n'
'var __compiledFunc__ = function render() {\n'
' return this.renderJsx();\n};\n'
' return __compiledFunc__.apply(this, arguments);\n }'
),
'source': (
'function render() {\n'
' return this.renderJsx();\n}'
),
'error': {},
},
'__style__': {},
'fieldId': 'jsx_' + _generate_suffix(),
},
'condition': True,
'hidden': False,
'title': '',
'isLocked': False,
'conditionGroup': '',
},
],
},
{
'componentName': 'RootFooter',
'id': next_node_id(),
'props': {},
'condition': True,
'hidden': False,
'title': '',
'isLocked': False,
'conditionGroup': '',
},
],
},
],
'id': form_uuid,
'connectComponent': [],
},
],
'actions': {
'module': {
'compiled': compiled_code,
'source': source_code,
},
'type': 'FUNCTION',
'list': [
{'id': 'getCustomState', 'title': 'getCustomState'},
{'id': 'setCustomState', 'title': 'setCustomState'},
{'id': 'forceUpdate', 'title': 'forceUpdate'},
{'id': 'didMount', 'title': 'didMount'},
{'id': 'didUnmount', 'title': 'didUnmount'},
{'id': 'renderJsx', 'title': 'renderJsx'},
],
},
'config': {
'connectComponent': [],
},
}
return json.dumps(schema, ensure_ascii=False)
# endregion
# region: schema_extractor
# ---------------------------------------------------------------------------
# 从 schema 中提取 / 注入源码(兼容标准 / YidaCodeCanvas / YidaAICanvas
# ---------------------------------------------------------------------------
def _parse_schema(schema):
if isinstance(schema, str):
return json.loads(schema)
if isinstance(schema, dict):
return schema
raise TypeError(f"schema must be dict or JSON string, got {type(schema).__name__}")
def _find_code_canvas_node(data: dict):
"""在 componentsTree 中查找 YidaCodeCanvas 或 YidaAICanvas 节点"""
try:
root = data['pages'][0]['componentsTree'][0]
found = _search_canvas_recursive(root)
if found:
return found
except (KeyError, IndexError, TypeError):
pass
return None
def _search_canvas_recursive(node: dict):
if node.get('componentName') in ('YidaCodeCanvas', 'YidaAICanvas'):
return node
for child in node.get('children', []):
found = _search_canvas_recursive(child)
if found:
return found
return None
def _detect_format(data: dict) -> str:
"""检测 schema 格式:'standard''code_canvas'"""
canvas = _find_code_canvas_node(data)
if canvas and canvas.get('props', {}).get('code'):
return 'code_canvas'
try:
if data['actions']['module']['source']:
return 'standard'
except (KeyError, TypeError):
pass
raise ValueError(
"Cannot detect schema format: neither actions.module.source "
"nor YidaCodeCanvas/YidaAICanvas.props.code found."
)
def extract_source_code(schema) -> str:
"""从宜搭自定义页面 schema 提取原始 JSX 源码。"""
data = _parse_schema(schema)
fmt = _detect_format(data)
if fmt == 'code_canvas':
canvas = _find_code_canvas_node(data)
code = canvas.get('props', {}).get('code')
if not code or not isinstance(code, str):
raise ValueError("YidaCodeCanvas/YidaAICanvas.props.code is empty or not a string")
return code
try:
source = data['actions']['module']['source']
except (KeyError, TypeError):
raise ValueError("Cannot find actions.module.source")
if not isinstance(source, str):
raise ValueError(f"actions.module.source is not a string, got {type(source).__name__}")
return source
def extract_compiled_code(schema) -> str:
"""从宜搭自定义页面 schema 提取编译后代码。"""
data = _parse_schema(schema)
fmt = _detect_format(data)
if fmt == 'code_canvas':
canvas = _find_code_canvas_node(data)
runtime_code = canvas.get('props', {}).get('runtimeCode')
if not runtime_code or not isinstance(runtime_code, str):
raise ValueError("YidaCodeCanvas/YidaAICanvas.props.runtimeCode is empty or not a string")
return runtime_code
try:
compiled = data['actions']['module']['compiled']
except (KeyError, TypeError):
raise ValueError("Cannot find actions.module.compiled")
if not isinstance(compiled, str):
raise ValueError(f"actions.module.compiled is not a string, got {type(compiled).__name__}")
return compiled
def inject_source_code(schema, new_source: str, new_compiled: str) -> str:
"""将新的源码和编译产物注入 schema,返回更新后的 JSON 字符串。"""
data = _parse_schema(schema)
data = copy.deepcopy(data)
fmt = _detect_format(data)
if fmt == 'code_canvas':
canvas = _find_code_canvas_node(data)
if not canvas:
raise ValueError("Cannot find YidaCodeCanvas/YidaAICanvas node for injection")
canvas['props']['code'] = new_source
canvas['props']['runtimeCode'] = new_compiled
else:
if 'actions' not in data or not isinstance(data['actions'], dict):
raise ValueError("Invalid schema: missing 'actions' key")
if 'module' not in data['actions'] or not isinstance(data['actions']['module'], dict):
raise ValueError("Invalid schema: missing 'actions.module' key")
data['actions']['module']['source'] = new_source
data['actions']['module']['compiled'] = new_compiled
return json.dumps(data, ensure_ascii=False)
# endregion
__all__ = [
'build_schema_content',
'build_default_page_data_source',
'merge_page_data_source',
'extract_source_code',
'extract_compiled_code',
'inject_source_code',
]
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""Pure-Python self checks for Yida custom-page scripts.
This file lives under scripts/ because packaged workspace zips include scripts
but may exclude tests/. It intentionally does not touch remote Yida resources.
Usage:
python yida_page_self_check.py
"""
from __future__ import annotations
import json
import re
import shutil
import sys
import tempfile
from argparse import Namespace
from pathlib import Path
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
import yida_custom_page_update as custom_update # noqa: E402
import yida_form_inspector as form_inspector # noqa: E402
import yida_jsx_pipeline as jsx_pipeline # noqa: E402
from yida_jsx_pipeline import lint_check # noqa: E402
from yida_page_compiler import build_page_source, compile_jsx_to_schema # noqa: E402
from yida_page_generate import generate, manifest_path_for # noqa: E402
def _args(template: str, output: Path, spec: str | None = None) -> Namespace:
return Namespace(
template=template,
spec=spec,
output=str(output),
compile=True,
form="FORM-SELF-CHECK",
title=None,
subtitle=None,
brand_name=None,
tagline=None,
item=None,
json=True,
)
def _assert_generated_ok(template: str, output: Path, spec: dict | None = None) -> None:
spec_path = None
if spec is not None:
spec_path = output.with_suffix(".spec.json")
spec_path.write_text(json.dumps(spec, ensure_ascii=False), encoding="utf-8")
result = generate(_args(template, output, str(spec_path) if spec_path else None))
if not result.get("ok"):
raise AssertionError(f"{template} generation failed: {result}")
manifest = manifest_path_for(output)
if not output.exists() or not manifest.exists():
raise AssertionError(f"{template} did not write output/manifest")
manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
if manifest_data.get("template") != template:
raise AssertionError(f"{template} manifest template mismatch: {manifest_data}")
source = output.read_text(encoding="utf-8")
lint = lint_check(source, filename=str(output))
if not lint.get("ok"):
raise AssertionError(f"{template} lint failed: {lint}")
compiled = compile_jsx_to_schema(source, form_uuid="FORM-SELF-CHECK")
if not compiled.get("ok") or not compiled.get("schema"):
raise AssertionError(f"{template} compile failed: {compiled.get('errors')}")
def check_page_generator() -> None:
tmp = Path(tempfile.mkdtemp(prefix="dws-yida-self-check-"))
try:
_assert_generated_ok("product-homepage", tmp / "home.jsx", {
"title": "解决方案中心",
"subtitle": "稳定生成的宜搭自定义页",
"features": [
{"title": "客户洞察", "text": "聚合客户和拜访信息。"},
{"title": "方案资产", "text": "沉淀可复用材料。"},
],
"metrics": [
{"value": "12", "label": "客户"},
{"value": "5", "label": "方案"},
],
})
_assert_generated_ok("todo-mvc", tmp / "todo.jsx", {
"title": "交付待办",
"todos": [
{"content": "确认字段", "done": True},
{"content": "发布页面", "done": False},
],
})
finally:
shutil.rmtree(tmp, ignore_errors=True)
def check_missing_custom_state_guard() -> None:
bad_source = """export function renderJsx() {
return <div>{_customState.title}</div>;
}
"""
lint = lint_check(bad_source, filename="bad.jsx")
rules = {item.get("rule") for item in lint.get("errors", [])}
if "missing-custom-state" not in rules:
raise AssertionError(f"missing-custom-state guard did not fire: {lint}")
def check_compiler_injects_custom_state() -> None:
source = """export function renderJsx() {
return React.createElement('div', null, _customState.title || 'ok');
}
"""
result = build_page_source(source)
if "var _customState = {};" not in result.get("code", ""):
raise AssertionError("compiler did not inject missing _customState store")
def check_modern_hooks_authoring() -> None:
source = """import React, { useState, useEffect } from 'react';
export default function Page() {
var [count, setCount] = useState(0);
useEffect(function() {
setCount(1);
}, []);
return <div>{count}</div>;
}
"""
lint = lint_check(source, filename="modern.jsx")
if not lint.get("ok"):
raise AssertionError(f"modern hooks authoring lint failed: {lint}")
result = compile_jsx_to_schema(source, form_uuid="FORM-SELF-CHECK")
if not result.get("ok") or not result.get("schema"):
raise AssertionError(f"modern hooks authoring compile failed: {result.get('errors')}")
compiled = result.get("compiled_code", "")
if "useState" in compiled or "useEffect" in compiled or "import React" in compiled:
raise AssertionError("modern hooks authoring was not lowered before publish")
if "_customState" not in compiled or "exports.renderJsx" not in compiled:
raise AssertionError("compiled hooks output is missing runtime contract")
def check_modern_helper_functions_are_bound() -> None:
source = """import React, { useState, useEffect } from 'react';
export default function Page() {
var [dataList, setDataList] = useState([]);
useEffect(function() {
loadData();
}, []);
var loadData = function() {
setDataList([{ id: '1', title: 'ok' }]);
};
var renderListItem = function(item) {
return <button onClick={function() { setDataList([]); }}>{item.title}</button>;
};
var renderListView = function() {
if (!dataList || dataList.length === 0) {
return <div>empty</div>;
}
return <div>{dataList.map(function(item) { return renderListItem(item); })}</div>;
};
return <div>{renderListView()}</div>;
}
"""
result = compile_jsx_to_schema(source, form_uuid="FORM-SELF-CHECK", minify=False)
if not result.get("ok") or not result.get("schema"):
raise AssertionError(f"modern helper compile failed: {result.get('errors')}")
intermediate = result.get("intermediate_code", "")
required = [
"export function loadData()",
"export function renderListView()",
"this.loadData();",
"this.renderListView()",
"this.renderListItem(item)",
]
missing = [text for text in required if text not in intermediate]
if missing:
raise AssertionError(f"modern helper functions were not bound: missing={missing}\n{intermediate}")
def check_modern_functional_state_updater() -> None:
source = """import React, { useState } from 'react';
export default function Page() {
var [items, setItems] = useState([]);
function addItem() {
setItems(function(prev) {
return prev.concat(['next']);
});
}
return <button onClick={addItem}>{items.length}</button>;
}
"""
result = build_page_source(source)
code = result.get("code", "")
if result.get("errors"):
raise AssertionError(f"functional updater compile failed: {result.get('errors')}")
if "setCustomState({ 'items': function(prev)" not in code:
raise AssertionError(f"functional updater call was not preserved:\n{code}")
if 'if (typeof value === "function")' not in code:
raise AssertionError(f"setCustomState does not execute functional updaters:\n{code}")
def check_modern_render_derived_vars_stay_in_render() -> None:
source = """import React, { useState } from 'react';
export default function Page() {
var [items, setItems] = useState([]);
function toRows(list) {
return list.map(function(item) { return { label: item, value: item.length }; });
}
var rows = toRows(items);
var maxValue = Math.max.apply(null, rows.map(function(row) { return row.value; }).concat([1]));
return <div>{rows.map((row, idx) => <span key={idx}>{row.label}:{maxValue}</span>)}</div>;
}
"""
result = compile_jsx_to_schema(source, form_uuid="FORM-SELF-CHECK", minify=False)
if not result.get("ok") or not result.get("schema"):
raise AssertionError(f"modern derived vars compile failed: {result.get('errors')}")
compiled = result.get("compiled_code", "")
render_idx = compiled.find("function renderJsx()")
rows_idx = compiled.find("var rows =")
max_idx = compiled.find("var maxValue =")
if render_idx < 0 or rows_idx < render_idx or max_idx < render_idx:
raise AssertionError(f"derived render vars escaped renderJsx:\n{compiled}")
module_prefix = compiled[:render_idx]
if "var maxValue =" in module_prefix or "var rows =" in module_prefix:
raise AssertionError(f"derived render vars leaked to module scope:\n{compiled}")
def check_jsx_arrow_expression_body_is_transformed() -> None:
source = """export function renderJsx() {
var names = ['A'];
return <select>{names.map((name, idx) => <option key={idx} value={name}>{name}</option>)}</select>;
}
"""
result = compile_jsx_to_schema(source, form_uuid="FORM-SELF-CHECK", minify=False)
if not result.get("ok") or not result.get("schema"):
raise AssertionError(f"arrow JSX expression compile failed: {result.get('errors')}")
compiled = result.get("compiled_code", "")
if "<option" in compiled or "</option>" in compiled:
raise AssertionError(f"arrow expression JSX was not transformed:\n{compiled}")
if "React.createElement('option'" not in compiled:
raise AssertionError(f"compiled option createElement missing:\n{compiled}")
def check_lint_allows_jsx_array_expression() -> None:
source = """export function renderJsx() {
return <div>{[['客户数', '1']].map((item, idx) => <span key={idx}>{item[0]}</span>)}</div>;
}
"""
lint = lint_check(source, filename="array-expression.jsx")
computed = [item for item in lint.get("errors", []) if item.get("rule") == "computed-property"]
if computed:
raise AssertionError(f"JSX array expression was misreported as computed property: {lint}")
def check_components_result_payload() -> None:
payload = {
"success": True,
"result": [
{
"componentName": "Page",
"label": "{\"en_US\":\"\",\"pureEn_US\":\"\",\"type\":\"i18n\",\"zh_CN\":\"\"}",
},
{
"componentName": "FormContainer",
"key": "formContainer_x",
"label": "{\"en_US\":\"\",\"pureEn_US\":\"\",\"type\":\"i18n\",\"zh_CN\":\"\"}",
},
{
"componentName": "TextField",
"key": "textField_x",
"label": "{\"en_US\":\"姓名\",\"pureEn_US\":\"姓名\",\"type\":\"i18n\",\"zh_CN\":\"姓名\"}",
"parentId": "formContainer_x",
},
],
}
old_pipeline_run_dws = jsx_pipeline._run_dws
old_inspector_run_dws = form_inspector._run_dws
try:
jsx_pipeline._run_dws = lambda args: (payload, None)
fields, err = jsx_pipeline.fetch_form_fields("APP_X", "FORM_X")
normalized_fields = [jsx_pipeline._normalize_field(item) for item in (fields or [])]
normalized = next((item for item in normalized_fields if item.get("fieldId") == "textField_x"), {})
if err or normalized.get("fieldId") != "textField_x" or normalized.get("label") != "姓名":
raise AssertionError(f"pipeline did not parse result payload: fields={fields} err={err}")
if any(item.get("fieldId") == "formContainer_x" for item in normalized_fields):
raise AssertionError(f"pipeline treated formContainer as a field: {normalized_fields}")
form_inspector._run_dws = lambda args: payload
comps = form_inspector._components("APP_X", "FORM_X")
normalized_comps = [form_inspector._normalize_field(item) for item in (comps or [])]
normalized_comp = next((item for item in normalized_comps if item.get("fieldId") == "textField_x"), {})
if normalized_comp.get("fieldId") != "textField_x" or normalized_comp.get("label") != "姓名":
raise AssertionError(f"inspector did not parse result payload: comps={comps}")
if any(item.get("fieldId") == "formContainer_x" for item in normalized_comps):
raise AssertionError(f"inspector treated formContainer as a field: {normalized_comps}")
finally:
jsx_pipeline._run_dws = old_pipeline_run_dws
form_inspector._run_dws = old_inspector_run_dws
def check_render_side_effect_guard() -> None:
bad_source = """export function renderJsx() {
var self = this;
if (self.didMountCalled !== true) {
self.didMountCalled = true;
self.loadLeaveRecords();
}
self.loadLeaveRecords = function() {
Yida.api.form.searchFormDatasV2({
appType: 'APP_X',
formUuid: 'FORM-X',
pageSize: 20
}).then(function(res) {}).catch(function(err) {});
};
return <div>bad</div>;
}
"""
lint = lint_check(bad_source, filename="bad-render.jsx")
rules = {item.get("rule") for item in lint.get("errors", [])}
expected = {"method-defined-in-render", "lifecycle-emulated-in-render", "api-call-in-render"}
missing = expected - rules
if missing:
raise AssertionError(f"render side-effect guards did not fire: missing={sorted(missing)} lint={lint}")
def check_custom_page_update_guards() -> None:
cases = [
({"formType": "display"}, "display"),
({"content": {"formType": "DISPLAY"}}, "display"),
({"data": {"type": "receipt"}}, "receipt"),
({"content": {"data": {"pageType": "process"}}}, "process"),
({}, ""),
]
for payload, expected in cases:
got = custom_update._extract_form_type(payload)
if got != expected:
raise AssertionError(f"_extract_form_type({payload!r})={got!r}, want {expected!r}")
resolved = custom_update._resolve_safe_path("/private/tmp/dws-yida-page-test.jsx")
if str(resolved) != "/private/tmp/dws-yida-page-test.jsx":
raise AssertionError(f"unexpected /private/tmp resolution: {resolved}")
def check_packaged_doc_script_links() -> None:
docs = [
_SCRIPT_DIR.parent / "references" / "products" / "yida.md",
_SCRIPT_DIR.parent / "references" / "products" / "yida-custom-page-codegen.md",
]
missing = []
link_re = re.compile(r"\]\((\.\./\.\./scripts/[^)]+\.py)\)")
for doc in docs:
if not doc.exists():
missing.append(str(doc))
continue
for rel in link_re.findall(doc.read_text(encoding="utf-8")):
target = (doc.parent / rel).resolve()
if not target.exists():
missing.append(f"{doc.name} -> {rel}")
if missing:
raise AssertionError("packaged doc script links missing: " + ", ".join(missing))
def run_all() -> None:
check_page_generator()
check_missing_custom_state_guard()
check_compiler_injects_custom_state()
check_modern_hooks_authoring()
check_modern_helper_functions_are_bound()
check_modern_functional_state_updater()
check_modern_render_derived_vars_stay_in_render()
check_jsx_arrow_expression_body_is_transformed()
check_lint_allows_jsx_array_expression()
check_components_result_payload()
check_render_side_effect_guard()
check_custom_page_update_guards()
check_packaged_doc_script_links()
def main() -> int:
try:
run_all()
except AssertionError as exc:
print(f"[FAIL] {exc}", file=sys.stderr)
return 1
print("[OK] yida_page_self_check passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,589 @@
#!/usr/bin/env python3
"""Yida process draft save/publish helper.
Flow for creating a new process definition:
1. create draft from the initial/published process id
2. generate process schema with yida_process_flow.build_flow_schema
3. save draft with `dws yida design process update`
4. optionally publish with `dws yida design process publish`
The flow schema is the same shape used by integration automation:
`build_automation_flow` output from references/yida-process-node.md can
be passed directly via --flow-file / --flow-json.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from yida_process_flow import ( # noqa: E402
FlowSchemaError,
build_flow_schema,
build_process_view_schema,
merge_process_view_schema,
)
MAX_FLOW_FILE_SIZE = 1024 * 1024
MAX_INLINE_JSON = 256 * 1024
def _gather_allowed_roots() -> list[Path]:
roots: list[Path] = []
extra = os.environ.get("OPENYIDA_ALLOWED_ROOTS", "")
if extra:
parts: list[str] = [extra]
for sep in (os.pathsep, ":", ","):
parts = [seg for chunk in parts for seg in chunk.split(sep)]
roots.extend(Path(part).expanduser().resolve() for part in parts if part.strip())
legacy = os.environ.get("OPENCLAW_WORKSPACE")
if legacy:
roots.append(Path(legacy).expanduser().resolve())
roots.append(Path.cwd().resolve())
roots.append(Path(tempfile.gettempdir()).resolve())
roots.append(Path("/tmp").resolve())
roots.append(Path("/private/tmp").resolve())
seen: set[str] = set()
result: list[Path] = []
for root in roots:
key = str(root)
if key not in seen:
seen.add(key)
result.append(root)
return result
def _resolve_safe_path(path_str: str) -> Path:
target = Path(path_str).expanduser()
target = target.resolve() if target.is_absolute() else (Path.cwd() / target).resolve()
roots = _gather_allowed_roots()
for root in roots:
try:
target.relative_to(root)
return target
except ValueError:
continue
listing = "\n - ".join(str(root) for root in roots)
raise ValueError(f"路径超出允许范围:{path_str}\n已尝试的允许根目录:\n - {listing}")
def _load_json(file_path: str | None, inline_json: str | None, label: str) -> Any:
if file_path:
safe = _resolve_safe_path(file_path)
if not safe.exists():
raise ValueError(f"{label} 文件不存在: {safe}")
if safe.stat().st_size > MAX_FLOW_FILE_SIZE:
raise ValueError(f"{label} 文件过大 (限制 {MAX_FLOW_FILE_SIZE:,} 字节)")
return json.loads(safe.read_text(encoding="utf-8"))
if inline_json:
if len(inline_json.encode("utf-8")) > MAX_INLINE_JSON:
raise ValueError(f"{label} 内联 JSON 过长 (限制 {MAX_INLINE_JSON:,} 字节)")
return json.loads(inline_json)
raise ValueError(f"必须提供 --{label}-file 或 --{label}-json")
def _run_dws(args: list[str], dry_run: bool = False) -> Any | None:
cmd = ["dws"] + args
if dry_run:
print(f" [dry-run] {' '.join(cmd)}")
return {"dry_run": True}
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print(" [FAIL] 找不到 'dws' 命令", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print(" [FAIL] dws 超时", file=sys.stderr)
return None
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
print(f" [FAIL] dws 失败 (exit {result.returncode}): {err}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
print(f" [FAIL] 非 JSON: {exc}\n 输出: {result.stdout[:300]}", file=sys.stderr)
return None
def _iter_dicts(value: Any):
if isinstance(value, dict):
yield value
for child in value.values():
yield from _iter_dicts(child)
elif isinstance(value, list):
for child in value:
yield from _iter_dicts(child)
def _iter_flow_nodes(nodes: list[dict[str, Any]]):
for node in nodes:
yield node
child_nodes = node.get("childNodes") or []
if isinstance(child_nodes, list):
yield from _iter_flow_nodes(child_nodes)
def _extract_payload(data: Any) -> Any:
if isinstance(data, dict):
for key in ("result", "content", "data"):
value = data.get(key)
if value is not None:
return value
return data
def _loads_maybe(value: Any) -> Any:
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value
def _i18n_text(value: Any) -> str:
value = _loads_maybe(value)
if isinstance(value, dict):
return str(value.get("zh_CN") or value.get("en_US") or value.get("pureEn_US") or value.get("value") or "")
return str(value or "")
def _extract_records(data: Any) -> list[Any]:
payload = _extract_payload(data)
payload = _loads_maybe(payload)
if isinstance(payload, dict):
for key in ("data", "result", "records", "list"):
value = payload.get(key)
if isinstance(value, list):
return value
if isinstance(payload, list):
return payload
return []
def _load_form_titles(app_type: str, dry_run: bool = False) -> dict[str, str]:
resp = _run_dws([
"yida", "app", "list-forms",
"--app", app_type,
"--format", "json",
], dry_run=dry_run)
titles: dict[str, str] = {}
if not resp or dry_run:
return titles
for item in _extract_records(resp):
item = _loads_maybe(item)
if not isinstance(item, dict):
continue
form_uuid = item.get("formUuid")
if isinstance(form_uuid, str) and form_uuid:
titles[form_uuid] = _i18n_text(item.get("title")) or form_uuid
return titles
def _field_from_component(item: Any) -> dict[str, Any] | None:
item = _loads_maybe(item)
if not isinstance(item, dict):
return None
field_id = item.get("key") or item.get("fieldId") or item.get("componentId") or item.get("id")
component_name = item.get("componentName")
if not isinstance(field_id, str) or not field_id:
return None
if component_name in {"Page", "FormContainer"}:
return None
label = _i18n_text(item.get("label") or item.get("varName")) or field_id
return {
"fieldId": field_id,
"name": field_id,
"value": field_id,
"label": label,
"text": label,
"componentName": component_name,
"componentOption": "[]",
"required": False,
"componentProps": {
"defaultDataSource": {},
"relateAppType": "",
"relateOrderEnable": False,
"relateOrderConfig": [],
},
}
def _load_form_schema(app_type: str, form_uuid: str, dry_run: bool = False) -> dict[str, Any] | None:
resp = _run_dws([
"yida", "design", "form", "get-schema",
"--app", app_type,
"--form", form_uuid,
"--format", "json",
], dry_run=dry_run)
if not resp or dry_run:
return None
payload = _extract_payload(resp)
payload = _loads_maybe(payload)
return payload if isinstance(payload, dict) else None
def _iter_schema_components(value: Any, parent: dict[str, Any] | None = None):
value = _loads_maybe(value)
if isinstance(value, list):
for item in value:
yield from _iter_schema_components(item, parent)
return
if not isinstance(value, dict):
return
current_parent = parent
if isinstance(value.get("componentName"), str):
yield value, parent
current_parent = value
for key in ("pages", "componentsTree", "children"):
child = value.get(key)
if isinstance(child, (list, dict)):
yield from _iter_schema_components(child, current_parent)
def _field_from_schema_node(item: Any, parent: dict[str, Any] | None = None) -> dict[str, Any] | None:
item = _loads_maybe(item)
if not isinstance(item, dict):
return None
component_name = item.get("componentName")
props = item.get("props") if isinstance(item.get("props"), dict) else {}
field_id = props.get("fieldId") or item.get("key") or item.get("fieldId") or item.get("componentId") or item.get("id")
if not isinstance(field_id, str) or not field_id:
return None
if component_name in {"Page", "RootHeader", "RootContent", "RootFooter", "FormContainer"}:
return None
label = _i18n_text(props.get("label") or item.get("label") or item.get("varName")) or field_id
field_props = copy.deepcopy(props)
if parent and isinstance(parent, dict):
parent_component = parent.get("componentName")
parent_props = parent.get("props") if isinstance(parent.get("props"), dict) else {}
parent_id = parent_props.get("fieldId") or parent.get("fieldId") or parent.get("id")
if parent_component:
field_props.setdefault("parentComponentName", parent_component)
if parent_id:
field_props.setdefault("parentId", parent_id)
return {
"fieldId": field_id,
"name": field_id,
"value": field_id,
"label": label,
"text": label,
"componentName": component_name,
"componentOption": "[]",
"required": bool(props.get("required", False)),
"props": field_props,
"componentProps": {
"defaultDataSource": copy.deepcopy(props.get("defaultDataSource") or {}),
"relateAppType": props.get("relateAppType", ""),
"relateOrderEnable": bool(props.get("relateOrderEnable", False)),
"relateOrderConfig": copy.deepcopy(props.get("relateOrderConfig") or []),
},
}
def _load_form_fields_from_schema(app_type: str, form_uuid: str, dry_run: bool = False) -> list[dict[str, Any]]:
schema = _load_form_schema(app_type, form_uuid, dry_run=dry_run)
if not schema:
return []
fields: list[dict[str, Any]] = []
seen: set[str] = set()
for item, parent in _iter_schema_components(schema):
field = _field_from_schema_node(item, parent)
if not field:
continue
field_id = str(field.get("fieldId") or "")
if field_id in seen:
continue
seen.add(field_id)
fields.append(field)
return fields
def _load_form_fields(app_type: str, form_uuid: str, dry_run: bool = False) -> list[dict[str, Any]]:
fields = _load_form_fields_from_schema(app_type, form_uuid, dry_run=dry_run)
if fields:
return fields
resp = _run_dws([
"yida", "form", "components",
"--app", app_type,
"--form", form_uuid,
"--format", "json",
], dry_run=dry_run)
if not resp or dry_run:
return []
fields = []
for item in _extract_records(resp):
field = _field_from_component(item)
if field:
fields.append(field)
return fields
def _collect_data_create_targets(flow: dict[str, Any], default_app: str) -> set[tuple[str, str]]:
targets: set[tuple[str, str]] = set()
for node in _iter_flow_nodes(flow.get("nodes") or []):
props = node.get("props") or {}
node_type = str(node.get("type") or "")
if node_type == "dataCreate":
form_uuid = props.get("formUuid")
elif node_type == "dataRetrieve":
form_uuid = props.get("sourceId")
else:
continue
if not isinstance(form_uuid, str) or not form_uuid:
continue
if node_type == "dataRetrieve" and not form_uuid.startswith("FORM-"):
continue
app_type = props.get("appType") if isinstance(props.get("appType"), str) else ""
targets.add((app_type or default_app, form_uuid))
return targets
def _build_data_create_metadata(flow: dict[str, Any], app_type: str, dry_run: bool = False) -> dict[tuple[str, str], dict[str, Any]]:
targets = _collect_data_create_targets(flow, app_type)
if not targets:
return {}
titles_by_app: dict[str, dict[str, str]] = {}
metadata: dict[tuple[str, str], dict[str, Any]] = {}
for target_app, form_uuid in sorted(targets):
if target_app not in titles_by_app:
titles_by_app[target_app] = _load_form_titles(target_app, dry_run=dry_run)
metadata[(target_app, form_uuid)] = {
"title": titles_by_app[target_app].get(form_uuid, form_uuid),
"fields": _load_form_fields(target_app, form_uuid, dry_run=dry_run),
}
return metadata
def _load_source_view(app_type: str, process_code: str, process_id: str, dry_run: bool = False) -> dict[str, Any] | None:
if not app_type or not process_code or not process_id or dry_run:
return None
resp = _run_dws([
"yida", "design", "process", "get",
"--app", app_type,
"--process-code", process_code,
"--process-id", process_id,
"--format", "json",
], dry_run=dry_run)
if not resp:
return None
for item in _iter_dicts(resp):
view_json = item.get("viewJson")
if isinstance(view_json, str) and view_json:
parsed = _loads_maybe(view_json)
if isinstance(parsed, dict):
return parsed
return None
def _extract_process_id(data: Any) -> str:
for item in _iter_dicts(data):
for key in ("processId", "id", "processVersionId", "result", "content"):
value = item.get(key)
if isinstance(value, (str, int)) and str(value):
return str(value)
return ""
def _load_optional_text(file_path: str | None, inline_value: str | None, label: str) -> str:
if file_path:
safe = _resolve_safe_path(file_path)
if not safe.exists():
raise ValueError(f"{label} 文件不存在: {safe}")
if safe.stat().st_size > MAX_FLOW_FILE_SIZE:
raise ValueError(f"{label} 文件过大 (限制 {MAX_FLOW_FILE_SIZE:,} 字节)")
return safe.read_text(encoding="utf-8")
return inline_value or ""
def _summarize_flow(flow: dict[str, Any]) -> list[str]:
nodes = list(_iter_flow_nodes(flow.get("nodes") or []))
counts: dict[str, int] = {}
for node in nodes:
node_type = str(node.get("type") or "unknown")
counts[node_type] = counts.get(node_type, 0) + 1
lines = [
f"节点数: {len(nodes)}",
"节点类型: " + ", ".join(f"{key}={counts[key]}" for key in sorted(counts)),
]
data_targets = []
card_rules = []
for node in nodes:
props = node.get("props") if isinstance(node.get("props"), dict) else {}
node_name = _i18n_text(node.get("name")) or str(node.get("nodeId") or "")
node_type = str(node.get("type") or "")
if node_type == "dataCreate":
data_targets.append(f"{node_name}->{props.get('appType') or '<当前应用>'}/{props.get('formUuid')}")
if node_type == "sendCard":
biz_id = props.get("bizId") if isinstance(props.get("bizId"), dict) else {}
card_rules.append(
f"{node_name}: page={props.get('cardPageCode')}, sendType={props.get('sendType')}, "
f"bizId={biz_id.get('value')}"
)
if data_targets:
lines.append("新增数据目标: " + "; ".join(data_targets))
if card_rules:
lines.append("卡片规则: " + "; ".join(card_rules))
return lines
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
description="宜搭流程定义保存/发布:新建草稿 -> 生成流程 schema -> 保存流程 -> 可选发布",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
ap.add_argument("--app", help="应用编码 appType--schema-only 时可不传")
ap.add_argument("--form", help="流程表单 formUuid;创建草稿时必填")
ap.add_argument("--process-code", required=True, help="流程 code,如 TPROC-XXX")
ap.add_argument("--source-process-id", help="源流程版本 id,用于 create-draft")
ap.add_argument("--draft-process-id", help="已有草稿 processId;传入后跳过 create-draft")
ap.add_argument("--flow-file", help="流程 schema / build_automation_flow 输入 JSON 文件")
ap.add_argument("--flow-json", help="流程 schema / build_automation_flow 输入内联 JSON")
ap.add_argument("--view-file", help="可选 viewJson 文件")
ap.add_argument("--view-json", help="可选内联 viewJson")
ap.add_argument("--publish", action="store_true", help="保存后发布流程")
ap.add_argument("--schema-only", action="store_true", help="只输出生成后的流程 schema,不调用 dws")
ap.add_argument("--yes", action="store_true", help="确认执行保存/发布")
ap.add_argument("--dry-run", action="store_true", help="打印将执行的命令,不调用 dws")
args = ap.parse_args(argv)
try:
flow_spec = _load_json(args.flow_file, args.flow_json, "flow")
flow = build_flow_schema(flow_spec, process_code=args.process_code)
if args.form:
flow.setdefault("props", {})["bindingForm"] = args.form
view_content = _load_optional_text(args.view_file, args.view_json, "view") if (args.view_file or args.view_json) else ""
except (ValueError, json.JSONDecodeError, FlowSchemaError) as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
flow_json = json.dumps(flow, ensure_ascii=False, separators=(",", ":"))
if args.schema_only:
print(json.dumps(flow, ensure_ascii=False, indent=2))
return 0
if not args.app:
print("错误: 保存/发布流程必须提供 --app", file=sys.stderr)
return 1
if not args.yes and not args.dry_run:
print("错误: 保存/发布流程是高影响操作,必须显式传 --yes", file=sys.stderr)
return 1
if not args.draft_process_id and (not args.form or not args.source_process_id):
print("错误: 未传 --draft-process-id 时,必须提供 --form 和 --source-process-id 创建草稿", file=sys.stderr)
return 1
if not view_content:
print("Step 0/4: 生成流程设计器 viewJson")
print(" [检查] 流程摘要")
for line in _summarize_flow(flow):
print(f" - {line}")
metadata = _build_data_create_metadata(flow, args.app, dry_run=args.dry_run)
source_view_id = args.source_process_id or args.draft_process_id or ""
source_view = _load_source_view(args.app, args.process_code, source_view_id, dry_run=args.dry_run)
if source_view:
view = merge_process_view_schema(source_view, flow, data_create_metadata=metadata)
else:
view = build_process_view_schema(flow, data_create_metadata=metadata)
view_content = json.dumps(view, ensure_ascii=False, separators=(",", ":"))
print(f" [OK] viewJson {len(view_content):,} 字节")
# Step 1: create or reuse draft
draft_process_id = args.draft_process_id or ""
if draft_process_id:
print(f"Step 1/4: 复用草稿 processId={draft_process_id}")
else:
print("Step 1/4: 新建流程草稿")
resp = _run_dws([
"yida", "design", "process", "create-draft",
"--app", args.app,
"--form", args.form,
"--process-id", args.source_process_id,
"--format", "json",
], dry_run=args.dry_run)
if args.dry_run:
draft_process_id = "<draft-process-id>"
else:
if not resp:
return 1
draft_process_id = _extract_process_id(resp)
if not draft_process_id:
print(" [FAIL] create-draft 返回中未找到 processId", file=sys.stderr)
return 1
print(f" [OK] 新草稿 processId={draft_process_id}")
print(f"Step 2/4: 生成流程 schema ({len(flow_json):,} 字节)")
# Step 3: save draft
print("Step 3/4: 保存流程草稿")
update_args = [
"yida", "design", "process", "update",
"--app", args.app,
"--process-code", args.process_code,
"--process-id", draft_process_id,
"--content", flow_json,
"--yes",
"--format", "json",
]
if view_content:
update_args.extend(["--view-content", view_content])
resp = _run_dws(update_args, dry_run=args.dry_run)
if not args.dry_run and not resp:
return 1
# Step 4: optional publish
if args.publish:
print("Step 4/4: 发布流程")
resp = _run_dws([
"yida", "design", "process", "publish",
"--app", args.app,
"--process-code", args.process_code,
"--process-id", draft_process_id,
"--yes",
"--format", "json",
], dry_run=args.dry_run)
if not args.dry_run and not resp:
return 1
else:
print("Step 4/4: 跳过发布(未传 --publish")
print(json.dumps({
"ok": True,
"app": args.app,
"processCode": args.process_code,
"draftProcessId": draft_process_id,
"schemaSize": len(flow_json),
"published": bool(args.publish),
"dryRun": bool(args.dry_run),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,494 @@
"""
宜搭报表 schema 构造 chart 装配进 Page > RootContent 栅格布局
主入口:
build_report_schema_with_filters(report_title, charts, filters, report_id, corp_id)
apply_chart_changes_to_schema(schema, changes, cube_tenant_id)
"""
from __future__ import annotations
import copy
from typing import Any, Optional
from yida_schema_common import (
build_report_components_map,
DATA_SOURCE_FIT_COMPILED,
DATA_SOURCE_FIT_SOURCE,
i18n,
next_node_id,
generate_field_id,
)
from yida_report_charts import (
build_chart_component,
CHART_TYPE_MAP,
normalize_cube_code,
normalize_field_code,
)
# ---------------------------------------------------------------------------
# 报表骨架常量
# ---------------------------------------------------------------------------
_PAGE_STYLE = ":root {\n background-color: #f2f3f5;\n}\n"
_PAGE_PROPS = {
"pageStyle": _PAGE_STYLE,
"containerStyle": {},
"userVariables": [
{"text": "工号", "id": "varWorkNo"},
{"text": "部门名称", "id": "varDeptName"},
{"text": "所属公司编号", "id": "varCorpNo"},
{"text": "部门编码", "id": "varDeptNo"},
],
"templateVersion": "1.0.0",
"className": "page_m9o7d9ml",
"params": [],
}
_INIT_METHODS = {
"__initMethods__": {
"type": "js",
"source": "function (exports, module) { /*set actions code here*/ }",
"compiled": "function (exports, module) { /*set actions code here*/ }",
}
}
_CONSTRUCTOR = {
"type": "js",
"compiled": (
"function constructor() {\n"
"var module = { exports: {} };\n"
"var _this = this;\n"
"this.__initMethods__(module.exports, module);\n"
"Object.keys(module.exports).forEach(function(item) {\n"
" if(typeof module.exports[item] === 'function'){\n"
" _this[item] = module.exports[item];\n"
" }\n"
"});\n"
"}"
),
"source": (
"function constructor() {\n"
"var module = { exports: {} };\n"
"var _this = this;\n"
"this.__initMethods__(module.exports, module);\n"
"Object.keys(module.exports).forEach(function(item) {\n"
" if(typeof module.exports[item] === 'function'){\n"
" _this[item] = module.exports[item];\n"
" }\n"
"});\n"
"}"
),
}
# ---------------------------------------------------------------------------
# 布局计算
# ---------------------------------------------------------------------------
def _next_layout_position(layout: list[dict[str, Any]], w: int) -> tuple[int, int]:
"""Calculate next (x, y) for a new item in the 6-column react-grid-layout grid.
Finds the last row, checks if ``w`` fits to the right of existing items;
if yes returns (right_edge, row_y), otherwise wraps to a new row.
"""
if not layout:
return 0, 0
max_y = max(li.get("y", 0) for li in layout)
last_row = [li for li in layout if li.get("y", 0) == max_y]
last_row_right = max(li.get("x", 0) + li.get("w", 0) for li in last_row)
last_row_h = max(li.get("h", 0) for li in last_row)
if last_row_right + w <= 6:
return last_row_right, max_y
return 0, max_y + last_row_h
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def build_report_schema_with_filters(
report_title: str,
charts: list[dict[str, Any]],
filters: Optional[list[dict[str, Any]]] = None,
report_id: str = "",
corp_id: str = "",
) -> dict[str, Any]:
"""构造完整报表 schema(含图表 + 筛选器)。"""
if filters is None:
filters = []
# 构造所有图表节点
chart_nodes: list[dict[str, Any]] = []
chart_field_ids: list[str] = []
layout_items: list[dict[str, Any]] = []
component_names: set[str] = {"Page", "RootHeader", "RootContent", "RootFooter"}
x = 0
y = 0
row_max_h = 0
for chart in charts:
node, field_id, default_layout = build_chart_component(chart, cube_tenant_id=corp_id)
chart_nodes.append(node)
chart_field_ids.append(field_id)
component_names.add(node["componentName"])
w = default_layout["w"]
h = default_layout["h"]
if x + w > 6:
x = 0
y += row_max_h
row_max_h = 0
item: dict[str, Any] = {"i": field_id, "x": x, "y": y, "w": w, "h": h,
"moved": False, "static": False}
for k in ("minH", "maxH", "resizeHandles"):
if k in default_layout:
item[k] = default_layout[k]
layout_items.append(item)
row_max_h = max(row_max_h, h)
x += w
if x >= 6:
x = 0
y += row_max_h
row_max_h = 0
# 筛选器组件
filter_nodes: list[dict[str, Any]] = []
for flt in filters:
f_node = _build_select_filter(flt, corp_id, chart_nodes)
if f_node:
filter_nodes.append(f_node)
component_names.add("YoushuSelect")
# 筛选器 layout(顶部一行)
if filter_nodes:
filter_layout: list[dict[str, Any]] = []
fx = 0
for fn in filter_nodes:
fn_field_id = fn.get("props", {}).get("fieldId", fn["id"])
filter_layout.append({"i": fn_field_id, "x": fx, "y": 0, "w": 2, "h": 2})
fx += 2
# 把图表 layout 的 y 下移
offset_y = 2
for li in layout_items:
li["y"] += offset_y
layout_items = filter_layout + layout_items
all_children = filter_nodes + chart_nodes
components_map = build_report_components_map(sorted(component_names))
schema: dict[str, Any] = {
"schemaType": "superform",
"schemaVersion": "5.0",
"pages": [
{
"utils": [],
"componentsMap": components_map,
"componentsTree": [
{
"componentName": "Page",
"id": next_node_id(),
"props": copy.deepcopy(_PAGE_PROPS),
"css": "body {\n background-color: #f2f3f5;\n}\n",
"dataSource": {
"offline": [],
"globalConfig": {
"fit": {
"compiled": DATA_SOURCE_FIT_COMPILED,
"source": DATA_SOURCE_FIT_SOURCE,
"type": "js",
"error": {},
},
},
"online": [],
"list": [],
"sync": True,
},
"methods": copy.deepcopy(_INIT_METHODS),
"lifeCycles": {
"constructor": copy.deepcopy(_CONSTRUCTOR),
},
"children": [
{
"componentName": "RootHeader",
"id": next_node_id(),
"props": {},
},
{
"componentName": "RootContent",
"id": next_node_id(),
"props": {
"layout": layout_items,
"rglSwitch": True,
"contentBgColor": "transparent",
},
"children": all_children,
},
{
"componentName": "RootFooter",
"id": next_node_id(),
"props": {},
},
],
},
],
"id": report_id or next_node_id(),
"connectComponent": [],
},
],
"actions": {
"module": {"source": "", "compiled": ""},
"list": [],
},
}
return schema
# ---------------------------------------------------------------------------
# 增量修改
# ---------------------------------------------------------------------------
def apply_chart_changes_to_schema(
schema: dict[str, Any],
changes: list[dict[str, Any]],
cube_tenant_id: str = "",
) -> dict[str, Any]:
"""增量修改报表 schemaadd/remove/replace/update-props)。"""
root_content = _find_root_content(schema)
if root_content is None:
raise ValueError("schema 中找不到 RootContent")
children: list[dict[str, Any]] = root_content.get("children", [])
layout: list[dict[str, Any]] = root_content.get("props", {}).get("layout", [])
for change in changes:
action = change.get("action", "")
if action == "add":
chart_def = change.get("chart", {})
node, field_id, default_layout = build_chart_component(chart_def, cube_tenant_id=cube_tenant_id)
after_title = change.get("after")
insert_idx = len(children)
if after_title:
idx = _find_chart_index_by_title(children, after_title)
if idx is not None:
insert_idx = idx + 1
children.insert(insert_idx, node)
nx, ny = _next_layout_position(layout, default_layout["w"])
item: dict[str, Any] = {"i": field_id, "x": nx, "y": ny,
"w": default_layout["w"], "h": default_layout["h"],
"moved": False, "static": False}
for lk in ("minH", "maxH", "resizeHandles"):
if lk in default_layout:
item[lk] = default_layout[lk]
layout.append(item)
_ensure_component_in_map(schema, node["componentName"])
elif action == "remove":
title = change.get("title", "")
idx = _find_chart_index_by_title(children, title)
if idx is not None:
removed = children.pop(idx)
remove_keys = {removed.get("id"), removed.get("props", {}).get("fieldId")}
layout[:] = [li for li in layout if li.get("i") not in remove_keys]
elif action == "replace":
title = change.get("title", "")
chart_def = change.get("chart", {})
idx = _find_chart_index_by_title(children, title)
if idx is not None:
old_node = children[idx]
old_keys = {old_node.get("id"), old_node.get("props", {}).get("fieldId")}
node, field_id, default_layout = build_chart_component(chart_def, cube_tenant_id=cube_tenant_id)
for li in layout:
if li.get("i") in old_keys:
li["i"] = field_id
break
children[idx] = node
_ensure_component_in_map(schema, node["componentName"])
elif action == "update-props":
title = change.get("title", "")
props_patch = change.get("props", {})
idx = _find_chart_index_by_title(children, title)
if idx is not None:
children[idx].setdefault("props", {}).update(props_patch)
root_content["children"] = children
root_content.setdefault("props", {})["layout"] = layout
return schema
# ---------------------------------------------------------------------------
# 筛选器
# ---------------------------------------------------------------------------
def auto_generate_filters(charts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""从图表字段中自动提取 select-like 字段作为筛选器。"""
seen: set[str] = set()
filters: list[dict[str, Any]] = []
prefixes = ("selectField_", "radioField_", "checkboxField_", "multiSelectField_")
_SCAN_KEYS = ("xField", "yField", "columnFields", "kpi", "kpiField",
"leftYFields", "rightYFields", "columnList", "valueField")
for chart in charts:
for key in _SCAN_KEYS:
val = chart.get(key, "")
if not val:
continue
fields = val if isinstance(val, list) else [val]
for f in fields:
fc = f.get("fieldCode", "") if isinstance(f, dict) else str(f)
if not fc:
continue
base = fc.replace("_value", "") if fc.endswith("_value") else fc
if any(base.startswith(p) for p in prefixes) and base not in seen:
seen.add(base)
filters.append({
"type": "select",
"cubeCode": chart.get("cubeCode", ""),
"fieldCode": fc if fc.endswith("_value") else normalize_field_code(fc),
"label": base,
})
return filters
def _build_select_filter(
flt: dict[str, Any],
cube_tenant_id: str,
chart_nodes: list[dict[str, Any]],
) -> Optional[dict[str, Any]]:
if flt.get("type") != "select":
return None
node_id = next_node_id()
field_code = normalize_field_code(flt.get("fieldCode", ""))
cube_code = normalize_cube_code(flt.get("cubeCode", ""))
label = flt.get("label", field_code)
node: dict[str, Any] = {
"componentName": "YoushuSelect",
"id": node_id,
"props": {
"fieldId": generate_field_id("YoushuSelect"),
"cid": node_id,
"componentTitle": i18n(label),
"dataSetModelMap": {
"selectFilter": {
"dataViewQueryModel": {
"cubeCode": cube_code,
"fieldDefinitionList": [{
"classifiedCode": cube_code,
"cubeCode": cube_code,
"fieldCode": field_code,
"dataType": "VARCHAR",
"isDim": False,
"aggregateType": "NONE",
"alias": "filter_dim",
"aliasName": {"type": "i18n", "zh_CN": label, "en_US": label},
"timeGranularityType": None,
}],
"fieldList": ["filter_dim"],
"filterList": [],
"orderByList": [],
"cubeTenantId": cube_tenant_id,
},
"fieldList": [],
"youshuDataType": "real",
"cubeCodes": [cube_code] if cube_code else "",
"filterList": [],
"limit": "",
}
},
"filterLinkage": _build_filter_linkage(flt, chart_nodes, field_code),
},
}
return node
def _build_filter_linkage(
flt: dict[str, Any],
chart_nodes: list[dict[str, Any]],
field_code: str,
) -> list[dict[str, Any]]:
link_to = flt.get("linkTo")
linkage: list[dict[str, Any]] = []
targets = chart_nodes if link_to is None else [chart_nodes[i] for i in link_to if i < len(chart_nodes)]
for target in targets:
linkage.append({
"targetComponentId": target.get("id", ""),
"targetFieldCode": field_code,
})
return linkage
# ---------------------------------------------------------------------------
# 辅助
# ---------------------------------------------------------------------------
def _find_root_content(schema: dict[str, Any]) -> Optional[dict[str, Any]]:
pages = schema.get("pages", [])
if not pages:
return None
tree = pages[0].get("componentsTree", [])
if not tree:
return None
for child in tree[0].get("children", []):
if child.get("componentName") == "RootContent":
return child
return None
def _find_chart_index_by_title(nodes: list[dict[str, Any]], title: str) -> Optional[int]:
for idx, node in enumerate(nodes):
ct = node.get("props", {}).get("componentTitle", {})
zh = ct.get("zh_CN", "") if isinstance(ct, dict) else str(ct)
if zh == title:
return idx
return None
def _ensure_component_in_map(schema: dict[str, Any], component_name: str) -> None:
pages = schema.get("pages", [])
if not pages:
return
cm = pages[0].get("componentsMap", [])
from yida_schema_common import REPORT_COMPONENT_PACKAGE, REPORT_COMPONENT_VERSION
if not any(c.get("componentName") == component_name for c in cm):
cm.append({
"package": REPORT_COMPONENT_PACKAGE,
"version": REPORT_COMPONENT_VERSION,
"componentName": component_name,
})
def parse_report_config(raw: Any) -> tuple[list[dict[str, Any]], Optional[list[dict[str, Any]]]]:
"""解析入参:支持 [charts] 数组 或 {charts, filters} 对象。"""
if isinstance(raw, list):
return raw, None
if isinstance(raw, dict):
return raw.get("charts", []), raw.get("filters")
raise ValueError("charts 入参必须是数组或 {charts, filters} 对象")
def count_report_children(schema: dict[str, Any]) -> int:
"""Return children count in RootContent, or -1 if RootContent not found."""
rc = _find_root_content(schema)
if rc is None:
return -1
return len(rc.get("children", []))
def is_empty_report_skeleton(schema: dict[str, Any]) -> bool:
return count_report_children(schema) == 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
宜搭报表 schema 生成/修改编排get-schema apply chart changes update-schema
用法:
python yida_report_update.py --app APP_X --form REPORT-XXX \\
--changes-file changes.json --corp-id <corpId> --yes
changes.json 格式非空数组 20
[
{"action": "add", "chart": {"type": "bar", "title": "新图", ...}, "after": "现有图标题"},
{"action": "remove", "title": "废弃图表"},
{"action": "replace", "title": "旧图", "chart": {"type": "line", ...}},
{"action": "update-props", "title": "总览", "props": {"isHeightAuto": true}}
]
action: add / remove / replace / update-props
chart 对象格式字段对象格式数据绑定模型布局系统常见陷阱等完整规范
请参考同目录下的参考文档references/yida-report-builder.md
构建 chart 前必须先获取源表字段
dws yida form components --app <appType> --form <源表formUuid>
新建场景CLI `dws yida design form create --form-type report` 拿到 formUuid
再调本脚本传全 add changes 自动走全量构建即使空报表 schema 暂无
RootContent也会自动生成完整报表骨架
更新场景传含 add/remove/replace/update-props changes 增量修改既有 schema
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from yida_report_builder import ( # noqa: E402
apply_chart_changes_to_schema,
build_report_schema_with_filters,
count_report_children,
)
MAX_CHANGES = 20
MAX_FILE_SIZE = 512 * 1024
MAX_INLINE_JSON = 64 * 1024
def _resolve_safe_path(path_str: str) -> Path:
allowed_root = os.environ.get("OPENCLAW_WORKSPACE", os.getcwd())
allowed_root_p = Path(allowed_root).resolve()
target = Path(path_str).resolve() if Path(path_str).is_absolute() else (Path.cwd() / path_str).resolve()
try:
target.relative_to(allowed_root_p)
except ValueError:
raise ValueError(f"路径超出允许范围:{path_str}\n允许根目录:{allowed_root_p}")
return target
def _run_dws(args: list[str], dry_run: bool = False) -> Any | None:
cmd = ["dws"] + args
if dry_run:
print(f" [dry-run] {' '.join(cmd)}")
return {"dry_run": True}
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print(" ✗ 找不到 'dws' 命令", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print(" ✗ dws 超时", file=sys.stderr)
return None
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
print(f" ✗ dws 失败 (exit {result.returncode}): {err}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f" ✗ 非 JSON: {e}\n 输出: {result.stdout[:300]}", file=sys.stderr)
return None
def _extract_payload(data: Any) -> Any:
"""Unwrap common dws JSON envelopes and parse JSON-string payloads."""
current = data
for _ in range(3):
if isinstance(current, dict):
for key in ("content", "result", "data"):
if key in current and current[key] not in (None, ""):
current = current[key]
break
else:
break
continue
break
if isinstance(current, str):
stripped = current.strip()
if stripped.startswith(("{", "[")):
try:
return json.loads(stripped)
except json.JSONDecodeError:
return current
return current
def _extract_title(info: Any) -> str:
payload = _extract_payload(info)
if isinstance(payload, dict):
title = payload.get("title") or payload.get("name") or ""
if isinstance(title, dict):
return str(title.get("zh_CN") or title.get("en_US") or "报表")
if title:
return str(title)
return "报表"
def _load_changes(args: argparse.Namespace) -> list[dict]:
if args.changes_file:
safe = _resolve_safe_path(args.changes_file)
if not safe.exists():
raise ValueError(f"文件不存在: {safe}")
if safe.stat().st_size > MAX_FILE_SIZE:
raise ValueError(f"文件过大 (限制 {MAX_FILE_SIZE:,} 字节)")
with safe.open("r", encoding="utf-8") as f:
changes = json.load(f)
elif args.changes_json:
if len(args.changes_json.encode("utf-8")) > MAX_INLINE_JSON:
raise ValueError("--changes-json 过长")
changes = json.loads(args.changes_json)
else:
raise ValueError("必须提供 --changes-file 或 --changes-json")
if not isinstance(changes, list) or not changes:
raise ValueError("changes 必须是非空数组")
if len(changes) > MAX_CHANGES:
raise ValueError(f"changes 过多 ({len(changes)} > {MAX_CHANGES})")
valid_actions = {"add", "remove", "replace", "update-props"}
for i, c in enumerate(changes):
if c.get("action") not in valid_actions:
raise ValueError(f"change #{i+1} action 无效: {c.get('action')}")
return changes
def main() -> int:
ap = argparse.ArgumentParser(description="宜搭报表 schema 生成/修改",
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
ap.add_argument("--app", required=True, help="应用编码 appType")
ap.add_argument("--form", required=True, help="报表 formUuid")
ap.add_argument("--changes-file", help="变更定义 JSON 文件路径")
ap.add_argument("--changes-json", help="变更定义 JSON 内联")
ap.add_argument("--corp-id", default="", help="企业 ID")
ap.add_argument("--yes", action="store_true", help="确认写入")
ap.add_argument("--dry-run", action="store_true", help="只生成不写入")
ap.add_argument("--force-rebuild", action="store_true", help="强制全量重建(丢弃已有图表)")
args = ap.parse_args()
try:
changes = _load_changes(args)
except ValueError as e:
print(f"错误: {e}", file=sys.stderr)
return 1
# Step 1
print("Step 1/3: 获取现有 schema")
resp = _run_dws(["yida", "design", "form", "get-schema", "--app", args.app,
"--form", args.form, "--format", "json"], dry_run=args.dry_run)
if args.dry_run:
print(json.dumps({"ok": True, "dry_run": True, "changeCount": len(changes)}, ensure_ascii=False))
return 0
if not resp:
return 1
schema = _extract_payload(resp)
if not isinstance(schema, dict):
print("错误: get-schema 返回内容不是对象,无法生成报表 schema", file=sys.stderr)
return 1
print(" ✓ 拿到 schema")
# Step 2
all_add = all(c.get("action") == "add" for c in changes)
child_count = count_report_children(schema)
do_full_build = False
if child_count < 0:
if all_add:
if args.force_rebuild:
print(" ⚠ schema 结构异常(找不到 RootContent),--force-rebuild 强制全量重建")
else:
print(" ⚠ schema 结构异常(找不到 RootContent)+ 全 add → 自动全量构建")
do_full_build = True
else:
print("错误: schema 结构异常,找不到 RootContent,无法安全操作", file=sys.stderr)
print(" 提示: 若这是新建空报表,请传全 add;若是已有报表,请先确认 schema 是否完整", file=sys.stderr)
return 1
elif child_count == 0 and all_add:
print(" ⚠ 空骨架 + 全 add → 全量构建")
do_full_build = True
elif args.force_rebuild and all_add:
print(f" ⚠ --force-rebuild: 强制全量重建(丢弃已有 {child_count} 个组件)")
do_full_build = True
try:
if do_full_build:
if not all_add:
print("错误: 全量构建只支持全 add 操作", file=sys.stderr)
return 1
info = _run_dws(["yida", "design", "form", "get-info", "--app", args.app,
"--form", args.form, "--format", "json"])
title = _extract_title(info)
charts = [c["chart"] for c in changes]
schema = build_report_schema_with_filters(report_title=title, charts=charts,
report_id=args.form, corp_id=args.corp_id)
else:
print(f"Step 2/3: 应用 {len(changes)} 条变更(已有 {child_count} 个组件)")
schema = apply_chart_changes_to_schema(schema, changes, cube_tenant_id=args.corp_id)
except ValueError as e:
print(f"错误: {e}", file=sys.stderr)
return 1
print(" ✓ 变更完成")
# Step 3
schema_json = json.dumps(schema, ensure_ascii=False, separators=(",", ":"))
print(f"Step 3/3: 写入 schema ({len(schema_json):,} 字节)")
resp = _run_dws(["yida", "design", "form", "update-schema", "--app", args.app,
"--form", args.form, "--form-type", "report",
"--content", schema_json, "--yes", "--format", "json"])
if not resp:
return 1
print(" ✓ 写入成功")
print(json.dumps({"ok": True, "formUuid": args.form, "changeCount": len(changes)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,340 @@
"""
宜搭 schema 公共工具模块
提供 form / report / custom-page 三类 builder 共用的基础函数
- next_node_id / generate_field_id ID 生成
- i18n / build_yida_i18n 国际化包装
- build_option_data_source 选项 dataSource 构造 + 语义色彩
- build_components_map componentsMap 构造
- FIELD_TYPE_ALIAS 字段类型别名映射
"""
from __future__ import annotations
import random
import string
import time
from typing import Any, Optional
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
COMPONENT_PACKAGE = "@ali/vc-deep-yida"
COMPONENT_VERSION = "1.5.169"
REPORT_COMPONENT_PACKAGE = "@/components/vc-yida-report"
REPORT_COMPONENT_VERSION = "1.0.6"
UTILS_LEGAO_BUILTIN = {
"name": "legaoBuiltin",
"type": "npm",
"content": {
"package": "@ali/vu-legao-builtin",
"version": "3.0.0",
"exportName": "legaoBuiltin",
},
}
UTILS_YIDA_PLUGIN = {
"name": "yidaPlugin",
"type": "npm",
"content": {
"package": "@ali/vu-yida-plugin",
"version": "1.0.13",
"exportName": "yidaPlugin",
},
}
# 字段类型别名 → 标准 componentName
FIELD_TYPE_ALIAS: dict[str, str] = {
"text": "TextField",
"textfield": "TextField",
"textarea": "TextareaField",
"textareafield": "TextareaField",
"number": "NumberField",
"numberfield": "NumberField",
"rate": "RateField",
"ratefield": "RateField",
"date": "DateField",
"datefield": "DateField",
"cascadedate": "CascadeDateField",
"cascadedatefield": "CascadeDateField",
"daterange": "CascadeDateField",
"radio": "RadioField",
"radiofield": "RadioField",
"select": "SelectField",
"selectfield": "SelectField",
"checkbox": "CheckboxField",
"checkboxfield": "CheckboxField",
"multiselect": "MultiSelectField",
"multiselectfield": "MultiSelectField",
"country": "CountrySelectField",
"countryselectfield": "CountrySelectField",
"address": "AddressField",
"addressfield": "AddressField",
"attachment": "AttachmentField",
"attachmentfield": "AttachmentField",
"image": "ImageField",
"imagefield": "ImageField",
"employee": "EmployeeField",
"employeefield": "EmployeeField",
"department": "DepartmentSelectField",
"departmentselectfield": "DepartmentSelectField",
"table": "TableField",
"tablefield": "TableField",
"association": "AssociationFormField",
"associationformfield": "AssociationFormField",
"serialnumber": "SerialNumberField",
"serialnumberfield": "SerialNumberField",
"divider": "Divider",
}
DATA_SOURCE_FIT_COMPILED = (
"'use strict';\n\nvar __preParser__ = function fit(response) {\n"
" var content = response.content !== undefined ? response.content : response;\n"
" var error = {\n"
" message: response.errorMsg || response.errors && response.errors[0] && response.errors[0].msg || response.content || '远程数据源请求出错,success is false'\n"
" };\n"
" var success = true;\n"
" if (response.success !== undefined) {\n"
" success = response.success;\n"
" } else if (response.hasError !== undefined) {\n"
" success = !response.hasError;\n"
" }\n"
" return {\n"
" content: content,\n"
" success: success,\n"
" error: error\n"
" };\n"
"};"
)
DATA_SOURCE_FIT_SOURCE = (
"function fit(response) {\r\n"
" const content = (response.content !== undefined) ? response.content : response;\r\n"
" const error = {\r\n"
" message: response.errorMsg ||\r\n"
" (response.errors && response.errors[0] && response.errors[0].msg) ||\r\n"
" response.content || '远程数据源请求出错,success is false',\r\n"
" };\r\n"
" let success = true;\r\n"
" if (response.success !== undefined) {\r\n"
" success = response.success;\r\n"
" } else if (response.hasError !== undefined) {\r\n"
" success = !response.hasError;\r\n"
" }\r\n"
" return {\r\n"
" content,\r\n"
" success,\r\n"
" error,\r\n"
" };\r\n"
"}"
)
SUPPORTED_FIELD_TYPES = {
"TextField", "TextareaField", "NumberField", "RateField",
"DateField", "CascadeDateField",
"RadioField", "SelectField", "CheckboxField", "MultiSelectField",
"CountrySelectField", "AddressField",
"AttachmentField", "ImageField",
"EmployeeField", "DepartmentSelectField",
"TableField", "AssociationFormField", "SerialNumberField",
"Divider",
}
OPTION_FIELD_TYPES = {"RadioField", "SelectField", "CheckboxField", "MultiSelectField"}
# ---------------------------------------------------------------------------
# ID 生成
# ---------------------------------------------------------------------------
_node_counter = 0
def _random_chars(n: int = 6) -> str:
return "".join(random.choices(string.ascii_letters + string.digits, k=n))
def unique_id(prefix: str = "", separator: str = "") -> str:
timestamp = str(int(time.time() * 1000000))[-6:]
random_part = _random_chars(6)
return f"{prefix}{separator}{timestamp}{random_part}"
def next_node_id() -> str:
global _node_counter
_node_counter += 1
ts = int(time.time() * 1000)
base36 = ""
n = ts
while n > 0:
n, r = divmod(n, 36)
base36 = "0123456789abcdefghijklmnopqrstuvwxyz"[r] + base36
return f"node_oc{base36}{_node_counter}"
def generate_field_id(component_name: str) -> str:
if not component_name:
return unique_id()
first_char_lower = component_name[0].lower()
rest_of_name = component_name[1:]
return f"{first_char_lower}{rest_of_name}_{unique_id()}"
# ---------------------------------------------------------------------------
# 国际化
# ---------------------------------------------------------------------------
def i18n(text: str, en_text: Optional[str] = None) -> dict[str, str]:
return {
"type": "i18n",
"zh_CN": text,
"en_US": en_text or text,
}
def build_yida_i18n(text: str, translations: Optional[dict[str, str]] = None) -> dict[str, str]:
result: dict[str, str] = {"type": "i18n", "zh_CN": text, "en_US": text}
if translations:
result.update(translations)
return result
# ---------------------------------------------------------------------------
# 语义色彩
# ---------------------------------------------------------------------------
_NEGATIVE_KEYWORDS = ['不通过', '拒绝', '失败', '错误', '', '不同意', '驳回', '取消', '删除', '禁止', '异常', '警告', '危险']
_POSITIVE_KEYWORDS = ['通过', '同意', '成功', '完成', '', '正常', '确认', '批准', '接受', '优秀', '合格']
_PROCESSING_KEYWORDS = ['处理中', '进行中', '待审核', '审核中', '处理', '待定', '等待']
_WARNING_KEYWORDS = ['注意', '提醒', '待办', '紧急', '重要']
_PAUSE_KEYWORDS = ['暂停', '挂起', '冻结', '停用']
COLOR_PALETTE = [
'#e0f0ff', '#e0f4e6', '#fff2e0', '#ffece6', '#eee9fe',
'#e0f2f2', '#fff7e0', '#fde5ec', '#f6e4ff', '#e8ebfc',
'#f6f6f7', '#bbddff', '#bbe7c8', '#ffe2bb', '#ffcfd8',
'#d9cefd', '#bbe3e3', '#ffeebb', '#fac4d4', '#ebc4ff',
'#cbd2f8', '#edeeef',
'#007fff', '#00a532', '#fd9100', '#f2510c', '#704af7',
'#009595', '#fdbd00', '#e9235d', '#b421fd', '#3954e4',
'#76787a', '#0058b1', '#007423', '#b16600', '#a62700',
'#4f34af', '#006868', '#b18500', '#a41841', '#7e17b1',
'#2a3d9f', '#181c1f',
]
def get_semantic_color(text: str) -> Optional[str]:
text_lower = text.lower().strip()
if any(k in text_lower for k in _NEGATIVE_KEYWORDS):
return '#FF4D4F'
if any(k in text_lower for k in _POSITIVE_KEYWORDS):
return '#52C41A'
if any(k in text_lower for k in _PROCESSING_KEYWORDS):
return '#1890FF'
if any(k in text_lower for k in _WARNING_KEYWORDS):
return '#FA8C16'
if any(k in text_lower for k in _PAUSE_KEYWORDS):
return '#8C8C8C'
return None
def build_option_data_source(
options: list[str],
*,
is_checkbox: bool = False,
used_colors: Optional[set[str]] = None,
) -> tuple[list[dict[str, Any]], set[str]]:
if used_colors is None:
used_colors = set()
color_idx = 0
data_source: list[dict[str, Any]] = []
for idx, opt_name in enumerate(options):
semantic = get_semantic_color(opt_name)
if semantic:
color = semantic
else:
while color_idx < len(COLOR_PALETTE) and COLOR_PALETTE[color_idx] in used_colors:
color_idx += 1
color = COLOR_PALETTE[color_idx % len(COLOR_PALETTE)]
color_idx += 1
used_colors.add(color)
data_source.append({
"text": i18n(opt_name),
"value": opt_name,
"defaultChecked": False if is_checkbox else (idx == 0),
"color": color,
})
return data_source, used_colors
# ---------------------------------------------------------------------------
# componentsMap
# ---------------------------------------------------------------------------
def build_components_map(component_names: list[str]) -> list[dict[str, str]]:
seen: set[str] = set()
result: list[dict[str, str]] = []
for name in component_names:
if name not in seen:
seen.add(name)
result.append({
"package": COMPONENT_PACKAGE,
"version": COMPONENT_VERSION,
"componentName": name,
})
return result
_REPORT_LOWCODE_COMPONENTS = {"YoushuSelect"}
def build_report_components_map(component_names: list[str]) -> list[dict[str, str]]:
seen: set[str] = set()
result: list[dict[str, str]] = []
for name in component_names:
if name not in seen:
seen.add(name)
if name in _REPORT_LOWCODE_COMPONENTS:
result.append({
"devMode": "lowcode",
"componentName": name,
})
else:
result.append({
"package": REPORT_COMPONENT_PACKAGE,
"version": REPORT_COMPONENT_VERSION,
"componentName": name,
})
return result
def collect_component_names(field_nodes: list[dict[str, Any]]) -> list[str]:
names: list[str] = []
def _walk(node: dict[str, Any]) -> None:
cn = node.get("componentName", "")
if cn:
names.append(cn)
for child in node.get("children", []):
_walk(child)
for node in field_nodes:
_walk(node)
return names
# ---------------------------------------------------------------------------
# 类型解析
# ---------------------------------------------------------------------------
def normalize_field_type(raw_type: str) -> str:
lower = raw_type.lower().replace("_", "").replace("-", "")
if lower in FIELD_TYPE_ALIAS:
return FIELD_TYPE_ALIAS[lower]
if raw_type in SUPPORTED_FIELD_TYPES:
return raw_type
raise ValueError(f"不支持的字段类型: {raw_type}")