docs/Ͷ×Ê/¹¤¾ß/Æ¥ÅäÓëУÑé.py
@@ -40,6 +40,8 @@
}
WANT = ['自开始建设累计', '本年计划投资', '自年初累计', '本月完成', '建设阶段', '形象进度']
NUMF = ['总投资', '自开始建设累计', '本年计划投资', '自年初累计', '本月完成']
CITY17 = ('武汉', '黄石', '十堰', '宜昌', '襄阳', '鄂州', '荆门', '孝感', '荆州', '黄冈',
          '咸宁', '随州', '恩施', '仙桃', '潜江', '天门', '神农架')
GROUP_PAT = re.compile(r'^(一|二|三|四|五|六|七|八)、')
SKIP_PAT = re.compile(r'^(小\s*计|合\s*计|总\s*计)$')
@@ -64,6 +66,13 @@
            s = s[:-2]
        s = s.rstrip('.。-')
    return s
def normname2(s):
    """投资系统比对专用:在 normname åŸºç¡€ä¸Šå†åŽ»æŽ‰é—´éš”å·ç­‰çº¯è£…é¥°å­—ç¬¦ã€‚"""
    for ch in ('·', '.', '•', '▪', '●', '.', '-'):
        s = str(s or '').replace(ch, '')
    return normname(s)
def to_num(v):
@@ -144,6 +153,55 @@
    return out, period
def normname3(s):
    """再去掉括号及其内容(用于识别「名称多了括注」的同一项目)。"""
    t = normname(s)
    t = re.sub(r'\([^)]*\)', '', t)
    for ch in ('·', '.', '•', '●', '-', '.', '·'):
        t = t.replace(ch, '')
    return t
def best_name_hint(key, tkeys, cutoff=0.80):
    """在目标表项目名里找与 key æœ€åƒçš„一个(宽松/去括注两种口径取最优)。
    è¿”回 (目标表项目名, ç›¸ä¼¼åº¦);找不到返回 (None, 0)。
    """
    best = (None, 0.0)
    for normf in (lambda s: normname(s, loose=True), normname3):
        base = normf(key)
        table = [normf(x) for x in tkeys]
        hit = difflib.get_close_matches(base, table, n=1, cutoff=cutoff)
        if hit:
            ratio = difflib.SequenceMatcher(None, base, hit[0]).ratio()
            if ratio > best[1]:
                best = (tkeys[table.index(hit[0])], ratio)
    return best
def sys_lookup(key, table):
    """在投资系统字典里找项目:先精确(含去间隔号),再宽松+相似度(>=0.90)。
    è¿”回 (记录, è¯´æ˜Ž);找不到返回 (None, '')。
    """
    if not table:
        return None, ''
    s = table.get(key)
    if s:
        return s, ''
    for kk, vv in table.items():
        if normname2(kk) == normname2(key):
            return vv, '名称写法不同(投资系统:%s)' % vv['项目名称']
    idx = {}
    for kk in table:
        idx.setdefault(normname2(kk), []).append(kk)
    cands = difflib.get_close_matches(normname2(key), list(idx.keys()), n=1, cutoff=0.90)
    if cands:
        kk = idx[cands[0]][0]
        return table[kk], '名称写法不同(投资系统:%s)' % table[kk]['项目名称']
    return None, ''
def find_system_files(indir, ym):
    """在输入目录里找「本月/上月」投资系统查询结果。文件名或『时期』列里的年月都认。"""
    found = {}
