15.3 供应商验收与质量门
15.2 把 extraRework 归成超支的一大来源,而返工最常见的起点是验收时才发现不能用。所以验收必须是交付前的一台门,不是交付后的一句评语。卷一 13.3 验收标准与返工归因
讲的是验收标准本身该怎么写;本节把标准落成一台可执行的门:每条规则一个阈值,每件交付物一组测量值,逐项判定后给出一个布尔结论。
质量门的设计目标只有一个:把「我觉得不行」变成「哪条规则、哪个数、超了多少」。这样供应商拿到的是可执行的整改清单,而不是一句模糊的否定;项目经理拿到的是一个能进流水线的 pass / reject。本节阈值与测量值仍是本套构造的示例参数,与任何真实供应商无关。
15.3.1 质量门:逐项规则 → 分级 → 批次门禁
门的结构分三层,自下而上:
- 规则:一条规则 = 一个字段 + 一个阈值 + 一个比较方向 + 一个级别。本节用两类——
max(测量值 ≤ 阈值,如面数、贴图尺寸、材质槽、骨骼数)与minCount(计数 ≥ 阈值,如交付文件数)。 - 交付物结论:把一件交付物跑过全部规则,得到一组违例;按级别汇总成
pass(无违例)/conditional(只有 warn)/reject(有任一 block)。 - 门禁:包内没有
reject交付物即accepted;批次门禁要所有包都accepted才pass。
关键判据是级别的语义:block 意味着「这件的错不可逆,整批不能用」;warn 意味着「可疑但可能刻意,人工确认后可放行」。级别不是按数值大小定的——面数超一点点也是 block(几何进了引擎就要重做),材质槽多一个只是 warn(改个材质分配即可)。这与卷二 12.2 门禁的分级与阻断策略
是同一套分级哲学,只是这里判的是交付物而不是导入记录。
15.3.2 D 层:规则表与交付物
下面这份 D 层配置声明阈值、五条规则与八件交付物的测量值。阈值与测量值都是示例,规则表的结构才是本节的重点:
{
"project": "stardust-inn",
"chapter": 15,
"specVersion": 1,
"note": "15.3 供应商验收与质量门的示例参数。阈值(面数 / 贴图 / 材质槽 / 骨骼)与交付物测量值都是本套构造的示例,与任何真实供应商、真实产能无关;本节只演示「逐项规则 → 分级 → 批次门禁」的整数判据。",
"thresholds": {"triMax": 6000, "texMax": 1024, "materialSlotsMax": 2, "boneMax": 30},
"rules": [
{"code": "A_TRI", "kind": "max", "field": "triCount", "limit": "triMax", "level": "block"},
{"code": "A_TEX", "kind": "max", "field": "texSize", "limit": "texMax", "level": "block"},
{"code": "A_SLOT", "kind": "max", "field": "materialSlots", "limit": "materialSlotsMax", "level": "warn"},
{"code": "A_BONE", "kind": "max", "field": "boneCount", "limit": "boneMax", "level": "block"},
{"code": "A_FILES", "kind": "minCount", "field": "fileCount", "limit": 2, "level": "block"}
],
"deliveries": [
{"id": "d_lamp_01", "package": "pkg_furniture", "triCount": 4200, "texSize": 512, "materialSlots": 2, "boneCount": 0, "fileCount": 3},
{"id": "d_counter_01", "package": "pkg_furniture", "triCount": 7100, "texSize": 1024, "materialSlots": 2, "boneCount": 0, "fileCount": 3},
{"id": "d_chair_01", "package": "pkg_furniture", "triCount": 3100, "texSize": 1024, "materialSlots": 3, "boneCount": 0, "fileCount": 3},
{"id": "d_bed_01", "package": "pkg_furniture", "triCount": 5600, "texSize": 2048, "materialSlots": 2, "boneCount": 0, "fileCount": 3},
{"id": "d_icon_coin", "package": "pkg_ui_icons", "triCount": 40, "texSize": 128, "materialSlots": 1, "boneCount": 0, "fileCount": 2},
{"id": "d_icon_star", "package": "pkg_ui_icons", "triCount": 40, "texSize": 128, "materialSlots": 1, "boneCount": 0, "fileCount": 2},
{"id": "d_icon_gem", "package": "pkg_ui_icons", "triCount": 40, "texSize": 128, "materialSlots": 1, "boneCount": 0, "fileCount": 1},
{"id": "d_mascot", "package": "pkg_concept", "triCount": 5800, "texSize": 1024, "materialSlots": 2, "boneCount": 34, "fileCount": 3}
]
}
15.3.3 S 层:判门
下面这段是 S 层脚本,逐件跑规则、汇总级别、再按包聚合出验收率与批次门禁。全整数计数:
#!/usr/bin/env python3
# file: sim/ta15_accept.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta15_accept.py
"""15.3 的 S 层验收器:把一批交付物逐条对照 config/ta15_acceptance.json 的规则表,判
pass / conditional / reject,再按包聚合成验收率与批次门禁。
判据(全整数):规则分 ``max``(测量值 ≤ 阈值)与 ``minCount``(计数 ≥ 阈值);违反记一条
带级别 block / warn 的违例。交付物结论:有任一 block → reject,无 block 但有 warn →
conditional,否则 pass。包门禁:包内没有 reject 即 accepted;批次门禁:所有包都 accepted
才 pass。落 out/ta15_accept.json 供 P 层对照。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def evaluate(delivery, rules, thresholds):
viols = []
for r in rules:
limit = thresholds[r["limit"]] if isinstance(r["limit"], str) else r["limit"]
val = delivery[r["field"]]
if (r["kind"] == "max" and val > limit) or (r["kind"] == "minCount" and val < limit):
viols.append({"code": r["code"], "level": r["level"], "value": val, "limit": limit})
return viols
def verdict_of(viols):
if any(v["level"] == "block" for v in viols):
return "reject"
return "conditional" if viols else "pass"
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/ta15_acceptance.json"), encoding="utf-8"))
rules, thresholds = spec["rules"], spec["thresholds"]
deliveries = []
for d in spec["deliveries"]:
viols = evaluate(d, rules, thresholds)
deliveries.append({"id": d["id"], "package": d["package"], "verdict": verdict_of(viols),
"blockCount": sum(1 for v in viols if v["level"] == "block"),
"warnCount": sum(1 for v in viols if v["level"] == "warn"), "violations": viols})
deliveries.sort(key=lambda x: x["id"])
packages = {}
for d in deliveries:
g = packages.setdefault(d["package"], {"id": d["package"], "total": 0, "accepted": 0,
"conditional": 0, "rejected": 0})
g["total"] += 1
g["rejected" if d["verdict"] == "reject" else "accepted"] += 1
if d["verdict"] == "conditional":
g["conditional"] += 1
pkg_rows = []
for g in sorted(packages.values(), key=lambda x: x["id"]):
g["ratePermille"] = g["accepted"] * 1000 // g["total"]
g["gate"] = "accepted" if g["rejected"] == 0 else "rejected"
pkg_rows.append(g)
gate = "pass" if all(g["gate"] == "accepted" for g in pkg_rows) else "reject"
lines = [f"{d['id']}:{d['verdict']}" for d in deliveries]
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
counts = {k: sum(1 for d in deliveries if d["verdict"] == k) for k in ("pass", "conditional", "reject")}
rep = {"project": spec["project"], "deliveryCount": len(deliveries), "verdictCounts": counts,
"blockTotal": sum(d["blockCount"] for d in deliveries),
"warnTotal": sum(d["warnCount"] for d in deliveries),
"gate": gate, "digest": digest, "packages": pkg_rows, "deliveries": deliveries}
with open(os.path.join(ROOT, "out/ta15_accept.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"deliveries={len(deliveries)} pass={counts['pass']} conditional={counts['conditional']} "
f"reject={counts['reject']} gate={gate} digest={digest}")
for d in deliveries:
codes = ",".join(v["code"] for v in d["violations"]) or "-"
print(f" {d['verdict']:11s} {d['id']:14s} block={d['blockCount']} warn={d['warnCount']} {codes}")
for g in pkg_rows:
print(f" pkg {g['id']:14s} total={g['total']} accepted={g['accepted']} "
f"rejected={g['rejected']} rate={g['ratePermille']}permille gate={g['gate']}")
print("wrote out/ta15_accept.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta15_accept.py:
deliveries=8 pass=3 conditional=1 reject=4 gate=reject digest=b24f47e1f7e42993
reject d_bed_01 block=1 warn=0 A_TEX
conditional d_chair_01 block=0 warn=1 A_SLOT
reject d_counter_01 block=1 warn=0 A_TRI
pass d_icon_coin block=0 warn=0 -
reject d_icon_gem block=1 warn=0 A_FILES
pass d_icon_star block=0 warn=0 -
pass d_lamp_01 block=0 warn=0 -
reject d_mascot block=1 warn=0 A_BONE
pkg pkg_concept total=1 accepted=0 rejected=1 rate=0permille gate=rejected
pkg pkg_furniture total=4 accepted=2 rejected=2 rate=500permille gate=rejected
pkg pkg_ui_icons total=3 accepted=2 rejected=1 rate=666permille gate=rejected
wrote out/ta15_accept.json
8 件交付物判成 pass 3 / conditional 1 / reject 4,四件 reject 各踩一条不同的 block 规则(面数、贴图、骨骼、文件数)——四条规则都被真实触发过,这正是一份规则表该有的样子。d_chair_01 只踩了 A_SLOT 的 warn,判 conditional,人工确认后放行。批次门禁 reject:三个包都至少有一件 reject,整批不能收。
15.3.4 P 层:验收内核
下面这段是 P 层 TS 内核,与上面的 Python 逐条对应——同一套规则求值、同一套按包聚合与 digest 口径:
// file: core/src/ta15/accept.ts
// tier: P
// verify: node --test 'core/test/ta15_*.test.ts'
// 15.3 的 P 层验收内核。与 sim/ta15_accept.py 同一套规则表、同一份 config/ta15_acceptance.json。
// 全整数计数与枚举字符串,两侧逐位一致。只允许可擦除 TS 语法。
import { createHash } from "node:crypto";
export type Violation = { code: string; level: string; value: number; limit: number };
export type Judged = { id: string; package: string; verdict: string; blockCount: number; warnCount: number; violations: Violation[] };
export type PkgRow = { id: string; total: number; accepted: number; conditional: number; rejected: number; ratePermille: number; gate: string };
export function evaluate(d: any, rules: any[], thresholds: Record<string, number>): Violation[] {
const viols: Violation[] = [];
for (const r of rules) {
const limit = typeof r.limit === "string" ? thresholds[r.limit] : r.limit;
const val = d[r.field];
if ((r.kind === "max" && val > limit) || (r.kind === "minCount" && val < limit)) {
viols.push({ code: r.code, level: r.level, value: val, limit });
}
}
return viols;
}
export function verdictOf(viols: Violation[]): string {
if (viols.some((v) => v.level === "block")) return "reject";
return viols.length > 0 ? "conditional" : "pass";
}
export function judge(deliveries: any[], rules: any[], thresholds: Record<string, number>): Judged[] {
const rows: Judged[] = deliveries.map((d) => {
const viols = evaluate(d, rules, thresholds);
return { id: d.id, package: d.package, verdict: verdictOf(viols), violations: viols,
blockCount: viols.filter((v) => v.level === "block").length,
warnCount: viols.filter((v) => v.level === "warn").length };
});
rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return rows;
}
export function packageGate(rows: Judged[]): PkgRow[] {
const map = new Map<string, PkgRow>();
for (const d of rows) {
let g = map.get(d.package);
if (g === undefined) {
g = { id: d.package, total: 0, accepted: 0, conditional: 0, rejected: 0, ratePermille: 0, gate: "" };
map.set(d.package, g);
}
g.total += 1;
if (d.verdict === "reject") g.rejected += 1; else g.accepted += 1;
if (d.verdict === "conditional") g.conditional += 1;
}
const out = [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
for (const g of out) {
g.ratePermille = Math.floor((g.accepted * 1000) / g.total);
g.gate = g.rejected === 0 ? "accepted" : "rejected";
}
return out;
}
export function verdictLines(rows: Judged[]): string[] {
return rows.map((r) => `${r.id}:${r.verdict}`);
}
export function digest16(lines: string[]): string {
return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}
15.3.5 跨语言对照:digest 逐位一致
P 层测试读 Python 落盘的 out/ta15_accept.json,断言逐件结论、按包聚合与 digest 逐位一致,并用一条负例守住分级语义:
// file: core/test/ta15_accept.test.ts
// tier: P
// verify: node --test 'core/test/ta15_*.test.ts'
// 跨语言对照:读 out/ta15_accept.json,断言 TS 侧验收结果、按包聚合与 digest 逐位一致。
// 负例:block 级违例必须把交付物判 reject;只有 warn 时只能判 conditional,不许升级成 reject。
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 { judge, packageGate, verdictLines, digest16, evaluate, verdictOf } from "../src/ta15/accept.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/ta15_acceptance.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta15_accept.json"), "utf8"));
test("验收:TS 复算与 Python 产物逐位一致", () => {
const rows = judge(spec.deliveries, spec.rules, spec.thresholds);
assert.deepEqual(rows, py.deliveries);
assert.equal(digest16(verdictLines(rows)), py.digest);
assert.deepEqual(packageGate(rows), py.packages);
assert.equal(py.gate, "reject");
console.log(`accept digest = ${py.digest}`);
});
test("计数:8 条交付 = pass 3 / conditional 1 / reject 4", () => {
const rows = judge(spec.deliveries, spec.rules, spec.thresholds);
assert.equal(rows.length, 8);
assert.deepEqual(py.verdictCounts, { pass: 3, conditional: 1, reject: 4 });
assert.equal(py.blockTotal, 4);
assert.equal(py.warnTotal, 1);
});
test("负例:block 违例判 reject,只有 warn 判 conditional", () => {
const rules = spec.rules;
const th = spec.thresholds;
const reject = evaluate({ id: "x", package: "p", triCount: 9999, texSize: 128, materialSlots: 1, boneCount: 0, fileCount: 3 }, rules, th);
assert.equal(verdictOf(reject), "reject");
const cond = evaluate({ id: "y", package: "p", triCount: 100, texSize: 128, materialSlots: 9, boneCount: 0, fileCount: 3 }, rules, th);
assert.equal(verdictOf(cond), "conditional");
const ok = evaluate({ id: "z", package: "p", triCount: 100, texSize: 128, materialSlots: 1, boneCount: 0, fileCount: 3 }, rules, th);
assert.equal(verdictOf(ok), "pass");
});
实跑 node --test 'core/test/ta15_accept.test.ts':
accept digest = b24f47e1f7e42993
✔ 验收:TS 复算与 Python 产物逐位一致
✔ 计数:8 条交付 = pass 3 / conditional 1 / reject 4
✔ 负例:block 违例判 reject,只有 warn 判 conditional
ℹ pass 3
ℹ fail 0
两侧 digest 都是 b24f47e1f7e42993——结论是枚举字符串、计数是整数,故这是逐位一致。
15.3.6 边界:真实交付物的测量不可复现
本节判的测量值(面数、贴图尺寸、材质槽、骨骼数、文件数)都是配置里写死的整数。但真实交付物上,这些数要靠 DCC 与引擎去量——需真实环境(DCC / 引擎 / 渲染器),命令行不可复现:本机没有 Blender、Maya、Substance、ZBrush,也没有任何引擎的导入器,本节无法打开一个真实的 .fbx 去数它的面数。
把边界标清楚的意义是:规则与阈值是可复现的,测量值不是。门禁的判据、分级、聚合口径全都能进 CI 逐位对拍;但「这件交付物的面数到底是多少」必须在真实管线的导入期由工具量出来(这正是卷二第 3 章导入期校验要干的事)。本节把测量值当成输入,只负责从测量到结论这一段——这一段才是可自动化、可回归、可跨语言对拍的部分。下一章把本章的三个包、三条账与这台门,装配进一条完整可运行的美术管线。
阅读导航:上一节:15.2 成本结构与超支归因 · 下一节:16.1 管线装配与检查表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。