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
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# -*- coding: utf-8 -*-
"""格式探测.py —— 投资月报源文件格式探测(只读,不修改任何源文件)
 
用法:
    python docs\投资\工具\格式探测.py --month 2026-07
    python docs\投资\工具\格式探测.py --month 2026-07 --body 物流(物流园区)
输出:
    <out>\格式探测_<YYYY-MM>.md     人读报告
    <out>\格式探测_<YYYY-MM>.tsv   机器可读明细(供程序回归比对)
 
原则:
    1) 列位不写死:按表头关键字在“前 14 行 × 前 34 列”里匹配定位;
    2) 工作表先按“表头区是否带本期年月标记”优先,再按命中字段数打分,
       避免取到空模板表 / 历史月表(如潜江、林区、襄阳的多月工作簿);
    3) 项目行判定:名称列非空且不是小计/合计/分类行,且有数值锚点(自开始建设累计等),
       兼容“无序号列”(随州)与“缺序号”(林区 7 月)两种情况;
    4) 市州分段:先找出「市州行」(首 5 列里恰好只有 1 个单元格是市州名,同行其余列全空),
       再只取目标市州段内的项目行。湖北省十堰交通物流…表的 88 行里,R11-R63 是隐藏的
       武汉历史区块,若不按市州分段就会把武汉的历史项目当成十堰的取进来;
    5) 隐藏行 / 隐藏工作表不作为判定依据:黄石物流 R10 是隐藏行,但它是一条真实项目行
       (阳新县焦山物流仓储中心),且已计入黄石市州汇总行,必须取。
"""
import sys, os, io, re, argparse, datetime as _dt
 
for _base in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
    for _sub in ('_pylibs', os.path.join('..', '_pylibs')):
        _lib = os.path.abspath(os.path.join(_base, _sub))
        if os.path.isdir(_lib) and _lib not in sys.path:
            sys.path.insert(0, _lib)
 
try:
    import xlrd
except ImportError:
    xlrd = None
try:
    import openpyxl
except ImportError:
    openpyxl = None
 
FIELD_PAT = [
    ('项目名称',     r'^(项目名称|项目名称及站级|项目及站级|项目名称及战级)$'),
    ('建设单位',     r'(投资建设\s*单位名称|投资建设单位名称|项目业主|建设单位)'),
    ('建设性质',     r'^(建设性质|建设 性质)$'),
    ('开工时间',     r'^(开工时间|开工年)$'),
    ('竣工时间',     r'^(竣工时间|建成时间|完工年)$'),
    ('总投资',       r'(计划总投资|总投资)'),
    ('自开始建设累计', r'(自开始建设|自开始建设至本年)'),
    ('本年计划投资', r'(本年计划投资|本年计划)'),
    ('自年初累计',   r'((自)?年初累计完成投资|自年初累计|自年初-累计完成投资)'),
    ('本月完成',     r'(本月完成投资|本月完成|当月完成投资|^本月$)'),
    ('建设阶段',     r'^(项目建设阶段|建设阶段)$'),
    ('形象进度',     r'^(形象进度|进度情况)$'),
    ('工可',         r'^工可'),
    ('初设',         r'^初设'),
    ('市州',         r'^(市州|市州名称)$'),
    ('日期',         r'^(20\d\d[-./]\d{1,2})'),
]
KEY = ['项目名称', '建设单位', '建设性质', '开工时间', '竣工时间', '总投资',
       '自开始建设累计', '本年计划投资', '自年初累计', '本月完成', '建设阶段', '形象进度']
BAD_NAME = ('填报单位', '签章', '单位负责人', '统计负责人', '审核', '说明', '注:', '月报', '报表',
            '序 号', '序号', '项目名称', '项目业主')
 
CITIES = ('武汉', '黄石', '十堰', '宜昌', '襄阳', '鄂州', '荆门', '孝感', '荆州', '黄冈',
          '咸宁', '随州', '恩施', '仙桃', '潜江', '天门', '神农架', '林区')
 
 
def city_segments(cell, nrows, ncols, maxc=5):
    # 市州行 = 首 maxc 列里恰好只有 1 个单元格是市州名(同行其余列为空)。
    # 返回 [(行号1基, 市州名), ...],已按行号排序。
    segs = []
    for r in range(min(nrows, 800)):
        vals = [norm(cell(r, c)) for c in range(min(ncols, maxc))]
        hit = [i for i, v in enumerate(vals) if v in CITIES]
        if len(hit) == 1 and sum(1 for v in vals if v) == 1:
            segs.append((r + 1, vals[hit[0]]))
    return segs
 
 
