以下情境为教学假设,人物与数字不代表经核实的真实案例。
「做完」不是一个事件,而是一个判断。合同里写「完成开发并通过验收」,等于把判断权交给了客户:客户可以今天确认,也可以下个月确认;可以因为一个按钮的颜色不确认,也可以因为预算周期不确认。这个选择权有价格,价格就是被延迟的现金。
Nord Studio 的咨询合同把这个问题放大了。它按 85 美元/小时、每月 110 小时计费,咨询年收入 112,200 美元,公司年收入 154,320 美元;账期 Net 30,付款前提是验收;应收占年收入 7.3%,约 11,265 美元,折合 27 天;逾期率 8%,坏账准备 2%;SDE 只有 34,320 美元。如果所有回款都因验收晚 30 天,上限口径的占用是 12,684 美元,相当于全年 SDE 的 37.0%;只算受验收影响的咨询收入,则是 9,222 美元,占 26.9%。这不是项目管理的延迟,而是财务上的结构性占用。
LedgerLite 的结构不同。它卖的是订阅,收入按月确认,账期 Net 15;年末应收 12,000 美元,约 18 天收入;SDE 130,240 美元。订阅也需要上线和确认,但确认通常发生在单个客户的开通环节,不会把全部 260 家客户的回款绑在同一个验收动作上。同样的 30 天延迟,对 LedgerLite 是 19,746 美元,占 SDE 的 15.2%;绝对金额更大,但相对承受力更强。
验收为什么是财务条款
验收至少改变三个财务量:收入确认时点、回款起点、履约成本上限。三者经常被混为一谈,但它们的触发条件不同,延迟一天的影响也不同。
| 财务后果 | 触发条件 | 延迟一天的影响 | 谁承担 |
|---|---|---|---|
| 收入确认 | 交付完成、验收通过或服务期间流逝 | 收入晚一天进入利润表 | 卖方 |
| 回款起点 | 发票开出、验收通过或里程碑达成 | 现金晚一天到账,应收多一天 | 卖方 |
| 履约成本 | 返工、修改、补充交付 | 成本多一天人工或外包 | 卖方 |
| 争议成本 | 双方对「是否完成」有不同理解 | 谈判、取证、律师费增加 | 双方,但卖方先垫现金 |
对订阅生意,收入确认通常跟着服务期间走,验收的影响主要在开通和回款;对咨询和定制项目,验收是收入确认和开票的共同前提,影响最大。Nord 属于后者:验收不通过,收入不能确认,发票也不能开,现金时钟停在交付之后、开票之前。
验收和交付也不是一回事。交付是「把东西交出去」,验收是「确认东西符合条件」。合同只写交付,客户可以说「收到了,但还没验收」;合同写清验收条件,客户再说「没做完」就必须指出哪一条没通过。前者是把争议留给关系,后者是把争议落到清单。
最关键的一条:验收标准必须是第三方可以复核的。如果只有客户能判断「满意不满意」,验收就不是标准,而是客户的选择权。选择权在客户手里,现金时钟也在客户手里。
模糊验收与可判定验收的对照
下面的对照不是为了把合同写得更长,而是为了把「做完」拆成可以逐项检查的条件。表中的具体阈值是演示用占位值,真实合同应按项目填写;本节只讨论财务后果,不提供法律意见。
| 维度 | 模糊写法 | 可判定写法 | 财务差异 |
|---|---|---|---|
| 完成 | 完成网站开发 | 12 个页面全部交付,每页含默认、空、错误三种状态 | 范围可数,履约成本有上限 |
| 质量 | 符合客户要求 | 24 项检查清单全部通过,检查人和日期写在验收单上 | 返工次数可约束 |
| 性能 | 性能良好 | 指定网络条件下首屏不超过 2.5 秒,连续 3 次 | 达标与否可复现 |
| 数据 | 数据准确 | 抽样 200 条,字段一致率不低于 99.5%,未解释差异为 0 | 验收不靠感觉 |
| 时间 | 尽快上线 | 交付后 5 个工作日内书面确认,逾期未提出书面异议视为通过 | 现金时钟有终点 |
| 修改 | 免费修改到满意 | 含 2 轮修改,超出按变更单计费 | 成本上限可算 |
| 付款 | 验收后付款 | 每个模块验收后 10 个工作日内支付该模块的 30% | 回款分批,不被整体卡住 |
模糊写法的问题不是不严谨,而是把判定权交给了对方。「满意」没有第三方可复核的标准,客户可以一直不满意,现金时钟就一直不走。可判定写法不一定对卖方更有利,但它把争议从「做没做完」变成「哪一项没通过」,后者可以修、可以计价、可以分批回款。
可判定验收还要写清三件事:谁验收、用什么证据、多久不回复算通过。缺一个,标准就会在项目结束时重新谈判。验收人最好写角色而不是姓名,避免负责人离职后条款失效;证据最好是验收单、测试报告或带时间戳的截图,而不是聊天记录里的「看起来可以」;视为通过的天数要写进合同,不能靠催。
分模块验收是另一个降低财务风险的办法。一个 12 页的网站如果只在全部完成后验收一次,任何一个页面争议都会卡住全部回款;如果按 3 个模块验收、每个模块单独开票,争议只影响一个模块的金额。分模块会增加管理成本,但它把「全有或全无」变成了「部分确定」。
修改轮次上限则控制履约成本。合同写「免费修改到满意」,等于把返工成本写成无限;写「含 2 轮修改,超出按变更单计费」,返工就有了价格。对一人公司,返工不是加班就能解决,它会直接挤掉下一个项目的收入。
验收延迟 30 天与 60 天的现金影响
验收延迟的现金影响可以用一个简单公式估算:延迟现金 = 计算基数 × 延迟天数 / 365。下表用全部年收入作为上限口径,假设所有回款都晚同样的天数;如果只有部分项目延迟,按延迟项目的收入加权即可。
| 案例 | 年收入(上限口径) | 日均收入 | 延迟 30 天 | 占年收入 | 延迟 60 天 | 占年收入 |
|---|---|---|---|---|---|---|
| Nord Studio | 154,320 美元 | 423 美元 | 12,684 美元 | 8.2% | 25,368 美元 | 16.4% |
| LedgerLite | 240,240 美元 | 658 美元 | 19,746 美元 | 8.2% | 39,492 美元 | 16.4% |
百分比是通用的:任何业务延迟 30 天,都会多占用 8.2% 的计算基数;延迟 60 天,多占用 16.4%。美元金额随基数放大,但承受力要看 SDE。Nord 的 12,684 美元相当于 SDE 34,320 美元的 37.0%,60 天则是 73.9%;LedgerLite 的 19,746 美元相当于 SDE 130,240 美元的 15.2%,60 天是 30.3%。
Nord 的上限口径包含了产品订阅收入,但产品是按月计费,不受项目验收影响。如果只按受验收影响的咨询收入 112,200 美元计算,30 天的影响是 9,222 美元,占 SDE 的 26.9%;60 天是 18,444 美元,占 53.7%。真实影响更接近后者;上限口径仍然有用,因为它给出了「所有回款都被验收卡住」的最坏情况。LedgerLite 的 19,746 美元同样是全部订阅回款的上限;如果只算新签客户的开通延迟,基数更小。
更重要的是,验收延迟通常不是一次性的。如果所有回款都因验收晚 30 天,企业会永久多占用 30 天收入;这不是某个月的现金流波动,而是营运资金的结构性增加。要恢复原来的现金水平,要么提高收入,要么缩短验收,要么用外部资金补上这 30 天。对没有融资的一人公司,第三条通常不存在。
账龄表看不到这段等待。Nord 的应收是年收入 7.3%、27 天,看起来比 Net 30 还快;但验收发生在开票之前,交付到验收的 30 天不在应收里。真实现金周期 = 验收等待 + 开票后回款 = 30 + 27 = 57 天。合同只写 Net 30,会让人误以为 30 天就能回款,实际要多等一个验收周期。
LedgerLite 的账龄更接近合同:Net 15,实际 18 天,差 3 天。这 3 天来自开票批次和银行转账,不是验收等待。订阅生意的现金周期短,不是因为客户更守信用,而是因为验收被拆散到每个客户的开通环节,没有被集中成一个项目级门槛。
把验收写成可执行判定
一条可执行的验收条件至少有四个部分:对象、阈值、证据、时限。对象是「检查什么」,阈值是「达到多少算过」,证据是「谁来记录、记录在哪里」,时限是「多久不回复算通过」。四者缺一,条件就会在项目收尾时重新解释。
验收条件可以分成两类。第一类是二值条件:有或无、通过或不通过,例如「12 个页面全部交付」「隐私政策页面上线」。第二类是阈值条件:有数字和方向,例如「首屏不超过 2.5 秒」「字段一致率不低于 99.5%」「错误率不高于 0.5%」。二值条件适合范围和存在性,阈值条件适合性能、质量和数据准确性。两类条件混在一张验收单上,判定才不会变成主观讨论。
下面的 scope.ts 把这两类条件固化成模型。evaluateScope 接收条件列表,逐条判定,返回通过数、通过率和阻塞项;acceptanceCashImpact 把延迟天数换算成被占用的现金和占年收入的比例。
// file: src/contract/scope.ts
// tier: L1
// verify: node --test test/contract/scope.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type BinaryCriterion = {
id: string;
kind: "binary";
description: string;
met: boolean;
};
export type ThresholdCriterion = {
id: string;
kind: "threshold";
description: string;
actual: number;
target: number;
direction: "atLeast" | "atMost";
};
export type Criterion = BinaryCriterion | ThresholdCriterion;
export type CriterionResult = {
id: string;
description: string;
passed: boolean;
};
export type ScopeAssessment = {
results: CriterionResult[];
passed: number;
total: number;
passedBps: Bps;
accepted: boolean;
blocking: string[];
};
export type AcceptanceImpact = {
annualRevenueCents: Cents;
delayDays: number;
dailyRevenueCents: Cents;
delayedCashCents: Cents;
shareOfYearBps: Bps;
};
function isFiniteNumber(value: number): boolean {
return typeof value === "number" && Number.isFinite(value);
}
function checkThreshold(criterion: ThresholdCriterion): boolean {
return criterion.direction === "atLeast"
? criterion.actual >= criterion.target
: criterion.actual <= criterion.target;
}
/**
* Evaluate written acceptance criteria. The function does not decide whether
* a customer is satisfied; it only checks whether the criteria in the
* contract are met. A single failed criterion blocks acceptance, because a
* partial pass is exactly where "done" becomes negotiable again.
*/
export function evaluateScope(criteria: Criterion[]): Result<ScopeAssessment> {
if (criteria.length === 0) {
return fail("scope needs at least one criterion");
}
const seen = new Set<string>();
const results: CriterionResult[] = [];
for (const criterion of criteria) {
if (criterion.id.trim() === "") {
return fail("criterion id must not be empty");
}
if (seen.has(criterion.id)) {
return fail(`duplicate criterion id: ${criterion.id}`);
}
if (criterion.description.trim() === "") {
return fail(`criterion ${criterion.id} needs a description`);
}
let passed: boolean;
if (criterion.kind === "binary") {
if (typeof criterion.met !== "boolean") {
return fail(`criterion ${criterion.id} needs a boolean result`);
}
passed = criterion.met;
} else if (criterion.kind === "threshold") {
if (
!isFiniteNumber(criterion.actual) ||
!isFiniteNumber(criterion.target)
) {
return fail(`criterion ${criterion.id} needs finite numbers`);
}
if (
criterion.direction !== "atLeast" &&
criterion.direction !== "atMost"
) {
return fail(`criterion ${criterion.id} has an unknown direction`);
}
passed = checkThreshold(criterion);
} else {
return fail(`criterion ${criterion.id} has an unknown kind`);
}
seen.add(criterion.id);
results.push({
id: criterion.id,
description: criterion.description,
passed,
});
}
const passed = results.filter((result) => result.passed).length;
return ok({
results,
passed,
total: results.length,
passedBps: divide(passed, results.length),
accepted: passed === results.length,
blocking: results
.filter((result) => !result.passed)
.map((result) => result.id),
});
}
/**
* Cash delayed by acceptance is proportional to the delay, not to the work
* completed. A 30-day acceptance lag on every invoice permanently ties up
* 30/365 of annual revenue in the business.
*/
export function acceptanceCashImpact(
annualRevenueCents: Cents,
delayDays: number,
): Result<AcceptanceImpact> {
if (!Number.isInteger(annualRevenueCents) || annualRevenueCents <= 0) {
return fail("annual revenue must be a positive integer amount in cents");
}
if (!Number.isInteger(delayDays) || delayDays < 0) {
return fail("delay days must be a non-negative integer");
}
const dailyRevenueCents = Math.round(annualRevenueCents / 365);
const delayedCashCents = Math.round(
(annualRevenueCents * delayDays) / 365,
);
return ok({
annualRevenueCents,
delayDays,
dailyRevenueCents,
delayedCashCents,
shareOfYearBps: divide(delayedCashCents, annualRevenueCents),
});
}
evaluateScope 有三个刻意的设计。第一,所有条件都通过才算验收通过,部分通过仍然是阻塞;如果业务上允许分模块验收,应该把每个模块建成独立的验收单,而不是让同一张单子部分通过。第二,重复编号、空描述、非有限数字都会报错;验收单最常见的失败不是条件太严,而是同一个条件被两个版本重复引用。第三,阻塞项按输入顺序返回,方便直接对应到验收单的行号。
acceptanceCashImpact 把延迟变成现金。它不判断延迟是否合理,只回答「如果所有回款晚 N 天,企业要多占用多少现金」。对 Nord,30 天是 12,684 美元,60 天是 25,368 美元;对 LedgerLite,30 天是 19,746 美元,60 天是 39,492 美元。代码返回的 shareOfYearBps 在 30 天时是 822 个基点,60 天时是 1644 个基点,与上表的 8.2% 和 16.4% 对应。
代码不能替代合同谈判,但它能防止一种常见错误:把「验收后付款」当成一句没有成本的描述。只要把验收天数代进去,这句话立刻变成一个现金数字。数字出来之后,再决定要不要接受这个条款,比在项目结束时才发现回款被卡住要好。
小结
- 验收是财务条款,不是项目收尾动作;它同时决定收入确认、回款起点和履约成本上限。
- 模糊验收把判定权交给客户,现金时钟停在客户手里;可判定验收把争议落到具体条件。
- 一条可执行的条件要有对象、阈值、证据、时限;缺一个,标准就会重新谈判。
- 验收延迟 N 天,等于永久多占用 N/365 的年收入;30 天是 8.2%,60 天是 16.4%。
- Nord 的 30 天延迟:全部收入上限口径是 12,684 美元,占 SDE 37.0%;咨询收入口径是 9,222 美元,占 26.9%。
- Nord 的 60 天延迟:上限口径是 25,368 美元,占 SDE 73.9%;咨询收入口径是 18,444 美元,占 53.7%。
- LedgerLite 的 30 天延迟是 19,746 美元,占 SDE 的 15.2%;订阅把验收拆散,承受力更强。
- 账龄表只统计开票后,验收等待不在其中;真实现金周期要加上验收时间。
- 分模块验收、修改轮次上限、视为通过条款,都是把验收变成可计算量的方法。
阅读导航:上一节:1.1 一份商业合同的八个部分 · 下一节:1.3 价格、调价与付款条款:金额、时点、前提与违约责任 。
常见错误与边界
- 把交付当验收。 交付只证明东西发出去了,验收才确认符合条件;合同只写交付,客户可以说「收到了,但还没验收」。
- 验收标准只写在邮件里。 邮件不是合同附件,项目换人或换邮箱就找不到;验收条件必须和合同放在一起。
- 没有视为通过条款。 客户不回复不等于通过;没有时限,验收可以无限期悬空,现金时钟也无限期停着。
- 全部验收才付款。 一个大项目只在最后验收一次,任何一个小争议都会卡住全部回款;分模块验收能把影响限制在一个模块。
- 修改次数没有上限。 「免费修改到满意」把返工成本写成无限;含几轮修改、超出怎么计费,必须写清楚。
- 验收人没有写进合同。 只写「客户验收」,没有角色和权限;负责人离职或换岗,验收单可能没人能签。
- 把 AR 账龄当成完整现金周期。 账龄只统计已开票未收回,验收等待在开票之前;Nord 的 27 天应收不等于 27 天回款。
- 用「满意」「符合预期」做标准。 这些词没有第三方可复核的含义;争议时只能回到谈判,不能回到清单。
- 认为验收条件越多越好。 100 项清单会推高维护成本,也会给客户更多拖延理由;抓住影响回款和成本的必过项更重要。
- 把验收延迟当成一次性波动。 如果验收总是晚 30 天,所有回款都会跟着晚,企业会永久多占用 30 天收入;这是营运资金结构问题,不是单月现金流问题。
下一节把验收之后的动作接上:验收通过只是回款起点,金额、时点、前提和违约责任才决定现金什么时候真正到账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。