15.1 外包切分与计价模型
外包要算的从来不是「一单多少钱」,而是「哪些工作可以独立地交出去、各自怎么计价、超了怎么办」。这条线上「讲道理」的文章站内已经有一整套:站内 game/industry/
栏目下的外包系列短文——《游戏美术外包行业:一张角色立绘背后的协作链条》
、《游戏外包供应商管理:从试单、验收到长期协作的实战方法》
、《游戏外包验收标准:怎么避免交付时才发现不能用》
——把协作链条、供应商生命周期、交付判据都讲透了。那几篇讲道理,本章讲可算的账:把外包切分与计价写成一份全整数、可跨语言逐位对拍的模型。
红线写在最前面:本节出现的所有单价、人天、返工比例、里程碑比例,都是本套为演示算法而构造的示例参数,不代表任何真实公司的报价、产能或成本。 公开可观察的只是计价结构本身,不是具体金额——金额区间因地区、品类、供应商资历而剧烈波动,写死任何一个都是编造。
15.1.1 切分:什么叫「一个包」
切分的判据只有三条,缺一条就不该单独成包:
- 可独立验收:这个包有自己的验收标准与交付物清单,验收不依赖另一个包的进度;
- 可独立计价:这个包有自己的计价单位与数量(件 / 人天 / 里程碑),不与其他包共享一个总价;
- 可独立排期:这个包有自己的里程碑,晚交不会拖住别的包。
按这三条,最稳的切法是按资产类型切:家具 prefab、UI 图标、概念设计、图集,各自的技术规格、验收方式、计价单位都不同,天然可以分开。切分还必须是一个划分——每个资产恰属一个包,不重不漏。重复切分会让同一件资产被两个供应商各报一次价;漏切则会出现「谁都没做」的资产。
15.1.2 四种计价结构
公开可观察的计价结构只有四类,各自的适用场景与整数算法如下:
| 结构 | 计价单位 | 整数算法 | 适用 |
|---|---|---|---|
| 按件 | 件 | 单价 × 数量 | 离散资产:图标、家具、头像 |
| 阶梯单价 | 件 | 边际累进:每件按它落入的档位单价 | 大批量同质资产 |
| 按人天 | 人天 | 人天单价 × 人天数 | 探索性、难估件数的工作:概念设计 |
| 里程碑分期 | 总价 | 总价 × 里程碑千分比 | 交付物集中、周期长的工作:整套图集 |
「阶梯单价」特指边际累进(像累进税):前 N 件按一档价、接下来的 M 件按下一档价,而不是「总量落到哪档就全按那档」。两者算出的数差别很大,合同里必须写清是哪种——本节按边际实现。
15.1.3 D 层:切分与计价配置
下面这份是 D 层配置,声明四个包、各自的切分(资产列表)与计价结构。金额以「分」(cent)为最小整数单位,避免任何浮点:
{
"project": "stardust-inn",
"chapter": 15,
"specVersion": 1,
"note": "15.1 外包切分与计价模型的示例参数。全部数值(单价、人天、返工千分比、里程碑比例)都是本套为演示算法而构造的示例,与任何真实公司、真实报价、真实产能无关;本节只演示公开可观察的四种计价结构(按件 / 按人天 / 里程碑分期 / 阶梯单价)如何在整数域内算账。金额以「分」(cent)为最小整数单位。",
"currencyUnit": "cent",
"packages": [
{
"id": "pkg_furniture",
"structure": "perPiece",
"unitPrice": 48000,
"assets": ["furn_lamp_01", "furn_counter_01", "furn_chair_01", "furn_bed_01", "furn_rug_01"],
"reworkPermille": 150
},
{
"id": "pkg_ui_icons",
"structure": "tiered",
"tiers": [
{"upTo": 2, "unitPrice": 30000},
{"upTo": 5, "unitPrice": 26000},
{"upTo": 1000000, "unitPrice": 22000}
],
"assets": ["icon_coin", "icon_star", "icon_heart", "icon_bell", "icon_key", "icon_gem"],
"reworkPermille": 80
},
{
"id": "pkg_concept",
"structure": "perPersonDay",
"personDayPrice": 90000,
"personDays": 12,
"assets": ["concept_room", "concept_mascot"],
"reworkPermille": 250
},
{
"id": "pkg_atlas",
"structure": "milestone",
"total": 2400000,
"milestones": [
{"name": "kickoff", "permille": 300},
{"name": "review", "permille": 400},
{"name": "accept", "permille": 300}
],
"assets": ["atlas_ui", "atlas_fx"],
"reworkPermille": 0
}
]
}
15.1.4 S 层:算账
下面这段是 S 层脚本,读上面的配置算每个包的基线、返工与合计,并校验切分是不重不漏的划分。金额全整数、除法一律地板除,故这是精确类,跨语言必须逐位一致:
#!/usr/bin/env python3
# file: sim/ta15_pricing.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta15_pricing.py
"""15.1 的 S 层计价器:读 config/ta15_pricing.json,对四种计价结构分别算账。
四种结构(公开可观察的计价结构,本节不涉及任何真实报价):按件(单价 × 数量)、
阶梯单价(边际累进,每件按落入档位计价)、按人天(人天单价 × 人天数)、里程碑分期
(总价 × 各里程碑千分比,千分比之和须为 1000)。全部为整数:金额以「分」为单位,
除法一律地板除;返工成本 = 基线 × 返工千分比 // 1000。切分校验:每个资产恰属一个包。
落 out/ta15_pricing.json 供 P 层对照。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def base_cost(pkg):
st = pkg["structure"]
if st == "perPiece":
return pkg["unitPrice"] * len(pkg["assets"])
if st == "tiered":
cost, prev, remaining = 0, 0, len(pkg["assets"])
for t in pkg["tiers"]:
take = min(t["upTo"] - prev, remaining)
cost += take * t["unitPrice"]
remaining -= take
prev = t["upTo"]
if remaining <= 0:
break
if remaining > 0:
raise ValueError(f"阶梯表未覆盖 {len(pkg['assets'])} 件")
return cost
if st == "perPersonDay":
return pkg["personDayPrice"] * pkg["personDays"]
if st == "milestone":
if sum(m["permille"] for m in pkg["milestones"]) != 1000:
raise ValueError(f"{pkg['id']} 里程碑千分比之和 != 1000")
return pkg["total"]
raise ValueError(f"未知计价结构 {st}")
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/ta15_pricing.json"), encoding="utf-8"))
packages = spec["packages"]
seen = {}
for p in packages:
for a in p["assets"]:
if a in seen:
print(f"FAIL 资产重复切分:{a}")
return 1
seen[a] = p["id"]
rows, base_total, rework_total = [], 0, 0
for p in packages:
base = base_cost(p)
rework = base * p["reworkPermille"] // 1000
base_total += base
rework_total += rework
rows.append({"id": p["id"], "structure": p["structure"], "assetCount": len(p["assets"]),
"base": base, "rework": rework, "total": base + rework})
rows.sort(key=lambda r: r["id"])
lines = [f"{r['id']}:{r['base']}:{r['rework']}:{r['total']}" for r in rows]
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
rep = {"project": spec["project"], "currencyUnit": spec["currencyUnit"],
"packageCount": len(packages), "assetCount": len(seen),
"baseTotal": base_total, "reworkTotal": rework_total, "grandTotal": base_total + rework_total,
"digest": digest, "packages": rows}
with open(os.path.join(ROOT, "out/ta15_pricing.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"packages={len(packages)} assets={len(seen)} unit={spec['currencyUnit']}")
for r in rows:
print(f" {r['id']:14s} {r['structure']:12s} n={r['assetCount']:2d} "
f"base={r['base']:8d} rework={r['rework']:7d} total={r['total']:8d}")
print(f"baseTotal={base_total} reworkTotal={rework_total} grandTotal={base_total + rework_total}")
print(f"digest={digest}")
print("wrote out/ta15_pricing.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta15_pricing.py:
packages=4 assets=15 unit=cent
pkg_atlas milestone n= 2 base= 2400000 rework= 0 total= 2400000
pkg_concept perPersonDay n= 2 base= 1080000 rework= 270000 total= 1350000
pkg_furniture perPiece n= 5 base= 240000 rework= 36000 total= 276000
pkg_ui_icons tiered n= 6 base= 160000 rework= 12800 total= 172800
baseTotal=3880000 reworkTotal=318800 grandTotal=4198800
digest=ec45eec80ded7727
wrote out/ta15_pricing.json
四个包、15 件资产,基线合计 3880000 分、返工 318800 分、总计 4198800 分。注意 pkg_ui_icons:6 件按边际阶梯算成 2×30000 + 3×26000 + 1×22000 = 160000,三档全部被触发——这正是阶梯表必须覆盖全部数量的原因,否则尾部会静默少算。
15.1.5 P 层:同一套模型的 TS 内核
下面这段是 P 层 TS 内核,与上面的 Python 逐条对应——同一个边际阶梯规则、同一套排序与 digest 口径:
// file: core/src/ta15/pricing.ts
// tier: P
// verify: node --test 'core/test/ta15_*.test.ts'
// 15.1 的 P 层计价内核。与 sim/ta15_pricing.py 同一套规则、同一份 config/ta15_pricing.json。
// 金额全整数(分),除法一律 Math.floor(a/b)(a、b 为整数),故两侧逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
export type Row = { id: string; structure: string; assetCount: number; base: number; rework: number; total: number };
export function baseCost(p: any): number {
if (p.structure === "perPiece") return p.unitPrice * p.assets.length;
if (p.structure === "tiered") {
let cost = 0, prev = 0, remaining = p.assets.length;
for (const t of p.tiers) {
const take = Math.min(t.upTo - prev, remaining);
cost += take * t.unitPrice;
remaining -= take;
prev = t.upTo;
if (remaining <= 0) break;
}
if (remaining > 0) throw new Error(`阶梯表未覆盖 ${p.assets.length} 件`);
return cost;
}
if (p.structure === "perPersonDay") return p.personDayPrice * p.personDays;
if (p.structure === "milestone") {
const sum = p.milestones.reduce((n: number, m: { permille: number }) => n + m.permille, 0);
if (sum !== 1000) throw new Error(`${p.id} 里程碑千分比之和 != 1000`);
return p.total;
}
throw new Error(`未知计价结构 ${p.structure}`);
}
export function priceAll(packages: any[]): Row[] {
const rows: Row[] = packages.map((p) => {
const base = baseCost(p);
const rework = Math.floor((base * p.reworkPermille) / 1000);
return { id: p.id, structure: p.structure, assetCount: p.assets.length, base, rework, total: base + rework };
});
rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return rows;
}
export function rowLines(rows: Row[]): string[] {
return rows.map((r) => `${r.id}:${r.base}:${r.rework}:${r.total}`);
}
export function digest16(lines: string[]): string {
return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}
15.1.6 跨语言对照:digest 逐位一致
P 层测试读的是 Python 落盘的 out/ta15_pricing.json(不是同一份源码),并带两条负例守住阶梯与里程碑:
// file: core/test/ta15_pricing.test.ts
// tier: P
// verify: node --test 'core/test/ta15_*.test.ts'
// 跨语言对照:读 sim/ta15_pricing.py 落盘的 out/ta15_pricing.json,断言 TS 侧计价结果与 digest
// 与之逐位一致(全整数类)。再断言切分是不重不漏的划分,并用两条负例守住「阶梯表必须覆盖
// 全部数量」「里程碑千分比必须凑满 1000」。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { priceAll, rowLines, digest16 } from "../src/ta15/pricing.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/ta15_pricing.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta15_pricing.json"), "utf8"));
test("计价:TS 复算与 Python 产物逐位一致", () => {
const rows = priceAll(spec.packages);
assert.deepEqual(rows, py.packages);
assert.equal(digest16(rowLines(rows)), py.digest);
assert.equal(rows.reduce((n, r) => n + r.base, 0), py.baseTotal);
assert.equal(rows.reduce((n, r) => n + r.rework, 0), py.reworkTotal);
console.log(`pricing digest = ${py.digest}`);
});
test("切分:每个资产恰属一个包(不重不漏)", () => {
const all = spec.packages.flatMap((p: { assets: string[] }) => p.assets);
assert.equal(new Set(all).size, all.length);
assert.equal(all.length, py.assetCount);
assert.equal(py.packageCount, 4);
});
test("负例:里程碑千分比之和不为 1000 必须报错", () => {
const bad = {
id: "x", structure: "milestone", assets: [], reworkPermille: 0, total: 100,
milestones: [{ name: "a", permille: 600 }, { name: "b", permille: 300 }],
};
assert.throws(() => priceAll([bad]), /1000/);
});
test("负例:阶梯表未覆盖数量必须报错,不许静默少算", () => {
const bad = {
id: "y", structure: "tiered", assets: ["a", "b", "c"], reworkPermille: 0,
tiers: [{ upTo: 2, unitPrice: 100 }],
};
assert.throws(() => priceAll([bad]), /未覆盖/);
});
实跑 node --test 'core/test/ta15_pricing.test.ts':
pricing digest = ec45eec80ded7727
✔ 计价:TS 复算与 Python 产物逐位一致
✔ 切分:每个资产恰属一个包(不重不漏)
✔ 负例:里程碑千分比之和不为 1000 必须报错
✔ 负例:阶梯表未覆盖数量必须报错,不许静默少算
ℹ pass 4
ℹ fail 0
两侧的 digest 都是 ec45eec80ded7727——金额全整数,这是逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见卷一第 4 章)。
15.1.7 边界:真实报价不可复现
本节算的全是模型内的数:给定这份示例配置,四种结构分别算出什么。但「一个真实的家具外包到底报多少、某家供应商的返工率到底是几个千分点」——需真实环境(真实供应商与合同),命令行不可复现。这些数字取决于地区、品类、供应商资历与当期供需,本节既没有也不该编造它们。
把边界标清楚的意义是:计价模型与报价数字是两件事。模型通用、可对拍、能进 CI;报价一次性、不可复现、只能进合同。下一节把这份基线接到结算,看超支是怎么长出来的。
阅读导航:上一节:14.3 工业化与质量的一致性 · 下一节:15.2 成本结构与超支归因 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。