
十年手工台账的脏数据全景:2147 台设备、174 处合并单元格
源台账 2147 台设备、174 处合并单元格、85 行“无编号”、三种编号口径——把一个手工维护十年的 Excel 逐列读懂的全过程,以及“哪些必须问人”的判断方法。
IStarry
7 min read
十年手工台账的脏数据全景:2147 台设备、174 处合并单元格,和一张必须逐列读懂的 Excel
说明:本文所有设备编号均已脱敏改写(保留"字母/前导零/中文/重复"的形态特征),往来单位名与人员姓名一律化名。行号(如 R337)保持原样,因为它们是可与
docs/data-analysis.md逐条核对的证据。
0. 现场
打开那张文件,第一眼看到的是这个:
| 项 | 值 |
|---|---|
| Sheet 数 | 1 个 |
| 名义范围 | A1:U540(实际只用了 A–K 共 11 列,L–U 全空) |
| 标题行 | R1,合并 A1:K1,一行公司名 + "借出设备明细"(字间还夹着空格) |
| 表头行 | R2(合并 A2:A4、B2:B4 … K2),11 列 |
| 数据区 | R5–R466;R467 是合计行;R3–R4 空 |
| 合并单元格 | 174 处 |
| 非空数据行 | 400 行 |
| 文件年龄 | 最早一笔记录 2009-04-11,最新一笔 2026-08-24 |
174 处合并单元格不是排版洁癖,而是这张表真正的心智模型。它由"设备主块"构成,每个块是一组共享(类别 + 名称 + 型号)的连续行:
- 名称/型号/数量在块上纵向合并,只出现在块首;
- 编号清单在块内逐行续写,常见每行 8–10 个;
- 借出事件(G–K 列)与编号续写行交错分布在同一个块内——借出事件可以独占一行,也可以和当行的编号共存;
- 编号多的借出事件还会跨行续写:时间/公司/台数只在事件首行写,后续行只续编号。
一句话:这不是一张表,是一张表里套着一张表。 A–F 是设备台账,G–K 是借出流水,两者在同一片行区间里交错。
1. 先证明"数字是自洽的"
在讨论"脏"之前,得先确认这份文件内部是自洽的——否则后面所有的清洗都是在流沙上盖楼。
第一个要闭合的口径是数量。台账数量(D 列)和财务数量(E 列)是两个口径,必须能对上:
D 合计 2147 = E 非合计行合计 2147 = 合计行(E) 2147 ✓
这里有个值得记下的插曲:第一次统计时算出了 4294。正好是 2147 的两倍——因为统计脚本把 R467 那行合计行的 E=2147 也当成了数据行计入。合计行被算了两遍。
一件"脏数据"其实是分析工具自己的 bug。这提醒了一件事:在指责数据之前,先排除自己的统计口径错误。如果当时直接把 4294 当成"数据有问题"去改,那就从"统计错"升级成了"改坏数据"。
E 列还有一个容易被误判为矛盾的地方:它按"批次"分段合并。比如某块 E 出现在三处(28 + 70 + 20 = 118 = D),另一块两处(20 + 10 = 30 = D)。看着像"两列数字不一致",实际是财务分段口径——段首带 D 是正常形态,不是数量矛盾。
2. 三个数字必须同时闭合(本文最核心的一段)
这是我在读这张表时得到的最有价值的认识。关于"编号",这张表里同时存在三个不同的数字:
| 口径 | 数值 | 含义 |
|---|---|---|
| F 列 token 总数 | 1666 | 编号单元格里被切分出来的"词"有多少个 |
| 去重后纯数字编号 | 1443 | 有多少个不一样的编号 |
| 带编号的设备台数 | 1580 | 有多少台设备带编号(同号多台各算一台) |
三个数字都不一样,而且都不是错误。它们分别是"词数""种类数""台数"。
台数那一侧同样有三条线:
| 口径 | 数值 |
|---|---|
| 台账台数(D 合计) | 2147 |
| 其中带编号 | 1580 |
| 其中无编号 | 567(85 行"无编号"的 D 合计 566 台 + 1 处描述文本按 1 台计) |
我做的对账(文档里没有直接写这条恒等式,是我按上面两组数字核算的):
token 侧: 1666 = 1580(真实编号) + 85("无编号"标记) + 1(描述文本)
台数侧: 2147 = 1580(带编号) + 567(无编号,含描述 1 台)
横切验证: 85 行的 D 合计 = 566 台,加描述 1 台 = 567 ✓三条线全部闭合。这就是"读懂了这张表"的判据。
反过来看:如果只对上"2147 = 台账总数"这一条,你会以为表没问题;如果只对上"1666 个 token",你会以为有 1666 台设备(多出 85 台的"无编号"标记和 1 处描述文本);如果只对上"去重 1443",你会以为丢了 704 台。
任何单一口径的对账都可能通过,只有三条线同时闭合才算真的读懂了。 这是我在这个项目里学到的最实用的一条数据迁移经验。
3. 脏数据分类学
下面按"处理方式"分五类。这个分类比"按数据类型分"更有用,因为它直接决定了系统该怎么反应。
第一类:格式噪声(确定性高 → 自动规范化)
| 形态 | 实例 | 处理 |
|---|---|---|
| 编号分隔符不统一 | 空格、换行、全角空格混用 | 统一按空白切分并 trim |
| 首尾多余空格 | F/J 列大量尾随空格 | 入库前 trim |
| 名称内多余空格 | 电子双针机 (兄弟) | 保留原文,仅界面展示时 trim |
| 全半角括号混用 | (重机) / (兄弟) | 保留原文 |
| 日期格式不统一 | 2022.6.14、2021.11.04、2022.3.01 | 统一解析为 ISO |
| 块间空档行 | R350 / R381 / R415 / R428 等 | 跳过 |
| 合计行 | R467 | 不导入 |
| 行内换行 | 名称里嵌 \n | 规范化 |
这一类处理起来没有风险,但有个容易犯的错:"规范化"和"改写"是两件事。空格可以 trim,日期可以转格式,但 (重机) 和 (兄弟) 的全半角差异不能统一——那是原文语义,统一了就等于改了用户的数据。我的取舍是:入库字段 trim,业务文本保留原文,只在展示层做视觉归一。
第二类:编号(最难的一类,见 §4 专章)
先看形态清单:
- 纯数字为主(1–8 位),少量带字母前缀或混合(
L02405240017、2404E0371、PL0TK00002)。 - 短序号被多个块重复使用:
001–023在验布机、马连机、三点定位机、超声波接带机等各块里各有自己的一套。它们不是全局唯一。 - 全局重复数字 token 92 个。其中一部分是同块内疑似重复录入:某块 R216 写
2013 2013、R218 写2027 2027,另一块 R230–233 把3273–3284整段双写。 - 疑似 O/0 混淆:R317 的
O7102、R394 的O431701——大写字母 O 的位置应该是数字 0。 - 编号列里是描述文本:R16 写的是"拉布机配件",整块设备以描述代替编号。
- "无编号":85 行、566 台。真实无编号,不得伪造编号——系统用内部码(
EQ-前缀)承载身份。 - 编号带括号注释:
11613(11370(带拖布轮)、(3314 2030 2002 1906(带拖布轮),还有(2026.9.4入南库)这种藏着归还日期的注释。
第 7 条特别有意思:2026.9.4入南库 里的日期,是整张表唯一能推断"已归还"的证据。而全文只有 2 例。也就是说,这张表没有归还列,归还信息只以括号注释的形式零星散落在编号单元格里面。
第 2 条则是后来一次重大返工的起点(见第 6 篇),这里先记一笔:"编号全局唯一"这个假设,在这张表上是错的。
第三类:数量口径
| 形态 | 处理 |
|---|---|
| D(台账)与 E(财务)分段 | E 不落库,仅作校验提示(V2) |
| I(台数)与当行 J(编号数)不一致 9 行 | 经查是跨行续写:I 只在首行填,编号续到下一行 |
| 块内 F 的 token 数可能多于 D | 报告差异,不静默 |
那 9 行(R145/147/151/155/164/170/173/175/195)特别典型:台数写 10,当行编号只有 4 个,看着像丢数据。实际是编号续写到了下一行——I 列是"本次借出台数",只在事件首行填写。
"看起来不一致"和"实际不一致"之间的距离,就是你对这张表心智模型的理解程度。
第四类:日期
- 143 行借出日期都是
YYYY.M.D文本格式,写法不统一(前导零有无都有)。 - R337 缺"日":只写了
2018.4。 - 括号注释里的日期(
2026.9.4入南库)需要从自由文本里提取。
缺"日"怎么处理?补 1 日?还是整行标注异常跳过?——这被列为待确认问题 W-11,交回业务方,而不是猜一个。
第五类:字典与枚举
类别列(A) 是最典型的"看起来没问题"的地方。分区统计出来是这样:
| 行范围 | A 列标注 | D 合计(台) | 借出事件 |
|---|---|---|---|
| R5–R30 | 裁剪设备 | 70 | 2 |
| R32–R342 | 缝纫设备 | 1668 | 118 |
| R344 | 买发网厂(?) | 1 | 0 |
| R345–R349 | 买华诺(?) | 12 | 4 |
| R351–R380 | 买针织厂(?) | 61 | 11 |
| R382–R414 | 包装设备 | 141 | 9 |
| R416–R427 | 技术设备 | 13 | 0 |
| R429–R466 | (无类别标注) | 181 | 0 |
| 合计 | — | 2147 | 144 |
四个明确类别(裁剪/缝纫/包装/技术)之外,出现了三个 买… 形式的标注——它们看起来像类别,但更像是"采购来源"或某种录入习惯。同时最后 181 台整段没有类别。
这三处要怎么归类?猜的成本极低(随便建三个类别就完事),但代价是用户以后永远会看到三个语义错误的类别项。所以它变成了待确认问题 W-1,而且答案很有意思:原样保留为类别字典项,不改变原文语义,无类别的那 181 台归入新类别「其他」。
这是一个真正尊重数据的决定:宁可保留一个语义可疑的原文类别,也不替用户重新解释他的数据。
往来单位(H) 共 19 个,频次从 41 次到 1 次。这里藏着整张表最微妙的一个语义问题:其中 4 个单位名称含同一个字号(一个总厂名 + 三个分厂)——它们到底是"借出去给别的公司",还是"本厂内部调拨"?这两者在数据模型上完全不同:前者是外借方(borrower),后者是内部单位(team)。
我没有猜,而是把它列为问题 W-5 交回业务方。答案后来改变了数据模型的语义:这 4 个建内部单位,其余 15 个建外借方。
名称与型号(B/C):B 列非空 193 行,不同名称 161 个。噪声形态包括行内多余空格、括号品牌注记(重机/兄弟/富山)、近义名称(送布机 / 松布机)。同名多型号也常见——"手提割刀"一个名称下有四个型号,各自独立的数量行。
处理原则(W-9 确认):保持原文,不做语义归并。 因为"送布机"和"松布机"在系统眼里可能是同一台机器,但在用户眼里可能是两种设备——归并是不可逆的破坏性操作,而展示层的排序/搜索可以解决 90% 的实际不便。
4. 核心设计:给每个 token 三种命运
上面第二类是全文最难处理的部分。我最终采用的设计非常简洁,但它是整个导入器的价值观所在。
对 F 列切分出的每个 token,分类函数给出四种判定(internal/importer/parser.go:572,精简):
func classifyF(main string) (fTokenKind, string) {
m := strings.TrimSpace(main)
if m == "" {
return tkReview, "主体为空"
}
if m == tokenNoNumber { // "无编号"
return tkNoNumber, ""
}
// 有汉字 + 有字母数字?
if hasASCII {
return tkNumber, "" // 编号(可带中文前缀/后缀)
}
if !hasHan {
return tkReview, "既无汉字也无字母数字,无法判断" // 纯符号
}
// 纯中文:
if strings.HasSuffix(m, "号") || strings.HasSuffix(m, "编号") {
return tkNumber, "" // 情况 A:设备一号 / 中文编号
}
if looksLikeDescription(m) {
return tkDesc, "" // 情况 B:拉布机配件 / 拖布轮
}
return tkReview, "纯中文内容无法可靠判断为编号或描述" // 情况 C
}四种判定对应四种命运:
| 判定 | 命运 | 是否丢数据 |
|---|---|---|
tkNumber | 作为设备编号原样入库(含中文、字母、符号) | 否 |
tkNoNumber | 该行按"无编号"按台数展开,内部码承载身份 | 否 |
tkDesc | 转成"无编号设备",原文进备注 | 否(原文保留) |
tkReview | 不猜、不丢,进 REVIEW 清单等人工确认 | 否(挂起) |
四种命运里没有一种是"丢弃"。 这是这个导入器最硬的一条约束,也是它和大多数"清洗脚本"的根本区别。
情况 B 的启发式判断写得很克制(parser.go:607)——它只在明确的收尾词上生效:
// looksLikeDescription 纯中文名词短语的启发式判断(情况 B 例子:拉布机配件、拖布轮)。
// 仅对明显名词短语收尾词生效;不在清单内的一律 REVIEW 交人工,绝不猜。
func looksLikeDescription(m string) bool {
n := utf8.RuneCountInString(m)
if n < 2 {
return false
}
for _, suf := range []string{"配件", "备件", "附件", "零件", "部件", "机件", "托板", "拖布轮", "轮", "刀", "针"} {
if strings.HasSuffix(m, suf) {
return true
}
}
return false
}这份收尾词清单只有 11 个词,而且故意很短。像"精密台面"这样的纯中文内容不在清单里,就落到情况 C 交给人工——代码注释里写着"绝不猜",这就是字面意思。
测试用例把这条边界钉得很死(internal/importer/parse_test.go:260):
{"6041", tkNumber}, {"001", tkNumber}, {"A01", tkNumber}, {"JUKI-8700", tkNumber},
{"ABC-001", tkNumber}, {"车间A-01", tkNumber}, {"缝制A-02", tkNumber},
{"设备一号", tkNumber}, {"中文编号", tkNumber}, // 情况 A:中文编号
{"无编号", tkNoNumber},
{"拉布机配件", tkDesc}, {"拖布轮", tkDesc}, // 情况 B:明显描述
{"", tkReview}, {"①②③", tkReview}, {"精密台面", tkReview}, // 情况 C:无法判断注意 {""}(空)、{"①②③"}(纯符号)、{"精密台面"}(无法归类的纯中文)都落在 REVIEW。这不是没做完,这是设计目标:宁可让用户多点一次确认,也不让系统替用户决定。
5. 校验矩阵:三档,而不是"通过/不通过"
有了分类,还需要一个"哪些必须拦、哪些只需提示、哪些必须问人"的分级。我的答案是三档:
| 档位 | 含义 | 系统行为 |
|---|---|---|
| BLOCK | 语义确定地错了 | 整体中止,回滚,禁止写库 |
| WARN | 可以继续,但用户应当知道 | 预览高亮 + 报告列出 |
| REVIEW | 系统无法判断 | 挂起,人工逐项确认后才放行 |
这张矩阵里有一条铁律被写在文档里:
所有 BLOCK 项未解除前禁止写库。
最终形成的 11 条规则(V1–V11)覆盖了:数量与展开数一致(BLOCK)、财务分段一致(WARN)、块内重复编号(WARN)、跨块短序号(已废弃)、O/0 疑似混淆(WARN)、编号空但有数量(WARN)、借出编号不在本块(WARN)、台数与编号数不等(续写合并后仍不等则 BLOCK)、日期异常(WARN)、F 列内容分类(WARN/REVIEW)、表头一致性(BLOCK)。
这套分级最大的价值,是让"不确定"有了容身之处。 一个只有"通过/不通过"的校验器,面对"精密台面"这种内容只有两个选择:猜一个,或者整批失败。三档分级让它有了第三种选择——挂起等确认,而这一档恰恰是真实数据里最常发生的。
6. 一次误伤:670 台设备被错误阻断
这套规则不是一次成型的。早期版本的 V3 规则把"块内出现重复编号"定为 BLOCK——理由是"编号应该唯一,重复就是录入错误"。
这个假设在真实文件上炸了。
某个"平车"设备块有 670 台,其编号在块内大量重复。按 V3,整块被判 BLOCK,670 台设备无法导入。
问题出在对"重复编号"的语义判断上。真实业务里的情况是:多台真机共用同一个编号是合法的——比如一批设备按资产标签编号,标签可能重复、可能缺失,但机器是实打实的 670 台。
修正后的语义(后来成为决策 18 的核心):重复编号 = 同号多台真机,按 WARN 提示并逐台展开,不再 BLOCK。
// validateGroups 分组级校验并标记 OK/BLOCK。
// 决策 18:同组重复编号 = 多台真机 → 仅 WARN 提示(不再 BLOCK);数量不一致仍 BLOCK。这条修正的连带影响比想象中大:如果编号可以重复,那它就不能作为唯一键——而这直接推翻了数据库里已经建好的唯一索引。这次推翻的完整过程是第 6 篇的主题。
这里值得先记下一条方法论:"这条数据脏"往往是"我的模型错"的另一种说法。 670 台设备的编号重复,在"编号必须唯一"的模型下是脏数据;在"每台机器有身份,编号只是标签"的模型下,它就是正常数据。判定脏之前,先问自己:是我的规则太强,还是数据真的错?
顺着这个顺序排下去,脏数据的处置也有先后:先怀疑模型,再问业务方,最后才动数据;而"清洗"只允许是规范化,不允许是改写。理由是——"数据脏"常常是"模型错"的别名(670 台那次就是),而改写过的原值不可逆,追溯链会断在这里。代价是导入功能得往后排:动手之前先花一轮把这张表读懂,短期是额外成本,长期省掉的是返工。
7. 把"不知道"变成可交付物:W-1 到 W-15
这是整篇里我最想推荐给别人的做法。
面对这张表,我识别出了 15 个无法自行确定的问题,编号为 W-1 到 W-15,逐个列出在文档里,附"影响范围"和"状态",然后等用户答复:
| 编号 | 问题 | 处理方式 |
|---|---|---|
| W-1 | 买… 三处标注的含义 | 问用户 → 原样保留为类别 |
| W-2 | 当前在借设备如何确定(表里根本没有归还列) | 问用户 → 默认全在库,后续手动补录 |
| W-3 | 同块重复编号的性质 | 问用户 → 后被决策 18 重新定义 |
| W-4 | 跨块短序号复用的唯一约束粒度 | 问用户 → 后被决策 18 重新定义 |
| W-5 | 带同一字号的单位是内部调拨还是外部借出 | 问用户 → 4 个建内部单位,其余 15 个建外借方 |
| W-6 | 备注列的人名是否作为历史操作人 | 默认留档 |
| W-7 | "带拖布轮"等括号注释是否结构化 | 默认入备注 |
| W-8 | 无编号设备"按台数借出"如何建模 | 未启用 |
| W-9 | 近义名称是否人工归并 | 默认不归并 |
| W-10 | 借出编号不在本块内的行如何处理 | 未启用 |
| W-11 | 2018.4 缺"日"怎么办 | 未启用 |
| W-12 | "入南库"是否意味着需要"仓库位置"维度 | 未启用 |
| W-13 | 财务数量是否保留入库 | 默认不落库 |
| W-14 | 是否有已报废/维修设备清单 | 无清单 → 默认全部在库 |
| W-15 | 品牌括号是否需要独立字段 | 默认不入库 |
这份清单的价值,在于它把"我不知道"从一种失败状态变成了一个可交付物。
有几个观察:
- W-2 是整张表的死穴。 表里没有归还列,意味着无法从文件判断哪些设备现在还在外面。我当时面临着巨大的诱惑:根据借出记录和零星的"入南库"注释,推断出一个"当前在借"状态。我没有这么做,而是把它列成问题。用户的答复是"默认全部在库,剩下的我在系统里手动补录"——接受"数据不完整",而不是让系统编造一个看起来完整的状态。
- 有些问题的答案改变的是架构,不只是字段。 W-5(内部调拨 vs 外部借出)决定了数据模型里是 team 还是 borrower;W-3/W-4 的答案最终让数据库删掉了一个唯一索引。
- "未启用"是一个合法的答案。 W-8/W-10/W-11/W-12 到最后都没启用——因为 W-2 的方案决定了借出历史不自动导入,这些问题自然就不需要回答了。不是每个问题都要有答案,但每个问题都要被记录。
Related Articles
Win7 如何一路锁死技术选型(以及一个 131072 字节的空库事故)
一句“顺便支持一下 Win7”,决定了后端工具链锁定 Go 1.20、无 CGO、纯 Go SQLite,前端按 Chrome 109 构建。以及一个把数据写进错位目录的空库事故,和交付脚本的自检化。
编号不是身份:我删掉了亲手建的那个唯一索引
项目早期给设备编号加了唯一索引,后来因为一个 670 台的设备块被整块拦下而删掉它。记录这次建模推翻,以及它连锁引发的两处真实 bug。
5,693,048 台设备:一次差点写进生产库的导入事故
有人上传了一份“另一种版式”的 Excel,导入预览显示“预计新增设备 5,693,048 台”,而文件里只有 230 台。我逐列拆解根因,以及事后长出来的三条防线。