v1.1.0: 实现核心业务逻辑(扫描/任务书/派发/状态机)

新增(scripts/ 分层完整实现):
- scripts/util/{constants,date_util,runtime_paths,logging_setup}.py
- scripts/db/{connection,seed_data,repository}.py(SQLite + 5 张表 + 50 份样例合同)
- scripts/service/{scanner,task_generator,dispatcher,html_renderer}.py
- scripts/cli/app.py(argparse 子命令)
- scripts/templates/task.html.j2(Jinja2 渲染,无 jinja2 时降级到 str.replace)
- examples/{contracts_sample,performance_sample}.csv(本地样例数据)

修改:
- scripts/main.py:argparse + jiangchang_skill_core 降级
- SKILL.md:version 1.0.1 → 1.1.0
- CHANGELOG.md:加 v1.1.0 条目
- .env.example:加 DB_PATH / HTML_OUTPUT_DIR / JIANGCHANG_DATA_DIR

本地验证(9 个 CLI 命令全部 exit code = 0):
- health / version / config-path / init-db:基础 4 命令
- scan-expiring-contracts --thresholds 90,60,30:42 份可续聘 → 红 10 / 橙 10 / 黄 10,生成 30 任务
- render-html:写 output/<task_id>.html(4.9 KB)
- generate-renewal-task:单份合同任务书生成
- mark-contract-renewed:状态机更新 + 关闭 open alerts
- dispatch-task-notice:按单位/月份派发 payload(dry-run 模式)

业务规则(AI 续聘建议本地版,规则顺序判断):
1. 师德 < 60 → non_renew
2. 近 3 年考核有不合格(< 60)→ observe
3. 全部 >= 80 且逐年上升 → renew
4. 其他 → observe

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:17:43 +08:00
parent e2b45d690e
commit 06c053d7d7
20 changed files with 1796 additions and 82 deletions

View File

@@ -0,0 +1,123 @@
"""派发器:按单位/月份汇总任务 → 生成派发 payload JSON → 写派发日志。
v1.1.0 dry-run 模式:payload 写 data/dispatch/{unit}_{month}.json,
但**不真正调用企业微信 API**。开发者后续接入:
- 真实 API:调用 WECOM_API_BASE_URL + WECOM_API_TOKEN
- 在 dispatch_log 写 status='sent' 而非 'dry_run'
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from db import repository as repo
from util.date_util import parse_iso_date
from util.logging_setup import get_skill_logger
from util.runtime_paths import get_dispatch_dir
logger = get_skill_logger()
def build_payload(
conn: sqlite3.Connection,
unit_id: str,
month: str = "",
) -> dict:
"""构建派发 payload:含单位、月份、任务清单、收件人、消息正文。"""
rows = repo.list_open_alerts_by_unit_month(conn, unit_id, month)
# 单位名(取第一条)
unit_name = rows[0]["unit_name"] if rows else "(无)"
items = []
for r in rows:
items.append({
"contract_id": r["contract_id"],
"teacher_name": r["teacher_name"],
"alert_level": r["alert_level"],
"days_to_expire": r["days_to_expire"],
"contract_end": r["contract_end"],
})
if month:
title = f"{unit_name} {month} 合同到期人员清单及续聘建议"
else:
title = f"{unit_name} 待续聘合同清单"
body_lines = [
title,
"",
f"共 {len(items)} 份合同需在到期前办理续聘手续。",
"",
]
for it in items:
level_zh = {"yellow": "黄色", "orange": "橙色", "red": "红色"}.get(
it["alert_level"], it["alert_level"]
)
body_lines.append(
f"- {it['teacher_name']}(合同至 {it['contract_end']},{level_zh}预警,距到期 {it['days_to_expire']} 天)"
)
body_lines.append("")
body_lines.append("请人事秘书尽快通知相关教师备齐续聘材料。")
payload = {
"schema_version": 1,
"channel": "wecom",
"dry_run": True,
"to": {
"unit_id": unit_id,
"unit_name": unit_name,
"role": "人事秘书",
},
"title": title,
"body": "\n".join(body_lines),
"items": items,
"generated_at": datetime.now().isoformat(timespec="seconds"),
}
return payload
def dispatch(
conn: sqlite3.Connection,
unit_id: str,
month: str = "",
) -> dict:
"""派发 dry-run:写 payload 文件 + 写 dispatch_log。"""
payload = build_payload(conn, unit_id, month)
items = payload["items"]
task_ids = [f"T-{it['contract_id']}" for it in items]
dispatch_dir = get_dispatch_dir()
dispatch_dir.mkdir(parents=True, exist_ok=True)
month_part = month or "all"
payload_path = dispatch_dir / f"{unit_id}_{month_part}.json"
payload_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
log_id = f"L-{uuid.uuid4().hex[:8]}"
repo.insert_dispatch_log(
conn, log_id, unit_id, month or "all", task_ids, str(payload_path),
status="dry_run",
)
conn.commit()
logger.info(
"dispatcher: unit=%s month=%s items=%d payload=%s log=%s",
unit_id, month or "(all)", len(items), payload_path, log_id,
)
return {
"log_id": log_id,
"unit_id": unit_id,
"unit_name": payload["to"]["unit_name"],
"month": month or "(all)",
"item_count": len(items),
"payload_path": str(payload_path),
"status": "dry_run",
}