15.2 成本结构与超支归因

15.1 算出的基线是「报价时的数」,结算是「实际花掉的数」,两者之差就是超支。本节把成本拆成基线 + 变更 + 超计划返工 + 管理分摊四个加项,再把超支分解到三个可归因桶,S 层 Python 与 P 层 TS 各自复算且 digest 逐位一致;所有数字均为本套构造的示例参数。

15.2 成本结构与超支归因

15.1 算出的合计是报价时的数——按四种计价结构签合同时预期的账。结算是实际花掉的数。两者之差就是超支,而超支最没用的形态是一个孤零零的总数:它只说「多花了」,不说「多花在哪」。本节把成本结构写清,再把超支分解到可归因的桶里。

这一节与卷一 7.3 预算超支的归因与裁剪顺序 是同一条纪律的两个面:那一节做的是面数预算的超支归因(超了多少面、该先裁谁),本节做的是成本的超支归因(超了多少钱、钱花在哪类原因上)。两者共用同一条铁律——归因桶之和必须恰好等于差额,不许有「其它」这种兜底桶。本节所有金额仍是本套构造的示例参数,与真实报价无关。

15.2.1 成本结构:四个加项

一个包从签约到结算,成本由四项相加而成:

加项含义整数算法
基线15.1 算出的 total,签约时冻结baseline
变更签约后追加的需求(改规格、加资产)change
超计划返工实际返工超出签约时预估返工的部分extraRework
管理分摊项目管理、协调、验收的开销baseline × overheadPermille // 1000

即 actual = baseline + change + extraRework + overhead。把管理分摊写成基线的千分比而不是固定值,是因为协调成本随包的大小增长——大包耗的沟通与验收工时本来就多。注意 change 与 extraRework 是结算时才知道的实测量,不是预估;一旦签约后追加需求,基线就冻结不动,追加全部落进 change。

15.2.2 超支归因:桶必须恰好覆盖差额

超支 overrun = actual - baseline = change + extraRework + overhead。右边三项就是三个归因桶,它们之和恒等于超支——这不是巧合,是结构决定的:所有加项都在基线之上。但真正的纪律在于桶的集合必须穷尽且互斥:任何一笔实际支出都必须落进且只落进一个桶;如果算出来桶之和 ≠ 超支,说明有一笔钱没被归类,模型就是错的,必须报错而不是四舍五入抹平。

归因的价值在于行动不同:change 高说明需求管理有问题,extraRework 高说明验收标准或规格传达有问题,overhead 高说明包切得太碎或协调太重。三个桶指向三套完全不同的整改动作。

15.2.3 D 层:成本行配置

下面这份 D 层配置声明管理分摊率与每个包的追加项。baseline 显式写出来,且必须等于 15.1 算出的 total——S 层会逐包断言,防止两处各写一遍导致漂移:

{
  "project": "stardust-inn",
  "chapter": 15,
  "specVersion": 1,
  "note": "15.2 成本结构与超支归因的示例参数。overheadPermille(管理分摊千分比)、change(变更追加)、extraRework(超出计划的返工追加)与 baseline(报价基线)都是本套构造的示例,与任何真实报价无关;baseline 必须等于 15.1 计价模型算出的 total,S 层会逐包断言。金额以「分」为最小整数单位。",
  "currencyUnit": "cent",
  "overheadPermille": 60,
  "lines": [
    {"package": "pkg_atlas", "baseline": 2400000, "change": 0, "extraRework": 0},
    {"package": "pkg_concept", "baseline": 1350000, "change": 180000, "extraRework": 90000},
    {"package": "pkg_furniture", "baseline": 276000, "change": 24000, "extraRework": 12000},
    {"package": "pkg_ui_icons", "baseline": 172800, "change": 0, "extraRework": 6400}
  ]
}

15.2.4 S 层:从基线到结算

下面这段是 S 层脚本,先断言每行 baseline 等于 15.1 的 total,再叠加三个加项、分解超支。全整数、地板除:

