zhizhijie
2026-09-21 e2eca00a1443eba3169798bfeaba3f3697a819bd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# -*- coding: utf-8 -*-
"""市州月报解析.py —— 把 17 市州的月报源文件解析成统一的「项目行」记录(只读)
 
用法:
    python docs\投资\工具\市州月报解析.py --month 2026-07
    python docs\投资\工具\市州月报解析.py --month 2026-07 --body 客运(客运站)
输出:
    docs\投资\输出\解析结果_<YYYY-MM>.tsv   一行一个项目(机器可读,供写库/写表用)
    docs\投资\输出\解析报告_<YYYY-MM>.md    人读:每个文件的市州/项目数/字段求和/一致性校验
 
设计要点(与《市州月报格式差异报告》一致):
    1) 列位不写死:按表头关键字定位列(复用 格式探测.py 的 FIELD_PAT 与 scan_fields);
    2) 选表:先按“表头区年月标记”命中本期,再按字段命中数打分 —— 不看隐藏与否;
    3) 选行:先找「市州行」切段,只取本文件对应市州那一段;
    4) 项目行:名称列非空 + 非小计/合计/分类行 + 有数值锚点;兼容无序号列/缺序号;
    5) 分类行(一、二、三、四、五、)与「小计/合计」行单独收集:分类行作为项目的归属,
       小计/合计行只用于**一致性校验**(项目行求和 vs 小计行)。
    6) 隐藏行/隐藏表一律不作为过滤条件(黄石 R10 是真实项目行)。
"""
import sys, os, io, re, argparse, json, importlib.util
 
_here = os.path.dirname(os.path.abspath(__file__))
_root = os.path.abspath(os.path.join(_here, '..', '..', '..'))
for _b in (_here, _root, os.getcwd()):
    for _sub in ('_pylibs',):
        _lib = os.path.abspath(os.path.join(_b, _sub))
        if os.path.isdir(_lib) and _lib not in sys.path:
            sys.path.insert(0, _lib)
 
_spec = importlib.util.spec_from_file_location('_fmt', os.path.join(_here, '格式探测.py'))
F = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(F)
 
# 需要从源文件取出的字段(与目标明细表列一一对应)
FIELDS = ['项目名称', '建设单位', '建设性质', '开工时间', '竣工时间', '总投资',
          '自开始建设累计', '本年计划投资', '自年初累计', '本月完成', '建设阶段', '形象进度']
# 数值字段(用于一致性校验)
NUM_FIELDS = ['总投资', '自开始建设累计', '本年计划投资', '自年初累计', '本月完成']
 
GROUP_PAT = re.compile(r'^(一|二|三|四|五|六|七|八)、')
SKIP_PAT = re.compile(r'^(小\s*计|合\s*计|总\s*计)')
TOTAL_PAT = re.compile(r'^(合\s*计|总\s*计)')
 
# 少数文件既无文件名城市、也无「市州行」,只能按业务事实人工登记(有据可查:汉口客运中心=武汉)
CITY_OVERRIDE = [
    ('站场固投(南山、汉口站)', '武汉'),
]
 
 
def city_by_override(fname):
    for key, city in CITY_OVERRIDE:
        if key in fname:
            return city
    return None
 
 
def num(v):
    """尽量转成数值;转不了返回 None。"""
    if isinstance(v, (int, float)) and not isinstance(v, bool):
        return v
    s = F.norm(v)
    if not s:
        return None
    s = s.replace(',', '').replace('%', '').replace('%', '')
    try:
        return float(s)
    except ValueError:
        return None
 
 
def pretty(v):
    if v is None:
        return ''
    if isinstance(v, float) and v.is_integer():
        return str(int(v))
    return str(v)
 
 
def pick_columns(cell, nrows, ncols, merges=None):
    """按表头定位列(含两级表头消歧)——实现见 格式探测.py 的 pick_columns,保证两处口径一致。"""
    return F.pick_columns(cell, nrows, ncols, merges, fields=FIELDS)
 
 
