以下情境为教学假设,人物与数字不代表经核实的真实案例。
数据室(data room)是买方在报价后、签约前集中查看材料的地方。它看起来像一个按目录整理的网盘,实际更像一份证据清单:放进去的每一份文件,都会成为买方尽调报告的一行,也可能成为交易文件里陈述与保证(representations and warranties)的基础。放错一份,比少放一份麻烦得多。少放,买方会再要;放错,买方会认为你在误导。
这一节的原则只有一句:宁可少放,不可放错。Nord Studio 的数据室难点不在文件数量,而在口径:服务收入 112,200 美元、产品订阅 42,120 美元,两条线的确认方式不同;两位合伙人各 50%,股权与退出文件却几乎没有;应收占年收入 7.3%,逾期 8%。LedgerLite 则相反,它的数据室可以按标准模板搭:MRR 20,020 美元、递延收入 22,000 美元、应收 12,000 美元、现金 95,000 美元,每一项都能回到系统导出和银行流水。
Tidecast 只在平台结算与 IP 授权处出现:Steam 占销量 80%,平台结算单与音乐买断授权决定了它的数据室结构和别人的不一样。
数据室不是网盘
网盘的目标是「文件都在」,数据室的目标是「每个数字都能被独立复核」。两者的差别体现在三个地方。
第一,数据室有边界。买方不需要你的内部聊天记录、草稿报价和员工个人文件;这些东西放进去不会增加可信度,只会增加解释成本。第二,数据室有版本。同一份 MRR 表改过三次,买方需要知道哪一版是最终版、改了什么、旧版为什么被替换。第三,数据室有权限。哪些文件对买方可见、哪些只对顾问可见、哪些必须脱敏后才能看,要在上传前决定,而不是等买方点开再补救。
LedgerLite 的数据室可以做到「每个数字三条证据」:收入有订阅系统导出、发票和银行入账;递延收入有系统余额和会计政策;应收有账龄表和催收记录。Nord 的数据室如果只放利润表和几份咨询合同,买方会立刻发现缺口:服务收入按小时计费,工时单在哪里;产品订阅有 90 家、月费 39 美元,续费记录在哪里;两位合伙人的股权和退出安排,为什么只有口头说明。
数据室的质量不取决于文件多少,而取决于「一个不认识的买方能不能自己走完验证」。走不完,买方就会把缺口写进尽调报告,再用价格或交易结构保护自己。
目录结构:七个顶层目录
顶层目录要在第一次上传前定好,之后只增不改。下面七个目录覆盖了大多数独立开发者的交易材料,顺序也是买方通常的阅读顺序。
| 顶层目录 | 放什么 | 不放什么 | 责任人 |
|---|---|---|---|
| entity | 注册证、章程、股东名册、董事决议、良好存续证明 | 与交易无关的个人证件 | 创始人 |
| financials | 三年报表、月度管理账、MRR 桥、预算与实际对比 | 未定稿的草稿账 | 财务负责人 |
| customers | 前十大客户、收入明细、续约与流失记录、账龄表 | 客户联系人的个人电话与住址 | 销售或创始人 |
| contracts | 客户合同、平台协议、供应商合同、外包与 IP 转让 | 已终止且无争议的旧合同 | 法务或创始人 |
| tax | 所得税、流转税、跨境预提、完税凭证、税务计提 | 个人税务文件 | 财务负责人 |
| product | 架构说明、数据流、灾备、路线图、开源清单 | 生产密钥、数据库密码、内部账号 | 技术负责人 |
| compliance | 隐私政策、DPA、安全问卷、数据处理记录、保险单 | 未签署的模板合同 | 法务或创始人 |
financials 下面再按年份分,当前年度按月分;customers 下面把「前十大明细」和「全量聚合」分开,前者需要脱敏,后者可以直接给;contracts 按交易对手建子目录,每个子目录里放签署版、附件和变更协议;tax 按年度和税种分。目录层级不要超过三层,买方找不到文件就会来问,问得越多,交易越慢。
每个目录放一个 index.md,写清目录内容、覆盖期间、文件数量和最后更新日期。索引不是装饰:它让买方知道「这里应该有什么」,也让卖方在补充材料时不会重复上传。索引本身也要版本化,更新时在末尾追加一行变更记录。
一个常见的错误是建一个 misc/ 或 other/ 目录。它会在三天内变成垃圾桶,所有不知道放哪的文件都进去,最后没人能解释里面有什么。宁可多建一个明确的子目录,也不要留一个没有边界的杂项目录。
命名、版本、权限与脱敏边界
命名规则的目的不是好看,而是让文件可以被排序、筛选和引用。推荐格式是:
<category>/<yyyy-mm>-<kebab-name>.<ext>
例如 financials/2025-10-mrr-bridge.csv、customers/2025-10-top-ten-redacted.csv、contracts/acme/2024-07-msa-signed.pdf。分类必须与顶层目录一致,日期用文件所属期间而不是上传日期,名称用英文小写 kebab-case,扩展名保留原始格式。不要用空格、中文文件名、final、final-v2、最新版 这类无法排序的词。
| 规则 | 错误做法 | 后果 |
|---|---|---|
| 分类与目录一致 | 把税表放进 financials/ | 买方按目录核对时漏项 |
| 日期是所属期间 | 用上传日期命名历史报表 | 三年对账无法按期间排序 |
| 版本只增不减 | 覆盖旧文件或改回 v1 | 买方无法判断哪版是最终版 |
| 权限上传前设定 | 先给 buyer 权限再脱敏 | 个人信息已经暴露,无法撤回 |
| 索引与文件同步 | 加了文件不改索引 | 买方认为材料不完整 |
版本规则是「同一路径,版本递增」。addFile 会拒绝版本号不大于现有最大值的文件,所以 v2 只能建立在 v1 之后,不能覆盖,也不能回退。买方默认看到的是每个路径的最新版本;旧版本保留在后台,用于回答「这个数字什么时候改过」。如果一份文件被替换是因为口径变化,变更记录里要写清旧口径、新口径、影响期间和重述金额,而不是只写「更新」。
权限分两层:buyer 和 internal。buyer 是买方及其顾问可见,internal 只用于内部准备。不要把内部工作底稿放进 buyer 再指望买方不看;数据室的访问日志会记录谁在什么时候下载了什么。真正的敏感材料——个人证件、生产密钥、未签署的报价、员工合同——根本不应该进入数据室,而不是靠权限藏着。
脱敏边界:删什么、留什么
脱敏不是把文件涂黑,而是保留验证所需的信息,去掉与交易无关的个人信息。判断标准是:买方需要这个字段来验证收入、成本、合同或合规吗?需要就保留或做可逆的假名化;不需要就删除。
| 文件类型 | 应删除或替换 | 应保留 | 原因 |
|---|---|---|---|
| 前十大客户名单 | 联系人姓名、邮箱、电话、住址 | 客户编号、行业、年收入、合同起止日 | 验证集中度与续约,不需要个人信息 |
| 客户合同 | 其他客户的报价、个人签名、银行账号 | 期限、终止、转让、责任上限、SLA | 验证可转移性与义务 |
| 银行流水 | 账号、个人消费明细、无关对手方 | 余额、收入入账、主要付款方 | 验证现金真实性与完整性 |
| 税表 | 个人税号、家庭信息 | 申报收入、税种、税额、完税日期 | 验证申报与账面一致 |
| 平台结算单 | 买家个人信息、支付账号 | 毛收入、退款、分成、净得、结算周期 | 验证平台收入与净得率 |
| 产品文档 | 密钥、密码、内部账号、未公开漏洞 | 架构、数据流、灾备、依赖清单 | 验证可迁移性与安全边界 |
LedgerLite 的前十大客户可以只保留客户编号和年收入,因为买方要验证的是「前五家 31%、最大一家 9%」这个结构,而不是客户联系人是谁。Nord 的前 3 家客户合计 48%,同样只需要编号、合同期限和收入金额;如果合同里包含客户自己的商业条款,脱敏时保留与本次交易相关的期限、终止和转让条款,删掉与交易无关的定价细节。
脱敏有一个硬边界:包含个人数据的文件,如果对买方可见,必须先脱敏。代码里的 redactionRequired 就是这条规则:personalData 为真、visibility 为 buyer、redacted 为假,三者同时成立就报问题。这不是建议,而是上传前的阻断条件。把未脱敏的客户名单放进数据室,买方可能不会当场说什么,但会在陈述与保证里加一条「个人数据处理合规」的特别赔偿。
三年对账:把数据室接回报表
数据室最终要回答一个问题:报表上的收入、现金和税,能不能用文件接起来。做法是选一条主线,把同一笔经济业务在四类证据里串起来。
| 报表行 | 数据室证据 | 对账方法 | 常见断点 |
|---|---|---|---|
| 收入 | 发票、订阅系统导出、平台结算单 | 按客户和月份汇总,与收入明细比对 | 一次性收入跨期、退款未冲减 |
| 银行入账 | 银行流水、支付平台提现记录 | 按金额和日期匹配,允许在途时间差 | 平台结算延迟、股东个人账户收款 |
| 应收 | 账龄表、催收记录、合同账期 | 期初 + 新增 − 收款 = 期末 | 逾期未计提、坏账准备不足 |
| 递延收入 | 订阅系统余额、履约计划 | 系统余额与账面余额调节 | 年付折扣分摊、退款准备 |
| 税表 | 申报表、完税凭证、税务计提 | 申报收入与账面收入调节 | 跨境收入、平台代扣、税率差异 |
LedgerLite 的三年对账可以这样走:ARR 240,240 美元,MRR 20,020 美元;年末应收 12,000 美元,约 18 天收入;递延收入 22,000 美元;现金 95,000 美元;应付 8,000 美元;权益 80,000 美元。资产合计 110,000 美元,负债与权益合计 110,000 美元。利润表的净利润 58,299 美元,经营现金流 64,299 美元,差额可以用递延收入增加 8,000 美元与应收增加 2,000 美元解释。买方不需要你背出这些数字,但需要能在数据室里按同一路径复算一遍。
Nord 的对账难点在收入线:咨询收入 112,200 美元按小时计费,账期 Net 30,续约率 70%;产品收入 42,120 美元来自 90 家、月费 39 美元,月流失 3.0%。两条线的收入确认、退款政策和应收账龄都不同,不能合并成一张「总收入」表就结束。正确的做法是分线列示,再给一张合并调节表:咨询按工时确认,产品按订阅期间确认,退款单独列示,最后与银行入账和税表对上。
Tidecast 的对账主线是平台结算:标价收入 191,904 美元,退款 4% 即 7,676 美元,Steam 分成 30% 即 44,215 美元,官网支付费 5% 即 1,842 美元,净得 138,171 美元,净得率 72.0%。买方会拿平台后台的结算单逐月核对,任何一个月对不上,都会要求解释退款、汇率或结算周期。平台代收销售税的部分要单独列示,不能混进净得。
三年对账的期间口径要写清:至少覆盖三个完整会计年度;成立不足三年就覆盖全部期间;当前年度提供月度数据,历史年度提供年度数据加关键月份。所有文件用系统导出,不用截图;所有调节表保留公式和来源,不只保留结果。
把数据室写成代码
下面的 data-room.ts 把目录、版本、脱敏和权限固化成模型。addFile 校验分类、可见性和版本递增;latestVersions 每个路径只保留最高版本;redactionRequired 判断买方可见的个人数据是否未脱敏;auditRoom 返回总数、最新版本数、买方可见数、脱敏数、分类统计和问题清单。问题分三类:命名不合规、路径前缀与分类不一致、个人数据未脱敏。
// file: src/value/data-room.ts
// tier: L1
// verify: node --test test/value/data-room.test.ts
import { type Result, fail, ok } from "../core/result.ts";
export type DataRoomCategory =
| "entity"
| "financials"
| "customers"
| "contracts"
| "tax"
| "product"
| "compliance";
export const DATA_ROOM_CATEGORIES: DataRoomCategory[] = [
"entity",
"financials",
"customers",
"contracts",
"tax",
"product",
"compliance",
];
export type Visibility = "buyer" | "internal";
export type DataRoomFile = {
path: string;
category: DataRoomCategory;
version: number;
redacted: boolean;
personalData: boolean;
visibility: Visibility;
};
export type RoomIssueCode = "naming" | "category-prefix" | "personal-data";
export type RoomIssue = {
path: string;
code: RoomIssueCode;
detail: string;
};
export type RoomSummary = {
total: number;
latest: number;
buyerVisible: number;
redacted: number;
byCategory: Record<DataRoomCategory, number>;
issues: RoomIssue[];
};
const FILE_PATTERN = /^[a-z]+\/(?:[a-z0-9-]+\/)?[0-9]{4}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+$/;
function isCategory(value: string): value is DataRoomCategory {
return (DATA_ROOM_CATEGORIES as string[]).includes(value);
}
function isVisibility(value: string): value is Visibility {
return value === "buyer" || value === "internal";
}
/** Files at the same path may add versions, but never move backwards. */
export function addFile(
files: DataRoomFile[],
file: DataRoomFile,
): Result<DataRoomFile[]> {
if (file.path.trim() === "") {
return fail("path must not be empty");
}
if (!isCategory(file.category)) {
return fail(`unknown category: ${file.category}`);
}
if (!isVisibility(file.visibility)) {
return fail("visibility must be buyer or internal");
}
if (!Number.isInteger(file.version) || file.version < 1) {
return fail("version must be a positive integer");
}
const samePath = files.filter((entry) => entry.path === file.path);
const maxVersion = samePath.reduce(
(max, entry) => Math.max(max, entry.version),
0,
);
if (file.version <= maxVersion) {
return fail(`version ${file.version} must exceed ${maxVersion} for ${file.path}`);
}
return ok([...files, { ...file }]);
}
/** One entry per path: the highest version wins. */
export function latestVersions(files: DataRoomFile[]): DataRoomFile[] {
const latest = new Map<string, DataRoomFile>();
for (const file of files) {
const current = latest.get(file.path);
if (current === undefined || file.version > current.version) {
latest.set(file.path, file);
}
}
return [...latest.values()].sort((a, b) => a.path.localeCompare(b.path));
}
/** A buyer-facing file with personal data must be redacted first. */
export function redactionRequired(file: DataRoomFile): boolean {
return file.personalData && file.visibility === "buyer" && !file.redacted;
}
export function auditRoom(files: DataRoomFile[]): RoomSummary {
const latest = latestVersions(files);
const byCategory = {} as Record<DataRoomCategory, number>;
for (const category of DATA_ROOM_CATEGORIES) {
byCategory[category] = 0;
}
const issues: RoomIssue[] = [];
for (const file of latest) {
byCategory[file.category] += 1;
if (!FILE_PATTERN.test(file.path)) {
issues.push({
path: file.path,
code: "naming",
detail: "expected <category>/<yyyy-mm>-<kebab-name>.<ext>",
});
} else if (file.path.split("/")[0] !== file.category) {
issues.push({
path: file.path,
code: "category-prefix",
detail: `path prefix does not match category ${file.category}`,
});
}
if (redactionRequired(file)) {
issues.push({
path: file.path,
code: "personal-data",
detail: "buyer-visible file contains personal data but is not redacted",
});
}
}
return {
total: files.length,
latest: latest.length,
buyerVisible: latest.filter((file) => file.visibility === "buyer").length,
redacted: latest.filter((file) => file.redacted).length,
byCategory,
issues,
};
}
代码里有四个刻意的边界。第一,版本只能递增,不能覆盖或回退;这保证买方看到的永远是最新版本,同时保留历史。第二,latestVersions 先按路径聚合再排序,调用方不需要保证上传顺序。第三,命名检查只看最新版本,旧版本的命名问题不会重复报警;但旧版本仍然保留,供追溯。第四,auditRoom 把「个人数据未脱敏」作为独立问题码,而不是混在命名问题里,因为这两类问题的处理方式完全不同:命名可以批量重命名,脱敏必须逐份检查。
实际使用时,先跑 auditRoom 把问题清零,再让买方进来。问题清零不代表材料完整,但至少说明「放进去的文件没有低级错误」。完整性靠目录索引和 12.1 的清单补,不靠这个模型。
小结
- 数据室是陈述与保证的证据底座;放错一份比少放一份更危险。
- 七个顶层目录:entity、financials、customers、contracts、tax、product、compliance;顺序固定,索引随文件更新。
- 命名用
<category>/<yyyy-mm>-<kebab-name>.<ext>;日期是所属期间,名称用英文 kebab-case。 - 版本只增不减;同一路径的 v2 建立在 v1 之后,变更记录写清口径与影响期间。
- 权限分 buyer 与 internal;个人数据对买方可见前必须脱敏,这是阻断条件。
- 三年对账把收入、银行、应收、递延和税表接成一条链;LedgerLite 能走通,Nord 必须分咨询与产品两条线。
- 平台型业务先对平台结算单;Tidecast 的净得率 72.0% 来自标价、退款、分成和支付费四步。
阅读导航:上一节:12.1 尽调清单:财务、客户、合同 · 下一节:12.3 常见红旗与提前修复 。
常见错误与边界
- 把网盘当数据室。 没有索引、没有版本、没有权限,买方只能靠猜;数据室的价值是可复核,不是文件多。
- 用截图代替导出。 截图无法筛选和复算,也无法证明完整性;能导出的用 CSV 或原始 PDF。
- 覆盖旧版本。 覆盖会抹掉口径变化的痕迹;正确做法是新增版本并写变更记录。
- 先给权限再脱敏。 访问日志会留下记录,个人信息一旦可见就无法撤回;脱敏必须在上传前完成。
- 把个人数据混进客户名单。 买方验证集中度只需要客户编号和收入,不需要联系人和住址。
- 三年只给一年。 只有一年数据,趋势和季节性无法判断;至少三个完整年度,成立不足三年则覆盖全部期间。
- 收入不分线。 Nord 的咨询与产品确认方式不同,合并成一张表只会掩盖对账断点,不会解决它。
- 平台收入只看净得。 不看标价、退款、分成和支付费,买方无法验证净得率;Tidecast 的四步缺一不可。
数据室准备完,下一节处理更棘手的问题:有些材料即使齐全,也会被买方认定为红旗。届时重点不再是「有没有文件」,而是「怎么解释、能不能修」。
对账的报表基础可以回看 利润表 与 13 周现金表 ;前者决定收入与成本的列示,后者决定短期现金能否被验证。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。