15.3 供应商验收与质量门

验收不是「看一眼觉得行」,而是一台可执行的门:每条规则给一个阈值,每件交付物给一组测量值,逐项判定后落到 pass / conditional / reject 三级,再按包聚合成验收率与批次门禁。本节用一份示例规则表跑 8 件交付物,S 层 Python 与 P 层 TS 各自复算且 digest 逐位一致。

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 管线装配与检查表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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