让AI改Excel报价表,一行成本被它乘了6:139行脚本锁公式,谁动谁现形
AI改Excel,30秒回你一句“改完了”。它没撒谎——它确实改完了,只是改的方向你没想到:把一行超时服务成本凭空乘了6,900块的活儿变成5400块。没拦住的话,这份报价就直接报出去了。
这篇讲我们怎么用三个命令把这种事故焊死:scan体检、lock上锁、diff比对。脚本139行,文末全贴,拿走就能跑。
别觉得这事离你远。预算表、工时统计、对账单、合伙分账、房租水电分摊——只要表里有公式,你就已经站在这个坑的边上。把表丢给AI改的人越来越多,回传时能逐格核完的没几个,事故只是还没轮到你头上。
一、活儿是怎么翻车的
真实场景,客户信息隐去:一份劳务报价表要按新人数重算。表不复杂,三行服务项,单价乘数量,底部合计。这种活儿以前自己改公式,现在习惯扔给AI助手,快。
回传的表肉眼看不出任何问题:格式没乱,数字也都对得上。问题藏在E4单元格里——超时值守那行,原公式是=C4D4,150元/小时乘6小时。AI回传时自作主张,写成了=C4D4*6。
它多半是把表里“6人”这个人数系数理解成要再乘一遍。一行900块的成本变成5400,合计跟着多出4500。这种错误肉眼查不出来:数字格式正确、表格没坏、合计照样算得通——只有把改动前后的公式逐格调出来对,才知道哪里被动了手脚。
那次是真翻车了,好在发现得早——是复核时顺手核了一遍合计,6700块的表核出11200,才顺着往下扒出E4的猫腻。要是直接把回传件接着往下传,这页纸就带着虚增的成本走出去了。
复盘的结论很直接:问题出在我们没法证明AI没改不该改的地方。人工逐格核对一张几十行的表,二十分钟起步,还保证不了漏网;靠“看一眼”守不住,得靠工具。另外我们把一条规矩写进了给AI的任务描述:公式区当只读,明确“只改数值格,公式一行别碰”。指令管住态度,工具管住结果,两个都要有。下面就是那套工具的三步。
二、第一步scan:先弄清楚哪些格子动不得
python excel_guard_lite.py scan 报价表_原件.xlsx 实测输出:
[劳务报价] 公式 4 个: E3 = =C3*D3 E4 = =C4*D4 E5 = =C5*D5 E6 = =SUM(E3:E5) [劳务报价] 带格式空白格 5 个(看着空,别让AI碰):F2, F3, F4, F5, F6 [劳务报价] 合并单元格 1 个 体检扫出来的三类东西就是“禁区”:4个公式格、5个带格式空白格、1个合并单元格。带格式空白格最阴——看着是空的,其实带着边框,往往是留给别人填的位置。AI手滑在这里塞个东西、或者顺手清个格式,表就废了一半,澄清口径全乱。先把禁区数清楚,再谈交给谁改。我们现在的习惯是,拿到任何一张不是自己亲手做的表,第一动作就是scan一遍——别人的表里藏着什么,扫完才有底。
三、第二步lock:给公式上锁,副本拿去分发
python excel_guard_lite.py lock 报价表_原件.xlsx 实测输出:
>> 护身副本已生成:报价表_原件.guarded.xlsx >> 锁定 9 个公式/带格式空白格;普通格照常可填。 >> 原始文件未动。把 guarded 副本发给AI,原件自己封存。 锁定逻辑很朴素:公式格和带格式空白格全部锁死,整表开启保护,普通格照常能填能选。生成的是新文件,原件一个字节都不动。上锁效果我们回读验证过:
python -c "from openpyxl import load_workbook; ws=load_workbook('报价表_原件.guarded.xlsx')['劳务报价']; print('工作表保护:', bool(ws.protection.sheet)); print('E4(公式格) locked:', ws['E4'].protection.locked); print('B3(普通格) locked:', ws['B3'].protection.locked)" 工作表保护: True E4(公式格) locked: True B3(普通格) locked: False 再补一个反向验证——拿diff去比原件和上锁副本:
上锁这个动作本身不改任何值。以后分发表格就发guarded副本:AI能读、人能填、公式动不了。等于给AI划了只读的工作面——它能看全表、能在允许的格子里填数,想碰公式先过锁这一关。
四、第三步diff:回传件先过安检,公式被动就报警
模拟一次事故现场——AI把E4擅自改成=C4D46后回传:
python excel_guard_lite.py diff 报价表_原件.xlsx 报价表_AI改后.xlsx 实测输出:
======================================================== 逐格比对: A = 报价表_原件.xlsx B = 报价表_AI改后.xlsx ======================================================== [公式被改] 劳务报价!E4 '=C4*D4' → '=C4*D4*6' -------------------------------------------------------- >> 共 1 处变动,其中公式被改 1 处。 就是这一下,900变5400当场现形。复跑时命令结束敲 echo $?,那个数字就是退出码——它是留给自动化用的:公式被改退出码2,干净退出码0。挂进流程里,回传件先跑diff,退出码不是0直接拦截,人只看打印出来的那几行。
还有一个容易漏掉的场景:AI没动公式,但“顺手”把数字改了。我们把C4单价从150改成155再diff:
[值变动] 劳务报价!C4 150 → 155 -------------------------------------------------------- >> 共 1 处变动,其中公式被改 0 处。 值变动会列出来,但退出码给0——因为改值很多时候是正常业务动作:人数从5调到6、单价按新合同调整,本来就该改。公式才是红线,公式被改直接判死;值改得对不对,名单在这儿,人来核。
五、五个坑,都是实测踩出来的
- lock生成副本走的是openpyxl重存。我们拿带柱状图的表实测过,图表往返还在;但openpyxl对图片、部分数据验证这类复杂对象的往返历来有兼容风险——所以流程上原件永远封存。副本丢了顶多重做,原件没了没处哭。
- 工作表保护是无密码的弱保护,定位是防手滑、防AI擅动,挡不住存心破解的人。真要防高手,靠diff这道关卡,那把锁只是第一道门。
- 只认.xlsx。.xls老格式直接报错,先另存成xlsx再跑。
- 这份精简版diff只比对值和公式,格式被改它不报。要盯格式,思路是给每个单元格算一份格式指纹——数字格式、字体、边框、填充拼一起做哈希,新旧文件指纹对不上就列出来。完整版是这么做的,有需要可以照这个思路加。
- 加表删表,diff会列出来但照样放行,退出码仍是0——红线只有公式格。删表比改公式更狠:整张表没了,红线一格都查不着。所以底账永远要封存原件,回传件里表被人抽了,你手里得有原本能比。
六、这套流程适合谁
报价表只是我们的场景,任何带公式的表都适用:预算、工时、成本测算、对账单、分账表。分工口诀三句话:原件封存,guarded副本分发,回传件必过diff。
AI照样用,改得也确实快——但“改完了”三个字不算数,退出码0才算数。
Excel报价表这种东西,AI改十次对九次,错的那一次是钱。锁不锁,自己掂量。
完整脚本(excel_guard_lite.py,139行)
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ excel_guard_lite.py —— Excel公式护身工具(精简版) 用法: python excel_guard_lite.py scan 报价表.xlsx # 体检:列出公式格/带格式空白格/合并格 python excel_guard_lite.py lock 报价表.xlsx # 上锁:生成 报价表.guarded.xlsx,公式锁定+保护工作表 python excel_guard_lite.py diff 原件.xlsx 回传件.xlsx # 逐格比对:公式被改→打印差异→退出码2 依赖:pip install openpyxl(只认 .xlsx,.xls 老格式不支持) """ import sys import openpyxl from openpyxl.styles import Protection def is_formula(cell): return isinstance(cell.value, str) and cell.value.startswith("=") def styled_blank(cell): """看似空但带格式(边框/填充)的单元格——常是留给填写/备注的位置,不能乱动。""" if cell.value not in (None, ""): return False b = cell.border if b and any([b.left.style, b.right.style, b.top.style, b.bottom.style]): return True fill = cell.fill if fill and fill.fgColor and fill.fgColor.rgb not in (None, "00000000"): return True return False def guarded_name(path): return path[:-5] + ".guarded.xlsx" if path.lower().endswith(".xlsx") else path + ".guarded.xlsx" def cmd_scan(path): wb = openpyxl.load_workbook(path, data_only=False) for ws in wb.worksheets: formulas, blanks = [], [] for row in ws.iter_rows(): for c in row: if is_formula(c): formulas.append((c.coordinate, c.value)) elif styled_blank(c): blanks.append(c.coordinate) print(f"[{ws.title}] 公式 {len(formulas)} 个:") for co, f in formulas: print(f" {co} = {f}") if blanks: print(f"[{ws.title}] 带格式空白格 {len(blanks)} 个(看着空,别让AI碰):" + ", ".join(blanks)) if ws.merged_cells.ranges: print(f"[{ws.title}] 合并单元格 {len(ws.merged_cells.ranges)} 个") print() def cmd_lock(path): wb = openpyxl.load_workbook(path, data_only=False) out = guarded_name(path) locked = 0 for ws in wb.worksheets: for row in ws.iter_rows(): for c in row: keep = is_formula(c) or styled_blank(c) c.protection = Protection(locked=keep) locked += 1 if keep else 0 ws.protection.sheet = True ws.protection.enable() wb.save(out) print(f">> 护身副本已生成:{out}") print(f">> 锁定 {locked} 个公式/带格式空白格;普通格照常可填。") print(">> 原始文件未动。把 guarded 副本发给AI,原件自己封存。") def cmd_diff(path_a, path_b): wb_a = openpyxl.load_workbook(path_a, data_only=False) wb_b = openpyxl.load_workbook(path_b, data_only=False) names_a = {ws.title for ws in wb_a.worksheets} names_b = {ws.title for ws in wb_b.worksheets} changes = [] for added in sorted(names_b - names_a): changes.append(("SHEET+", added, "", "")) for removed in sorted(names_a - names_b): changes.append(("SHEET-", removed, "", "")) for ws_a in wb_a.worksheets: if ws_a.title not in names_b: continue ws_b = wb_b[ws_a.title] coords = set() for row in ws_a.iter_rows(): for c in row: coords.add(c.coordinate) for row in ws_b.iter_rows(): for c in row: coords.add(c.coordinate) for co in sorted(coords): va, vb = ws_a[co].value, ws_b[co].value if va != vb: kind = "FORMULA" if (is_formula(ws_a[co]) or is_formula(ws_b[co])) else "VALUE" changes.append((kind, f"{ws_a.title}!{co}", va, vb)) formula_changes = [c for c in changes if c[0] == "FORMULA"] print("=" * 56) print(f"逐格比对:\n A = {path_a}\n B = {path_b}") print("=" * 56) if not changes: print(">> 完全一致:值/公式没有任何变动。") else: mark = {"FORMULA": "[公式被改]", "VALUE": "[值变动]", "SHEET+": "[新增表]", "SHEET-": "[删表]"} for kind, where, a, b in changes: print(f"{mark.get(kind, kind)} {where}\n {a!r}\n → {b!r}") print("-" * 56) print(f">> 共 {len(changes)} 处变动,其中公式被改 {len(formula_changes)} 处。") sys.exit(2 if formula_changes else 0) def main(): if len(sys.argv) < 3: print(__doc__) sys.exit(1) cmd = sys.argv[1] if cmd == "scan": cmd_scan(sys.argv[2]) elif cmd == "lock": cmd_lock(sys.argv[2]) elif cmd == "diff": if len(sys.argv) < 4: print(__doc__) sys.exit(1) cmd_diff(sys.argv[2], sys.argv[3]) else: print(__doc__) sys.exit(1) if __name__ == "__main__": main()