def city_of_text(text):
    for c in CITIES:
        if c in str(text or ''):
            return c
    return None
 
 
def norm(s):
    s = str(s or '')
    for ch in (' ', '\u3000', '\n', '\r', '\t'):
        s = s.replace(ch, '')
    return s
 
 
def colname(i):
    s = ''
    i += 1
    while i:
        i, r = divmod(i - 1, 26)
        s = chr(65 + r) + s
    return s
 
 
def colindex(letters):
    n = 0
    for ch in letters:
        n = n * 26 + (ord(ch) - 64)
    return n - 1
 
 
def sheet_merges(path):
    """返回 {sheet_name: [(rlo, rhi, clo, chi), ...]}(0 基、右开)。
 
    用于识别「两级表头」:组表头(跨多列的合并单元格)不能直接当成某字段的列。
    读不到合并信息时返回已解析到的部分(不抛异常)。
    """
    with open(path, 'rb') as fh:
        magic = fh.read(4)
    out = {}
    try:
        if magic[:4] == b'PK\x03\x04':
            if openpyxl is None:
                return out
            wb = openpyxl.load_workbook(path, data_only=True)
            for nm in wb.sheetnames:
                ws = wb[nm]
                out[nm] = [(r.min_row - 1, r.max_row, r.min_col - 1, r.max_col)
                           for r in ws.merged_cells.ranges]
            wb.close()
        else:
            if xlrd is None:
                return out
            bk = xlrd.open_workbook(path, formatting_info=True)
            for si in range(bk.nsheets):
                sh = bk.sheet_by_index(si)
                out[sh.name] = list(getattr(sh, 'merged_cells', []) or [])
    except Exception:
        return out
    return out
 
 
def merge_span(merges, row1, col0):
    """该单元格(1 基行号、0 基列号)所在合并区的 (列0起, 列0止右开);不在合并区返回 None。"""
    for (rlo, rhi, clo, chi) in (merges or ()):
        if rlo <= row1 - 1 < rhi and clo <= col0 < chi:
            return (clo, chi)
    return None
 
 
def open_sheets(path):
    """返回 [(sheet_name, nrows, ncols, cell_fn), ...]。
 
    注意:`.et`(WPS)实为 OLE2/BIFF8 复合文档,xlrd 可直接读,不必走 Excel COM。
    """
    with open(path, 'rb') as fh:
        magic = fh.read(4)
    if magic[:4] == b'PK\x03\x04':
        if openpyxl is None:
            raise RuntimeError('需要 openpyxl 读取 .xlsx')
        wb = openpyxl.load_workbook(path, data_only=True)
        out = []
        for nm in wb.sheetnames:
            ws = wb[nm]
            out.append((nm, ws.max_row, ws.max_column,
                        (lambda r, c, _w=ws: _w.cell(row=r + 1, column=c + 1).value)))
        wb.close()
        return out
    if xlrd is None:
        raise RuntimeError('需要 xlrd 读取 .xls')
    bk = xlrd.open_workbook(path)
    out = []
    for si in range(bk.nsheets):
        sh = bk.sheet_by_index(si)
        out.append((sh.name, sh.nrows, sh.ncols,
                    (lambda r, c, _s=sh: _s.cell_value(r, c) if (r < _s.nrows and c < _s.ncols) else '')))
    return out
 
 
def scan_fields(cell, nrows, ncols, maxrow=14, maxcol=34):
    hits = {}
    for r in range(min(nrows, maxrow)):
        for c in range(min(ncols, maxcol)):
            t = norm(cell(r, c))
            if not t or len(t) > 30:
                continue
            for f, p in FIELD_PAT:
                if re.search(p, t):
                    hits.setdefault(f, []).append((r + 1, colname(c), t[:24]))
    return hits
 
 
def is_num(v):
    if isinstance(v, (int, float)) and not isinstance(v, bool):
        return True
    return bool(re.match(r'^-?\d+(\.\d+)?$', norm(v)))
 
 
def _num_count(cell, nrows, col, start_row1):
    """统计该列在表头行以下的数值单元格个数(用于区分同名列,如「自开始建设累计完成投资」与「…新增建筑面积」)。"""
    n = 0
    for r in range(start_row1, min(nrows, 800)):
        v = cell(r, col)
        if isinstance(v, (int, float)) and not isinstance(v, bool):
            n += 1
    return n
 
 