@@ -195,6 +253,23 @@
    rep.append('> åªè¯»ï¼šæœ¬æ­¥éª¤ä¸æ”¹ä»»ä½•文件;输出的《变更清单》供「写入侧」执行。')
    rep.append('')
    changes = []
    alerts = []
    def add_alert(level, kind, cat, city, name, src, detail, action):
        alerts.append(dict(级别=level, ç±»åž‹=kind, å“ç±»=cat, å¸‚å·ž=city, é¡¹ç›®åç§°=name,
                           æ¥æº=src, è¯¦æƒ…=detail, å»ºè®®åŠ¨ä½œ=action))
    # æºæ–‡ä»¶è§£æžæ ¡éªŒï¼ˆç”± å¸‚州月报解析.py äº§å‡ºï¼‰
    checks = []
    p_ck = os.path.join(out, '解析校验_%s.tsv' % a.month)
    if os.path.exists(p_ck):
        _lines = io.open(p_ck, encoding='utf-8-sig').read().splitlines()
        if _lines:
            _ckc = _lines[0].split('\t')
            for _ln in _lines[1:]:
                _v = _ln.split('\t')
                _v += [''] * (len(_ckc) - len(_v))
                checks.append(dict(zip(_ckc, _v)))
    # æŠ•资系统文件(本期 / ä¸Šæœˆï¼‰
    indir = os.path.join(a.root, '输入')
@@ -214,11 +289,41 @@
    rep.append('| ä¸Šæœˆï¼ˆæ ¡éªŒåŸºæ•°ï¼‰ | %s | %d |' % (os.path.basename(pre_path) if pre_path else '**未找到(用户口径 a:需每月放一份)**', len(cur_pre)))
    rep.append('')
    # â€”— å¼¹çª—规则:投资系统文件缺失 / æºæ–‡ä»¶è§£æžå¼‚常 / æºæ–‡ä»¶å†…部对不上 â€”—
    if not cur_path:
        add_alert('阻断', '缺本期投资系统表', '', '', '', os.path.basename(indir),
                  '未在「输入」目录找到 %s çš„æŠ•资系统查询结果' % a.month,
                  '把本期投资系统导出的《模板_查询结果》放进 docs\\投资\\输入\\ åŽé‡è·‘')
    if not pre_path:
        add_alert('警告', '缺上月投资系统表', '', '', '', os.path.basename(indir),
                  '未找到 %s çš„æŠ•资系统查询结果 â†’ æœˆåº¦æ ¡éªŒï¼ˆæœ¬æœˆè‡ªå¹´åˆç´¯è®¡ = ä¸Šæœˆè‡ªå¹´åˆç´¯è®¡ + æœ¬æœˆå®Œæˆï¼‰æ— æ³•执行' % prev_ym(a.month),
                  '每月把上月、本月两份投资系统查询结果都放进 docs\\投资\\输入\\')
    if not checks:
        add_alert('阻断', '缺源文件解析校验', '', '', '', os.path.basename(p_ck),
                  '未找到《解析校验》清单,无法判断源文件是否自洽、是否解析失败',
                  '先跑 å¸‚州月报解析.py --month %s' % a.month)
    for ck in checks:
        st = ck.get('状态', 'OK')
        if st and st != 'OK':
            add_alert('阻断' if st.startswith('解析失败') else '警告', '源文件解析异常',
                      ck.get('品类', ''), ck.get('市州', ''), '', ck.get('文件', ''),
                      st, '人工打开源文件确认表头/数据;必要时登记特例后再导入')
        elif ck.get('一致性', '') == '⚠️ ä¸ä¸€è‡´':
            add_alert('警告', '源文件内部对不上', ck.get('品类', ''), ck.get('市州', ''), '', ck.get('文件', ''),
                      '项目行求和 %s/%s/%s â‰  %s è¡Œåˆè®¡ %s/%s/%s' % (
                          ck.get('求和_自开始建设累计', ''), ck.get('求和_自年初累计', ''), ck.get('求和_本月完成', ''),
                          ck.get('合计行', ''), ck.get('合计_自开始建设累计', ''),
                          ck.get('合计_自年初累计', ''), ck.get('合计_本月完成', '')),
                      '让市州核对源文件(合计行或项目行有错),确认后再导入')
    for cat, spec in TARGETS.items():
        rep.append('## %s â€”— ç›®æ ‡æ˜Žç»†è¡¨' % cat)
        rep.append('')
        if not os.path.exists(spec['path']):
            rep.append('目标表不存在:%s' % spec['path']); rep.append(''); continue
            rep.append('目标表不存在:%s' % spec['path']); rep.append('')
            add_alert('阻断', '缺目标明细表', cat, '', '', spec['path'],
                      '目标明细表不存在,本季度该品类无法写入', '把模板表放到 docs\\投资\\输出\\')
            continue
        tgt = read_target(spec)
        prow = [r for r in parsed if r['品类'] == cat]
        tmap = {}
