# -*- coding: utf-8 -*-
"""
A-15 医药代表拜访与学术活动合规排查 —— 数据质检脚本（闸3）
运行：python verify.py
读取：data/a15_compliance_screening.xlsx（不存在时回退 ../03-数据源/）
断言三类：A 数据完整性 / B 规则正确性 / C 指标达标
"""
import os, math, datetime as dt, sys

HERE = os.path.dirname(os.path.abspath(__file__))
CANDIDATES = [
    os.path.join(HERE, "data", "a15_compliance_screening.xlsx"),
    os.path.join(os.path.dirname(HERE), "03-数据源", "a15_compliance_screening.xlsx"),
]
SRC = next((p for p in CANDIDATES if os.path.exists(p)), None)
if SRC is None:
    print("❌ 找不到数据源 xlsx：%s" % CANDIDATES)
    sys.exit(1)

# 与 PRD / rules.md / gen_data.py 三处保持一致的常量
W_DEV, W_AMT, W_FREQ, W_HIST = 0.35, 0.25, 0.20, 0.20
LV_HIGH, LV_MID, LV_LOW = 0.70, 0.45, 0.25
CONF_GATE = 0.50
OBS_WINDOW_DAYS = 90
BASE = dt.datetime(2026, 9, 30, 9, 0, 0)
BAN_WORDS = ("姓名",)

N = 0
FAILS = []


def ck(cond, msg):
    global N
    N += 1
    if not cond:
        FAILS.append(msg)
    return cond


def g(d, k, default=""):
    """openpyxl 读回空单元格是 None，统一转空串"""
    v = d.get(k, default)
    return default if v is None else v


def num(d, k, default=0.0):
    v = g(d, k, "")
    if v == "" or v is None:
        return default
    try:
        return float(v)
    except (TypeError, ValueError):
        return default


import openpyxl
wb = openpyxl.load_workbook(SRC, data_only=True)


def load(name):
    if name not in wb.sheetnames:
        return [], []
    ws = wb[name]
    rows = list(ws.values)
    if not rows:
        return [], []
    hdr = [str(c) if c is not None else "" for c in rows[0]]
    return hdr, [dict(zip(hdr, r)) for r in rows[1:]]


# ==================== A. 数据完整性 ====================
NEED_SHEETS = ["拜访记录", "学术活动", "法规规则库", "内控规则库", "预警单",
               "整改跟踪", "复发观察", "PV线索", "规则命中明细"]
for s in NEED_SHEETS:
    ck(s in wb.sheetnames, "A01 缺少标签页 %s" % s)

hV, V = load("拜访记录")
hA, A = load("学术活动")
hR, R = load("法规规则库")
hP, P = load("内控规则库")
hW, W = load("预警单")
hF, F = load("整改跟踪")
hO, O = load("复发观察")
hPV, PV = load("PV线索")
hH, H = load("规则命中明细")

ck(len(V) == 300, "A02 拜访记录条数 %d ≠ 300" % len(V))
ck(len(A) == 90, "A03 学术活动条数 %d ≠ 90" % len(A))
ck(len(R) == 12, "A04 法规规则库条数 %d ≠ 12" % len(R))
ck(len(P) == 10, "A05 内控规则库条数 %d ≠ 10" % len(P))
ck(len(W) > 0, "A06 预警单为空")
ck(len(F) == len(W), "A07 整改跟踪条数 %d ≠ 预警单 %d" % (len(F), len(W)))
ck(len(H) == len(W), "A08 规则命中明细条数 %d ≠ 预警单 %d" % (len(H), len(W)))
ck(len(PV) == 9, "A09 PV线索条数 %d ≠ 9" % len(PV))

rec_of = {}
for r in V:
    ck(g(r, "记录单号") != "", "A10 拜访记录缺记录单号")
    rec_of[g(r, "记录单号")] = ("拜访", r)
for r in A:
    ck(g(r, "活动单号") != "", "A11 学术活动缺活动单号")
    rec_of[g(r, "活动单号")] = ("学术活动", r)