def parse_sheet(cell, nrows, ncols, city=None, merges=None):
    """解析一张工作表 → (项目行列表, 段信息, 校验信息)"""
    cols, hits = pick_columns(cell, nrows, ncols, merges)
    namec = cols.get('项目名称')
    if namec is None:
        return [], {}, {}
    segs = F.city_segments(cell, nrows, ncols)
    if city is None and len({s[1] for s in segs}) == 1:
        city = segs[0][1]
    lo, hi, city_row = 1, 10 ** 9, None
    if city:
        for i, (rr, cc) in enumerate(segs):
            if cc == city:
                city_row, lo = rr, rr
                hi = segs[i + 1][0] if i + 1 < len(segs) else 10 ** 9
                break
    project_name_pat = None
    # 数值锚点列:自开始建设累计 / 自年初累计 / 总投资
    anchors = [cols[k] for k in ('自开始建设累计', '自年初累计', '总投资') if k in cols]
 
    rows, group, subtotals, city_vals = [], '', {}, None
    for r in range(min(nrows, 800)):
        if not (lo <= r + 1 < hi):
            continue
        if city_row and r + 1 == city_row:
            # 市州行本身通常就带着本市州合计(黄石 R5 / 孝感 R7 / 咸宁 R6 …),留作校验
            city_vals = {f: num(cell(r, cols[f])) for f in NUM_FIELDS if f in cols}
            continue
        a = F.norm(cell(r, 0))
        name = F.norm(cell(r, namec))
        joined = a + name
        if GROUP_PAT.match(joined):
            group = joined[:40]
            continue
        if SKIP_PAT.match(a) or SKIP_PAT.match(name):
            # 小计/合计行:全部收集,用于一致性校验。
            # 规则:优先「合计/总计」行;没有合计行时,取「自开始建设累计」最大的那行小计
            #(源文件常有多个空小计行,如林区客运 R11 空、R14 有值、R17 只有文字)。
            vals = {}
            for f in NUM_FIELDS:
                if f in cols:
                    vals[f] = num(cell(r, cols[f]))
            vals['_kind'] = 'total' if TOTAL_PAT.match(a) or TOTAL_PAT.match(name) else 'sub'
            subtotals['R%d' % (r + 1)] = vals
            continue
        if not name or len(name) < 4:
            continue
        if any(name.startswith(x) for x in F.BAD_NAME):
            continue
        if namec == 0:
            numbered = True                       # 无序号列(随州):名称列就是 A 列
        else:
            numbered = bool(re.match(r'^\d{1,3}(\.0)?$', a)) or a == ''
        has_value = any(num(cell(r, c)) is not None for c in anchors) if anchors else True
        if not (numbered and has_value):
            continue
        rec = {'行号': r + 1, '序号': pretty(cell(r, 0)), '分类': group}
        for f in FIELDS:
            c = cols.get(f)
            v = cell(r, c) if c is not None else ''
            rec[f] = pretty(num(v)) if f in NUM_FIELDS else F.norm(v)
        rows.append(rec)
    seg_info = {'市州': city or '', '市州行': city_row or '', '段起': lo,
                '段止': '' if hi >= 10 ** 9 else hi,
                '全部市州行': ['R%s:%s' % s for s in segs]}
    return rows, seg_info, {'列位': {f: F.colname(c) for f, c in cols.items()},
                            '小计合计行': subtotals, '市州行值': city_vals}
 
 