@@ -235,12 +340,19 @@
        loose_tgt = {}
        for k in tmap:
            loose_tgt.setdefault(normname(k, loose=True), []).append(k)
        plain_tgt = {}
        for k in tmap:
            plain_tgt.setdefault(normname3(k), []).append(k)
        tkeys = list(tmap.keys())
        new_in_src, suspicious = [], []
        for k in raw_new:
            nl = normname(k, loose=True)
            if nl in loose_tgt:
                suspicious.append((k, loose_tgt[nl][0], '宽松等价(去括号/去“项目”二字后相同)'))
                continue
            n3 = normname3(k)
            if n3 and n3 in plain_tgt:
                suspicious.append((k, plain_tgt[n3][0], '去掉括注与间隔号后相同'))
                continue
            close = difflib.get_close_matches(nl, [normname(x, loose=True) for x in tkeys], n=1, cutoff=0.90)
            if close:
@@ -254,6 +366,12 @@
        rep.append('- **疑似同一项目、需人工确认** **%d**(按用户口径:**不自动新增、不动名称,弹窗提示**)' % len(suspicious))
        rep.append('- ç›®æ ‡è¡¨æœ‰ä½†æœ¬æœŸæºæ–‡ä»¶æ²¡æœ‰ï¼ˆä¿æŒåŽŸå€¼ï¼‰**%d**' % len(only_tgt))
        rep.append('')
        for k1, hit1, why1 in suspicious:
            _p1 = pmap[k1][0]
            add_alert('警告', '疑似同一项目(名称写法不同)', cat, _p1.get('市州', ''), _p1['项目名称'],
                      os.path.basename(_p1['文件']),
                      '与目标表已有项目「%s」%s' % (tmap[hit1][0]['项目名称'], why1),
                      '人工确认是否同一项目:是 â†’ ä¸æ”¹åç§°ã€æŒ‰æŠ•资系统名称更新;否 â†’ æ•´è¡Œæ–°å¢ž')
        if suspicious:
            rep.append('### âš ï¸ ç–‘似同一项目但名称写法不同 â†’ éœ€äººå·¥ç¡®è®¤ï¼ˆç¨‹åºä¸è‡ªåŠ¨åˆå¹¶ã€ä¹Ÿä¸é‡å¤æ–°å¢žï¼‰')
            rep.append('')
