Files

240 lines
11 KiB
Python
Raw Permalink 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.
# -*- 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 # 仅某企业
────────────────────────────────────────────────────────────────────────
审计口径(2026-09-12 fullstack-okr-reminder-hardening):dry-run 与真实推送可区分
- notification_logs.status 语义固定为四类:
'dry_run' 仅打印,未产生任何外部通知(不调用中继)
'skipped' --push 但当期无目标,未投递
'sent' 真实投递成功(唯一会在中继侧留下消息的状态)
'failed' 真实投递失败
- 历史遗留:2026-09-11 的两行 status='pending' 实为 dry-run 记录,
已迁移为 'dry_run'('pending' 语义含混:既像"待推送"又像"已入队",已废弃不再写入)。
- 退出码契约(供 cron 感知失败):
0 = 正常(dry_run / sent / skipped)
1 = --push 投递失败(中继不可用或返回错误)
2 = 执行异常(DB 连接失败、脚本内部错误等)
────────────────────────────────────────────────────────────────────────
失败感知(2026-09-12 加固:首次触发失败不再无感知)
- main() 全量 try/except 兜底:任何异常 → 写本地失败日志 + 尽力经同一中继发一条运维告警 + 退出码 2
- --push 投递失败 → 同样写本地失败日志 + 尽力告警 + 退出码 1(原先静默退出 0)
- 退出码非 0 会被 Hermes cron(no_agent)判定为脚本失败,产生
"⚠ Cron watchdog 'OKR季度复盘提醒' script failed" 告警;该 job 的 failure_deliver
已指向运维通道,告警不再只落本地(cron job 60d4f6fc0ec5)。
- 本地失败日志(兜底留痕,中继也不可用时唯一可查痕迹):
/root/cma-management/backend/logs/okr_review_reminder_failures.log
────────────────────────────────────────────────────────────────────────
口径说明(季度为空的历史目标:**有意为之**,非遗漏)
- 筛选条件显式包含 `quarter IS NULL OR quarter = ''`:截至 2026-09-12,44 个 active 目标中
only 5 个标了 2026Q3,其余 39 个是战略地图导入的历史遗留(无季度标记)。
- 一并纳入提醒的意图 = 推动这些历史目标补标季度或清理归档(每季度提醒一次直到被处理),
而不是"提醒漏了过滤条件"。
- 若日后不再需要,把 _build_query 里的 `OR o.quarter IS NULL OR o.quarter = ''` 去掉即可
(改动点单点集中,勿改其他口径)。
测试钩子(仅本机验证用;不设置时全部走生产默认值,cron 运行时无感)
- OKR_REVIEW_RELAY_URL 覆盖中继地址(默认 http://127.0.0.1:8800/send)
- OKR_REVIEW_FAILLOG 覆盖失败日志路径(默认见上)
"""
import argparse
import json
import os
import sys
import traceback
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 = os.environ.get("OKR_REVIEW_RELAY_URL", "http://127.0.0.1:8800/send")
SOURCE = "管理会计OS"
JOB_ID = "60d4f6fc0ec5" # Hermes cron job(OKR季度复盘提醒),失败告警里带上便于定位
FAIL_LOG = os.environ.get("OKR_REVIEW_FAILLOG",
"/root/cma-management/backend/logs/okr_review_reminder_failures.log")
EXIT_OK = 0
EXIT_PUSH_FAILED = 1
EXIT_ERROR = 2
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}
# ── 失败留痕 / 告警(2026-09-12 加固)────────────────────────────────────
def append_fail_log(line):
"""本地兜底留痕:中继也不可用时,这里是唯一可查的失败痕迹。"""
try:
d = os.path.dirname(FAIL_LOG)
if d:
os.makedirs(d, exist_ok=True)
with open(FAIL_LOG, "a", encoding="utf-8") as f:
f.write("[%s] %s\n" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), line))
return True
except Exception as e: # 兜底不能再抛异常
print("== 本地失败日志写入失败: %s" % e)
return False
def alert_failure(reason):
"""失败告警:本地留痕(必做,不依赖网络)+ 尽力经同一中继推送运维告警。
约束:不新增通道 —— 告警复用与提醒完全相同的中继(RELAY_URL / SOURCE)。
返回 (logged, delivered)。
"""
append_fail_log("FAIL | %s" % reason)
msg = ("## ❗ 管理会计OS · 运维告警\n\n"
"**OKR 季度复盘提醒执行失败**(cron job `%s`)\n\n"
"- 时间:%s\n"
"- 原因:%s\n"
"- 中继:%s\n\n"
"排查:`scripts/okr_review_reminder.py`(本地失败日志见 backend/logs/"
"okr_review_reminder_failures.log)。" % (
JOB_ID, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), reason, RELAY_URL))
try:
res = push_wecom(msg)
delivered = bool(res.get("ok", True)) and not res.get("error")
except Exception as e: # push_wecom 已自吞异常,这里再兜一层
res, delivered = {"error": str(e)}, False
append_fail_log("ALERT | delivered=%s | %s" % (
delivered, json.dumps(res, ensure_ascii=False)[:300]))
print("== 失败告警: delivered=%s | %s" % (delivered, json.dumps(res, ensure_ascii=False)[:200]))
return True, delivered
def _build_query(entity_id, quarter):
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 entity_id is not None:
sql += " AND o.entity_id = :eid"
params["eid"] = entity_id
# 本季度 + 未设季度(历史遗留)的目标都纳入提醒 —— 有意为之,见模块 docstring「口径说明」
sql += " AND (o.quarter = :q OR o.quarter IS NULL OR o.quarter = '')"
params["q"] = quarter
sql += " ORDER BY o.entity_id, o.id"
return sql, params
def _record(db, quarter, msg, status, err, sent_at):
"""复用既有 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": sent_at})
db.commit()
def _run(args):
"""返回退出码(0=正常 1=投递失败);异常交给 main() 兜底。"""
quarter = current_quarter()
db = get_session_local()()
try:
sql, params = _build_query(args.entity_id, quarter)
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)
sent_at = None
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])
if ok:
status, err, sent_at, rc = "sent", None, datetime.now(), EXIT_OK
else:
status, err, rc = "failed", str(res.get("error"))[:400], EXIT_PUSH_FAILED
elif args.push:
status, err, rc = "skipped", None, EXIT_OK
print("== 无目标,跳过投递")
else:
status, err, rc = "dry_run", None, EXIT_OK
print("== dry-run:不调用中继、不产生外部通知")
_record(db, quarter, msg, status, err, sent_at)
print("== notification_logs 已记录: status=%s" % status)
if rc != EXIT_OK:
alert_failure("企微中继投递失败: %s" % err)
return rc
finally:
db.close()
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()
try:
rc = _run(args)
except SystemExit:
raise
except BaseException as exc: # 含 DB 连接失败;保证任何异常都有留痕+告警+非零退出码
print("== 执行异常: %s" % exc)
print(traceback.format_exc())
alert_failure("执行异常: %s: %s" % (type(exc).__name__, exc))
sys.exit(EXIT_ERROR)
sys.exit(rc)
if __name__ == "__main__":
main()