CaiWuXiTong/scripts/sync-ding-attendance-schedules.py
renshen c47af23709 fix: 日志漏写按公司排班判断工作日 (#1030)
合并 PR #1030:日志漏写扣分改用钉钉公司排班判断工作日。
2026-11-10 10:57:47 +08:00

195 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""从 DWS 查询钉钉排班并导入财务系统共享镜像。
排班查询依赖本机已登录的 DingTalk Workspace CLI。脚本按 20 人分批读取财务中心
成员在指定日期范围内的每日排班(包含休息日),再通过 Artisan 导入镜像。
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
DEFAULT_DWS = (
Path.home()
/ '.workbuddy/binaries/node/cli-connector-packages/lib/node_modules'
/ 'dingtalk-workspace-cli/vendor/dws'
)
def finance_members() -> list[dict]:
php = (
'require "vendor/autoload.php"; '
'$app=require "bootstrap/app.php"; '
'$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap(); '
'echo json_encode(app(App\\Services\\Auth\\FinanceRoster::class)->cachedFinanceMembers(),'
'JSON_UNESCAPED_UNICODE);'
)
process = subprocess.run(
[
'docker', 'compose', '-f', 'docker-compose.local.yml', 'exec', '-T',
'-u', 'www-data', '-w', '/var/www/html', 'app', 'php', '-r', php,
],
cwd=Path(__file__).resolve().parents[1],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if process.returncode != 0:
raise RuntimeError(process.stderr.decode('utf-8', 'replace').strip() or '读取财务人员失败')
members = json.loads(process.stdout.decode('utf-8'))
if not isinstance(members, list):
raise RuntimeError('财务人员列表格式无效')
return members
def resolve_dws(explicit: str) -> str:
candidates = [
explicit,
os.environ.get('DWS_BIN', ''),
shutil.which('dws') or '',
str(DEFAULT_DWS),
]
for candidate in candidates:
if candidate and Path(candidate).is_file() and os.access(candidate, os.X_OK):
return candidate
raise RuntimeError('找不到可执行的 dws,请设置 DWS_BIN')
def query_schedules(dws_bin: str, user_ids: list[str], start: str, end: str) -> list[dict]:
process = subprocess.run(
[
dws_bin, 'attendance', 'schedule', 'get',
'--users', ','.join(user_ids),
'--start', start,
'--end', end,
'--format', 'json',
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=120,
)
if process.returncode != 0:
raise RuntimeError(process.stderr.decode('utf-8', 'replace').strip() or 'DWS 排班查询失败')
payload = json.loads(process.stdout.decode('utf-8'))
if payload.get('success') is False:
raise RuntimeError(process.stderr.decode('utf-8', 'replace').strip() or 'DWS 排班查询未成功')
records = payload.get('result')
if not isinstance(records, list):
raise RuntimeError('DWS 排班响应缺少 result 数组')
return records
def import_schedules(records: list[dict], start: str, end: str) -> None:
process = subprocess.run(
[
'docker', 'compose', '-f', 'docker-compose.local.yml', 'exec', '-T',
'-u', 'www-data', 'app', 'php', 'artisan',
'business-mirror:import-ding-attendance-schedules',
'--stdin', f'--from={start}', f'--to={end}',
],
cwd=Path(__file__).resolve().parents[1],
input=json.dumps({'result': records}, ensure_ascii=False).encode('utf-8'),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if process.returncode != 0:
raise RuntimeError(
process.stderr.decode('utf-8', 'replace').strip()
or process.stdout.decode('utf-8', 'replace').strip()
or '排班导入失败'
)
print(process.stdout.decode('utf-8', 'replace').strip(), flush=True)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('--days', type=int, default=95)
parser.add_argument('--from', dest='start', default='')
parser.add_argument('--to', dest='end', default='')
parser.add_argument('--batch-size', type=int, default=20)
parser.add_argument('--dws-bin', default='')
parser.add_argument('--no-import', action='store_true')
args = parser.parse_args()
args.batch_size = min(max(args.batch_size, 1), 50)
end = args.end or date.today().isoformat()
start = args.start or (date.fromisoformat(end) - timedelta(days=max(args.days, 1) - 1)).isoformat()
if date.fromisoformat(start) > date.fromisoformat(end):
raise RuntimeError('开始日期不能晚于结束日期')
members = finance_members()
user_ids = []
seen = set()
for member in members:
user_id = str(member.get('userid') or '').strip()
if user_id and user_id not in seen:
seen.add(user_id)
user_ids.append(user_id)
if not user_ids:
raise RuntimeError('财务中心没有可查询排班的人员')
dws_bin = resolve_dws(args.dws_bin)
records = []
for offset in range(0, len(user_ids), args.batch_size):
chunk = user_ids[offset:offset + args.batch_size]
print(
f'查询排班:{offset + 1}-{offset + len(chunk)} / {len(user_ids)} 人,{start} 至 {end}',
flush=True,
)
records.extend(query_schedules(dws_bin, chunk, start, end))
unique = {str(record.get('id')): record for record in records if isinstance(record, dict) and record.get('id')}
records = list(unique.values())
print(f'查询完成:{len(records)} 条排班记录。', flush=True)
expected_days = {
(date.fromisoformat(start) + timedelta(days=offset)).isoformat()
for offset in range((date.fromisoformat(end) - date.fromisoformat(start)).days + 1)
}
dates_by_user: dict[str, set[str]] = {}
for record in records:
user_id = str(record.get('userId') or '').strip()
work_date = schedule_date(record.get('workDate'))
if user_id and work_date:
dates_by_user.setdefault(user_id, set()).add(work_date)
incomplete = [
user_id for user_id in user_ids
if dates_by_user.get(user_id, set()) != expected_days
]
if incomplete:
raise RuntimeError(
f'排班查询不完整:{len(incomplete)} 人未覆盖完整日期范围,未写入镜像。'
)
if not args.no_import:
import_schedules(records, start, end)
return 0
def schedule_date(value) -> str:
if value is None or value == '':
return ''
if isinstance(value, (int, float)):
timestamp = value / 1000 if value > 99_999_999_999 else value
return datetime.fromtimestamp(
timestamp,
timezone(timedelta(hours=8)),
).date().isoformat()
return str(value)[:10]
if __name__ == '__main__':
try:
raise SystemExit(main())
except Exception as exc:
print(f'排班同步失败:{exc}', file=sys.stderr)
raise SystemExit(1)