@@ -267,16 +385,17 @@
            rep.append('')
            rep.append('| é¡¹ç›®åç§° | æ¥æºæ–‡ä»¶ | è‡ªå¼€å§‹å»ºè®¾ç´¯è®¡ | è‡ªå¹´åˆç´¯è®¡ | æœ¬æœˆå®Œæˆ | åç§°ç›¸ä¼¼åº¦æç¤º |')
            rep.append('| --- | --- | --- | --- | --- | --- |')
            loose_keys = [normname(x, loose=True) for x in tkeys]
            for k in new_in_src:
                p0 = pmap[k][0]
                nl = normname(k, loose=True)
                close = difflib.get_close_matches(nl, loose_keys, n=1, cutoff=0.80)
                hint = ''
                if close:
                    hit = tkeys[loose_keys.index(close[0])]
                    hint = '⚠️ ä¸Žã€Œ%s」相似 %.2f,请确认是否同一项目' % (
                        hit, difflib.SequenceMatcher(None, nl, close[0]).ratio())
                _hit, _r = best_name_hint(k, tkeys)
                hint = ('⚠️ ä¸Žã€Œ%s」相似 %.2f,请确认是否同一项目' % (_hit, _r)) if _hit else ''
                _sim = hint
                add_alert('提示', '将新增项目', cat, p0.get('市州', ''), p0['项目名称'],
                          os.path.basename(p0['文件']),
                          '目标表没有这个项目;自开始建设累计/自年初累计/本月完成 = %s/%s/%s%s' % (
                              p0.get('自开始建设累计', ''), p0.get('自年初累计', ''), p0.get('本月完成', ''),
                              (';' + hint) if hint else ''),
                          '确认是真实新项目后写入(整行插入,复制相邻行格式)')
                rep.append('| %s | %s | %s | %s | %s | %s |' % (
                    p0['项目名称'], os.path.basename(p0['文件'])[:30], p0.get('自开始建设累计', ''),
                    p0.get('自年初累计', ''), p0.get('本月完成', ''), hint))
@@ -295,23 +414,51 @@
                                '项目名称': p['项目名称'], '写入来源': os.path.basename(p['文件']),
                                **{c: p.get(c, '') for c in WANT}})
        # ç›®æ ‡è¡¨æœ‰ã€æœ¬æœŸæºæ–‡ä»¶æ²¡æœ‰ â†’ ä¿æŒåŽŸå€¼ï¼ˆæç¤ºï¼Œä¸é˜»æ–­ï¼‰
        if only_tgt:
            _names = [tmap[k][0]['项目名称'] for k in only_tgt]
            add_alert('提示', '本期源文件里没有的项目', cat, '', '', '—',
                      '目标表里有 %d ä¸ªé¡¹ç›®æœ¬æœŸå¸‚州月报没报:%s%s' % (
                          len(_names), '、'.join(_names[:8]), ' ç­‰' if len(_names) > 8 else ''),
                      '按口径保持上月原值;若市州确实已停建,请人工确认是否保留')
        # ä¸‰æ–¹æ•°å€¼æ¯”对:源文件 vs æŠ•资系统(本期)
        if cur_sys:
            diff, missing = [], []
            diff, missing, shifted = [], [], []
            for k in matched:
                p, t = pmap[k][0], tmap[k][0]
                s = cur_sys.get(k)
                s, note = sys_lookup(k, cur_sys)
                if not s:
                    missing.append(t['项目名称'])
                    continue
                for c in ('自开始建设累计', '自年初累计', '本月完成'):
                if note:
                    add_alert('提示', '投资系统名称写法不同', cat, p.get('市州', ''), t['项目名称'],
                              os.path.basename(p['文件']), note + ';已按投资系统这条记录做数值对照(项目名称不动)',
                              '知悉即可')
                for c in ('总投资', '自开始建设累计', '本年计划投资', '自年初累计', '本月完成'):
                    pv, sv = to_num(p.get(c)), s.get(c)
                    if pv is None or sv is None:
                        continue
                    if abs(pv - sv) > 0.05:
                        diff.append((t['项目名称'], c, pretty(pv), pretty(sv), pretty(sv - pv)))
                    if abs(pv - sv) <= 0.05:
                        continue
                    diff.append((t['项目名称'], c, pretty(pv), pretty(sv), pretty(sv - pv)))
                    if abs(pv - sv) > 1.0:
                        add_alert('警告', '源文件与投资系统不一致', cat, p.get('市州', ''), t['项目名称'],
                                  os.path.basename(p['文件']),
                                  '%s:市州月报 %s ï¼ æŠ•资系统 %s(差 %s)' % (c, pretty(pv), pretty(sv), pretty(sv - pv)),
                                  '人工确认以哪个为准;若是取列错位则修解析规则后重跑')
                    for c2 in ('总投资', '自开始建设累计', '本年计划投资', '自年初累计', '本月完成'):
                        if c2 == c:
                            continue
                        sv2 = s.get(c2)
                        if sv2 is not None and abs(pv - sv2) <= 0.05:
                            shifted.append((t['项目名称'], c, c2))
                            add_alert('警告', '疑似取列错位', cat, p.get('市州', ''), t['项目名称'],
                                      os.path.basename(p['文件']),
                                      '市州月报「%s」= %s,恰好等于投资系统「%s」' % (c, pretty(pv), c2),
                                      '检查源文件表头(组表头合并单元格最容易错位),修取列规则后重跑解析')
            rep.append('')
            rep.append('### æºæ–‡ä»¶ vs æŠ•资系统(本期):**数值不一致 %d å¤„**(需弹窗提示)' % len(diff))
            rep.append('### æºæ–‡ä»¶ vs æŠ•资系统(本期):**数值不一致 %d å¤„**(需弹窗提示),其中疑似取列错位 **%d** å¤„' % (len(diff), len(shifted)))
            rep.append('')
            if diff:
                rep.append('| é¡¹ç›®åç§° | å­—段 | å¸‚州月报 | æŠ•资系统 | å·®é¢ |')