def pick_columns(cell, nrows, ncols, merges=None, fields=None):
    """按表头关键字定位列 → ({字段: 0 基列号}, hits)。
 
    两级表头消歧:若某字段的命中落在**跨多列的合并单元格**里,说明它是组表头,不能直接当该字段的列;
    此时在本合并区内排除「其它字段更深的子表头」所占的列,若只剩唯一一列,则该列才是本字段的列。
    例:鄂州《企业月报汇总》R5 组表头「自年初累计完成投资(万元)」合并 I5:K5,
        子表头 I=本年计划投资、J=合计、K=其中:本月完成投资 →
        「自年初累计」落在 J(与投资系统 G 列一致),而不是 I(那是本年计划投资)。
    """
    hits = scan_fields(cell, nrows, ncols)
    fields = list(fields) if fields else [k for k, _ in FIELD_PAT]
    best = {}
    for f in fields:
        if f not in hits:
            continue
        uniq, seen = [], set()
        for h in sorted(hits[f], key=lambda h: (h[0], colindex(h[1]))):
            if h[1] in seen:
                continue
            seen.add(h[1]); uniq.append(h)
        # 同名列消歧:优先「表头下方真的有数字」的那一列。
        # 例:武汉物流「自开始建设累计完成投资(万元)」H 与「自开始建设累计新增建筑面积」P 同名,
        #     只有 H 列有数字 → 取 H(按“命中词最短”会错取 P,那是建筑面积)。
        withnum = [h for h in uniq if _num_count(cell, nrows, colindex(h[1]), h[0]) > 0]
        pool = withnum or uniq
        best[f] = sorted(pool, key=lambda h: (h[0], colindex(h[1])))[0]
    cols = {}
    for f, h in best.items():
        row1, letter, _txt = h
        c0 = colindex(letter)
        span = merge_span(merges, row1, c0)
        if span and (span[1] - span[0]) > 1:
            taken = set()
            for f2, h2 in best.items():
                if f2 == f or h2[0] <= row1:
                    continue
                c2 = colindex(h2[1])
                if span[0] <= c2 < span[1]:
                    taken.add(c2)
            free = [c for c in range(span[0], span[1]) if c not in taken]
            if len(free) == 1:
                cols[f] = free[0]
                continue
        cols[f] = c0
    return cols, hits
 
 
def profile(cell, nrows, ncols, hits, city=None, merges=None):
    cols, _ = pick_columns(cell, nrows, ncols, merges)
    namecol = colname(cols['项目名称']) if '项目名称' in cols else None
    data_start = data_end = None
    project_rows = 0
    groups = []
    segs = 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
    if namecol is not None:
        nc = cols['项目名称']
        anchors = [cols[k] for k in ('自开始建设累计', '自年初累计', '总投资') if k in cols]
        for r in range(min(nrows, 800)):
            if not (lo <= r + 1 < hi):
                continue
            a = norm(cell(r, 0))
            name = norm(cell(r, nc))
            is_group = bool(re.match(r'^(一|二|三|四|五|六)、', a + name)) \
                or a in ('小计', '合计') or name in ('小计', '合计')
            if is_group:
                if name:
                    groups.append((r + 1, name[:20]))
                continue
            if not name or len(name) < 4 or any(name.startswith(x) for x in BAD_NAME):
                continue
            if nc == 0:
                numbered = True            # 无序号列(随州):名称列就是 A 列
            else:
                numbered = bool(re.match(r'^\d{1,3}(\.0)?$', a)) or a == ''
            has_value = any(is_num(cell(r, c)) for c in anchors) if anchors else True
            if numbered and has_value:
                data_start = data_start or r + 1
                data_end = r + 1
                project_rows += 1
    return dict(namecol=namecol, cols=cols, data_start=data_start, data_end=data_end,
                project_rows=project_rows, groups=groups[:6],
                city=city or '', city_row=city_row or '', segments=segs[:8],
                header_rows=sorted({h[0] for v in hits.values() for h in v}))
 
 
def hdr_dates(cell, nrows, ncols):
    """表头区(前 14 行 × 前 32 列)出现的年月标记,如 '202607'(兼容文本日期与 Excel 日期序列号)。"""
    out = set()
    for r in range(min(nrows, 14)):
        for c in range(min(ncols, 32)):
            v = cell(r, c)
            t = str(v or '')
            for m in re.finditer(r'(20\d{2})\s*[-./年]\s*(\d{1,2})', t):
                out.add('%s%02d' % (m.group(1), int(m.group(2))))
            if isinstance(v, (int, float)) and not isinstance(v, bool) and 40000 <= v <= 60000:
                d = _dt.date(1899, 12, 30) + _dt.timedelta(days=int(v))
                out.add('%04d%02d' % (d.year, d.month))
    return out
 
 