#!/usr/bin/env python3
# file: sim/ta15_cost.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta15_cost.py
"""15.2 的 S 层成本器:把 15.1 的计价基线(out/ta15_pricing.json)当作「报价基线」,
叠加变更追加、超计划返工与管理分摊,得到结算实际成本,再把超支分解到三个可归因桶。

成本结构(全整数,单位「分」):

    actual  = baseline + change + extraRework + overhead
    overhead = baseline * overheadPermille // 1000
    overrun  = actual - baseline = change + extraRework + overhead

归因桶 change / extraRework / overhead 之和必须恰好等于 overrun(不许有「其它」兜底桶)。
先断言 config/ta15_cost.json 里每行 baseline 等于 15.1 算出的 total,杜绝两处各写一遍。
落 out/ta15_cost.json 供 P 层对照。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def main() -> int:
    cost = json.load(open(os.path.join(ROOT, "config/ta15_cost.json"), encoding="utf-8"))
    pricing = json.load(open(os.path.join(ROOT, "out/ta15_pricing.json"), encoding="utf-8"))
    base_by_id = {p["id"]: p["total"] for p in pricing["packages"]}

    # 单一真源:baseline 必须等于 15.1 的 total,否则两处数字会各自漂移。
    for ln in cost["lines"]:
        want = base_by_id.get(ln["package"])
        if want is None:
            print(f"FAIL 未知包 {ln['package']}")
            return 1
        if ln["baseline"] != want:
            print(f"FAIL {ln['package']} baseline={ln['baseline']} != 15.1 total={want}")
            return 1

    rate = cost["overheadPermille"]
    rows, base_t, change_t, rework_t, oh_t = [], 0, 0, 0, 0
    for ln in sorted(cost["lines"], key=lambda x: x["package"]):
        base = ln["baseline"]
        overhead = base * rate // 1000
        actual = base + ln["change"] + ln["extraRework"] + overhead
        overrun = actual - base
        if ln["change"] + ln["extraRework"] + overhead != overrun:
            print(f"FAIL {ln['package']} 归因桶不覆盖超支")
            return 1
        base_t += base
        change_t += ln["change"]
        rework_t += ln["extraRework"]
        oh_t += overhead
        rows.append({"id": ln["package"], "baseline": base, "change": ln["change"],
                     "extraRework": ln["extraRework"], "overhead": overhead,
                     "actual": actual, "overrun": overrun})
    lines = [f"{r['id']}:{r['baseline']}:{r['actual']}:{r['overrun']}" for r in rows]
    digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
    actual_t = sum(r["actual"] for r in rows)
    overrun_t = sum(r["overrun"] for r in rows)
    rep = {"project": cost["project"], "currencyUnit": cost["currencyUnit"],
           "overheadPermille": rate, "baselineTotal": base_t, "actualTotal": actual_t,
           "overrunTotal": overrun_t,
           "buckets": {"change": change_t, "extraRework": rework_t, "overhead": oh_t},
           "bucketSum": change_t + rework_t + oh_t, "digest": digest, "lines": rows}

    with open(os.path.join(ROOT, "out/ta15_cost.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print(f"overheadPermille={rate} baselineTotal={base_t} actualTotal={actual_t} overrunTotal={overrun_t}")
    for r in rows:
        print(f"  {r['id']:14s} base={r['baseline']:8d} change={r['change']:7d} "
              f"rework={r['extraRework']:7d} oh={r['overhead']:7d} actual={r['actual']:8d} over={r['overrun']:7d}")
    print(f"buckets change={change_t} extraRework={rework_t} overhead={oh_t} sum={change_t + rework_t + oh_t}")
    print(f"digest={digest}")
    print("wrote out/ta15_cost.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta15_cost.py:

overheadPermille=60 baselineTotal=4198800 actualTotal=4763128 overrunTotal=564328
  pkg_atlas      base= 2400000 change=      0 rework=      0 oh= 144000 actual= 2544000 over= 144000
  pkg_concept    base= 1350000 change= 180000 rework=  90000 oh=  81000 actual= 1701000 over= 351000
  pkg_furniture  base=  276000 change=  24000 rework=  12000 oh=  16560 actual=  328560 over=  52560
  pkg_ui_icons   base=  172800 change=      0 rework=   6400 oh=  10368 actual=  189568 over=  16768
buckets change=204000 extraRework=108400 overhead=251928 sum=564328
digest=1461ed3be924dbc4
wrote out/ta15_cost.json

baselineTotal=4198800 正好等于 15.1 的 grandTotal——两节读的是同一份模型,不是各算各的。超支 564328 分分解成 change=204000 / extraRework=108400 / overhead=251928,三桶之和 564328 恰好覆盖,没有一分钱落在桶外。pkg_concept(概念设计)的 change 最高,说明这一包的需求最不稳定——这正是按人天计价的包最常见的超支形态。

15.2.5 P 层:成本内核

下面这段是 P 层 TS 内核,与上面的 Python 逐条对应——同一个管理分摊率、同一套断言与 digest 口径:

// file: core/src/ta15/cost.ts
// tier: P
// verify: node --test 'core/test/ta15_*.test.ts'
// 15.2 的 P 层成本内核。与 sim/ta15_cost.py 同一套成本结构、同一份 config/ta15_cost.json。
// 全整数(分):overhead 用 Math.floor(baseline*rate/1000),两侧逐位一致。
// 只允许可擦除 TS 语法。
import { createHash } from "node:crypto";

export type CostLine = { package: string; baseline: number; change: number; extraRework: number };
export type CostRow = {
  id: string; baseline: number; change: number; extraRework: number;
  overhead: number; actual: number; overrun: number;
};

export function attribute(lines: CostLine[], overheadPermille: number, baseById: Record<string, number>): CostRow[] {
  const rows: CostRow[] = lines.map((ln) => {
    const want = baseById[ln.package];
    if (want === undefined) throw new Error(`未知包 ${ln.package}`);
    if (ln.baseline !== want) throw new Error(`${ln.package} baseline=${ln.baseline} != 15.1 total=${want}`);
    const overhead = Math.floor((ln.baseline * overheadPermille) / 1000);
    const actual = ln.baseline + ln.change + ln.extraRework + overhead;
    const overrun = actual - ln.baseline;
    if (ln.change + ln.extraRework + overhead !== overrun) throw new Error(`${ln.package} 归因桶不覆盖超支`);
    return { id: ln.package, baseline: ln.baseline, change: ln.change, extraRework: ln.extraRework, overhead, actual, overrun };
  });
  rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
  return rows;
}

export function rowLines(rows: CostRow[]): string[] {
  return rows.map((r) => `${r.id}:${r.baseline}:${r.actual}:${r.overrun}`);
}

export function digest16(lines: string[]): string {
  return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}

15.2.6 跨语言对照:digest 逐位一致

P 层测试同时读 15.1 与 15.2 的 Python 产物,断言 TS 复算逐位一致,并用一条负例守住「基线不许与 15.1 漂移」:

// file: core/test/ta15_cost.test.ts
// tier: P
// verify: node --test 'core/test/ta15_*.test.ts'
// 跨语言对照:读 out/ta15_pricing.json(15.1 基线)与 out/ta15_cost.json(15.2 结算),断言
// TS 侧复算逐位一致。负例:baseline 与 15.1 total 不一致必须报错,而不是静默算出一个错数。
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 { attribute, rowLines, digest16 } from "../src/ta15/cost.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cost = JSON.parse(readFileSync(resolve(ROOT, "config/ta15_cost.json"), "utf8"));
const pricing = JSON.parse(readFileSync(resolve(ROOT, "out/ta15_pricing.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta15_cost.json"), "utf8"));

test("成本:TS 复算与 Python 产物逐位一致", () => {
  const baseById: Record<string, number> = {};
  for (const p of pricing.packages) baseById[p.id] = p.total;
  const rows = attribute(cost.lines, cost.overheadPermille, baseById);
  assert.deepEqual(rows, py.lines);
  assert.equal(digest16(rowLines(rows)), py.digest);
  assert.equal(rows.reduce((n, r) => n + r.baseline, 0), py.baselineTotal);
  assert.equal(rows.reduce((n, r) => n + r.actual, 0), py.actualTotal);
  assert.equal(rows.reduce((n, r) => n + r.overrun, 0), py.overrunTotal);
  console.log(`cost digest = ${py.digest}`);
});

test("归因:三个桶之和恰等于超支总额", () => {
  const b = py.buckets;
  assert.equal(b.change + b.extraRework + b.overhead, py.overrunTotal);
  assert.deepEqual(b, { change: 204000, extraRework: 108400, overhead: 251928 });
});

test("单一真源:15.1 的 grandTotal 等于 15.2 的 baselineTotal", () => {
  assert.equal(pricing.grandTotal, py.baselineTotal);
  assert.equal(pricing.grandTotal, 4198800);
});

test("负例:baseline 与 15.1 total 不一致必须报错", () => {
  const baseById: Record<string, number> = { pkg_x: 100 };
  const bad = [{ package: "pkg_x", baseline: 999, change: 0, extraRework: 0 }];
  assert.throws(() => attribute(bad, 60, baseById), /baseline/);
});

实跑 node --test 'core/test/ta15_cost.test.ts':

cost digest = 1461ed3be924dbc4
✔ 成本:TS 复算与 Python 产物逐位一致
✔ 归因:三个桶之和恰等于超支总额
✔ 单一真源:15.1 的 grandTotal 等于 15.2 的 baselineTotal
✔ 负例:baseline 与 15.1 total 不一致必须报错
ℹ pass 4
ℹ fail 0

两侧 digest 都是 1461ed3be924dbc4,且两侧都独立断言了 grandTotal == baselineTotal——跨语言一致与跨节一致同时成立。

15.2.7 边界:真实超支不可复现

本节算的是给定这份示例配置的超支分解。但真实的超支发生在真实项目里:需求方临时改主意、供应商换人、汇率变动、验收拖期——需真实环境(真实项目与合同),命令行不可复现。这些事件无法从任何模型推出来,本节也不假装能预测它们。

本节能给的是一件更朴素的东西:一套把超支「记账」的格式。有了它,项目结束后你至少能回答「这笔钱花在哪一类原因上」,而不是只有一个「超了」的结论。下一节把最常引发返工的那个环节——验收——做成一台可执行的门。

阅读导航:上一节:15.1 外包切分与计价模型 · 下一节:15.3 供应商验收与质量门 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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