@@ -326,6 +473,9 @@
            for nm1 in missing[:60]:
                rep.append('- %s' % nm1)
            rep.append('')
            for nm1 in missing:
                add_alert('提示', '投资系统查无此项目', cat, '', nm1, '—',
                          '投资系统本期查询结果里没有这个项目名称', '知悉即可;如需以投资系统为准,请人工核对名称')
        # æœˆåº¦æ ¡éªŒï¼šæœ¬æœˆè‡ªå¹´åˆç´¯è®¡ = ä¸Šæœˆè‡ªå¹´åˆç´¯è®¡ + æœ¬æœˆå®Œæˆ
        if cur_pre:
@@ -334,7 +484,7 @@
                p, t = pmap[k][0], tmap[k][0]
                nowj = to_num(p.get('自年初累计'))
                nowk = to_num(p.get('本月完成'))
                pre = cur_pre.get(k)
                pre, _note = sys_lookup(k, cur_pre)
                if nowj is None or nowk is None or not pre:
                    continue
                prej = pre.get('自年初累计')
@@ -342,6 +492,11 @@
                    continue
                if abs(nowj - (prej + nowk)) > 0.05:
                    bad.append((t['项目名称'], pretty(prej), pretty(nowk), pretty(nowj), pretty(prej + nowk)))
                    add_alert('警告', '月度等式不符', cat, p.get('市州', ''), t['项目名称'],
                              '%s + %s = %s,而本月自年初累计是 %s(差 %s)' % (
                                  pretty(prej), pretty(nowk), pretty(prej + nowk), pretty(nowj),
                                  pretty(nowj - prej - nowk)),
                              '写入时按等式纠正为 %s,并让市州确认' % pretty(prej + nowk))
            rep.append('### æœˆåº¦æ ¡éªŒã€Œæœ¬æœˆè‡ªå¹´åˆç´¯è®¡ = ä¸Šæœˆè‡ªå¹´åˆç´¯è®¡ + æœ¬æœˆå®Œæˆã€ï¼šä¸ç¬¦ **%d** é¡¹' % len(bad))
            rep.append('')
            if bad:
