# -*- 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()
|