IStarry

十年手工台账的脏数据全景: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.142021.11.042022.3.01统一解析为 ISO
块间空档行R350 / R381 / R415 / R428 等跳过
合计行R467不导入
行内换行名称里嵌 \n规范化

这一类处理起来没有风险,但有个容易犯的错:"规范化"和"改写"是两件事。空格可以 trim,日期可以转格式,但 (重机)(兄弟) 的全半角差异不能统一——那是原文语义,统一了就等于改了用户的数据。我的取舍是:入库字段 trim,业务文本保留原文,只在展示层做视觉归一。

第二类:编号(最难的一类,见 §4 专章)

先看形态清单:

  1. 纯数字为主(1–8 位),少量带字母前缀或混合(L024052400172404E0371PL0TK00002)。
  2. 短序号被多个块重复使用001023 在验布机、马连机、三点定位机、超声波接带机等各块里各有自己的一套。它们不是全局唯一
  3. 全局重复数字 token 92 个。其中一部分是同块内疑似重复录入:某块 R216 写 2013 2013、R218 写 2027 2027,另一块 R230–233 把 3273–3284 整段双写。
  4. 疑似 O/0 混淆:R317 的 O7102、R394 的 O431701——大写字母 O 的位置应该是数字 0。
  5. 编号列里是描述文本:R16 写的是"拉布机配件",整块设备以描述代替编号。
  6. "无编号":85 行、566 台。真实无编号,不得伪造编号——系统用内部码(EQ- 前缀)承载身份。
  7. 编号带括号注释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裁剪设备702
R32–R342缝纫设备1668118
R344买发网厂(?)10
R345–R349买华诺(?)124
R351–R380买针织厂(?)6111
R382–R414包装设备1419
R416–R427技术设备130
R429–R466(无类别标注)1810
合计2147144

四个明确类别(裁剪/缝纫/包装/技术)之外,出现了三个 买… 形式的标注——它们看起来像类别,但更像是"采购来源"或某种录入习惯。同时最后 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-112018.4 缺"日"怎么办未启用
W-12"入南库"是否意味着需要"仓库位置"维度未启用
W-13财务数量是否保留入库默认不落库
W-14是否有已报废/维修设备清单无清单 → 默认全部在库
W-15品牌括号是否需要独立字段默认不入库

这份清单的价值,在于它把"我不知道"从一种失败状态变成了一个可交付物

有几个观察:

  1. W-2 是整张表的死穴。 表里没有归还列,意味着无法从文件判断哪些设备现在还在外面。我当时面临着巨大的诱惑:根据借出记录和零星的"入南库"注释,推断出一个"当前在借"状态。我没有这么做,而是把它列成问题。用户的答复是"默认全部在库,剩下的我在系统里手动补录"——接受"数据不完整",而不是让系统编造一个看起来完整的状态。
  2. 有些问题的答案改变的是架构,不只是字段。 W-5(内部调拨 vs 外部借出)决定了数据模型里是 team 还是 borrower;W-3/W-4 的答案最终让数据库删掉了一个唯一索引。
  3. "未启用"是一个合法的答案。 W-8/W-10/W-11/W-12 到最后都没启用——因为 W-2 的方案决定了借出历史不自动导入,这些问题自然就不需要回答了。不是每个问题都要有答案,但每个问题都要被记录。

Related Articles

Knowledge Relations