feat(okr): 季度复盘提醒机制 — objectives active 清单经企微中继推送 + notification_logs 留痕 (fullstack-okr-kpi-gap-fix)

This commit is contained in:
Hermes CI Fix
2026-09-11 19:20:42 +08:00
parent c928d59f60
commit e2d2a1c165
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""OKR 季度复盘提醒(缺口③:周期复盘节奏机制)
用途:每季度第 2 个月中旬(2/5/8/11 月 15 日 09:00)提醒 owner 更新 objectives.progress / confidence。
- 只读 objectives 表(status='active'),不改任何目标值(本单先只做"提醒")
- 通道复用:与 backend/scripts/daily_push.py 同一企微中继(RELAY_URL=http://127.0.0.1:8800/send,SOURCE=管理会计OS)
发送记录复用既有 notification_logs 表(channel=wecom)留存审计
- 默认 dry-run(只打印,不推送);--push 才真正发送
用法:
cd /root/cma-management/backend && PYTHONPATH=. /usr/bin/python3 scripts/okr_review_reminder.py # dry-run
cd /root/cma-management/backend && PYTHONPATH=. /usr/bin/python3 scripts/okr_review_reminder.py --push # 推送
... --entity-id 1 # 仅某企业
"""
import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
from app.database import get_session_local # noqa: E402
RELAY_URL = "http://127.0.0.1:8800/send" # 与 daily_push.py 同一中继通道(不新造通道)
SOURCE = "管理会计OS"
def current_quarter(d=None):
d = d or datetime.now()
return "%dQ%d" % (d.year, (d.month - 1) // 3 + 1)
def build_message(rows, quarter):
"""rows: list of dict(id,title,owner,progress,confidence,quarter,entity_name)"""
lines = ["## ⏰ OKR 季度复盘提醒(%s)" % quarter, ""]
lines.append("请各目标负责人更新 **进度(progress)** 与 **信心指数(confidence)**。")
lines.append("")
lines.append("| 目标 | 负责人 | 进度 | 信心 | 季度 |")
lines.append("|---|---|---|---|---|")
for r in rows:
lines.append("| %s | %s | %s%% | %s | %s |" % (
r["title"] or "-", r["owner"] or "待指定",
r["progress"] if r["progress"] is not None else "-",
r["confidence"] if r["confidence"] is not None else "-",
r["quarter"] or "未设季度"))
lines.append("")
lines.append("共 %d 个进行中目标;请在 CMA → 战略地图 / OKR 详情中更新。" % len(rows))
lines.append("")
lines.append("---")
lines.append("💡 复盘节奏:季度中期校准一次,避免期末才发现偏差。")
return "\n".join(lines)
def push_wecom(msg):
"""复用 daily_push.py 的企微中继推送"""
data = urllib.parse.urlencode({"msg": msg, "source": SOURCE, "msgtype": "markdown"}).encode("utf-8")
req = urllib.request.Request(RELAY_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
return {"ok": False, "error": "推送异常: %s" % e}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--push", action="store_true", help="真正推送到企微中继(默认 dry-run 只打印)")
ap.add_argument("--entity-id", type=int, default=None, help="仅指定企业")
args = ap.parse_args()
quarter = current_quarter()
db = get_session_local()()
try:
sql = ("SELECT o.id, o.title, o.owner, o.progress, o.confidence, o.quarter, o.entity_id, e.name AS entity_name "
"FROM objectives o LEFT JOIN entities e ON e.id = o.entity_id "
"WHERE o.status = 'active'")
params = {}
if args.entity_id is not None:
sql += " AND o.entity_id = :eid"
params["eid"] = args.entity_id
# 本季度 + 未设季度(历史遗留)的目标都纳入提醒
sql += " AND (o.quarter = :q OR o.quarter IS NULL OR o.quarter = '')"
params["q"] = quarter
sql += " ORDER BY o.entity_id, o.id"
rows = [dict(r._mapping) for r in db.execute(text(sql), params).fetchall()]
msg = build_message(rows, quarter)
print("== 目标数: %d(季度 %s;entity_id=%s)" % (len(rows), quarter, args.entity_id))
print(msg)
if args.push and rows:
res = push_wecom(msg)
ok = bool(res.get("ok", True)) and not res.get("error")
print("== 推送结果:", json.dumps(res, ensure_ascii=False)[:300])
status = "sent" if ok else "failed"
err = None if ok else str(res.get("error"))[:400]
else:
status, err = "pending", None
# 复用既有 notifications 机制留存审计(notification_logs)
db.execute(text(
"INSERT INTO notification_logs (channel, recipient, title, content, status, error_msg, sent_at, created_at) "
"VALUES ('wecom', :r, :t, :c, :s, :e, :sa, NOW())"),
{"r": "企微群(中继)", "t": "OKR 季度复盘提醒(%s)" % quarter, "c": msg,
"s": status, "e": err, "sa": datetime.now() if status == "sent" else None})
db.commit()
print("== notification_logs 已记录: status=%s" % status)
finally:
db.close()
if __name__ == "__main__":
main()