for w in W:
    ck(g(w, "预警单号") != "", "A12 预警单缺预警单号")
    ck(g(w, "记录单号") in rec_of, "A13 预警单 %s 的记录单号 %s 不在记录表中"
       % (g(w, "预警单号"), g(w, "记录单号")))
    ck(g(w, "证据链完整") == "是", "A14 预警单 %s 证据链不完整" % g(w, "预警单号"))
    ck(g(w, "命中条款号") != "", "A15 预警单 %s 缺命中条款号" % g(w, "预警单号"))
    ck(g(w, "风险等级") in ("高危", "中危", "低危"),
       "A16 预警单 %s 风险等级异常：%s" % (g(w, "预警单号"), g(w, "风险等级")))

# 无自然人姓名字段（G-56）
for nm, hdr in [("拜访记录", hV), ("学术活动", hA), ("预警单", hW),
                ("整改跟踪", hF), ("复发观察", hO), ("PV线索", hPV)]:
    for c in hdr:
        ck(not any(b in c for b in BAN_WORDS), "A17 %s 出现姓名字段：%s" % (nm, c))

# 证据链三项齐全（G-53）：记录单号 + 条款号 + 命中明细里的证据字段
hit_by_warn = {g(h, "预警单号"): h for h in H}
for w in W:
    h = hit_by_warn.get(g(w, "预警单号"))
    ck(h is not None, "A18 预警单 %s 无命中明细" % g(w, "预警单号"))
    if h is not None:
        ck(g(h, "证据字段") != "", "A19 预警单 %s 命中明细缺证据字段" % g(w, "预警单号"))
        ck(g(h, "实际值") != "", "A20 预警单 %s 命中明细缺实际值" % g(w, "预警单号"))

# ==================== B. 规则正确性 ====================
# B1 法规轨一票定高危，不进加权（G-52）
n_reg = 0
for w in W:
    if g(w, "判定轨") == "法规轨":
        n_reg += 1
        ck(g(w, "风险等级") == "高危",
           "B01 法规轨 %s 未定高危（实为 %s），法规红线被稀释" % (g(w, "预警单号"), g(w, "风险等级")))
ck(n_reg == 30, "B02 法规轨命中条数 %d ≠ 30" % n_reg)

# B2 独立重算：风险分与置信度
for w in W:
    dev, amt = num(w, "偏离度"), num(w, "金额量级")
    freq, hist = num(w, "频次因子"), num(w, "历史因子")
    evi = num(w, "证据完整度")
    score = round(W_DEV * dev + W_AMT * amt + W_FREQ * freq + W_HIST * hist, 4)
    ck(abs(score - num(w, "风险分")) <= 0.001,
       "B03 %s 风险分重算不符：表内 %s vs 重算 %s" % (g(w, "预警单号"), num(w, "风险分"), score))
    conf = round(0.55 * evi + 0.45 * min(1.0, dev / 0.60), 4)
    ck(abs(conf - num(w, "置信度")) <= 0.001,
       "B04 %s 置信度重算不符：表内 %s vs 重算 %s" % (g(w, "预警单号"), num(w, "置信度"), conf))
    ck(0.0 <= num(w, "风险分") <= 1.0, "B05 %s 风险分越界" % g(w, "预警单号"))
    ck(0.0 <= num(w, "置信度") <= 1.0, "B06 %s 置信度越界" % g(w, "预警单号"))

# B3 出稿门槛（G-35）：已出稿的必须过置信度门槛
for w in W:
    if g(w, "出稿状态") == "已出稿":
        ck(num(w, "置信度") >= CONF_GATE,
           "B07 %s 已出稿但置信度 %.4f < %.2f" % (g(w, "预警单号"), num(w, "置信度"), CONF_GATE))

