15.1 外包切分与计价模型

外包的第一件事不是找供应商,而是把工作切成「可独立验收、可独立计价」的包。本节用一份示例配置,把按件、按人天、里程碑分期、阶梯单价四种公开可观察的计价结构写成全整数模型,S 层 Python 算账、P 层 TS 复算,两侧 digest 逐位一致;所有单价与人天均为本套构造的示例参数,与真实报价无关。

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 成本结构与超支归因 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则