@@ -354,6 +509,43 @@
            rep.append('### æœˆåº¦æ ¡éªŒï¼š**跳过**(未找到上月投资系统数据表,按用户口径 (a) éœ€æ¯æœˆæ”¾ä¸€ä»½ï¼‰')
            rep.append('')
    # æœ¬æœŸæ²¡æœ‰ä»»ä½•项目行的市州 â†’ æç¤ºï¼ˆå¯èƒ½æ¼æŠ¥ï¼‰
    for cat in TARGETS:
        _cities = {r['市州'] for r in parsed if r['品类'] == cat and r['市州']}
        _miss = [c for c in CITY17 if c not in _cities]
        if _miss:
            add_alert('提示', '本期无该市州数据', cat, '', '', '—',
                      '共 %d ä¸ªå¸‚州本期没有任何项目行:%s' % (len(_miss), '、'.join(_miss)),
                      '确认是「该市州确实没有项目」还是「漏报」;漏报则让市州补报')
    # ---- æ±‡æ€»ã€Šå¼¹çª—提示》(未来搬进 Java åŽç«¯æ—¶æŒ‰æ­¤é€æ¡å¼¹çª—) ----
    _lv = {'阻断': 0, '警告': 1, '提示': 2}
    alerts.sort(key=lambda x: (_lv.get(x['级别'], 9), x['类型'], x['品类'], x['市州'], x['项目名称']))
    _n = {k: sum(1 for x in alerts if x['级别'] == k) for k in ('阻断', '警告', '提示')}
    a_cols = ['序号', '级别', '类型', '品类', '市州', '项目名称', '来源', '详情', '建议动作']
    p_al = os.path.join(out, '弹窗提示_%s.tsv' % a.month)
    with io.open(p_al, 'w', encoding='utf-8-sig') as fh:
        fh.write('\t'.join(a_cols) + '\n')
        for _i, _x in enumerate(alerts, 1):
            fh.write('\t'.join(str(_i) if _k == '序号' else str(_x.get(_k, '')).replace('\t', ' ')
                                for _k in a_cols) + '\n')
    al_md = ['## âš ï¸ å¼¹çª—提示总览(共 %d é¡¹ï¼šé˜»æ–­ %d ï¼ è­¦å‘Š %d ï¼ æç¤º %d)' % (
                 len(alerts), _n['阻断'], _n['警告'], _n['提示']), '']
    al_md.append('> çº§åˆ«ï¼š**阻断**=不能写表,先处理;**警告**=数据有疑点,人工确认后再写;**提示**=仅告知。')
    al_md.append('> æœºå™¨å¯è¯»æ¸…单:`%s`(未来 Java/网页按此逐条弹窗)。规则见 `docs\\投资\\弹窗提示规则_2026-09-17.md`。'
                 % os.path.basename(p_al))
    al_md.append('')
    if alerts:
        al_md.append('| åºå· | çº§åˆ« | ç±»åž‹ | å“ç±» | å¸‚å·ž | é¡¹ç›®åç§° | è¯¦æƒ… | å»ºè®®åŠ¨ä½œ |')
        al_md.append('| --- | --- | --- | --- | --- | --- | --- | --- |')
        for _i, _x in enumerate(alerts, 1):
            al_md.append('| %d | %s | %s | %s | %s | %s | %s | %s |' % (
                _i, _x['级别'], _x['类型'], _x['品类'], _x['市州'], _x['项目名称'], _x['详情'], _x['建议动作']))
        al_md.append('')
    else:
        al_md.append('(无:所有检查项均通过)'); al_md.append('')
    rep = rep[:4] + al_md + rep[4:]
    cols = ['品类', '动作', '目标行号', '项目名称', '写入来源'] + WANT
    p_tsv = os.path.join(out, '变更清单_%s.tsv' % a.month)
    with io.open(p_tsv, 'w', encoding='utf-8-sig') as fh:
@@ -363,8 +555,10 @@
    p_md = os.path.join(out, '匹配校验报告_%s.md' % a.month)
    io.open(p_md, 'w', encoding='utf-8').write('\n'.join(rep) + '\n')
    print('变更条数 = %d' % len(changes))
    print('弹窗提示 = %d(阻断 %d / è­¦å‘Š %d / æç¤º %d)' % (len(alerts), _n['阻断'], _n['警告'], _n['提示']))
    print('wrote', p_md)
    print('wrote', p_tsv)
    print('wrote', p_al)
if __name__ == '__main__':