def pick_total(subtotals):
    """从收集到的小计/合计行里挑出用于校验的那一行:优先合计行,其次自开始建设累计最大的小计行。"""
    if not subtotals:
        return None, None
    totals = [(k, v) for k, v in subtotals.items() if v.get('_kind') == 'total']
    if totals:
        return totals[-1]
    subs = sorted(subtotals.items(), key=lambda kv: (kv[1].get('自开始建设累计') or 0), reverse=True)
    return subs[0]
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--month', required=True)
    ap.add_argument('--root', default=os.path.join('docs', '投资'))
    ap.add_argument('--body', default=None)
    ap.add_argument('--out', default=None)
    a = ap.parse_args()
    out = a.out or os.path.join(a.root, '输出')
    os.makedirs(out, exist_ok=True)
    ym = a.month.replace('-', '')
    bodies = [a.body] if a.body else ['客运(客运站)', '物流(物流园区)']
    md = '%d月' % int(a.month.split('-')[1])
 
    records, report, checks = [], [], []
    report.append('# 市州月报解析报告(%s)' % a.month)
    report.append('')
    report.append('(只读解析;一行一个项目行,明细见同名 `.tsv`)')
    report.append('')
    for body in bodies:
        d = os.path.join(a.root, '输入', body, md)
        body_key = body.split('(')[0]
        report.append('## %s' % body)
        report.append('')
        report.append('| 文件 | 工作表 | 市州 | 项目行 | 投资系统合计(自开始建设累计/自年初累计/本月完成) | 源文件小计行 | 一致性 |')
        report.append('| --- | --- | --- | --- | --- | --- | --- |')
        if not os.path.isdir(d):
            report.append('| (目录不存在:%s) | | | | | | |' % d)
            report.append('')
            continue
        files = sorted(f for f in os.listdir(d)
                       if os.path.isfile(os.path.join(d, f)) and not f.startswith('~$'))
        for f in files:
            path = os.path.join(d, f)
            city_file = F.city_of_text(f)
            try:
                sheets = F.open_sheets(path)
                sel = F.pick_sheet(sheets, ym=ym)
                if not sel:
                    report.append('| %s | (未选中工作表) | | | | | |' % f[:44])
                    checks.append({'品类': body_key, '市州': city_file or '', '文件': f, '工作表': '',
                                   '状态': '未选中工作表', '项目行数': 0, '一致性': '—'})
                    continue
                nm, nr, nc, cell, _hits = sel
                merges = F.sheet_merges(path).get(nm, [])
                rows, seg, extra = parse_sheet(cell, nr, nc, city=city_file, merges=merges)
                city = seg.get('市州') or city_file or city_by_override(f) or ''
                sums = {k: sum(num(r.get(k)) or 0 for r in rows) for k in NUM_FIELDS}
                sub = extra.get('小计合计行') or {}
                sub_txt, ok = [], '—'
                if not sub and extra.get('市州行值'):
                    sub = {'R%s(市州行)' % (seg.get('市州行') or '?'): extra['市州行值']}
                tok = tval = None
                if sub:
                    key, val = pick_total(sub)
                    tok, tval = key, val
                    sub_txt.append('%s=%s' % (key, '/'.join(pretty(val.get(k)) for k in ('自开始建设累计', '自年初累计', '本月完成'))))
                    if len(sub) > 1:
                        sub_txt.append('(另有 %d 行小计/合计)' % (len(sub) - 1))
                    same = all(abs((val.get(k) or 0) - sums[k]) < 0.05
                               for k in ('自开始建设累计', '自年初累计', '本月完成'))
                    ok = '✅ 一致' if same else '⚠️ 不一致'
                ck = {'品类': body_key, '市州': city, '文件': f, '工作表': nm, '状态': 'OK',
                      '项目行数': len(rows), '合计行': '', '一致性': ok,
                      '列位': ','.join('%s=%s' % (k, v) for k, v in (extra.get('列位') or {}).items())}
                for k in ('自开始建设累计', '自年初累计', '本月完成'):
                    ck['求和_' + k] = sums[k]
                if sub:
                    ck['合计行'] = tok
                    for k in ('自开始建设累计', '自年初累计', '本月完成'):
                        ck['合计_' + k] = tval.get(k)
                checks.append(ck)
                report.append('| %s | %s | %s | %d | %s | %s | %s |' % (
                    f[:44], nm[:14], city, len(rows),
                    '/'.join(pretty(sums[k]) for k in ('自开始建设累计', '自年初累计', '本月完成')),
                    ' '.join(sub_txt), ok))
                for r in rows:
                    rec = {'品类': body_key, '市州': city, '文件': f, '工作表': nm}
                    rec.update(r)
                    records.append(rec)
            except Exception as e:
                report.append('| %s | (解析失败:%s: %s) | | | | | |' % (f[:44], type(e).__name__, str(e)[:60]))
                checks.append({'品类': body_key, '市州': city_file or '', '文件': f, '工作表': '',
                               '状态': '解析失败:%s: %s' % (type(e).__name__, str(e)[:80]),
                               '项目行数': 0, '一致性': '—'})
        report.append('')
 
    # 市州汇总(跨文件合并同市州)
    by_city = {}
    for r in records:
        k = (r['品类'], r['市州'])
        s = by_city.setdefault(k, {f: 0.0 for f in NUM_FIELDS})
        for f in NUM_FIELDS:
            s[f] += num(r.get(f)) or 0
    report.append('## 按市州汇总(项目行求和,跨文件合并)')
    report.append('')
    report.append('| 品类 | 市州 | 项目数 | 计划总投资 | 自开始建设累计 | 本年计划 | 自年初累计 | 本月完成 |')
    report.append('| --- | --- | --- | --- | --- | --- | --- | --- |')
    for (bk, city), s in sorted(by_city.items()):
        n = sum(1 for r in records if r['品类'] == bk and r['市州'] == city)
        report.append('| %s | %s | %d | %s | %s | %s | %s | %s |' % (
            bk, city, n,
            *(pretty(s[f]) for f in ('总投资', '自开始建设累计', '本年计划投资', '自年初累计', '本月完成'))))
    report.append('')
 
    report.append('## 各源文件识别到的列位(**按表头关键字定位,不是固定列号**)')
    report.append('')
    report.append('| 品类 | 文件 | 工作表 | 项目名称 | 总投资 | 自开始建设累计 | 本年计划投资 | 自年初累计 | 本月完成 |')
    report.append('| --- | --- | --- | --- | --- | --- | --- | --- | --- |')
    _key5 = ('项目名称', '总投资', '自开始建设累计', '本年计划投资', '自年初累计', '本月完成')
    for ck in checks:
        _cm = dict(kv.split('=', 1) for kv in (ck.get('列位') or '').split(',') if '=' in kv)
        report.append('| %s | %s | %s | %s |' % (ck.get('品类', ''), ck.get('文件', '')[:34], ck.get('工作表', '')[:12],
                                                 ' | '.join(_cm.get(k, '—') for k in _key5)))
    report.append('')
 
    ck_cols = ['品类', '市州', '文件', '工作表', '状态', '项目行数', '一致性', '合计行', '列位',
               '求和_自开始建设累计', '求和_自年初累计', '求和_本月完成',
               '合计_自开始建设累计', '合计_自年初累计', '合计_本月完成']
    p_ck = os.path.join(out, '解析校验_%s.tsv' % a.month)
    with io.open(p_ck, 'w', encoding='utf-8-sig') as fh:
        fh.write('\t'.join(ck_cols) + '\n')
        for c in checks:
            fh.write('\t'.join(pretty(c.get(k, '')) if isinstance(c.get(k), float) else str(c.get(k, ''))
                                for k in ck_cols) + '\n')
    print('校验记录数 = %d' % len(checks))
 
    cols = ['品类', '市州', '文件', '工作表', '行号', '序号', '分类'] + FIELDS
    tsv = os.path.join(out, '解析结果_%s.tsv' % a.month)
    with io.open(tsv, 'w', encoding='utf-8-sig') as fh:
        fh.write('\t'.join(cols) + '\n')
        for r in records:
            fh.write('\t'.join(str(r.get(c, '')) for c in cols) + '\n')
    p_md = os.path.join(out, '解析报告_%s.md' % a.month)
    io.open(p_md, 'w', encoding='utf-8').write('\n'.join(report) + '\n')
    print('项目行合计 = %d' % len(records))
    print('wrote', p_md)
    print('wrote', tsv)
    print('wrote', p_ck)
 
 
if __name__ == '__main__':
    main()