# B4 分档与风险分一致（法规轨除外，其一票定高危不靠分）
for w in W:
    if g(w, "判定轨") == "法规轨":
        continue
    s = num(w, "风险分")
    lv = g(w, "风险等级")
    ok = ((lv == "高危" and s >= LV_HIGH) or (lv == "中危" and LV_MID <= s < LV_HIGH)
          or (lv == "低危" and s < LV_MID))
    if not ok:
        # R4 金额一票升级可把中/低危升为高危，需排除该情形
        rid = g(w, "记录单号")
        kind, rec = rec_of.get(rid, ("", {}))
        amt_val = abs(num(rec, "金额(元)") or num(rec, "赞助金额(元)"))
        upgraded = (kind == "拜访" and amt_val >= 5000.0) or (kind == "学术活动" and amt_val >= 50000.0)
        ck(upgraded and lv == "高危",
           "B08 %s 风险分 %.4f 与等级 %s 不匹配" % (g(w, "预警单号"), s, lv))

# B5 证据不足 / PV 线索不进判定集合
pv_ids = {g(p, "记录单号") for p in PV}
warn_rec_ids = {g(w, "记录单号") for w in W}
for rid in pv_ids:
    ck(rid not in warn_rec_ids, "B09 PV线索 %s 同时建了预警单，独立通道被破坏" % rid)
for r in V + A:
    rid = g(r, "记录单号") or g(r, "活动单号")
    if g(r, "归一状态") == "归一失败":
        ck(rid not in warn_rec_ids, "B10 证据不足记录 %s 进了判定集合" % rid)

# B6 金额类命中项实际值不得超过记录总金额
pol_by_id = {g(p, "条款号"): p for p in P}
for h in H:
    rid = g(h, "记录单号")
    kind, rec = rec_of.get(rid, ("", {}))
    total = abs(num(rec, "金额(元)") or num(rec, "赞助金额(元)"))
    if g(h, "判定轨") == "内控轨" and total > 0:
        pol = pol_by_id.get(g(h, "条款号"))
        if pol is not None and str(g(pol, "单位")).startswith("元"):
            ck(num(h, "实际值") <= total + 0.01,
               "B11 %s 命中项实际值 %.2f 超过记录总额 %.2f" % (rid, num(h, "实际值"), total))

# B7 命中明细的记录类型必须与预警单一致
warn_by_id = {g(w, "预警单号"): w for w in W}
for h in H:
    w = warn_by_id.get(g(h, "预警单号"))
    ck(w is not None and g(w, "记录单号") == g(h, "记录单号"),
       "B12 命中明细 %s 与预警单记录不一致" % g(h, "明细号"))

# B8 法规硬禁令在规则库里必须存在且现行有效
reg_by_id = {g(r, "条款号"): r for r in R}
for w in W:
    if g(w, "判定轨") == "法规轨":
        r = reg_by_id.get(g(w, "命中条款号"))
        ck(r is not None, "B13 预警单 %s 命中了不存在的法规条款 %s"
           % (g(w, "预警单号"), g(w, "命中条款号")))
        if r is not None:
            ck(g(r, "复核状态") == "现行有效", "B14 法规条款 %s 非现行有效" % g(w, "命中条款号"))

# B9 描述与条款 1:1（法规轨描述必须含本条款关键词）
KEY_BY_REG = {
    "REG-001": "礼品卡", "REG-002": "旅游", "REG-003": "讲课费", "REG-004": "审批流程",
    "REG-005": "无讲者资质", "REG-006": "说明书外适应症", "REG-007": "未经合规审批",
    "REG-008": "签到表", "REG-009": "家属", "REG-010": "科研协作",
    "REG-011": "有效率", "REG-012": "未履行备案",
}
for w in W:
    if g(w, "判定轨") == "法规轨":
        rid = g(w, "记录单号")
        _k, rec = rec_of.get(rid, ("", {}))
        kw = KEY_BY_REG.get(g(w, "命中条款号"), "")
        ck(kw in g(rec, "描述"),
           "B15 %s 描述与条款 %s 不匹配（缺关键词「%s」）" % (g(w, "预警单号"), g(w, "命中条款号"), kw))