def pick_sheet(sheets, ym=None):
    best = None
    for (nm, nr, nc, cell) in sheets:
        hits = scan_fields(cell, nr, nc)
        score = sum(3 if k == '项目名称' else 2 for k in hits if k in KEY)
        if ym and ym in hdr_dates(cell, nr, nc):
            score += 20
        if best is None or score > best[1]:
            best = ((nm, nr, nc, cell, hits), score)
    return best[0] if best else None
 
 
def month_dir(month):
    return '%d月' % int(month.split('-')[1])
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--month', required=True, help='如 2026-07')
    ap.add_argument('--root', default=os.path.join('docs', '投资'))
    ap.add_argument('--out', default=None)
    ap.add_argument('--body', default=None, help='只扫一个品类目录')
    args = ap.parse_args()
    out = args.out or os.path.join(args.root, '输出')
    os.makedirs(out, exist_ok=True)
    md = month_dir(args.month)
    ym = args.month.replace('-', '')
    bodies = [args.body] if args.body else ['客运(客运站)', '物流(物流园区)']
    rows, report = [], []
    for body in bodies:
        d = os.path.join(args.root, '输入', body, md)
        if not os.path.isdir(d):
            report.append('!! 目录不存在:%s' % d)
            continue
        files = sorted(f for f in os.listdir(d)
                       if os.path.isfile(os.path.join(d, f)) and not f.startswith('~$'))
        report.append('## %s(%d 个文件)' % (body, len(files)))
        for f in files:
            p = os.path.join(d, f)
            rec = dict(品类=body.split('(')[0], 文件=f, 工作表='', 表头行='', 数据起='', 数据止='',
                       项目行数=0, 名称列='', 分组行='', 市州='', 市州行='', 市州段='')
            file_city = city_of_text(f)
            try:
                sel = pick_sheet(open_sheets(p), ym=ym)
                if sel:
                    nm, nr, nc, cell, hits = sel
                    pr = profile(cell, nr, nc, hits, city=file_city,
                                 merges=sheet_merges(p).get(nm, []))
                    rec.update(工作表=nm, 表头行=','.join(map(str, pr['header_rows'])),
                               数据起=pr['data_start'], 数据止=pr['data_end'],
                               项目行数=pr['project_rows'], 名称列=pr['namecol'] or '',
                               分组行=';'.join('R%s:%s' % g for g in pr['groups']),
                               市州=pr['city'] or (file_city or ''), 市州行=pr['city_row'],
                               市州段=';'.join('R%s:%s' % s for s in pr['segments']))
                    for k in KEY:
                        if k in pr['cols']:
                            rec[k + '列'] = colname(pr['cols'][k])
            except Exception as e:
                rec['错误'] = '%s: %s' % (type(e).__name__, e)
            rows.append(rec)
            report.append('   %-46s sheet=%-12s 市州=%s@R%s 表头=%-12s 数据=%s~%s 项目行=%-3s 名称列=%s %s'
                          % (rec['文件'][:46], str(rec['工作表'])[:12], rec['市州'], rec['市州行'],
                             rec['表头行'], rec['数据起'],
                             rec['数据止'], rec['项目行数'], rec['名称列'],
                             ''.join('%s=%s ' % (k, rec.get(k + '列', '-')) for k in KEY)
                             + ('   [%s]' % rec['错误'] if rec.get('错误') else '')))
    cols = ['品类', '文件', '工作表', '市州', '市州行', '市州段', '表头行', '数据起', '数据止', '项目行数',
            '名称列'] + [k + '列' for k in KEY] + ['分组行', '错误']
    tsv = os.path.join(out, '格式探测_%s.tsv' % args.month)
    with io.open(tsv, 'w', encoding='utf-8-sig') as fh:
        fh.write('\t'.join(cols) + '\n')
        for r in rows:
            fh.write('\t'.join(str(r.get(c, '')) for c in cols) + '\n')
    mdpath = os.path.join(out, '格式探测_%s.md' % args.month)
    io.open(mdpath, 'w', encoding='utf-8').write(
        '# 投资月报源文件格式探测(%s)\n\n' % args.month + '\n'.join(report) +
        '\n\n明细表:`%s`\n' % tsv)
    print('wrote', mdpath)
    print('wrote', tsv)
 
 
if __name__ == '__main__':
    main()