# B10 复发必须升级，且关联原单
UP = {"高危": "高危", "中危": "高危", "低危": "中危"}
for o in O:
    if g(o, "是否复发") == "是":
        wid = g(o, "原预警单号")
        w = warn_by_id.get(wid)
        ck(w is not None, "B16 复发观察 %s 关联了不存在的预警单 %s" % (g(o, "观察单号"), wid))
        if w is not None:
            ck(g(o, "重开等级") == UP[g(w, "风险等级")],
               "B17 %s 复发未升级：%s → %s" % (wid, g(w, "风险等级"), g(o, "重开等级")))
        ck(g(o, "重开预警单号") != "", "B18 复发观察 %s 缺重开单号" % g(o, "观察单号"))
    if g(o, "观察状态") == "观察中":
        ck(g(o, "是否复发") == "", "B19 观察中不应判复发：%s" % g(o, "观察单号"))

# B11 观察窗：已满窗的销号时间必须真的满 90 天
n_full = 0
for o in O:
    ct = dt.datetime.strptime(g(o, "销号日期"), "%Y-%m-%d")
    if g(o, "观察状态") in ("已闭环", "已复发"):
        n_full += 1
        ck((BASE - ct).days >= OBS_WINDOW_DAYS,
           "B20 %s 观察窗未满却已判终态（%d 天）" % (g(o, "观察单号"), (BASE - ct).days))
    else:
        ck((BASE - ct).days < OBS_WINDOW_DAYS,
           "B21 %s 观察窗已满却仍标观察中（%d 天）" % (g(o, "观察单号"), (BASE - ct).days))

# B12 AI 不自动销号：销号必须有复核通过
fix_by_warn = {g(f, "预警单号"): f for f in F}
for w in W:
    if g(w, "预警状态") == "已销号":
        f = fix_by_warn.get(g(w, "预警单号"))
        ck(f is not None and g(f, "复核结论") == "复核通过",
           "B22 %s 已销号但无复核通过记录" % g(w, "预警单号"))
        ck(f is not None and g(f, "销号时间") != "", "B23 %s 已销号但无销号时间" % g(w, "预警单号"))

# B13 处分不由 AI 决定：整改跟踪里不得出现 AI 作为处置人
for f in F:
    role = g(f, "复核人角色")
    ck("AI" not in role and "机器人" not in role and "系统" not in role,
       "B24 整改单 %s 的复核人为非自然人角色：%s" % (g(f, "整改单号"), role))

# B14 PV 线索必须全部转交且时限 24h
for p in PV:
    ck(g(p, "转交状态") == "已转交", "B25 PV线索 %s 未转交" % g(p, "线索单号"))
    ck(num(p, "转交时限(h)") == 24, "B26 PV线索 %s 转交时限非 24h" % g(p, "线索单号"))

# ==================== C. 指标达标 ====================
n_reviewed = sum(1 for f in F if g(f, "复核结论") in ("复核通过", "判定不成立", "复核不通过"))
n_true = sum(1 for f in F if g(f, "复核结论") in ("复核通过", "复核不通过"))
n_reg_high = sum(1 for w in W if g(w, "判定轨") == "法规轨" and g(w, "风险等级") == "高危")
n_evi_ok = sum(1 for w in W if g(w, "证据链完整") == "是")
n_disp = sum(1 for f in F if g(f, "是否及时派单") in ("是", "否"))
n_disp_ok = sum(1 for f in F if g(f, "是否及时派单") == "是")
n_terminal = sum(1 for w in W if g(w, "预警状态") in ("已销号", "已超期未销", "已撤销"))
n_closed = sum(1 for w in W if g(w, "预警状态") == "已销号")
n_relapse = sum(1 for o in O if g(o, "是否复发") == "是")

K1 = n_true / n_reviewed if n_reviewed else 0.0
K2 = n_reg_high / n_reg if n_reg else 0.0
K3 = n_evi_ok / len(W) if W else 0.0
K4 = n_disp_ok / n_disp if n_disp else 0.0
K5 = n_closed / n_terminal if n_terminal else 0.0
K6 = n_relapse / n_full if n_full else 0.0
K7 = sum(1 for p in PV if g(p, "转交状态") == "已转交") / len(PV) if PV else 0.0

ck(K1 >= 0.80, "C01 K1 预警准确率 %.4f < 80%%" % K1)
ck(K2 == 1.0, "C02 K2 法规硬命中不放加权率 %.4f ≠ 100%%（硬红线）" % K2)
ck(K3 == 1.0, "C03 K3 证据链完整率 %.4f ≠ 100%%（硬红线）" % K3)
ck(K4 >= 0.90, "C04 K4 派单及时率 %.4f < 90%%" % K4)
ck(K5 >= 0.70, "C05 K5 整改闭环率 %.4f < 70%%" % K5)
ck(0.05 <= K6 <= 0.20, "C06 K6 销号后复发率 %.4f 超出 5%%~20%% 健康区间" % K6)
ck(K7 == 1.0, "C07 K7 PV线索转交率 %.4f ≠ 100%%（硬红线）" % K7)

# 每个指标补一条 0~1 区间断言，抓分母倒置或分子越界
for name, v, den in [("K1", K1, n_reviewed), ("K2", K2, n_reg), ("K3", K3, len(W)),
                     ("K4", K4, n_disp), ("K5", K5, n_terminal), ("K6", K6, n_full),
                     ("K7", K7, len(PV))]:
    ck(0.0 <= v <= 1.0, "C08 %s 越界：%.4f" % (name, v))
    ck(den > 0, "C09 %s 分母为 0" % name)
ck(n_true <= n_reviewed, "C10 K1 分子 %d > 分母 %d（判定成立应为已复核的子集）" % (n_true, n_reviewed))
ck(n_closed <= n_terminal, "C11 K5 分子 %d > 分母 %d（已销号应为已终态的子集）" % (n_closed, n_terminal))
ck(n_disp_ok <= n_disp, "C12 K4 分子越界")
ck(n_relapse <= n_full, "C13 K6 分子越界")

# 七个分母互不相同（B-35 教训：分母重复说明指标设计有冗余）
denoms = [n_reviewed, n_reg, len(W), n_disp, n_terminal, n_full, len(PV)]
ck(len(set(denoms)) == 7, "C16 七个分母出现重复：%s" % denoms)

# 等级分布合理性：不得出现单一等级吞掉全部
grades = {lv: sum(1 for w in W if g(w, "风险等级") == lv) for lv in ("高危", "中危", "低危")}
ck(grades["高危"] < len(W), "C17 全部为高危，分档失效：%s" % grades)
ck(sum(grades.values()) == len(W), "C18 等级分布求和 %d ≠ 预警单 %d" % (sum(grades.values()), len(W)))

# ==================== 输出 ====================
print("数据源：%s" % SRC)
print("记录 %d ｜ 预警单 %d ｜ 整改单 %d ｜ 复发观察 %d ｜ PV线索 %d ｜ 命中明细 %d"
      % (len(V) + len(A), len(W), len(F), len(O), len(PV), len(H)))
print("等级分布：%s" % grades)
print("--- 指标 ---")
print("K1 预警准确率            %.4f  ( %d / %d )" % (K1, n_true, n_reviewed))
print("K2 法规硬命中不放加权率   %.4f  ( %d / %d )" % (K2, n_reg_high, n_reg))
print("K3 证据链完整率          %.4f  ( %d / %d )" % (K3, n_evi_ok, len(W)))
print("K4 派单及时率            %.4f  ( %d / %d )" % (K4, n_disp_ok, n_disp))
print("K5 整改闭环率            %.4f  ( %d / %d )" % (K5, n_closed, n_terminal))
print("K6 销号后复发率          %.4f  ( %d / %d )" % (K6, n_relapse, n_full))
print("K7 PV线索转交率          %.4f  ( %d / %d )" % (K7, len(PV), len(PV)))
print("七个分母：%s" % denoms)
print("--- 断言 ---")
print("共 %d 项，通过 %d，失败 %d" % (N, N - len(FAILS), len(FAILS)))
if FAILS:
    print("❌ 失败明细（前 20 条）：")
    for f in FAILS[:20]:
        print("   - %s" % f)
    sys.exit(1)
print("✅ 全部 PASS（闸3 放行）")
