From d67f744f598ffecd1713d98fb8fa943fefad701f Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 11:44:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(app):=20=E5=B9=BF=E6=92=AD=E6=A1=A5=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=BD=AC=E5=8F=91=E8=BF=9B=E6=9D=A5?= =?UTF-8?q?=E7=9A=84=E4=B8=8D=E5=86=8D=20NOTIFY=20=E5=9B=9E=E5=8E=BB,?= =?UTF-8?q?=E6=B2=BB=E4=B8=A4=E5=8F=B0=E5=90=8E=E7=AB=AF=E4=BA=92=E6=A1=A5?= =?UTF-8?q?=E5=9B=9E=E5=BC=B9=E7=9A=84=E5=B9=BF=E6=92=AD=E9=A3=8E=E6=9A=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「自己发的自己不收」只挡一跳:收到别人的就 publish 到本地,而桥自己也订阅着这个 broadcaster, 那条转发被当成「自己推的」再 NOTIFY 一遍(from 换成自己)→ 对面不是它发的、再转发……两台接力 就是永动机。实测两台后端连同一个库,一次 POST /api/gov/process/validate 引出 45 秒 19 万条广播, 工作台三把查询被打成连续重取(用户看到的是「疯狂轮询三个接口」)。 转发进来的那条打记号(WeakSet,publish 同步派发),「本地 → 库」那一侧认出记号就不发; `bridgeBroadcaster` 第三参 `self` 可注入(同进程两座桥才模拟得出两台机器)。 2 测:转发不再 NOTIFY 出去 · 两台互桥一条广播各到一次就停(假频道封顶 50 条,让用例失败而不是撑爆进程)。 2026-08-26 修的,代码一直留在工作树没入库;CLAUDE.md 快照已记。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu --- .../app/__tests__/broadcast-bridge.test.ts | 55 +++++++++++++++++++ apps/api/src/app/broadcast-bridge.ts | 20 +++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/__tests__/broadcast-bridge.test.ts b/apps/api/src/app/__tests__/broadcast-bridge.test.ts index a5950c9..d9cb550 100644 --- a/apps/api/src/app/__tests__/broadcast-bridge.test.ts +++ b/apps/api/src/app/__tests__/broadcast-bridge.test.ts @@ -87,6 +87,61 @@ describe('bridgeBroadcaster', () => { expect(got).toEqual([]); }); + it('**转发别人的那一条不再 NOTIFY 出去** —— 转发不是「自己推的」', async () => { + const fake = fakePool(); + const bus = createBroadcaster(); + bridgeBroadcaster(fake.pool as never, bus); + await settle(); + + fake.deliver({ kind: 'system', id: 'gov', reason: 'POST /api/gov/process/validate', from: '别的机器' }); + await settle(); + /* + * 从前这里会 NOTIFY 一条(from 换成自己):对面收到不是它发的、再转发、再 NOTIFY…… + * 两台后端连同一个库时一条广播就此永动 —— 实测 45 秒 19 万条,工作台三把查询被打成连续重取。 + */ + expect(fake.notified).toEqual([]); + }); + + it('两台后端互桥,一条广播各到一次就停 —— 不来回弹', async () => { + /** 两个假池共用一条「频道」:谁 NOTIFY 了,另一个当场收到(自己也收到,和真库一样) */ + const a = fakePool(); + const b = fakePool(); + const busA = createBroadcaster(); + const busB = createBroadcaster(); + const relay = (from: typeof a, to: typeof b) => { + const origQuery = from.pool.query; + from.pool.query = async (sql: string, params: unknown[]) => { + const r = await origQuery(sql, params); + // 回弹起来是无穷的:封顶 50 条,让用例「失败」而不是把测试进程撑爆 + if (from.notified.length > 50) return r; + // 真库会把这条送给所有 LISTEN 的连接,包括发它的那个进程自己 + const wire = JSON.parse(String(params[1])) as Record; + from.deliver(wire); + to.deliver(wire); + return r; + }; + }; + // 两台机器两个身份(真环境里 SELF 按进程生成;测试里显式给,不然同进程两座桥同名) + bridgeBroadcaster(a.pool as never, busA, 'A'); + bridgeBroadcaster(b.pool as never, busB, 'B'); + await settle(); + relay(a, b); + relay(b, a); + + const seenA: string[] = []; + const seenB: string[] = []; + busA.subscribe((t) => seenA.push(t.reason)); + busB.subscribe((t) => seenB.push(t.reason)); + busA.publish({ kind: 'system', id: 'gov', reason: 'x' }); + for (let i = 0; i < 20; i += 1) await settle(); + + // A 本地一次、B 转发一次;NOTIFY 只有 A 发的那一条 —— B 转发的不再发回去 + expect(seenA).toEqual(['x']); + expect(seenB).toEqual(['x']); + expect(a.notified).toHaveLength(1); + expect(b.notified).toHaveLength(0); + }); + it('载荷坏了只丢这一条,连接照旧活着', async () => { const fake = fakePool(); const bus = createBroadcaster(); diff --git a/apps/api/src/app/broadcast-bridge.ts b/apps/api/src/app/broadcast-bridge.ts index 7d009ce..2e577e2 100644 --- a/apps/api/src/app/broadcast-bridge.ts +++ b/apps/api/src/app/broadcast-bridge.ts @@ -12,6 +12,12 @@ * · 本进程 publish 一条 → 顺手 `NOTIFY`(带上进程标识); * · 一条**专用连接**常年 `LISTEN`,收到别的进程发来的就在本地 publish 一次。 * **自己发的自己不再收**(靠进程标识认),不然一条推送在两台之间来回弹。 + * **转发进来的不再发出去**(2026-08-26):从前收到别人的就 `publish` 到本地 broadcaster, + * 而本桥自己也订阅着它 —— 那条转发被当成「自己推的」再 NOTIFY 一遍(from 换成自己), + * 对面收到不是它发的、再转发、再 NOTIFY……只挡「自己发的自己不收」这一跳,两台接力就是永动机: + * 实测两台后端连同一个库,一次 `POST /api/gov/process/validate` 引出 45 秒 19 万条广播, + * 工作台三把查询被打成连续重取(用户看到的就是「疯狂轮询三个接口」)。 + * 转发的那一条在本地 publish 前打上记号,「本地 → 库」那一侧认出记号就不再发。 * * 桥断了不影响业务:推送本就是「告诉在线浏览器去重取」的提示,丢一条最坏是 * 那一屏晚两秒(前端还有轮询兜底)。所以这里所有错误都只记一行,不往上抛。 @@ -33,14 +39,16 @@ type Wire = Touch & { readonly from: string }; * `notify` 用参数化的 `pg_notify` 而不是拼 `NOTIFY` 语句:载荷里有中文与引号, * 拼字符串迟早出事(而这条路上的字符串全来自业务)。 */ -export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop(): void } { +export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster, self: string = SELF): { stop(): void } { let listener: PoolClient | undefined; let stopped = false; + /** 从库上转发进来的那几条 —— 它们不是本进程推的,不许再 NOTIFY 回去(按对象身份认,publish 同步派发) */ + const relayed = new WeakSet(); /** 本地 → 库:自己推的顺手 NOTIFY 一条(不 await,广播不该拖慢业务) */ const unsubscribe = broadcaster.subscribe((touch) => { - if (stopped) return; - const wire: Wire = { ...touch, from: SELF }; + if (stopped || relayed.has(touch)) return; + const wire: Wire = { ...touch, from: self }; void pool .query('select pg_notify($1, $2)', [TOUCH_CHANNEL, JSON.stringify(wire)]) .catch((error: unknown) => { @@ -68,8 +76,10 @@ export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop( const wire = parsed as Partial; if (typeof wire.kind !== 'string' || typeof wire.id !== 'string') return; // **自己发的自己不收** —— 否则一条推送在进程之间来回弹 - if (wire.from === SELF) return; - broadcaster.publish({ kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' }); + if (wire.from === self) return; + const touch: Touch = { kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' }; + relayed.add(touch); + broadcaster.publish(touch); } catch { /* 载荷根本不是 JSON:丢这一条,不拖垮连接 */ } From 6b7bf82df3f5f2cd4952bb16594f67c4961de442 Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 11:44:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=E7=A8=B3=E5=AE=9A=E7=89=88?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=20stable.sh(.stable/=20=E5=85=A5=20ignore)+?= =?UTF-8?q?=20vite=20preview=20=E5=AE=BF=E4=B8=BB=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ./stable.sh 起一份稳定版(:8790 / :5180)托 .build/stable 的静态件;vite preview 有宿主校验, 经域名 / 反代进来会被拦 —— 放哪些宿主由起它的人说(PREVIEW_ALLOWED_HOSTS),开发那台 vite 不读它。 脚本只列环境变量名,密钥仍只从环境读。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu --- .gitignore | 3 + apps/web/vite.config.ts | 24 +++ stable.sh | 366 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100755 stable.sh diff --git a/.gitignore b/.gitignore index dee73b2..bfcb463 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ coverage/ # SDD 工作区(ledger / brief / 评审包)—— 记录归 git 历史,过程件不入库 .superpowers/ + +# 稳定版快照与运行态(./stable.sh 写的) +.stable/ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 698c158..dde37c1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,7 +5,31 @@ import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../..', import.meta.url)); const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url)); +/* + * 稳定版(./stable.sh)用 `vite preview` 托 .build/stable 的静态文件。preview 有一道宿主校验 + * (防 DNS rebinding),默认只放 localhost 与 IP —— 经域名 / 反代进来会被拦: + * 「Blocked request. This host ("…") is not allowed.」 + * 放哪些宿主由起它的人说(PREVIEW_ALLOWED_HOSTS,逗号分隔;`.example.com` 放整个子域;`*` 全放)。 + * 开发那台 `vite` 不读这个,不受影响。 + */ +const previewHosts = process.env.PREVIEW_ALLOWED_HOSTS?.trim(); +const preview = + previewHosts === undefined || previewHosts === '' + ? {} + : { + preview: { + allowedHosts: + previewHosts === '*' + ? (true as const) + : previewHosts + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), + }, + }; + export default defineConfig({ + ...preview, plugins: [react()], resolve: { alias: { diff --git a/stable.sh b/stable.sh new file mode 100755 index 0000000..2e9a86d --- /dev/null +++ b/stable.sh @@ -0,0 +1,366 @@ +#!/bin/bash +# 稳定版 —— 把**当前代码**构建成一份快照,在另一组端口上常驻,给外部访问。 +# 与 ./start.sh 起的开发服务(:8787 / :5173,tsup --watch + vite HMR)互不相干: +# 开发那边改代码、重启、崩掉,这边一个字都不动;要发新版,显式跑一次 update。 +# +# ./stable.sh 起(没有快照就先构建;已在跑的不动) +# ./stable.sh update 用当前代码重建快照,然后重启两个 —— 这才是「发一版」 +# ./stable.sh restart 不重建,只重启 +# ./stable.sh stop 停掉两个 +# ./stable.sh status 只看状态,一个进程都不动 +# ./stable.sh logs [api|web] 看日志(tail -f) +# ./stable.sh env-from-dev 把开发服务(:8787)进程里的环境变量抄成 .stable/env +# +# 落盘位置(全部 gitignore): +# apps/api/.build/stable/ 后端快照(tsup 一次性构建,不 watch)—— 必须住在 apps/api 下, +# 产物里 `import "hono"` 这类裸名要从 apps/api/node_modules 解析 +# apps/web/.build/stable/ 前端快照(vite build),由 vite preview 托静态 + /api 反代到稳定版后端 +# .stable/env 环境变量(KEY=VALUE,600 权限)。缺它起不来 —— Keycloak 三样是硬门 +# .stable/logs/ api.log / web.log / build.log +# .stable/RELEASE 这份快照是哪个 commit、什么时候构的 +# +# .stable/env 长这样(env-from-dev 会自动照开发进程抄一份;要单独一套自己改): +# KEYCLOAK_URL=https://… KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=task-dispatcher +# KEYCLOAK_PROVISIONING_CLIENT_ID=… KEYCLOAK_PROVISIONING_CLIENT_SECRET=…(可选,名册同步) +# JCAI_TEST_ACCOUNTS=… JCAI_SECRET_KEY=…(开发没设的话这里也别设 —— 库里的密文 +# 是用哪把钥匙封的就得用哪把开) +# DATABASE_URL=… (不填 = 与开发共用一个库,见下) +# STABLE_API_PORT=8790 STABLE_WEB_PORT=5180 +# STABLE_ALLOWED_HOSTS=jcai-bpos.akhr-ai.com 经域名/反代访问时要列在这儿(逗号分隔,`*` 全放)—— +# vite preview 有宿主校验,不列会 403「This host is not allowed」 +# +# ./stable.sh stop web / restart api —— 第二个参数可只动一个 +# +# 关于数据库:默认与开发共用同一个库(两台后端连一个库是本仓库既有的跑法,跨进程租约与 +# LISTEN/NOTIFY 桥都为此做过)。代价要知道:开发那边 `reset-runs` 会把稳定版上外部人跑出来的 +# 任务一起清掉;开发那边跑了新迁移,稳定版的旧代码就在改过的表结构上跑。真要隔离, +# 在 .stable/env 里把 DATABASE_URL 指到另一个库,并自己跑一遍迁移 + 播种。 +# +# 沿用 start.sh 踩过的坑:端口属主只认 LISTEN(不然会把浏览器的连接当成占用者); +# 起完轮询到 /api/health 的 body 里 db:"up" 才算数(它在库挂了时照样返 200)。 +set -u +cd "$(dirname "$0")" || exit 1 +REPO=$(pwd) +STABLE="$REPO/.stable" +ENV_FILE="$STABLE/env" +LOGDIR="$STABLE/logs" +API_DIR="$REPO/apps/api" +WEB_DIR="$REPO/apps/web" +API_OUT="$API_DIR/.build/stable" +WEB_OUT="$WEB_DIR/.build/stable" +DEV_API_PORT=8787 +mkdir -p "$LOGDIR" +WHAT=${1:-start} + +say() { printf '%s\n' "$*"; } +die() { say " ✗ $*"; exit 1; } + +# ── 环境 ──────────────────────────────────────────────────────────────── +ENV_KEYS='KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET JCAI_TEST_ACCOUNTS JCAI_SECRET_KEY JCAI_DEEPSEEK_API_KEY JCAI_LLM_API_KEY JCAI_LLM_BASE_URL DATABASE_URL JCAI_ATTACHMENT_ROOT JCAI_DISPATCH_INTERVAL_MS JCAI_MAIL_POLL_TICK_MS NODE_ENV' + +# 端口的真属主 —— 只认 LISTEN +owner_of() { lsof -ti:"$1" -sTCP:LISTEN 2>/dev/null | head -1; } + +env_from_dev() { + local pid envs k v n=0 + pid=$(owner_of "$DEV_API_PORT") + [ -n "$pid" ] || die "开发后端(:$DEV_API_PORT)没在跑,抄不到环境 —— 先 ./start.sh api,或照文件头的模版手写 $ENV_FILE" + # macOS 的 ps -E 把环境变量追在命令后面,空格分隔;这几个键的值都不含空格 + envs=$(ps -E -p "$pid" -o command= 2>/dev/null | tr ' ' '\n') + [ -n "$envs" ] || die "读不到 pid $pid 的环境(不是同一个用户起的?)" + : > "$ENV_FILE" + chmod 600 "$ENV_FILE" + for k in $ENV_KEYS; do + v=$(printf '%s\n' "$envs" | grep -m1 "^$k=" | cut -d= -f2-) + if [ -n "$v" ]; then + printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE" + n=$((n + 1)) + fi + done + printf '%s=%s\n%s=%s\n%s=%s\n' STABLE_API_PORT "${STABLE_API_PORT:-8790}" STABLE_WEB_PORT "${STABLE_WEB_PORT:-5180}" \ + STABLE_ALLOWED_HOSTS "${STABLE_ALLOWED_HOSTS:-}" >> "$ENV_FILE" + say " ✓ 从开发后端(pid $pid)抄了 $n 个变量 → $ENV_FILE(600)" + say " $(cut -d= -f1 "$ENV_FILE" | tr '\n' ' ')" +} + +load_env() { + if [ ! -f "$ENV_FILE" ]; then + say " $ENV_FILE 还没有,先照开发后端抄一份:" + env_from_dev + fi + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a + API_PORT=${STABLE_API_PORT:-8790} + WEB_PORT=${STABLE_WEB_PORT:-5180} + for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID; do + eval "v=\${$k:-}" + # shellcheck disable=SC2154 + [ -n "$v" ] || die "$ENV_FILE 里缺 $k —— 统一鉴权中心是唯一的登录途径,缺了后端拒绝启动(ADR-012)" + done +} + +# ── 构建快照 ──────────────────────────────────────────────────────────── +build() { + local sha dirty at + say '构建快照:' + say " api → apps/api/.build/stable …" + # --out-dir 盖过 tsup.config 的 outDir;config 里的 clean:true 清的就是这个目录,碰不到开发的 dist/ + if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) > "$LOGDIR/build.log" 2>&1; then + tail -20 "$LOGDIR/build.log" + die 'api 构建失败(全文 .stable/logs/build.log)' + fi + [ -f "$API_OUT/main.js" ] || die "构建说成功,可 $API_OUT/main.js 不在" + say " web → apps/web/.build/stable …" + if ! (cd "$WEB_DIR" && pnpm exec vite build --outDir .build/stable --emptyOutDir) >> "$LOGDIR/build.log" 2>&1; then + tail -20 "$LOGDIR/build.log" + die 'web 构建失败(全文 .stable/logs/build.log)' + fi + [ -f "$WEB_OUT/index.html" ] || die "构建说成功,可 $WEB_OUT/index.html 不在" + sha=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) + dirty=$([ -n "$(git status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '') + at=$(date '+%Y-%m-%d %H:%M:%S') + printf 'commit=%s%s\nbuilt_at=%s\n' "$sha" "$dirty" "$at" > "$STABLE/RELEASE" + say " ✓ 快照:$sha$dirty · $at" +} + +have_snapshot() { [ -f "$API_OUT/main.js" ] && [ -f "$WEB_OUT/index.html" ]; } + +# ── 起停 ──────────────────────────────────────────────────────────────── +# 直接 exec node,进程链上不留 pnpm/bash:pid 文件里记的就是真正听端口的那个进程 +start_api() { + if [ -n "$(owner_of "$API_PORT")" ]; then + say " api :$API_PORT 已在跑,不动它" + return 0 + fi + # cwd 必须是 apps/api:附件根目录默认 cwd/.data/attachments,与开发服务同一处 —— + # 共用一个库时,库里存的 file:// 指针两边都得指得到 + ( cd "$API_DIR" && PORT="$API_PORT" exec nohup node .build/stable/main.js < /dev/null >> "$LOGDIR/api.log" 2>&1 ) & + echo $! > "$STABLE/api.pid" + disown 2>/dev/null + say " api :$API_PORT 启动中… (pid $(cat "$STABLE/api.pid"),日志 .stable/logs/api.log)" +} + +start_web() { + if [ -n "$(owner_of "$WEB_PORT")" ]; then + say " web :$WEB_PORT 已在跑,不动它" + return 0 + fi + # vite preview:托 .build/stable 的静态文件,/api 反代沿用 vite.config 的 server.proxy(preview 默认继承), + # 目标由 API_PROXY_TARGET 指到稳定版后端;--host 绑全部网卡,外部才连得进来 + ( cd "$WEB_DIR" && API_PROXY_TARGET="http://127.0.0.1:$API_PORT" PREVIEW_ALLOWED_HOSTS="${STABLE_ALLOWED_HOSTS:-}" \ + exec nohup node node_modules/vite/bin/vite.js preview \ + --outDir .build/stable --host --port "$WEB_PORT" --strictPort < /dev/null >> "$LOGDIR/web.log" 2>&1 ) & + echo $! > "$STABLE/web.pid" + disown 2>/dev/null + say " web :$WEB_PORT 启动中… (pid $(cat "$STABLE/web.pid"),日志 .stable/logs/web.log)" +} + +# 只杀「确实是我们起的那个」:pid 文件里的 pid 还活着且命令里带 .build/stable,或端口属主命令里带 .build/stable +# (dist-stable 是 08-26 之前的旧落点,留着认一阵子,免得换目录那一刻停不掉老进程) +is_ours() { ps -o command= -p "$1" 2>/dev/null | grep -qE '\.build/stable|dist-stable'; } + +stop_one() { + local label=$1 port=$2 pidfile=$3 pid owner i + pid=$(cat "$pidfile" 2>/dev/null || true) + owner=$(owner_of "$port") + if [ -z "$owner" ] && { [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; }; then + say " $label 本来就没在跑" + rm -f "$pidfile" + return 0 + fi + # pid 文件里的与端口属主多半是同一个进程,别对它说两遍话 + [ "$owner" = "$pid" ] && owner='' + for i in $pid $owner; do + if is_ours "$i"; then + kill "$i" 2>/dev/null + elif [ -n "$(ps -o command= -p "$i" 2>/dev/null)" ]; then + say " ! :$port 的属主 pid $i 不是稳定版的进程,不动它:$(ps -o command= -p "$i" | cut -c1-80)" + fi + done + for i in 1 2 3 4 5 6 7 8; do + [ -z "$(owner_of "$port")" ] && { say " $label 已停"; rm -f "$pidfile"; return 0; } + sleep 1 + done + owner=$(owner_of "$port") + if is_ours "$owner"; then + kill -9 "$owner" 2>/dev/null + sleep 1 + [ -z "$(owner_of "$port")" ] && { say " $label 已停(-9)"; rm -f "$pidfile"; return 0; } + fi + say " ! $label :$port 还被占着 —— 手工看看 lsof -i:$port" + return 1 +} + +# ── 核实 ──────────────────────────────────────────────────────────────── +http_code() { curl -s -o /dev/null -m 3 -w '%{http_code}' "$1" 2>/dev/null; } + +# /api/health 在库挂了时照样返 200,得看 body 里的 db +wait_api() { + local i body + for i in $(seq 1 45); do + body=$(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) + case "$body" in + *'"db":"up"'*) say " ✓ api 就绪 (${i}s) $body"; return 0 ;; + *'"db":"down"'*) say " ✗ api 起来了但连不上库:$body"; return 1 ;; + esac + # 进程已经死了就别等满 45 秒 + if [ -f "$STABLE/api.pid" ] && ! kill -0 "$(cat "$STABLE/api.pid")" 2>/dev/null; then + say ' ✗ api 进程已退出 —— 日志末尾:' + tail -5 "$LOGDIR/api.log" | sed 's/^/ /' + return 1 + fi + sleep 1 + done + say ' ✗ api 45 秒没有应答 —— 看 .stable/logs/api.log' + return 1 +} + +wait_web() { + local i code + for i in $(seq 1 30); do + code=$(http_code "http://127.0.0.1:$WEB_PORT/") + [ "$code" = '200' ] && { say " ✓ web 就绪 (${i}s)"; return 0; } + if [ -f "$STABLE/web.pid" ] && ! kill -0 "$(cat "$STABLE/web.pid")" 2>/dev/null; then + say ' ✗ web 进程已退出 —— 日志末尾:' + tail -5 "$LOGDIR/web.log" | sed 's/^/ /' + return 1 + fi + sleep 1 + done + say ' ✗ web 30 秒没有应答 —— 看 .stable/logs/web.log' + return 1 +} + +verify() { + local rc=0 proxy spa + wait_api || rc=1 + wait_web || rc=1 + proxy=$(http_code "http://127.0.0.1:$WEB_PORT/api/health") + [ "$proxy" = '200' ] && say ' ✓ 反代 /api → 200' || { say " ✗ 反代 /api → ${proxy:-连不上}"; rc=1; } + # 三个台面是同一个 SPA,刷新 /design /ops 要吐 index.html + spa=$(http_code "http://127.0.0.1:$WEB_PORT/design") + [ "$spa" = '200' ] && say ' ✓ SPA 回退 /design → 200' || { say " ✗ SPA 回退 /design → ${spa:-连不上}"; rc=1; } + return $rc +} + +lan_ip() { ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true; } + +# 登录能不能跳回来,Keycloak 那一侧说了算:web-portal 客户端没登记这个来源的 redirect URI, +# 页面开得了、点「前往统一登录」就吃一句 Invalid parameter: redirect_uri。 +# 服务起好了不等于能用 —— 这里替人先问一次 Keycloak(只看它对 redirect_uri 的裁决,不登录)。 +WEB_CLIENT_ID=$(grep -m1 '^VITE_KEYCLOAK_CLIENT_ID=' "$WEB_DIR/.env.production" 2>/dev/null | cut -d= -f2-) +kc_redirect_ok() { + local origin=$1 enc body + enc=$(printf '%s/' "$origin" | sed 's,/,%2F,g; s,:,%3A,g') + body=$(curl -s -m 10 "$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=${WEB_CLIENT_ID:-web-portal}&response_type=code&scope=openid&redirect_uri=$enc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256" 2>/dev/null) + case "$body" in + '') return 2 ;; # Keycloak 连不上,说不出 + *'Invalid parameter: redirect_uri'*) return 1 ;; # 明确拒了 + *) return 0 ;; + esac +} + +# 列进 STABLE_ALLOWED_HOSTS 的域名,外部多半是 https 进来的,一并问 Keycloak(通配与整子域问不了,跳过) +allowed_origins() { + local h + for h in $(printf '%s' "${STABLE_ALLOWED_HOSTS:-}" | tr ',' ' '); do + case "$h" in '*' | .*) ;; *) printf 'https://%s\n' "$h" ;; esac + done +} + +report_kc() { + local origin + set -- "$@" $(allowed_origins) + for origin in "$@"; do + kc_redirect_ok "$origin" + case $? in + 0) say " ✓ Keycloak 接受 $origin 的 redirect_uri" ;; + 1) say " ✗ Keycloak 拒绝 $origin 的 redirect_uri —— 去 realm $KEYCLOAK_REALM 的客户端 ${WEB_CLIENT_ID:-web-portal} 把 $origin/* 加进 Valid redirect URIs 与 Web origins,不然这个入口登不进" ;; + *) say " ? Keycloak($KEYCLOAK_URL)没应答,登录能不能跳回来说不准" ;; + esac + done +} + +finish() { + local ip + say '' + if [ "$1" = '0' ]; then + ip=$(lan_ip) + say " 稳定版都好了 → http://localhost:$WEB_PORT/" + [ -n "$ip" ] && say " 局域网 → http://$ip:$WEB_PORT/" + report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"} + else + say ' 有起不来的,日志在 .stable/logs/' + fi +} + +status() { + local ip + say '稳定版状态:' + [ -f "$STABLE/RELEASE" ] && sed 's/^/ 快照 /' "$STABLE/RELEASE" || say ' 快照 (还没构建过)' + say " api :$API_PORT $(http_code "http://127.0.0.1:$API_PORT/api/health") $(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) pid $(owner_of "$API_PORT")" + say " web :$WEB_PORT $(http_code "http://127.0.0.1:$WEB_PORT/") pid $(owner_of "$WEB_PORT")" + say " 反代 /api $(http_code "http://127.0.0.1:$WEB_PORT/api/health")" + ip=$(lan_ip) + [ -n "$ip" ] && say " 入口 http://localhost:$WEB_PORT/ · http://$ip:$WEB_PORT/" + report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"} +} + +# ── 分派 ──────────────────────────────────────────────────────────────── +case "$WHAT" in + env-from-dev) + env_from_dev + exit 0 + ;; + logs) + exec tail -n 50 -f "$LOGDIR/${2:-api}.log" + ;; + status) + load_env + status + exit 0 + ;; + stop) + load_env + say '停止稳定版:' + [ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid" + [ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid" + exit 0 + ;; + start) + load_env + have_snapshot || build + say "起稳定版(api :$API_PORT · web :$WEB_PORT):" + start_api + start_web + ;; + update) + load_env + build + say '重启稳定版:' + stop_one api "$API_PORT" "$STABLE/api.pid" + stop_one web "$WEB_PORT" "$STABLE/web.pid" + start_api + start_web + ;; + restart) + load_env + have_snapshot || die '还没有快照,先 ./stable.sh update' + say "重启稳定版${2:+($2)}:" + [ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid" + [ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid" + start_api + start_web + ;; + *) + say "不认识的参数:$WHAT(可用:start / update / restart / stop / status / logs [api|web] / env-from-dev)" + exit 2 + ;; +esac + +RC=0 +verify || RC=1 +finish $RC +exit $RC From bd86dd6088e757a099cccde819267591db2d7c5b Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 11:44:35 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(w05):=20=E5=B7=AE=E5=BC=82=E8=AF=84?= =?UTF-8?q?=E4=BC=B0=E7=9A=84=E7=9B=AE=E6=A0=87=E4=BB=B6=E4=BA=94=E4=BB=BD?= =?UTF-8?q?=20HTML=20+=20=E4=B8=89=E4=BB=BD=E8=AF=B4=E6=98=8E=20+=20?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92;=E4=B8=89=E5=BC=A0?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 活动包 views/target-style.ts / report.ts 与两份 W05 文档都指着 docs/SS02-ACT-WORK-05-html —— 模版「逐字节搬自目标件」的那份目标件此前没入库。docs/img/cy 三张 png 现无引用,随批入库。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu --- .../ACT-WORK-05-一键启动入参说明.md | 46 + .../ACT-WORK-05-全流程分支图.html | 370 ++ ...05-执行工单-01-评估输入与资料补齐确认.html | 296 ++ ...CT-WORK-05-数据工单-01-芯片主数据创建.html | 341 ++ .../ACT-WORK-05-活动流程与工单说明.md | 297 ++ ...-WORK-05-验收工单-01-差异评估结论审核.html | 212 + ...ORK-05-验收工单-02-评审放行与落账确认.html | 226 + ...02-SS03-SS04_Demo台账字段细化与统一方案.md | 632 +++ docs/img/cy/cy-cy.png | Bin 0 -> 69208 bytes docs/img/cy/cy-qx.png | Bin 0 -> 68264 bytes docs/img/cy/cy-zz.png | Bin 0 -> 79890 bytes ...ct-work-05-part-substitution-assessment.md | 3775 +++++++++++++++++ 12 files changed, 6195 insertions(+) create mode 100644 docs/SS02-ACT-WORK-05-html/ACT-WORK-05-一键启动入参说明.md create mode 100644 docs/SS02-ACT-WORK-05-html/ACT-WORK-05-全流程分支图.html create mode 100644 docs/SS02-ACT-WORK-05-html/ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html create mode 100644 docs/SS02-ACT-WORK-05-html/ACT-WORK-05-数据工单-01-芯片主数据创建.html create mode 100644 docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md create mode 100644 docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-01-差异评估结论审核.html create mode 100644 docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-02-评审放行与落账确认.html create mode 100644 docs/SS02-ACT-WORK-05-html/SS02-SS03-SS04_Demo台账字段细化与统一方案.md create mode 100644 docs/img/cy/cy-cy.png create mode 100644 docs/img/cy/cy-qx.png create mode 100644 docs/img/cy/cy-zz.png create mode 100644 docs/superpowers/plans/2026-08-26-act-work-05-part-substitution-assessment.md diff --git a/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-一键启动入参说明.md b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-一键启动入参说明.md new file mode 100644 index 0000000..3d09761 --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-一键启动入参说明.md @@ -0,0 +1,46 @@ +# ACT-WORK-05 差异评估 — 一键启动入参说明(运营端) + +> 本文档与《SS02-SS03-SS04_Demo台账字段细化与统一方案》字段口径一致(统一字段名、编号归并、主记录/子表结构均按该方案 4.2 节与第五节执行)。 + +## 一、背景 + +ACT-WORK-05 差异评估平时由上游触发:ACT-WORK-03 检索调用,或 ACT-GATE-02 路径判定后进入。 + +**本文档是运营端一键启动功能的入参规格**:表单在独立运营端完成,参数在启动时填好传入,系统据此直接启动活动(人工扮演上游,把进入条件变成表单)。 + +**极简原则(2026-08-26 定)**:一键启动只收 **4 个参数** —— 客户、客户需求、原 PN、替代 PN(数组)。**启动不等资料**:应用场景、BOM 位、替代类型、datasheet、需求版本调整等一切其余信息,都在活动启动后、执行过程中由工作台的数据工单逐项补齐(数据工单一单一类可多条,确认即入库,活动从断点续跑)。 + +**渠道无感知原则**:活动与工单对触发渠道无感知——上游触发(ACT-WORK-03 / ACT-GATE-02)传入的字段集与本规格完全一致(同样 4 项)。运营端按本规格传参,活动按进入条件消费,不记录渠道;仅 `source_type=手动启动` 在运营端留痕,供 ACT-GATE-03 评审放行时追溯。 + +## 二、入参总表 + +一键启动的全部参数,就这四行: + +| 中文字段 | 统一字段名 | 必填性 | 输入方式 | 默认值 | 说明 | +|---|---|---|---|---|---| +| 客户 | customer_id | 必填 | L-02 选择器 | — | 新建 P-01 需求时挂客户 ID;决定通知对象与默认负责人线。 | +| 客户需求 | request_description | 必填 | 文本 | — | 保留原始需求语义;据此新建 P-01 需求主记录(版本 V1)。 | +| 原内部料号 | original_internal_pn | 必填 | P-08 选择器 | — | 差异评估基准,引用 P-08.internal_pn。 | +| 候选内部料号 | candidate_internal_pn[] | 必填(**数组,≥1**) | P-08 选择器(**多选**) | — | 被评估物料,**一次可传多个**,逐个评估、逐个做存在性检查;P-08 无记录不拦启动——启动后按存在性检查逐个派数据工单创建(候选 ≥2 也可选择放弃,记录放弃原因)。 | + +**系统带出 / 默认值**(不在表单上出现,启动时自动落):需求版本 V1、替代类型 Pin-to-Pin、规则版本当前发布版 R-2026.07(as_of 锁定,随结论落账)、期限 1 个工作日、负责人(PW)与审核(FAE)按客户线默认、`source_type=手动启动`。以上任何一项要改,都在启动后的工单里改,不回表单。 + +## 三、点击启动后系统行为 + +1. 生成任务号(如 TASK-BT02-20260818-007); +2. 新建 P-01 需求主记录(V1):客户 ID + 需求描述落账,绑定原 PN 与候选 PN 数组; +3. **存在性检查**(按 internal_pn 查 P-08 等台账,候选数组逐个查):原 PN 无数据 → 硬阻塞,派数据工单,活动停「等待数据」;候选 PN 无数据 → 候选 ≥2 可放弃【记录放弃原因】或派数据工单创建,候选 =1 必须创建;数据工单**确认即入库**(确认人=责任人留痕)后活动自动唤醒。**启动时的存在性检查只是惰性触发的第一次**——活动执行中每次读台账仍可能派发新数据工单(一单一类可多条,D02、D03…,全部确认后从断点续跑); +4. 生成输入快照 input_snapshot_ref(活动自持中间产物,支持版本回溯); +5. 进入步骤 1 · 建立评估上下文,派执行工单 01——启动传入的 4 项只读核对,**未齐资料全部在工单里补**:应用场景、BOM 位、替代类型确认、需求版本调整等缺口字段,PW 在工作台逐项填写,不回流表单。 + +## 四、极简参数集 + +手工必给的只有 4 个:**客户、客户需求、原 PN、候选 PN(数组)** —— 这就是一键启动的全部入参,没有第二张表。 + +设计意图:**启动的门槛越低,活动跑得越早**;资料不齐不该拦启动,缺口在执行中由数据工单追着人补,而不是在表单里追着人填。数据工单里 datasheet 选填(传了 AI 预填,没传全手填),同样不拦流程。 + +## 五、与现有工单的衔接 + +执行工单 01 的「评估输入清单」(`in.evaluation_inputs`)中,启动传入的 4 项回显为「已绑定 / 已锁定」的灰底只读行:客户、客户需求(及其新建的 P-01 需求与版本 V1)、原 PN、候选 PN(数组逐行);其余行(应用场景、BOM 位、替代类型、规则版本确认、负责人/期限调整等)为 PW 补缺口的可编辑行。**启动参数如有误,须退回启动人在运营端修订后重新启动,工单内不可改**。工单不展示、也不区分触发渠道——上游触发传入同一字段集时,呈现与本表单回显完全相同。 + +PN 不在库时派发的**数据工单**(`in.create_p08_fields` / `in.create_p08_params`):datasheet 在**数据工单里**上传(选填)——传了则 AI 预填字段与参数集(示例 8 项字段 + 7 行参数),没传则人全填;人在对话区【数据卡】确认即正式入库,确认人=数据责任人并留痕,入库后活动自动唤醒进步骤 1。 diff --git a/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-全流程分支图.html b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-全流程分支图.html new file mode 100644 index 0000000..a84e283 --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-全流程分支图.html @@ -0,0 +1,370 @@ + + + + + +ACT-WORK-05 差异评估 · 全流程与分支一览 + + + +
+ + +
+
SS02 · 需求分析与方案匹配 | BT-02 跨品牌替代选型场景
+

ACT-WORK-05 差异评估 · 全流程与分支一览

+
SS02 需求分析与方案匹配 | 负责人 FAE/RI | 门禁 ACT-GATE-03 评审放行 | 工单:1 数据(条件触发)+ 1 执行 + 2 验收
+
+ + +
+
业务场景(演示示例)
+

客户工业电源项目在用 LM2596S-5.0(TI,12V→5V、工业温域、40V 输入上限),原厂发布 PCN 该料转 NRND,客户要求验证替代料。你(FAE)从料库筛出 3 种候选:A = XL2596S-5.0(XLSEMI)、B = LM2596S-ADJ(TI 同族可调版)、C = 国产某 DCDC。

+

触发双通道:①上游触发(ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定)②运营端一键启动(可快速创建需求单:request_id 系统生成、requirement_version 默认 V1)。两通道传入同一字段集——活动内部不区分渠道,工单无差别呈现。

+
+ + +
+

本活动只回答一个问题——技术上哪种料能替(输出:每个候选的推荐等级与证据)。

+

它不回答:买不买得到、数量够不够、多少钱——那些是下游活动的事(SS03 询价、ACT-WORK-24 报价),本图末尾灰显仅作衔接示意。

+
+ + +
+

进入条件 · 启动校验(agent 创建活动实例前必查)

+
    +
  • 任务锚点(二选一):关联已有需求 request_id,或快速创建(customer_id + 需求描述 → 系统建 P-01、生成 request_id、版本默认 V1;原 PN 与候选 PN 数组作为独立入参绑定);
  • +
  • 启动仅绑定 4 项:客户 + 客户需求 + 原 PN + 候选 PN(数组);应用场景、BOM 位等其余资料启动后在工单内补齐,不回表单;
  • +
  • datasheet 选填 选填:只影响数据工单是「AI 预填」还是「人全填」,不影响能否启动;
  • +
  • 存在性检查 = 全链路惰性触发:按主键查台账,查无记录即派数据工单;启动校验只是第一次,之后每次读数仍可能再派(D02、D03…)。
  • +
+
+ +

主 链 · 启动期 + 5 步工作链条(右侧挂分支)

+
+ + +
+
+
0

启动期 · 参数绑定与存在性检查

运营端 / 上游 · 系统数据工单 01(条件触发)
+

参数绑定(任务 / 版本 / 双 PN / BOM 位 / 规则版本)→ 生成输入快照 input_snapshot_ref;存在性检查发现「完全没有」→ 派数据工单:活动停「等待数据」→ 确认入库 → 唤醒进步骤 1。

+
读写台账:P-01(W 写入/回链)· P-08(R 校验)· P-10(R/W 校验)
+
+
+
分支 · 启动前置 / 数据补齐
+
0a新客户无档案先建 L-02 客户档案(数据工单),再返回启动
+
0b候选无 P-08 主数据同分支 1d:派数据工单创建,确认入库后继续
+
0c未传 datasheet不再拦截:datasheet 已为选填,仅意味着数据工单全手填
+
+
+ +
+ + +
+
+
1

建立评估上下文

PW / AI执行工单 01
+

评估输入清单(一张表):已绑定行灰底只读核对(启动仅绑定 4 项:客户 / 需求 / 原 PN / 候选数组)+ 缺口行可编辑补齐(应用场景、BOM 位等待补项 + 默认·可改项,补充内容必填*,可加行/删行)。工单内不可改绑定值。

+
读台账:P-01 · P-09 · EXT-02(快照:input_snapshot_ref)
+
+
+
分支 · 绑定、缺口与数据创建
+
1d任何读数点查无记录 → 派数据工单一单一类可多条(字段即表头、每行一条,可批量创建);模板统一(实例仅两处可变:目标台账决定表头 schema、预填内容决定行集);多实例 D01/D02…。原 PN=硬阻塞·必须创建;候选 ≥2 可放弃或创建(人判断);候选 =1 必须创建。确认即正式入库(无草稿无审核、确认人=责任人留痕)→ 断点续跑。只建主数据/资料,事实/证据类(P-06)不可创建、挂验证任务
+
1a绑定参数有误退回触发方修订后重新触发(工单内不可改;手动→退回运营端启动人,上游→退回上游活动)
+
1b补缺口四情形(有而不全):参数未披露→挂验证任务 | 资料版本过旧→补最新版 | 应用约束不清→问客户并更新需求单版本(V1→V1.1)| 外部来源补充
+
1c缺口补不了留 UNKNOWN 进入评估(合法路径,不阻断)
+
+
+ +
+ + +
+
+
2

读取并标准化资料

AI不派工单
+

统一参数名 / 单位 / 版本 / 证据引用,产出标准化参数对照表。每次读数做存在性检查:主数据 / 资料类查无 → 派数据工单;事实 / 证据类(P-06)查无 → 挂验证任务。缺失项标记 UNKNOWN。

+
读台账:P-08 · P-10 · P-06 · GOV(规则版本 rule_version 锁定)
+
+
+
分支 · 标准化冲突
+
2a单位冲突阻断,回步骤 1 补证
+
2b证据版本冲突人工复核哪份资料为准
+
+
+ +
+ + +
+
+
3

逐项差异评估

AI不派工单
+

按硬约束(封装 / 工艺 / 电气上限等)与软约束逐项判定,输出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL,附影响、风险、证据与置信度,形成 P-11 差异明细子表草稿。

+
读:步骤 2 对照表 + GOV 规则(取参数同样做存在性检查)| 产出:P-11 差异明细子表草稿(审核前不落账)
+
+
+
分支 · 硬约束失败
+
3a硬约束 MISMATCH该候选直接「不推荐」——如候选 C 输入上限 32V < 客户 40V
+
+
+ +
+ + +
+
+
4

FAE/RI 审核与补证

FAE / RI验收工单 01
+

审核 P-11 差异明细子表草稿(含约束类型列):确认参数解释、风险等级、证据可信度与验证要求;四态逐项无漏项,UNKNOWN 必须有去向。

+
条件写:P-06(审核意见与验证要求 verification_requirement)
+
+
+
分支 · 审核驳回
+
4aUNKNOWN 无去向退回步骤 1~2 补证 / 挂验证任务
+
4b证据链不全退回补齐证据回链(证据来源含数据工单创建记录:人工录入 · 工单号 · 确认人)
+
+
+ +
+ + +
+
+
5

落账与提交 ACT-GATE-03

FAE / RI门禁验收工单 02
+

确认拟落账内容后提交门禁:P-11 替代关系主记录落账——推荐等级 recommendation_level 四态(推荐 / 条件推荐 / 备选 / 不推荐)、analysis_status=FAE确认;customer_approval_status 由独立审批流维护,本活动不碰。并回写 P-01。

+
写台账:P-11(W)· P-01(W 回写)| 每个候选各一条 P-11 记录
+
+
+
分支 · 门禁结果
+
5aGATE-03 放行P-11 落账生效,衔接下游
+
5bGATE-03 驳回REWORK:回步骤 4 重审,或回步骤 1 重补输入
+
+
+ +
+ + +

完 结 · 本示例三候选评估结果

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
候选推荐等级关键依据下一步
A XL2596S-5.0
XLSEMI
条件推荐温度范围更优(-40~+125℃ vs +85℃);使能阈值 UNKNOWN,挂 VAL-2026-0188 实测验证闭环后转正
B LM2596S-ADJ
TI 同族可调版
备选反馈网络不同(可调版 vs 固定 5V),需改 BOM 外围客户接受改设计则启用
C 国产某 DCDC不推荐硬约束失败:输入上限 32V < 客户 40V(分支 3a)归档,不再评估
+
+

注:多候选并行评估、独立落账(各一条 P-11 记录),不存在「选一个再评下一个」。

+
+
6a至少一个推荐交下游衔接(见灰显区)
+
6b全是条件推荐等验证闭环:通过→升级推荐;不过→降级不推荐
+
6c全不推荐重新检索替代料 / 继续用原 PN / 与客户协商放宽约束
+
6d客户不批准走独立审批流(customer_approval_status),不在本活动
+
+
+ + +

下 游 · 范围外,仅作衔接示意

+
+ 范围外 · 仅作衔接示意 +
    +
  • ACT-WORK-08 证据归集:GATE-03 放行后自动衔接,消费本活动结论。
  • +
  • SS03 询价 / ACT-WORK-24 报价:商务与供货问题(价格、交期、数量),不在本活动回答。
  • +
  • 客户批准审批流:维护 P-11.customer_approval_status,独立于本活动。
  • +
+
+ + +

图 例 · 全部分支对照表(18 条)

+
+ + + + + + + + + + + + + + + + + + + + + + +
分支触发条件去向
0a启动期:新客户无档案先建 L-02 客户档案(数据工单),再返回启动
0b启动期:候选无 P-08 主数据同分支 1d:派数据工单登记创建
0c启动期:未传 datasheet不再拦截——datasheet 已是选填,仅意味着数据工单由「AI 预填」变为「人全填」
1a步骤 1:绑定参数有误退回触发方修订后重新触发(工单内不可改)
1b步骤 1:输入存在缺口(有而不全)补缺口四情形:候选 PDF 未入库→补链接+版本;参数原厂未披露→挂验证任务(P-06);原 PN 资料版本过旧→补最新版;应用约束不清→回头问客户并更新需求单版本
1c步骤 1:缺口补不了留 UNKNOWN 进入评估(合法路径,不阻断)
1d任何读数点:查无记录(完全没有)惰性触发派数据工单(一单一类可多条,模板统一,可多实例 D01/D02/…):原 PN=硬阻塞·必须创建;候选 ≥2 可放弃【记录放弃原因】或创建(人判断);候选 =1 必须创建。确认即入库(责任人留痕)→ 活动从断点续跑。事实/证据类查无(如 P-06)不可创建,挂验证任务
2a步骤 2:单位冲突阻断,回步骤 1 补证
2b步骤 2:证据版本冲突人工复核哪份资料为准
3a步骤 3:硬约束 MISMATCH该候选直接「不推荐」(如候选 C 输入上限 32V < 40V)
4a步骤 4:UNKNOWN 无去向退回步骤 1~2 补证 / 挂验证任务
4b步骤 4:证据链不全退回补齐证据回链(证据来源含数据工单创建记录:人工录入 · 工单号 · 确认人)
5a步骤 5:ACT-GATE-03 放行P-11 落账(analysis_status=FAE确认),衔接下游
5b步骤 5:ACT-GATE-03 驳回REWORK:回步骤 4 重审,或回步骤 1 重补输入
6a完结:至少一个推荐交下游衔接(ACT-WORK-08 / SS03 / ACT-WORK-24)
6b完结:全是条件推荐等验证闭环——通过→升级推荐;不过→降级不推荐
6c完结:全不推荐重新检索替代料 / 继续用原 PN / 与客户协商放宽约束
6d完结:客户不批准走独立审批流(customer_approval_status),不在本活动
+
+ + +
+ 口径与同目录工单及三份文档(活动流程与工单说明 / 一键启动入参说明 / 数据工单 note)一致;示例数据为演示占位。 + ACT-WORK-05 · 差异评估 · 全流程分支图 +
+ +
+ + diff --git a/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html new file mode 100644 index 0000000..d527d56 --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html @@ -0,0 +1,296 @@ + + + + + +执行工单 1 · 评估输入与资料补齐确认 · SS02 ACT-WORK-05 差异评估 + + + +
+
+ +
+
执行
+
+
SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1
+

评估输入与资料补齐确认

+
收件人:PW · 王工 · 关联任务:TASK-BT02-20260818-007
+
+
+ 执行工单 + 步骤 1/5 · 建立评估上下文 + 最迟交回:1 个工作日 +
+
+ +
+ +

【做什么】—— 启动 4 项已绑定,其余信息在工单里补齐

+
+

AI 已完成:系统已按启动传入的 4 项完成绑定——客户 customer_id、客户需求 request_description(新建 P-01 · 版本 V1)、原 PN、候选 PN(数组,逐个评估)——并生成输入快照(input_snapshot_ref)。本轮评估对象:LM2596S-5.0(TI)→ 3 个候选(XL2596S-5.0 / LM2596S-ADJ / 国产 DCDC)Pin-to-Pin 跨品牌替代(BT-02 场景)。

+

需要你补齐:启动时未收的信息(应用场景、BOM 位——不回表单,直接在下表补);系统已带出默认值(替代类型 Pin-to-Pin、规则版本 R-2026.07、负责人/期限),可确认可改。本单缺口只覆盖「有而不全」;「完全没有」(PN 不在库)由系统派数据工单创建整条记录,不在本单。datasheet 在数据工单里上传(选填),启动与本单都不收。点「编辑」在下表「补充内容」列填入;加一行 = 补充 AI 未列出的资料项,删一行 = 该输入项不适用。未补齐的缺口项将按 UNKNOWN 进入差异评估。

+
+ +

【交回什么】—— 评估输入清单(已绑定项只读核对,缺口项补齐,*为必填)

+ +
+ 3 项资料存在缺口或待确认(应用场景 / BOM 位 / 使能阈值),另有 3 项默认值已带出可改;「已绑定」行启动时冻结,不可改 + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#输入项来源状态绑定值 / 缺口说明补充内容
1客户 customer_idL-02已绑定CUS-2025-0007—
2客户需求 request_descriptionP-01已绑定REQ-20260826-001 · V1(启动时新建:工业电源 12V→5V,替代 LM2596S-5.0)—
3原内部料号 original_internal_pnP-08已绑定LM2596S-5.0(TI)—
4候选内部料号 ① candidate_internal_pn[]P-08已绑定XL2596S-5.0(XLSEMI)—
5候选内部料号 ② candidate_internal_pn[]P-08已绑定LM2596S-ADJ(TI 同族可调版)—
6候选内部料号 ③ candidate_internal_pn[]P-08已绑定国产 DCDC(32V 输入上限 · 占位型号)—
7应用场景 application_scenarioP-01待补*启动时未收,需向客户确认12V→5V、工业温域、40V 输入上限(示例已补)
8BOM 位 bom_positionP-09待补*启动时未收U3 电源位(示例已补)
9替代类型 substitution_type—默认·可改默认 Pin-to-Pin—
10规则版本 rule_versionGOV默认·可改默认当前发布版 R-2026.07(as_of 锁定,随结论落账)—
11负责人 / 期限 owner · deadline—默认·可改PW · 王工 / 审核 FAE · 李工 · 1 个工作日(按客户线默认)—
12候选 PN 使能阈值P-08待确认·需补原厂未披露(有而不全)关联验证任务 VAL-2026-0188
13原 PN 资料版本P-10齐备Rev H(2023-09)已核验—
14外部来源清单EXT-02齐备——
+
+ +
+ + 新加行的「补充内容」为必填;「绑定值 / 缺口说明」栏写清这项资料是什么、AI 为什么拿不到 +
+ +
审批 / 提交:本工单只承载内容;「提交」请在对话区的【执行卡】中进行。提交后去向:补齐后进入步骤 2 · AI 读取与标准化,仍缺失的项将按 UNKNOWN 进入差异评估。注意:启动参数如有误,需退回触发方修订后重新触发,本单不可改。
+ +
+ +
+ SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1 · 评估输入与资料补齐确认 + 工单类型:执行 · WO-ACT-WORK-05-01 +
+ +
+
+ + + + diff --git a/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-数据工单-01-芯片主数据创建.html b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-数据工单-01-芯片主数据创建.html new file mode 100644 index 0000000..98a0fde --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-数据工单-01-芯片主数据创建.html @@ -0,0 +1,341 @@ + + + + + +数据工单 1 · 芯片主数据创建(P-08)· SS02 ACT-WORK-05 差异评估 + + + + +
+
+ +
+
数据
+ +
+
SS02 · ACT-WORK-05 差异评估 · 数据工单 1/1
+

芯片主数据创建(P-08)

+
收件人:PW · 王工 · 关联任务:TASK-BT02-20260818-007
+
+
+ 数据工单 + 等待数据 · 步骤 0.5/5 + 最迟交回:1 个工作日 +
+
+ +
+ +

【做什么】—— 批量创建 P-08 主数据记录

+
+

模板定位:本单是「数据工单」统一模板的实例——一单一类(可多条):目标台账=P-08、本批创建=XL2596S-5.0(1 条,可加行增建);字段即表头、每行一条新记录。同一活动每次读数缺数据都可能再派发新实例(D02、D03…),模板与规则完全相同。

+

触发原因:系统按 internal_pn 查 P-08 无「XL2596S-5.0」记录——是「完全没有」,不是字段缺失。本单为系统判断派发的数据创建工单(与执行工单的「有而不全」严格区分,互不嵌套)。

+

AI 预填:本单已上传 datasheet(选填),AI 已预填 1 条记录 · 8 项字段 + 7 行参数(标「预填」),请核对并补齐空缺;若本单未上传 datasheet,则全部手填,同为合法路径。

+

纪律:确认即正式入库(无草稿、无审核),确认人 = 数据责任人并留痕;只建主数据 / 资料,事实与证据记录(如验证记录)须实测产生;在对话区【数据卡】确认后,活动自动从「等待数据」唤醒进入步骤 1,本单不复用。

+
+ +

【创建什么 · 表 1】—— 主数据记录(每行一条,*为必填列)

+ +
+ 每行一条新记录,点「编辑」核对预填、补齐空缺;「+ 加一行」= 再建一条;必填列(*)全齐才允许确认 + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#内部料号 internal_pn全链路统一基准,不可自由文本品牌 brand_name与制造商、供应商分开制造商 manufacturer_name与品牌不一致时分开记录规格描述 specification_description按原厂资料口径封装 package影响硬约束比对工艺 process_technology硬约束比较项生命周期 lifecycle_statusActive/NRND/EOL/停产/风险EOL/PCN eol_pcn_status条件必填:通知/影响/生效时间Datasheet 引用+版本成对条件:有引用必填版本适配场景 application_scenarios判断实际适用性
1XL2596S-5.0预填XLSEMI预填XLSEMI预填降压型 DCDC 稳压器,3A,固定 5.0V,150kHz预填TO-263-5预填BCD预填Active预填—xlsemi.com/datasheet/XL2596S-5.0.pdf · Rev C预填工业电源;消费电源
+
+ +
+ + 新行的必填列(*)全部填写后才允许确认;删一行 = 该记录不建 +
+ +

【创建什么 · 表 2】—— 标准化参数集 normalized_parameter_set(每行一个参数,按所属料号归属,*必填)

+ +
+ AI 已预填 7 行参数(全部归属 XL2596S-5.0),点「编辑」核对修正;「+ 加一行」补充未列出的参数 + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#所属料号 internal_pn归属表 1 哪条记录参数名值单位约束(硬/软)说明
1XL2596S-5.0预填输入电压范围4.5~40V硬约束必须覆盖客户输入(本例上限 40V)
2XL2596S-5.0预填输出电压5.0(固定)V硬约束固定版 vs 可调版是硬差异点
3XL2596S-5.0预填输出电流3A硬约束满载能力,须 ≥ 设计电流
4XL2596S-5.0预填开关频率150kHz软约束影响外围电感 / 电容选型
5XL2596S-5.0预填静态电流4.2mA软约束影响待机功耗
6XL2596S-5.0预填工作温度-40~+125℃硬约束工业温域必须覆盖
7XL2596S-5.0预填使能阈值未披露V硬约束未披露项将进入 UNKNOWN 并挂验证任务
+
+ +
+ + 所属料号 / 参数名 / 值 / 单位 / 约束类型齐全;「未披露」请直接写未披露,将进入 UNKNOWN 并挂验证任务;「说明」列为系统注释,无需填写 +
+ +
提交 / 确认:本工单只承载内容;确认请在对话区【数据卡】中进行。
+ +
+ +
+ SS02 · ACT-WORK-05 差异评估 · 数据工单 1/1 · 芯片主数据创建(P-08) + 工单类型:数据 · WO-ACT-WORK-05-D01 +
+ +
+
+ + + + diff --git a/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md new file mode 100644 index 0000000..35c4052 --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md @@ -0,0 +1,297 @@ +# ACT-WORK-05 差异评估 — 活动流程与工单说明 + +> 素材来源:`activity/SS02-需求分析与方案匹配/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2.html`(v1.2 Demo 架构版) +> 工单目录:`SS02-ACT-WORK-05-html/`(1 数据(条件触发)+ 1 执行 + 2 验收,共 4 份) +> 本套工单已按《SS02-SS03-SS04_Demo台账字段细化与统一方案》(2026-08)完成字段级对齐。 + +--- + +## 一、活动定位 + +- **活动**:ACT-WORK-05 差异评估,P0,所属 SS02 需求分析与方案匹配子系统 / BT-02 跨品牌替代选型场景,负责人 FAE/RI。 +- **是什么**:将「候选 PN 是否可替代」拆解为参数、封装、工艺、生命周期、应用边界和验证条件的逐项比较,形成逐项可复核的差异结论(MATCH / MISMATCH / UNKNOWN / CONDITIONAL),并保留证据、风险和下一步动作。输出 AI 分析草稿和 FAE 审核结论,**不自动批准替代关系**。 +- **产出物**:差异评估结论 + 推荐等级 recommendation_level(推荐 / 条件推荐 / 备选 / 不推荐),审核通过后落账 **P-11 替代关系主记录**(含 P-11 差异明细子表,一条参数差异一条记录 difference_item_id),并回写 **P-01**(风险等级、验证要求、输出链接、下一步动作、当前阶段)。 +- **触发**:双通道——①上游触发:ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定后;②一键手动启动:运营端按钮表单直接启动(绕过 ACT-GATE-02 路径判定,故启动理由必填、source_type=手动启动 留痕,详见《ACT-WORK-05-一键启动入参说明.md》)。**触发渠道仅是活动外部关注点,活动内部与工单不承载触发渠道信息——两种通道传入同一字段集,工单无差别呈现。** +- **门禁**:ACT-GATE-03 评审放行(本活动终点)。 +- **下游**:ACT-WORK-08 证据归集消费本活动结论。 +- **冷启动能力**:依赖库为空(PN 按 internal_pn 查无记录)时,系统派**数据工单**创建主数据,确认即入库后活动自动从「等待数据」唤醒继续——活动不因空库终止。 +- **台账读写**:P-01(R+W)、P-06(R/C,条件写)、P-08(R;无记录时经数据工单 W 创建)、P-09(R)、P-10(R;无记录时经数据工单 W 创建)、P-11(R+W;其中客户批准状态 customer_approval_status **只读边界**,由审批流程维护,本活动不产生)、GOV 规则(R,带版本锁定 rule_version)。 +- **进入条件(极简口径)**:启动入参仅 **4 项**——客户 customer_id、客户需求 request_description(新建 P-01·V1)、原 PN、候选 PN(数组 ≥1);应用场景、BOM 位、替代类型、规则版本、负责人/期限等**启动后在工单内补齐,不回表单**;datasheet 在数据工单里上传(选填),只影响 AI 预填程度。 +- **退出条件**:每个候选 PN 都有差异状态、证据/缺口、风险和验证建议,并完成 FAE/RI 审核。 + +## 二、整体流程(5 步工作链条) + +**步骤 1 · 建立评估上下文(PW/AI)** +- AI 做什么:绑定评估对象,生成输入快照。 +- AI 怎么做:读 → 经读数工具读 P-01(启动新建的 demand 主记录:客户、需求描述、版本 V1)、P-09(BOM 位 bom_position)、EXT-02(外部来源清单);加工 → 绑定启动 4 项(客户/需求/原 PN/候选数组),核对资料齐备度,标出缺口项(应用场景、BOM 位等待补);输出 → 输入快照 input_snapshot_ref(活动自持中间产物)。 +- 人做什么:**PW 介入**——补启动时未收的信息(应用场景、BOM 位),确认或调整系统默认值(替代类型、规则版本、负责人/期限),补 AI 拿不到的资料(实测记录等)(执行工单 01)。 +- 触发纪律:触发传入参数即活动进入条件,系统据此生成 input_snapshot_ref 并完成绑定后派执行工单 01,PW 仅补缺口,绑定参数不可改(双通道与渠道无感知原则详见第七节)。 + +**步骤 2 · 读取并标准化资料(AI)** +- AI 做什么:把多来源资料整理成可比对的标准参数表。 +- AI 怎么做:读 → P-08(原 PN 物料基准)、P-10(候选原厂 Datasheet、补证、承诺函,资料类型由 document_type 区分)、P-06(历史验证事实)、GOV 规则(评估口径);**每次读台账均做存在性检查:查无记录→主数据/资料类派数据工单(一单一类可多条),事实/证据类(如 P-06)挂验证任务**;加工 → 统一参数名称、单位、版本和证据引用,缺失项标记 UNKNOWN;输出 → 标准化参数对照表(活动自持)。 +- 人做什么:无需介入(纯 AI 加工态)。 + +**步骤 3 · 逐项差异评估(AI)** +- AI 做什么:逐项给出差异结论。 +- AI 怎么做:读 → 步骤 2 的标准化对照表 + GOV 规则版本 rule_version(取参数时同样做存在性检查:查无记录→派数据工单或挂验证任务);加工 → 按硬约束(封装/工艺等)与软约束(constraint_type)逐项判定,输出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL(difference_status),并说明影响、风险等级、证据引用与置信度;输出 → P-11 差异明细子表草稿(一条参数差异一条记录 difference_item_id,**审核前不落账**),不改变客户批准关系。 +- 人做什么:无需介入(AI 计算态),结论交步骤 4 审核。 + +**步骤 4 · FAE/RI 审核与补证(FAE/RI)** +- AI 做什么:把步骤 3 草稿整理成可审核视图(四态 pill + 证据链)。 +- AI 怎么做:读 → P-11 差异明细子表草稿;加工 → 按验收判据组织展示(漏项检查、UNKNOWN 去向、证据回链、硬约束检查);输出 → 审核工单视图。 +- 人做什么:**FAE/RI 介入**——确认参数解释、风险等级、证据可信度和验证要求,必要时退回补证;审核意见与补充条件条件写 P-06(验收工单 01)。 + +**步骤 5 · 提交评审放行(FAE/RI + Gate)** +- AI 做什么:汇总拟落账内容供确认。 +- AI 怎么做:读 → 审后结论;加工 → 生成拟写 P-11 替代关系主记录字段值(替代类型/原内部料号/候选内部料号/推荐等级/推荐理由/差异摘要/替代分析报告/分析状态/风险等级/验证要求/证据可信度/差异规则版本)与拟回写 P-01 字段值(风险等级/验证要求/输出链接/下一步动作/当前阶段);输出 → 落账确认视图。 +- 人做什么:**FAE/RI 介入**——确认拟落账内容无误后,在对话区卡提交 ACT-GATE-03(验收工单 02)。放行后更新 P-01 状态,结论供 ACT-WORK-08 证据归集消费;客户批准状态 customer_approval_status 仍由审批流程维护,本活动不写。 + +## 三、工单划分逻辑 + +本活动为**纯数据/内容生产型**(同 I01),无物理执行环节(没有拜访、递送、线下执行),因此**没有执行回执类工单**。本活动使用**三类工单**,触发条件与分工严格区分: + +| 类型 | 触发条件 | 判断方 | 人做什么 | 入库方式 | +|---|---|---|---|---| +| **数据工单** | **完全没有**:按 internal_pn 查台账(P-08 等主数据/资料类)无此记录 | 系统(硬查询) | 从零创建整条记录 | **确认即正式入库**(无草稿态、无审核环节),确认人=数据责任人并留痕 | +| **执行工单** | **有而不全**:PN 在库、个别字段/资料缺失 | AI(字段级判断) | 补缺字段、补资料链接 | 对话区【执行卡】提交,缺口可留 UNKNOWN | +| **验收工单** | AI 已产出结论,需人拍板 | AI 产出 + 人判断 | 通过 / 驳回 | 对话区【验收卡】通过后落账 | + +- 数据工单与执行工单**严格区分、互不嵌套**:一个是「整条记录不存在」(系统判断),一个是「记录存在但字段不全」(AI 判断)。 +- 数据工单**铁律一**:只能建主数据/资料类记录(P-08 / P-10 / S-03 / L-02),不能建事实/证据记录(P-06 验证记录须实测产生,只能挂任务)。 + +对生产流程逐步判定: + +- **步骤 0.5(存在性检查,条件触发)**:启动校验发现 PN 不在库 → 派 **数据工单 01**(芯片主数据创建,本活动唯一数据单),确认入库前活动停在「等待数据」状态,确认后自动唤醒。 +- **步骤 1(建立评估上下文)**:AI 能读台账建快照,但「参数原厂未披露、资料版本过旧」这类 **AI 拿不到的可信来源资料只有人能补** → 派 **执行工单 01**(评估输入与资料补齐确认,本活动唯一执行单)。 +- **步骤 2(读取并标准化)、步骤 3(逐项差异评估)**:均为**纯 AI 加工态**——读台账、标准化、按规则计算,不需要人补内容、也没有产出要人拍板的中间结论 → **不派工单**,其结论并入步骤 4 的验收单呈现。 +- **步骤 4(FAE/RI 审核)**:AI 已产出差异结论(四态 + 风险 + 证据),需要人判断「结论可信不可信、UNKNOWN 有没有去向」→ 派 **验收工单 01**(差异评估结论审核)。 +- **步骤 5(提交 ACT-GATE-03)**:AI 拟好了落账内容,需要人确认「无误后入库 / 放行」→ 派 **验收工单 02**(评审放行与落账确认)。 + +结论:**1 数据(条件触发)+ 1 执行 + 2 验收**。本活动无独立复盘流程(结论复用由下游 ACT-WORK-08 与后续替代/方案流程承担),无需拆时间轴。三条硬约束全程成立:工单不承载审批/确认动作(动作在对话区卡);要素完整(期限/判据/必填/去向);交回内容可校验(执行单必填*、验收单判据清单、数据单必填全齐才允许确认)。 + +## 四、每个工单详细说明 + +### 工单 0:数据工单 01 · 芯片主数据创建(WO-ACT-WORK-05-D01,条件触发) +- **对应步骤**:步骤 0.5 · 等待数据(惰性触发点之一;启动校验期的存在性检查只是第一次)。 +- **三条定稿规则**: + - **一单一类(可多条)**:每张数据工单只针对**一种台账**;**字段即表头、每行一条新记录,可一次创建多条**(如 P-08 一次建多个料号);主记录与结构子表(如参数集)分表承载,子表行以主键(所属料号)归属记录;其它台账的缺失另行派发新实例。 + - **惰性触发(不限于启动)**:活动执行到任何一步,**每次读台账**发现「完全没有」(按主键查无记录)即派一张;同一活动可派发多张,按派发顺序编号 WO-<活动>-D01/D02/…;活动进入「等待数据」状态,全部确认入库后从断点续跑。启动校验只是惰性触发的第一次,不是唯一一次。并发去重不考虑(一活动一负责人)。 + - **模板统一**:数据工单只有**一个统一模板**(类型级),职责只有「数据创建」;实例化时按目标台账的字段 schema(《统一方案》字段表)渲染表头 + AI 预填/人全填。实例之间只有两处不同:**目标台账(决定表头 schema)、预填内容(决定行集)**(本单:目标台账=P-08 `meta.data.target_ledger`、本批记录=XL2596S-5.0 `meta.data.target_key`)。 +- **触发**:系统按主键查台账**无此记录**(完全没有,系统硬查询)——原 PN 无数据=硬阻塞必须创建;候选 PN 无数据:候选数 ≥2 时人可二选一(放弃该候选【记录放弃原因】或创建),候选数 =1 时不能放弃、必须创建。事实/证据类查无(如 P-06 无验证记录)**不可创建**,只能挂验证任务。 +- **创建什么**:①表 1 主数据记录(**字段即表头、每行一条新记录、可加行批量创建**:internal_pn★ / brand_name★ / manufacturer_name / specification_description★ / package★ / process_technology / lifecycle_status★ / eol_pcn_status(条件)/ datasheet_ref+datasheet_version(成对条件)/ application_scenarios,字段说明放表头第二行小字,tbody `in.create_p08_fields`);②表 2 标准化参数集 normalized_parameter_set★(**每行一个参数、以「所属料号 internal_pn」列归属表 1 记录**:所属料号★/参数名/值/单位/约束类型/说明只读注释列,可加行,tbody `in.create_p08_params`,示例预填 7 行)。启动时传了 datasheet → AI 预填(计数槽 `sd.prefill_count`,示例 1 条记录 · 8 项字段 + 7 行参数);没传 → 人全填,同为合法路径。 +- **为什么两张表(粒度不同,一张表摆不平)**:表 1 的粒度是「一个料一行」——每个字段对每条记录只有一个值,字段集合由台账 schema 定死(主记录);表 2 的粒度是「一个参数一行」——每个料挂一串行数不固定的参数,每行内部还有 参数名/值/单位/硬软约束 四件套(结构子表)。两者行数没有固定比例,合成一张表只有两条死路:①参数塞进料号行的某格——一坨文本,机器没法逐项消费(差异评估要逐项比对)、人没法校验必填,违反《统一方案》「重复明细用子表承载,不在单一文本字段堆叠」;②参数也变成列——不同料的参数清单完全不同(DCDC 和 MCU 没有共同列),列数不固定,表头无法按 schema 统一渲染,通用模板破产。所以必须两张表,靠表 2 的「所属料号」列做连接键(映射主表+子表的外键关系):建 3 个料 → 表 1 三行、表 2 按料号分 3 组;入库时系统按该键把参数集挂到各自主记录下。 +- **确认即入库与责任人**:人在对话区【数据卡】点确认 → **直接入正式库(record_status=有效),无草稿态、无审核环节**;确认人=该数据责任人,系统留痕(确认人 / 数据工单号 / 所属活动);必填字段全齐才允许确认(表单级校验,不算审核)。入库后活动自动唤醒进步骤 1;入库后发现错误走主数据治理修正,工单不复用。 +- **铁律一**:只能建主数据/资料类记录(P-08 / P-10 / S-03 / L-02),不能建事实/证据记录(P-06 验证记录须实测产生,只能挂任务)。 +- **为什么是独立类型**:条件触发(只在「完全没有」时派发,非常驻);产出是**共享资产**(进正式库、跨活动复用,不是本活动的中间产物);模板直接来自台账字段定义(P-08 字段表),不是活动自定义表单;与执行工单「有而不全」严格区分、互不嵌套。 + +### 工单 1:执行工单 01 · 评估输入与资料补齐确认(WO-ACT-WORK-05-01) +- **对应步骤**:步骤 1/5 · 建立评估上下文。 +- **做什么**:确认触发传入参数已绑定(只读回显),补齐「有而不全」的缺口资料。 +- **AI 产出**:一张「评估输入清单」(`in.evaluation_inputs`)——按状态列区分:**已绑定**(灰底只读行,启动时冻结,仅 4 项:客户 customer_id、客户需求 request_description(新建 P-01 · V1)、原内部料号 original_internal_pn、候选内部料号 candidate_internal_pn[] 数组逐行)、**待补**(应用场景 application_scenario、BOM 位 bom_position——启动时未收)、**默认·可改**(替代类型 substitution_type 默认 Pin-to-Pin、规则版本 rule_version 默认 R-2026.07、负责人/期限按客户线默认)、**待确认·需补**(候选 PN 使能阈值原厂未披露,P-08)、**齐备**(原 PN 资料版本 P-10 Rev H 已核验、外部来源清单 EXT-02);编辑闸读数 `sd.input_gap_count` 显示缺口项数。 +- **人填什么**:缺口行「补充内容」列(必填*)——资料链接 / 任务编号;可加行补充 AI 未列出的资料项(新行自动标「新增·待补」),可删行声明不适用;已绑定行在编辑态下仍只读、不可删。未补齐项按 UNKNOWN 进入评估。启动参数如有误,退回触发方修订后重新触发,本单不可改。 +- **为什么是执行**:本单只覆盖「**有而不全**」——PN 已在库、个别字段缺失(AI 判断);「**完全没有**」(PN 不在库)由系统派数据工单创建,不在本单,两类互不嵌套。可信来源资料的获取在 AI 识别规则之外(原厂未公开、实测未做),只有 PW 能补。 + +### 工单 2:验收工单 01 · 差异评估结论审核(WO-ACT-WORK-05-02) +- **对应步骤**:步骤 3~4/5 · 逐项差异评估 + FAE/RI 审核(步骤 2~3 为纯 AI 加工,不单独派单,结论并入本单)。 +- **AI 产出**:P-11 差异明细子表草稿(7 行示例:输出电压/输出电流/开关频率/封装 MATCH,工作温度 MISMATCH 高风险,使能阈值 UNKNOWN 挂 VAL-2026-0188,生命周期 CONDITIONAL 凭 P-10 供货承诺函),每项带约束类型 constraint_type(硬/软约束)、四态 pill difference_status、风险等级、证据引用 evidence_ref、AI 置信度。 +- **人审什么**(4 条判据):关键参数差异均有明确状态(无漏项);UNKNOWN 项均有去向(挂验证任务或补来源);证据链完整(可追溯 P-08/P-10/P-06 或 EXT-02);硬约束项未被自动判 MATCH。 +- **为什么是验收**:结论是 AI 按规则算出的,FAE/RI 只拍板通过/驳回,不改内容;审核意见与补充条件写 P-06。 + +### 工单 3:验收工单 02 · 评审放行与落账确认(WO-ACT-WORK-05-03) +- **对应步骤**:步骤 5/5 · 提交 ACT-GATE-03。 +- **AI 产出**:两张只读表——拟写 P-11 替代关系主记录(替代类型 substitution_type / 原内部料号 original_internal_pn / 候选内部料号 candidate_internal_pn / 推荐等级 recommendation_level「条件推荐」pill / 推荐理由 / 差异摘要 / 替代分析报告 analysis_report_ref RPT-2026-0188 / 分析状态 analysis_status「FAE确认」/ 客户批准状态 customer_approval_status「未提交」/ 风险等级 / 验证要求 / 证据可信度 / 差异规则版本 rule_version);拟回写 P-01(风险等级 / 验证要求 VAL-2026-0188 / 输出链接 / 下一步动作 / 当前阶段)。 +- **人审什么**(4 条判据):推荐等级与差异明细一致(存在 UNKNOWN/CONDITIONAL 不得给「推荐」);分析状态与客户批准状态分离(本单只写分析层 analysis_status=FAE确认,customer_approval_status 保持未提交,不由差异评估产生);GOV 规则版本已锁定(R-2026.07);回写 P-01 字段完整。 +- **为什么是验收**:落账内容已由 AI 拟好,人只确认「无误后入库 + 放行」;客户批准不在本单范围(customer_approval_status 只读边界,由审批流程维护)。 + +## 五、文件对应关系与闭环流程图 + +| 文件 | 类型 | 对应步骤 | 一句话 | +|---|---|---|---| +| `ACT-WORK-05-数据工单-01-芯片主数据创建.html` | 数据 1/1(条件触发) | 步骤 0.5 | PN 不在库时系统派发,人创建整条 P-08 记录,确认即入库(责任人留痕)后活动唤醒 | +| `ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html` | 执行 1/1 | 步骤 1 | 评估输入清单:已绑定项只读核对 + 「有而不全」缺口项 PW 补齐,缺口项按 UNKNOWN 进入评估 | +| `ACT-WORK-05-验收工单-01-差异评估结论审核.html` | 验收 1/2 | 步骤 3~4 | FAE/RI 审核 AI 逐项差异结论(四态 + 证据链),意见写 P-06 | +| `ACT-WORK-05-验收工单-02-评审放行与落账确认.html` | 验收 2/2 | 步骤 5 | 确认拟写 P-11 / 回写 P-01 内容,提交 ACT-GATE-03 | +| `ACT-WORK-05-一键启动入参说明.md` | 说明文档 | 触发 | 运营端一键启动的入参总表、系统行为与工单衔接说明 | +| `ACT-WORK-05-全流程分支图.html` | 可视化 | 全流程 | 面向业务负责人的流程与全部分支一览(自包含零依赖) | + +``` +触发(双通道:ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定 | 一键手动启动,绕过 GATE-02,启动理由留痕) + │ + ▼ +步骤0.5 存在性检查(系统,条件触发)── PN 不在库?(全链路惰性检查的第一次;之后每次读台账仍可能再派 D02/D03…) + │──►【数据工单 01】芯片主数据创建 ── 一单一类可多条(字段即表头、每行一条),对话区【数据卡】确认即入库 + │ 确认前活动停「等待数据」;全部确认入库后从断点续跑 + ▼ +步骤1 建立评估上下文(PW/AI) + │──►【执行工单 01】评估输入与资料补齐确认 ── PW 补资料,对话区【执行卡】提交 + ▼ +步骤2 读取并标准化资料(AI,不派单)── 缺失项标 UNKNOWN + ▼ +步骤3 逐项差异评估(AI,不派单)── P-11 差异明细子表草稿(不落账) + ▼ +步骤4 FAE/RI 审核与补证 + │──►【验收工单 01】差异评估结论审核 ── 对话区【验收卡】通过/驳回 + │ 通过:意见与验证要求条件写 P-06 + │ 驳回:退回补证 / 验证(回步骤 1 或挂 VAL 任务) + ▼ +步骤5 提交评审放行 + │──►【验收工单 02】评审放行与落账确认 ── 对话区【验收卡】通过/驳回 + │ 通过:P-11 替代关系主记录落账 + P-01 回写 + 提交 ACT-GATE-03 + ▼ +ACT-GATE-03 评审放行(门禁,本活动终点) + ▼ +下游 ACT-WORK-08 证据归集消费 +(P-11.customer_approval_status 客户批准:全程只读边界,由审批流程另行维护) +``` + +## 六、动态数据标注 + +命名空间速查:`meta.*` 任务元数据 | `card.*` 工单身份 | `in.*` 执行/数据工单输入行集 | `out.*` 验收单输出行集 | `sd.*` 状态读数。 + +### 工单 0(数据 01)动态位置 + +| 位置 | 动态数据 | 绑定地址 | 类型 | +|---|---|---|---| +| 卡头 sub | 收件人 | `meta.task.owner` | data-slot | +| 卡头 sub | 关联任务 | `meta.task.id` | data-slot | +| 卡头 side | 当前步骤(等待数据 · 步骤 0.5/5) | `meta.phase` | data-slot | +| 卡头 side | 最迟交回 | `card.deadline` | data-slot | +| note 读数 | AI 预填项数 | `sd.prefill_count` | data-slot | +| 表 1 tbody | 主数据记录行集(每行一条记录,可加行批量创建) | `in.create_p08_fields` | data-rows | +| 表 1 表头 th / 单元格 td | 内部料号★/品牌★/制造商/规格描述★/封装★/工艺/生命周期★/EOL-PCN(条件)/Datasheet 引用+版本(成对)/适配场景 | internal_pn / brand_name / manufacturer_name / specification_description / package / process_technology / lifecycle_status / eol_pcn_status / datasheet_ref / application_scenarios | data-h / data-field | 列标签 / 列绑定 | +| 表 2 tbody | 标准化参数集行集(每行一个参数,按所属料号归属,可加行) | `in.create_p08_params` | data-rows | +| 表 2 表头 th / 单元格 td | 所属料号★ / 参数名 / 值 / 单位 / 约束类型 / 说明(只读注释 ro) | internal_pn / param / value / unit / constraint / desc | data-h / data-field | 列标签 / 列绑定 | +| 页脚 | 工单编号 | `card.id` | data-slot | + +### 工单 1(执行 01)动态位置 + +| 位置 | 动态数据 | 绑定地址 | 类型 | +|---|---|---|---| +| 卡头 sub | 收件人 | `meta.task.owner` | data-slot | +| 卡头 sub | 关联任务 | `meta.task.id` | data-slot | +| 卡头 side | 当前步骤 | `meta.phase` | data-slot | +| 卡头 side | 最迟交回 | `card.deadline` | data-slot | +| 编辑闸读数 | 缺口项数 | `sd.input_gap_count` | data-slot | +| 表格 tbody | 评估输入清单行集(已绑定行只读 + 缺口行可编辑) | `in.evaluation_inputs` | data-rows | +| 表头 th | 输入项 / 来源 / 状态 / 绑定值·缺口说明 / 补充内容(req*) | data-h | 列标签 | +| 单元格 td | input_item / source_ledger / status / detail / supplement | data-field | 列绑定 | +| 页脚 | 工单编号 | `card.id` | data-slot | + +### 工单 2(验收 01)动态位置 + +| 位置 | 动态数据 | 绑定地址 | 类型 | +|---|---|---|---| +| 卡头 sub | 收件人 | `meta.task.owner` | data-slot | +| 卡头 sub | 关联任务 | `meta.task.id` | data-slot | +| 卡头 side | 当前步骤 | `meta.phase` | data-slot | +| 卡头 side | 最迟交回 | `card.deadline` | data-slot | +| 表格 tbody | 差异明细行集(P-11 差异明细子表草稿,difference_item_id) | `out.difference_items` | data-rows | +| 表头 th | 参数项 parameter_name / 原PN值 original_value / 候选PN值 candidate_value / 单位 parameter_unit / 约束类型 constraint_type / 结论 difference_status / 风险等级 / 证据引用 evidence_ref / AI置信度 | data-h | 列标签 | +| 单元格 td | parameter_name / original_value / candidate_value / parameter_unit / constraint_type / difference_status / risk_level / evidence_ref / confidence | data-field | 列绑定 | +| 页脚 | 工单编号 | `card.id` | data-slot | + +### 工单 3(验收 02)动态位置 + +| 位置 | 动态数据 | 绑定地址 | 类型 | +|---|---|---|---| +| 卡头 sub | 收件人 | `meta.task.owner` | data-slot | +| 卡头 sub | 关联任务 | `meta.task.id` | data-slot | +| 卡头 side | 当前步骤 | `meta.phase` | data-slot | +| 卡头 side | 最迟交回 | `card.deadline` | data-slot | +| 表 1 tbody | 拟写 P-11 替代关系主记录行集 | `out.ledger_p11` | data-rows | +| 表 2 tbody | 拟回写 P-01 行集 | `out.writeback_p01` | data-rows | +| 表头 th | 字段 / 拟写入值 | data-h | 列标签 | +| 单元格 td | field / value | data-field | 列绑定 | +| 页脚 | 工单编号 | `card.id` | data-slot | + +--- + +## 七、启动与触发(实现规格) + +1. **双通道与渠道无感知原则**:触发方式 = ①上游触发(ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定)②运营端一键手动启动。两通道传入**同一字段集**;活动内部与工单不承载、不区分触发渠道。source_type / 启动理由属运营端留痕字段,不传入活动。 +2. **进入条件(启动校验,agent 必须在创建活动实例前校验,极简口径)**:启动入参仅 **4 项**——客户 customer_id、客户需求 request_description(无 P-01 记录则新建·版本 V1)、原 PN、候选 PN(数组 ≥1)。**应用场景、BOM 位、替代类型、规则版本、负责人/期限不是启动入参**——替代类型默认 Pin-to-Pin、规则版本默认当前发布版、负责人/期限按客户线默认,均在执行工单 01 列为「默认·可改」行由 PW 确认或调整;应用场景、BOM 位为「待补」行由 PW 补录。**datasheet 在数据工单里上传(选填)**——传了则 AI 在数据工单里预填,没传则人全填,不影响能否启动。 +3. **存在性检查 = 全链路惰性触发(系统硬查询)**:启动校验期的存在性检查只是**第一次**;活动执行中**每次读台账**(步骤 2 读 P-08/P-10/P-06、步骤 3 取参数等)发现「完全没有」均再派新数据工单(一单一类可多条,按派发顺序 D01/D02/…)。活动可并行等待多张数据工单,全部确认入库后从断点续跑。启动期的两档语义: + - 原 PN 无数据 → **硬阻塞**:必须派数据工单创建,确认入库前活动停在「等待数据」状态; + - 候选 PN 无数据 → 候选数 **≥2** 时人可二选一(放弃该候选【记录放弃原因】或派数据工单创建);候选数 **=1** 时不能放弃、必须创建; + - 数据工单确认入库后,活动自动唤醒进入步骤 1。 + - datasheet 的有无不参与拦截,只决定数据工单是「AI 预填」还是「人全填」。 +4. **任务锚点二选一(重点:request_id 与 requirement_version 是系统产出,不是入参)**: + - **关联已有需求**:选 request_id,版本自动带出; + - **快速创建**(客户口述需求未建档场景):入参 = customer_id(老客户下拉选;新客户先建 L-02,属分支 0a)+ 需求描述(口述整理成文字);系统行为 = **自动创建 P-01 记录、生成 request_id、requirement_version 默认 V1**,并把原 PN 与候选 PN 数组作为独立启动参数挂载绑定。 +5. **口述需求的版本纪律**:版本属于需求记录而非客户口述;客户后续补充 / 澄清 → 更新 P-01 记录,requirement_version 递增(V1→V1.1,旧版保留);每次评估锁 as_of 版本(input_snapshot_ref 记录当时按哪版评),全程可回溯。 +6. **启动后系统行为序列**:校验进入条件(4 项) →(快速创建时建 P-01 · V1)→ **存在性检查**(按 internal_pn 查台账,候选数组逐个查;缺则派数据工单,活动停「等待数据」,确认入库后唤醒)→ 绑定客户 / 需求 / 原 PN / 候选数组 + 落系统默认值(替代类型、规则版本、负责人/期限)→ 生成 input_snapshot_ref → 进入步骤 1 派执行工单 01。 +7. **冻结与退回纪律**:触发时绑定的参数在工单内只读不可改;绑错的唯一处理 = 退回触发方修订后重新触发(手动 → 退回运营端启动人;上游 → 退回上游活动)。 +8. 参数字段集详见同目录《ACT-WORK-05-一键启动入参说明.md》(运营端规格)。 + +## 八、完整流程分支总表(含示例) + +1. **示例场景**(口径注意:本活动只评技术可替代性,示例不涉及库存数量 / 采购情节):客户工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代;候选 A=XL2596S-5.0、B=LM2596S-ADJ、C=国产某 DCDC;一键快速创建需求单 REQ-20260826-001(V1);应用场景(12V→5V、工业温域、40V 输入上限)启动后在执行工单 01 补录。 +2. **分支总表**(18 条全列): + +| 分支 | 触发条件 | 处理与去向 | +|---|---|---| +| 0a | 启动期:新客户无档案 | 先建 L-02 客户档案(数据工单),再返回启动 | +| 0b | 启动期:候选无 P-08 主数据 | 同分支 1d:派数据工单登记创建 | +| 0c | 启动期:未传 datasheet | **不再拦截**——datasheet 已是选填,仅意味着数据工单由「AI 预填」变为「人全填」 | +| 1a | 步骤 1:绑定参数有误 | 退回触发方修订后重新触发(工单内不可改) | +| 1b | 步骤 1:输入存在缺口(有而不全) | 补缺口四情形:候选 PDF 未入库→补链接+版本;参数原厂未披露→挂验证任务(P-06);原 PN 资料版本过旧→补最新版;应用约束不清→回头问客户并更新需求单版本 | +| 1c | 步骤 1:缺口补不了 | 留 UNKNOWN 进入评估(合法路径,不阻断) | +| 1d | 任何读数点:查无记录(完全没有) | 惰性触发派数据工单(一单一类可多条,模板统一,可多实例 D01/D02/…):原 PN=硬阻塞·必须创建;候选 ≥2 可放弃【记录放弃原因】或创建(人判断);候选 =1 必须创建。确认即入库(责任人留痕)→ 活动从断点续跑。事实/证据类查无(如 P-06)不可创建,挂验证任务 | +| 2a | 步骤 2:单位冲突 | 阻断,回步骤 1 补证 | +| 2b | 步骤 2:证据版本冲突 | 人工复核哪份资料为准 | +| 3a | 步骤 3:硬约束 MISMATCH | 该候选直接「不推荐」(示例:候选 C 输入上限 32V < 客户 40V) | +| 4a | 步骤 4:UNKNOWN 无去向 | 退回步骤 1~2 补证 / 挂验证任务 | +| 4b | 步骤 4:证据链不全 | 退回补齐证据回链 | +| 5a | 步骤 5:ACT-GATE-03 放行 | P-11 落账(analysis_status=FAE确认),衔接下游 | +| 5b | 步骤 5:ACT-GATE-03 驳回 | REWORK:回步骤 4 重审,或回步骤 1 重补输入 | +| 6a | 完结:至少一个推荐 | 交下游衔接(ACT-WORK-08 / SS03 / ACT-WORK-24) | +| 6b | 完结:全是条件推荐 | 等验证闭环——通过→升级推荐;不过→降级不推荐 | +| 6c | 完结:全不推荐 | 重新检索替代料 / 继续用原 PN / 与客户协商放宽约束 | +| 6d | 完结:客户不批准 | 走独立审批流(customer_approval_status),不在本活动 | + +3. **结果矩阵(示例)**: + +| 候选 | 推荐等级 | 关键依据 | 下一步 | +|---|---|---|---| +| A · XL2596S-5.0(XLSEMI) | 条件推荐 | 温度范围更优(-40~+125℃ vs +85℃);使能阈值 UNKNOWN,挂 VAL-2026-0188 实测 | 验证闭环后转正 | +| B · LM2596S-ADJ(TI 同族可调版) | 备选 | 反馈网络不同(可调版 vs 固定 5V),需改 BOM 外围 | 客户接受改设计则启用 | +| C · 国产某 DCDC | 不推荐 | 硬约束失败:输入上限 32V < 客户 40V(分支 3a) | 归档,不再评估 | + +4. **多候选并行原则**:N 个候选 = N 条独立 P-11 记录,并行评估、独立落账,不存在「选一个再评下一个」。 +5. 可视化版本见同目录《ACT-WORK-05-全流程分支图.html》。 + +## 九、后端实现纪律(agent 必读,逐条不可违反) + +1. request_id / requirement_version / input_snapshot_ref 一律系统生成,agent 不得要求调用方传入。 +2. 活动实例创建前必须做进入条件校验与存在性检查;原 PN 无数据必须硬阻塞并派数据工单,返回缺什么;候选 PN 无数据按规则处理(≥2 可放弃【记录放弃原因】或创建,=1 必须创建)。 +3. 已绑定参数任何接口不允许修改;修改请求一律返回「退回触发方重新触发」。 +4. UNKNOWN 是一等公民:缺参数不得伪造、不得留空字符串冒充,必须显式标 UNKNOWN 并记录去向(挂验证任务 / 补来源)。 +5. 硬约束 MISMATCH 不得被任何人工操作改判 MATCH;人只能补证据后重评。 +6. analysis_status 与 customer_approval_status 严格分离:本活动只能推进 analysis_status(AI草稿→FAE确认),customer_approval_status 一律不碰(由独立审批流维护)。 +7. 规则版本 as_of 锁定:评估开始即记录 rule_version;评估中途 GOV 规则更新不得影响进行中的评估。 +8. 本活动输出只回答「技术可替代性」:不得在活动内做数量、价格、交期、采购判断(那是 SS03 / ACT-WORK-24 的事)。 +9. 执行工单缺口的处理闭环:补不了的缺口必须能以 UNKNOWN 合法放行,不得阻塞流程等人填(人可以不填)。 +10. 多候选并行:任何「单候选」假设(字段只存一个 candidate)都是错的,必须支持 N 候选独立评估与落账。 +11. 数据工单**确认即入库**:无草稿态、无审核环节,确认直接写正式库(record_status=有效);确认人=数据责任人,系统必须留痕(确认人 / 数据工单号 / 所属活动)。 +12. 数据工单必填字段全齐才允许确认(表单级校验,不算审核);缺必填不得放行确认。 +13. 事实/证据类记录禁止工单创建:P-06 验证记录等只能实测产生、挂任务闭环;数据工单只能建主数据/资料类记录(P-08 / P-10 / S-03 / L-02)。 +14. datasheet 选填:不得作为启动拦截条件;其有无只决定数据工单 AI 预填程度。无 datasheet 创建的数据,差异明细证据来源必须标「人工录入 · 数据工单 WO-xxx · 确认人 xxx」。 +15. 并发去重不考虑:一个活动只绑一个负责人,不实现数据工单的去重 / 合并逻辑。 +16. **一单一类(可多条)**:每张数据工单只针对一种台账,禁止混合台账;一张单可批量创建多条记录(字段即表头、每行一条);不同台账 / 不同读数点的缺失 = 多张单(D01/D02/…,按派发顺序编号)。 +17. **惰性触发不限于启动**:每次读台账都必须做存在性检查,查无记录即派单;不得只在启动时检查一次。活动可同时等待多张数据工单,全部确认后从断点续跑。 +18. **模板统一**:数据工单只有一个统一模板(类型级,职责只有数据创建);实例化按目标台账字段 schema(《统一方案》字段表)渲染表头,实例间仅两处可变——目标台账(决定表头 schema)、预填内容(决定行集)。 + +--- + +## 附:v1.2 待确认 4 项 — 落地状态 + +素材第六节列出 4 项待确认问题。按《SS02-SS03-SS04_Demo台账字段细化与统一方案》(2026-08),本套工单的落地状态如下: + +| # | 待确认问题 | 落地状态 | +|---|---|---| +| 1 | 差异明细正式归属是什么?(P0) | **已落地**:差异明细 = P-11 差异明细子表(difference_item_id,一条参数一条记录);验收工单 01 的 `out.difference_items` 行集即其草稿视图,含 parameter_name / original_value / candidate_value / parameter_unit / constraint_type / difference_status / evidence_ref 等字段。 | +| 2 | P-11 何时允许写入?(P0) | **已落地**:以 analysis_status 枚举区分写入时机(AI草稿→FAE确认),客户批准独立为 customer_approval_status 字段,由审批流程维护,不由差异评估产生;验收工单 02 只写分析层(analysis_status=FAE确认),customer_approval_status 保持「未提交」。 | +| 3 | 硬约束规则由哪个 GOV 对象维护?(P0) | rule_version 字段已落在差异明细子表(记录评估时采用的硬/软约束规则版本,本单锁 R-2026.07);维护方归属仍待 GOV 确认(P0 遗留)。 | +| 4 | 是否按 BOM 位独立评估?(P1) | bom_position 字段已落在 P-01 与 P-11 主记录(执行单评估输入清单以「BOM 位 bom_position」待补可编辑行承载,启动后补录,示例 U3 电源位);多 BOM 位边界不混用,不做按位独立评估。 | +| 5 | 冷启动与数据工单 | **已落地**:新增第三类工单「数据工单」——PN 不在库(完全没有,系统硬查询)时派发,人创建整条主数据记录,**确认即入库**(无草稿态、无审核环节),确认人=数据责任人并留痕;与执行工单「有而不全」严格区分、互不嵌套。 | diff --git a/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-01-差异评估结论审核.html b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-01-差异评估结论审核.html new file mode 100644 index 0000000..3093802 --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-01-差异评估结论审核.html @@ -0,0 +1,212 @@ + + + + + +验收工单 1 · 差异评估结论审核 · SS02 ACT-WORK-05 差异评估 + + + +
+
+ +
+
验收
+
+
SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2
+

差异评估结论审核

+
收件人:FAE · 李工 · 关联任务:TASK-BT02-20260818-007
+
+
+ 验收工单 + 步骤 3~4/5 · 逐项差异评估 + FAE/RI 审核 + 最迟交回:1 个工作日 +
+
+ +
+ +

【AI 结论说明】—— 结论是怎么算出来的

+
+

AI 已完成的计算:按 GOV 评估规则(锁定版本 R-2026.07,rule_version),对 Original=LM2596S-5.0(TI) / Candidate=XL2596S-5.0(XLSEMI) 逐项比对,产出四态结论——MATCH / MISMATCH / UNKNOWN / CONDITIONAL,并给出风险等级、证据引用与置信度。差异明细 = P-11 差异明细子表(一条参数差异一条记录,difference_item_id);当前为 AI 草稿,FAE 审核通过前不落账。评估参数可能含本次数据工单创建的主数据(确认人背书;未附 datasheet 的参数项会显式标注「人工录入」)。

+
+ +

【验收判据】—— 你据此判断「通过 / 驳回」

+
+
判断这份差异评估结论是否可信,请逐项核对
+
    +
  • 关键参数差异均有明确状态:逐项结论无漏项,每个参数都有四态之一的判定;
  • +
  • UNKNOWN 项均有去向:每个 UNKNOWN 都挂了验证任务(如 VAL-2026-0188)或补来源动作,不允许悬空;
  • +
  • 证据链完整:每项结论可追溯至 P-08 / P-10 / P-06 或 EXT-02 的具体记录,或追溯至数据工单创建记录(标注:人工录入 · 数据工单号 · 确认人);
  • +
  • 硬约束项未被自动判 MATCH:封装 / 工艺等硬约束未经证据支持不得判 MATCH。
  • +
+
+ +

【AI 产出的差异明细】—— 只读展示(P-11 差异明细子表草稿 · 一条参数一条记录 difference_item_id)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#参数项 parameter_name原 PN 值 original_value候选 PN 值 candidate_value单位 parameter_unit约束类型 constraint_type结论 difference_status风险等级证据引用 evidence_refAI 置信度
1输出电压5.05.0V硬约束MATCH低P-08 · P-100.98
2输出电流33A硬约束MATCH低P-08 · P-100.97
3开关频率150150kHz硬约束MATCH低P-08 · P-100.95
4封装TO-263-5TO-263-5—硬约束MATCH低P-08 · P-100.99
5工作温度-40~+85-40~+125℃硬约束MISMATCH高P-100.93
6使能阈值1.4未披露V硬约束UNKNOWN中挂 VAL-2026-0188—
7生命周期ActiveActive—软约束CONDITIONAL中P-10(候选供货承诺函)0.90
+
+ +
审批:本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。审核意见与补充条件写入 P-06(验证要求 verification_requirement);通过后去向:进入步骤 5 · 评审放行与落账确认。
+ +
+ +
+ SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2 · 差异评估结论审核 + 工单类型:验收 · WO-ACT-WORK-05-02 +
+ +
+
+ + diff --git a/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-02-评审放行与落账确认.html b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-02-评审放行与落账确认.html new file mode 100644 index 0000000..65e8795 --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-02-评审放行与落账确认.html @@ -0,0 +1,226 @@ + + + + + +验收工单 2 · 评审放行与落账确认 · SS02 ACT-WORK-05 差异评估 + + + +
+
+ +
+
验收
+
+
SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2
+

评审放行与落账确认

+
收件人:FAE · 李工 · 关联任务:TASK-BT02-20260818-007
+
+
+ 验收工单 + 步骤 5/5 · 提交 ACT-GATE-03 + 最迟交回:1 个工作日 +
+
+ +
+ +

【AI 结论说明】—— 本单确认什么

+
+

本活动以 ACT-GATE-03 评审放行为终点。本单确认拟落账内容:评估结论层写 P-11 替代关系主记录(替代类型 substitution_type / 原内部料号 original_internal_pn / 候选内部料号 candidate_internal_pn / 推荐等级 recommendation_level / 推荐理由 / 差异摘要 / 替代分析报告 / 分析状态 analysis_status / 风险等级 / 验证要求 / 证据可信度 / 差异规则版本),需求状态回写 P-01(风险等级 / 验证要求 / 输出链接 / 下一步动作 / 当前阶段)。客户批准不属于本活动——客户批准状态 customer_approval_status 由审批流程维护,本单只写分析层(analysis_status=FAE确认),customer_approval_status 保持「未提交」,不由差异评估产生。

+
+ +

【验收判据】—— 你据此判断「通过 / 驳回」

+
+
放行与落账前,请逐项核对
+
    +
  • 推荐等级与差异明细一致:差异明细中存在 UNKNOWN / CONDITIONAL 时,推荐等级 recommendation_level 不得给「推荐」;
  • +
  • 分析状态与客户批准状态分离:本单只写分析层(analysis_status=FAE确认),customer_approval_status 保持「未提交」,不由差异评估产生;
  • +
  • GOV 规则版本已锁定:本次评估按 R-2026.07 执行,rule_version 随结论一并落账;
  • +
  • 回写 P-01 字段完整:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全。
  • +
+
+ +

【拟写 P-11 · 替代关系主记录】—— 只读展示

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#字段拟写入值
1替代类型 substitution_typePin-to-Pin(BT-02 跨品牌)
2原内部料号 original_internal_pnLM2596S-5.0(TI)
3候选内部料号 candidate_internal_pnXL2596S-5.0(XLSEMI)
4推荐等级 recommendation_level条件推荐 (枚举:推荐 / 条件推荐 / 备选 / 不推荐,四态之一)
5推荐理由 recommendation_reason温度范围候选更优,使能阈值待 VAL-2026-0188 闭环
6差异摘要 difference_summary7 项比对:4 MATCH / 1 MISMATCH / 1 UNKNOWN / 1 CONDITIONAL
7替代分析报告 analysis_report_refRPT-2026-0188
8分析状态 analysis_statusFAE确认 (枚举:AI草稿 / FAE确认 / 待客户验证 / 客户已批准 / 不批准 / 作废)
9客户批准状态 customer_approval_status未提交 (由审批流程维护,本活动不产生)
10风险等级 risk_level中
11验证要求 verification_requirementVAL-2026-0188(使能阈值实测)
12证据可信度 confidence_score0.93
13差异规则版本 rule_versionR-2026.07
+
+ +

【拟回写 P-01 · 需求状态】—— 只读展示

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#字段拟写入值
1风险等级 risk_level中 (2 项待闭环)
2验证要求 verification_requirementVAL-2026-0188(使能阈值实测)
3输出链接 output_refRPT-2026-0188
4下一步动作 next_action提交 ACT-GATE-03
5当前阶段 current_stage提交 ACT-GATE-03
+
+ +
审批:本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后去向:在对话区卡提交 ACT-GATE-03 评审放行;放行后结论供 ACT-WORK-08 证据归集消费。
+ +
+ +
+ SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2 · 评审放行与落账确认 + 工单类型:验收 · WO-ACT-WORK-05-03 +
+ +
+
+ + diff --git a/docs/SS02-ACT-WORK-05-html/SS02-SS03-SS04_Demo台账字段细化与统一方案.md b/docs/SS02-ACT-WORK-05-html/SS02-SS03-SS04_Demo台账字段细化与统一方案.md new file mode 100644 index 0000000..3800a1a --- /dev/null +++ b/docs/SS02-ACT-WORK-05-html/SS02-SS03-SS04_Demo台账字段细化与统一方案.md @@ -0,0 +1,632 @@ +# SS02—SS03—SS04 Demo台账字段细化与统一方案 + +## 一、结论摘要 + +本成果以《SS01-02-03-04-台账体系全景-18Activity关联矩阵》的台账编号为唯一编号标准,对六组Demo Activity进行字段级反推和补齐:[1] [2] [3] [4] [5] [6]。结论是:**当前全景体系的台账编号总体可承载Demo,但核心内容描述仍不足以直接支撑执行,必须补充字段级定义、条件必填规则和主记录/子表结构。** + +本方案将Demo中的细分编号按全景标准归并,例如P-01-01→P-01、P-08-01→P-08、P-11-01/P-11-02→P-11的不同关系状态或子对象;不新增与全景体系冲突的台账编号。[1] [2] [3] 对于逐参数差异、逐报价阶梯和逐份单证等重复明细,采用子表承载,避免在单一文本字段中堆叠数据。 + +## 二、编号归并与建模原则 + +> **编号原则:**全景矩阵的44个台账编号是主键标准;Demo中的-01/-02属于局部架构或对象层标识,除非全景矩阵明确存在对应台账,否则不作为新的全景台账编号。 + +|Demo原编号/写法|统一全景编号|统一后的对象/说明| +|---|---|---| +|P-01-01|P-01|客户具体需求与项目任务主台账;任务、需求版本、报价上下文和Activity回链统一归入。| +|P-06-01|P-06|样品交付与芯片验证记录台账;验证事实与样品追溯统一归入。| +|P-08-01|P-08|芯片主数据台账;全链路只认internal_pn。| +|P-09-02|P-09|客户技术与机会档案;技术档案字段统一挂客户ID。| +|P-10-01/02/03|P-10|原厂技术资料库;资料类型由document_type区分。| +|P-11-01/P-11-02|P-11|替代选型与批准关系库;analysis_status与customer_approval_status分离,差异明细设子表。| +|P-04-01/P-04-02|P-04|交易框架与承诺台账;框架主记录与承诺子记录分层。| +|D-01物流F组/交接E组|D-01|交易与执行台账;交接事实与物流节点作为不同对象层,但共享order_id。| + +## 三、Activity覆盖校验 + +下表说明每个Demo的关键读写台账、字段补足点和覆盖结论。[1] [2] [3] [4] [5] [6] + +|Activity|原矩阵台账编号|统一台账编号|主写入台账|关键新增/细化字段|覆盖结论| +|---|---|---|---|---|---| +|ACT-WORK-05 差异评估|P-01-01、P-06-01、P-08-01、P-09-02、P-10-01/02/03、P-11-01、P-11-02、GOV|P-01、P-06、P-08、P-09、P-10、P-11、GOV|P-01、P-11;条件写P-06|requirement_version、input_snapshot_ref、bom_position、normalized_parameter_set、difference_item_id、difference_status、impact_statement、constraint_type、rule_version、confidence_score|已覆盖;差异明细由P-11差异明细子表承载,客户批准状态与差异分析状态分离。| +|ACT-WORK-24 报价方案制定|P-01-01、P-02-01、P-03-01、P-04-01/02、P-06-01、P-08-01、P-11-02、GOV|P-01、P-02、P-03、P-04、P-06、P-08、P-11、GOV|P-02;回写P-01|quote_version、quote_type、quote_valid_until、delivery_commitment、payment_terms、tax_terms、incoterms、exchange_rate、gross_margin_status、rule_version、QuoteLine、blocking_reason、substitution_approval_status|已覆盖;P-02采用主记录+QuoteLine明细结构,正式发布与AI草稿以状态和版本区分。| +|SS03 Activity 1 发起询价|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 RFQ层;条件写S-03|source_type/source_id、rfq_batch_id、rfq_line_id、quote_due_at、outbound_content_ref、sent_evidence_ref、reminder_count、last_reminded_at、overdue_flag、temporary_supplier_flag|已覆盖;最小粒度固定为询价批次×供应商×内部料号。| +|SS03 Activity 2 回填报价/比价/定点|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 Quote/Decision层|supplier_quote_id、quote_revision_no、comparability_status、comparison_basis_snapshot_ref、baseline_match_status、baseline_unit_price、comparison_currency_code、conversion_rate、price_variance_pct、selected_supplier_id、selected_quote_id、unselected_reason、notification_recipient_ref、score_rule_version|已覆盖;跨币种、无基准、不可比、涨幅>10%、代理证及临时供应商门禁均有字段承载。| +|ACT-11-A2 提货交接与风险转移|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03;触发D-04相关门禁|allocation_ref、handover_no、receiver_name、sign_time、risk_transfer_time、handover_diff、handover_file_ref、handover_doc_status、handover_notes、document_set_complete、consistency_check_status、allocation_status、last_ship_date|已覆盖;交接事实、风险转移派生、文件状态、单证门禁和库存同步均已落台账。| +|ACT-11-A3 物流执行信息跟踪|D-01、D-10|D-01、D-10|D-01物流节点事实层|logi_pickup/logi_clearance/logi_release/logi_departure(统一节点结构)、track_note、track_visible、logistics_confirmation_channel、logistics_confirmation_interval_days、logistics_max_reminder_count、alert_thresholds|已覆盖;每个节点包含预计时间、实际时间、来源、采集方式、确认状态、异常标记和证据引用。| + +## 四、字段统一总表 + +### 4.1 通用治理字段 + +以下字段建议作为所有涉及Demo台账的通用治理字段继承,但不要求在每张业务台账中重复手工维护。`record_status`只管理记录生命周期;`*_status`管理具体业务过程;`evidence_ref`用于AI和人工结果的可审计回链。 + +|统一字段名|中文字段名|数据类型|必填性|维护/来源|Demo使用|定义/取值/校验规则| +|---|---|---|---|---|---|---| +|record_id|记录ID|Text/AutoNumber|是|系统|所有Demo|每条台账记录的唯一主键;业务编号不能替代数据库主键。| +|record_status|记录状态|Enum|是|台账Owner|所有Demo|有效/草稿/归档/作废;不得用业务过程状态替代记录生命周期状态。| +|record_version|记录版本|Text/Integer|是|系统/Owner|W05/W24/SS03/11-A2/11-A3|每次影响业务判断的修改递增;报价、价格、规则、客户映射必须可回溯。| +|source_type|来源类型|Enum|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|来源对象的类型,如客户需求、采购计划、价格维护、异常补货、订单、外部回复。| +|source_id|来源对象ID|Text/Link|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|与source_type成对出现;不得把说明文字写入ID字段。| +|data_owner|数据Owner|Link|是|台账Owner|所有Demo|负责定义、质量和变更审批的角色或人员。| +|evidence_ref|证据引用|URL/Attachment/Structure|条件必填|AI/PW/Owner|W05/W24/SS03/11-A2/11-A3|指向原始邮件、聊天记录、签署件、数据表、资料页码或其他证据;可多值。| +|created_at|创建时间|DateTime|系统|系统|所有Demo|系统生成。| +|updated_at|更新时间|DateTime|系统|系统|所有Demo|系统生成。| +|last_verified_at|最后核验时间|DateTime|条件必填|Owner/PW/FAE/采购|所有Demo|对影响下游决策的事实标记最近一次人工或规则核验时间。| + +### 4.2 按全景台账编号展开的字段 + +#### P-01 客户具体需求与项目任务主台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|request_id|需求编号|Text/AutoNumber|是|主记录|SS02|W05/W24/SS03-1/SS03-2/D-01|客户需求与项目任务主记录的统一业务键;SS03写入S-04.source_id。|重命名/统一|任务ID、request_id、需求编号| +|customer_id|客户ID|Link|是|主记录|L-02/SS02|W05/W24/SS03-1/SS03-2|统一关联L-02;需求没有客户时需明确内部需求标识。|保留/细化|customer_id、客户| +|project_id|项目ID|Link|条件必填|主记录|P-05/SS02|W05/W24/SS03-1/SS03-2|有项目场景必填;与客户ID共同确定需求归属和结果通知对象。|新增/细化|project_id、客户/项目| +|requirement_version|需求版本|Text|是|主记录|SS02/系统|W05/W24|需求基线版本;差异评估与报价均按版本回溯。|新增/统一|任务描述/需求版本、版本| +|request_type|需求类型|Enum|是|主记录|SS02/系统|W05/W24/SS03|客户需求/RFQ/询价/技术需求/价格维护/其他;采购来源可由source_type补充。|新增/统一|需求类型、RFQ类型| +|request_description|需求描述|LongText|是|主记录|SS02/需求Owner|W05/W24|保留原始需求语义,不能只保留结构化字段。|新增/细化|任务描述| +|input_snapshot_ref|输入快照|URL/Attachment/Structure|W05必填;W24条件|主记录|系统/AI|W05/W24|记录本次评估或报价运行时的输入快照,支持版本回溯。|新增|输入快照| +|bom_id|BOM ID|Link|条件必填|主记录|SS02|W05/W24|有BOM场景必填;与bom_position一并确定物料位置。|新增|BOM范围| +|bom_position|BOM位|Text|条件必填|主记录|SS02/FAE|W05/W24|按BOM位记录原物料、替代物料和报价明细;不同BOM位批准边界不得混用。|新增|BOM位、PN/BOM范围| +|original_internal_pn|原内部料号|Link(P-08)|W05必填;其他条件|主记录|P-08/FAE|W05/W24|差异评估基准;引用P-08.internal_pn。|重命名/统一|原型号/原PN、原PN| +|candidate_internal_pn|候选内部料号|Link(P-08)|W05条件;W24条件|主记录|P-08/FAE|W05/W24|候选替代物料;多个候选应进入P-11子记录而非逗号拼接。|重命名/统一|候选PN、Candidate_PN| +|customer_pn|客户料号|Text|条件必填|主记录|L-02/D-10映射|W24/SS03/D-01|若客户使用客户料号,保留并与internal_pn分开。|新增/统一|customer_mpn、客户料号| +|application_scenario|应用场景|LongText/Enum|W05必填;W24条件|主记录|FAE/客户|W05/W24|解释需求约束、验证条件与报价适用场景。|保留/细化|应用场景、适配场景| +|target_qty|目标数量|Number/Structure|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的数量基准;阶梯放入数量阶梯子表。|重命名/统一|数量、target_qty、报价类型/数量| +|target_delivery_date|目标交期|Date|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的日期基准。|重命名/统一|target_delivery_date、目标交期| +|target_price_internal|内部目标价|Currency|否|主记录|SS02|W24/SS03|仅内部判断;默认不对外发送;币种必须同时记录。|保留/细化|target_price_internal、目标价| +|target_price_currency_code|内部目标价币种|ISO 4217 Enum|target_price有值时必填|主记录|SS02|W24/SS03|与target_price_internal成对出现。|新增/统一|currency| +|requested_price_types|所需价格类型|MultiEnum|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|参考价/量产价/现货价/样品价;上游默认值可补充,删除或替换需留痕。|重命名/统一|price_types_default、price_types_requested、报价类型| +|quote_due_at|期望报价返回时间|DateTime|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|客户需求来源从P-01读取;内部或维护型询价由采购填写。|重命名/统一|quote_return_due_at、quote_due_at、截止时间| +|technical_constraints|技术约束|Structure|W05/W24条件|主记录|FAE/AI|W05/W24|包含参数、单位、版本、硬约束/软约束,供差异评估和报价前提检查。|新增|参数边界、硬/软约束| +|risk_level|风险等级|Enum|W05/W24条件|主记录|FAE/BDPM|W05/W24|低/中/高/关键;必须记录确认人和时间。|统一|FAE人工判断/风险等级、风险等级| +|verification_requirement|验证/复测/确认要求|LongText/Structure|W05条件;W24条件|主记录|FAE|W05/W24|用于关闭UNKNOWN或CONDITIONAL,并可回链P-06验证记录。|重命名/统一|验证/复测/确认要求、验证要求| +|latest_quote_id|最新报价ID|Link(P-02)|W24/SS03条件|主记录|系统|W24/SS03-2|聚合P-02最新有效报价,不直接复制价格。|新增|关联报价、最新报价状态| +|latest_quote_status|最新报价状态|Lookup/Enum|W24/SS03条件|主记录|系统|W24/SS03-2|由P-02.quote_status/publish_status聚合。|重命名/统一|最新报价状态、报价状态| +|request_status|需求状态|Enum|是|主记录|SS02|W05/W24/SS03|待处理/已响应/已成交/已放弃/关闭;不得与Activity运行状态混用。|重命名/统一|状态机、需求状态| +|current_activity|当前Activity|Link/Enum|W05/W24条件|主记录|系统|W05/W24|如ACT-WORK-05、ACT-WORK-24;用于流程位置显示。|重命名/统一|当前Activity| +|current_stage|当前阶段|Enum/Text|W05/W24条件|主记录|系统/Owner|W05/W24|评估、报价、审批、发布等阶段。|新增/统一|当前阶段| +|next_action|下一步动作|LongText|W05/W24条件|主记录|FAE/BDPM/系统|W05/W24|明确责任人、动作和截止时间。|统一|下一步动作| +|output_ref|标准输出链接|URL/Attachment|W05/W24条件|主记录|系统/FAE/BDPM|W05/W24|差异报告或报价文件的正式输出链接。|重命名/统一|标准输出类型/输出文件链接、输出链接| + +#### P-02 报价与商务记录台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|quote_id|报价ID|Text/AutoNumber|是|报价主记录|系统|W24/SS03-2|报价主记录唯一标识。|保留/细化|报价ID| +|request_id|关联需求编号|Link(P-01)|是|报价主记录|系统|W24/SS03-2|回链P-01.request_id。|重命名/统一|关联任务、关联需求号| +|quote_version|报价版本|Text|是|报价主记录|系统/BDPM|W24|同一需求条件变化创建新版本;旧版本保留不可覆盖。|新增/统一|报价版本、版本| +|quote_type|报价类型|Enum|是|报价主记录|BDPM|W24|正式报价/参考价/项目报价/其他;与price_type(明细价格类型)区分。|新增/统一|报价类型| +|quote_status|报价状态|Enum|是|报价主记录|系统/BDPM|W24/SS03-2|草稿/待审核/已批准/已发布/已过期/已撤回。|重命名/统一|报价发布状态、报价状态| +|quote_currency_code|报价币种|ISO 4217 Enum|是|报价主记录|系统/BDPM|W24|报价金额的统一币种;明细价格若币种不同必须换算并留痕。|重命名/统一|报价价格/币种、currency| +|valid_from|报价生效时间|Date/DateTime|条件必填|报价主记录|BDPM|W24|正式发布报价必须有生效时间。|新增/统一|生效期| +|quote_valid_until|报价有效期至|Date|条件必填|报价主记录|BDPM|W24|不得与P-03价格来源有效期混用。|重命名/统一|报价有效期、valid_until| +|delivery_commitment|报价交期承诺|Date/Text/Structure|是|报价主记录|PW/BDPM|W24|记录交期数值、计算口径、来源和是否为承诺。|重命名/统一|报价交期、交期| +|payment_terms|付款条件|Text/Structure|条件必填|报价主记录|BDPM/P-04|W24|来源于P-04或本次商务确认。|新增/统一|付款| +|tax_terms|税费条件|Text/Structure|条件必填|报价主记录|BDPM|W24|税率、含税/未税口径必须明确。|新增|税费| +|incoterms|贸易术语|Enum|条件必填|报价主记录|BDPM/P-04|W24|使用标准Incoterms枚举并记录版本/地点。|新增/统一|Incoterms、交货条款| +|quote_assumptions|报价前提条件|LongText/Structure|是|报价主记录|BDPM|W24|含付款、税费、Incoterms、MOQ/SPQ、供货边界等。|重命名/细化|报价前提条件| +|technical_boundary|技术边界|LongText/Structure|条件必填|报价主记录|FAE/BDPM|W24|记录替代、验证、规格限制与客户批准状态。|新增|技术边界| +|cost_basis|成本基准|Currency/Structure|条件必填|报价主记录|BDPM/采购|W24|记录成本来源、币种、数量口径和时点。|新增|成本| +|exchange_rate|报价汇率|Number|跨币种时必填|报价主记录|系统/BDPM|W24|统一比较币种时使用;必须关联exchange_rate_source与as_of。|新增|汇率| +|exchange_rate_source|汇率来源|Text/URL|exchange_rate有值时必填|报价主记录|系统/BDPM|W24|记录汇率来源。|新增|汇率| +|exchange_rate_as_of|汇率时点|Date|exchange_rate有值时必填|报价主记录|系统|W24|统一币种计算基准日。|新增|汇率| +|gross_margin_status|毛利状态|Enum/Structure|条件必填|报价主记录|系统/BDPM|W24|未计算/通过/低于阈值/缺成本;口径由GOV规则维护。|新增|毛利状态| +|rule_version|报价规则版本|Text/Link|是|报价主记录|GOV/系统|W24|记录计算、毛利、价格优先级使用的规则版本。|新增/统一|规则版本| +|approval_status|商务审批状态|Enum|是|报价主记录|Gate/审批人|W24|未提交/待审批/已通过/已驳回;通过前不得发布。|重命名/统一|商务联审结论/审批记录| +|approval_comment|商务审批意见|LongText|条件必填|报价主记录|审批人|W24|驳回或有条件通过时必须填写。|新增/统一|审批记录、审批意见| +|publish_status|报价发布状态|Enum|是|报价主记录|系统/Gate|W24|草稿/待审批/已批准/已发布/过期/撤回。|重命名/统一|报价发布状态| +|quote_document_ref|正式报价文件链接|URL/Attachment|已发布时必填|报价主记录|系统/BDPM|W24|只指向正式发布版本。|重命名/统一|报价文件、台账链接| +|blocking_reason|阻断原因|LongText/Structure|条件必填|报价主记录|AI/BDPM|W24|价格过期、冲突、阶梯缺失、技术批准缺失等逐项记录。|新增|阻断原因| +|quote_line_id|报价明细ID|Text/AutoNumber|是|报价明细QuoteLine|系统|W24|最小粒度为报价版本×PN×BOM位×数量阶梯。|新增|QuoteLine| +|internal_pn|内部料号|Link(P-08)|是|报价明细QuoteLine|P-08/FAE|W24|报价物料统一引用P-08。|统一|PN| +|bom_position|BOM位|Text|条件必填|报价明细QuoteLine|FAE|W24|同一报价中不同BOM位不能混淆。|统一|BOM位| +|quantity_tier|数量阶梯|Structure|是|报价明细QuoteLine|AI/BDPM|W24|含min_qty、max_qty或阶梯描述。|拆分/结构化|数量阶梯| +|price_type|价格类型|Enum|是|报价明细QuoteLine|BDPM|W24|参考价/量产价/现货价/样品价等。|新增/统一|price_type| +|unit_price|报价单价|Currency|是|报价明细QuoteLine|系统/BDPM|W24|与currency_code成对存在。|重命名/统一|报价价格、单价| +|currency_code|明细币种|ISO 4217 Enum|是|报价明细QuoteLine|系统/BDPM|W24|明细币种;若与主报价币种不同必须记录换算。|重命名/统一|币种| +|lead_time_days|供货交期天数|Number|条件必填|报价明细QuoteLine|供应商/采购/BDPM|W24|统一为自然日或工作日一种口径,口径写入字段说明。|重命名/统一|LT、报价交期| +|moq_qty|最小起订量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与前提条件校验。|拆分/统一|MOQ| +|spq_qty|标准包装量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与包装约束校验。|拆分/统一|SPQ| +|price_source_ref|价格来源引用|Link(P-03/S-05/S-04)|是|报价明细QuoteLine|系统|W24|指向Price List、采购基准或专项询价记录。|新增/统一|来源报价、价格来源| +|substitution_approval_status|替代批准状态|Enum|条件必填|报价明细QuoteLine|P-11|W24|未检查/待验证/已批准/不批准;未满足时阻断发布。|新增|技术替代批准状态| + +#### P-03 Price List台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|price_list_id|PriceList ID|Text/AutoNumber|是|价格记录|系统/BDPM|W24|价格来源唯一标识。|重命名/统一|PriceListID| +|internal_pn|内部料号|Link(P-08)|是|价格记录|P-08/价格Owner|W24|统一引用P-08。|重命名/统一|PN/料号| +|customer_id|适用客户ID|Link(L-02)|条件必填|价格记录|价格Owner|W24|客户专属价必填;公共渠道价可用scope_type标识。|新增|客户或渠道| +|price_scope_type|价格适用范围|Enum|是|价格记录|BDPM|W24|客户专属/渠道/公共/项目。|新增|客户或渠道| +|price_type|价格类型|Enum|是|价格记录|BDPM|W24|与报价所需价格类型对齐。|新增/统一|价格类型| +|list_unit_price|Price List单价|Currency|是|价格记录|价格Owner|W24|与currency_code成对。|重命名/统一|客户专属价/单价| +|currency_code|价格币种|ISO 4217 Enum|是|价格记录|价格Owner|W24|统一币种编码。|重命名/统一|币种| +|release_version|Release版本|Text|是|价格记录|价格Owner|W24|报价时按as_of选取有效版本。|重命名/统一|Release版本、版本| +|valid_from|生效时间|Date|是|价格记录|价格Owner|W24|与valid_to组成有效区间。|保留/细化|生效期| +|valid_to|失效时间|Date|是|价格记录|价格Owner|W24|报价时点必须落在有效区间内。|重命名/统一|失效期、有效期| +|quantity_tier|数量阶梯|Structure|条件必填|价格记录|价格Owner|W24|含适用数量和折扣条件。|拆分/结构化|数量阶梯/折扣条款| +|discount_terms|折扣条款|LongText/Structure|条件必填|价格记录|价格Owner|W24|结构化记录折扣条件和例外。|细化|折扣条款| +|source_quote_id|来源报价ID|Link(P-02/S-04)|条件必填|价格记录|系统/BDPM|W24|能追溯价格来源。|重命名/统一|来源报价| +|price_status|价格状态|Enum|是|价格记录|价格Owner|W24|草稿/有效/暂停/过期/替代。|新增/统一|状态| + +#### P-04 交易框架与承诺台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|framework_id|交易框架ID|Text/AutoNumber|是|框架主记录|商务Owner|W24|统一框架合同主键。|重命名/统一|框架ID| +|customer_id|客户ID|Link(L-02)|是|框架主记录|商务Owner|W24|交易框架归属客户。|保留/统一|客户名称| +|contract_version|合同版本|Text|是|框架主记录|商务Owner|W24|报价采用的合同/框架版本。|重命名/统一|合同版本| +|contract_status|合同状态|Enum|是|框架主记录|商务Owner|W24|草稿/有效/到期/终止。|保留/细化|合同状态| +|account_status|交易账户状态|Enum|是|框架主记录|商务Owner|W24|正常/冻结/待准入/关闭。|重命名/统一|交易账户状态| +|payment_terms|账期与付款条款|Text/Structure|条件必填|框架主记录|商务Owner|W24|供报价继承;与D-04信用台账区分。|重命名/统一|账期条款、付款条件| +|credit_limit|信用额度|Currency|条件必填|框架主记录|商务Owner|W24/11-A2条件|可供信用门禁使用。|保留/细化|信用额度| +|cooperation_scope|合作范围|LongText/Structure|条件必填|框架主记录|商务Owner|W24|定义品牌、产品线、区域等边界。|保留/细化|合作范围| +|incoterms|默认贸易术语|Enum|条件必填|框架主记录|商务Owner|W24|报价若覆盖本条件,回链本字段。|新增/统一|交易条款、Incoterms| +|effective_from|框架生效时间|Date|是|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期| +|effective_to|框架失效时间|Date|条件必填|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期| +|commitment_id|承诺ID|Text/AutoNumber|条件必填|承诺子记录|系统/商务Owner|W24|每条价格、交期、数量或条件承诺一个ID。|重命名/统一|承诺ID| +|commitment_party|承诺方|Enum/Link|是|承诺子记录|商务Owner|W24|我方/客户/供应商/其他。|统一|承诺方| +|commitment_type|承诺类型|Enum|是|承诺子记录|商务Owner|W24|价格/交期/数量/质量/付款/其他。|新增/统一|承诺内容| +|commitment_content|承诺内容|LongText/Structure|是|承诺子记录|商务Owner|W24|结构化值+说明,避免只写自由文本。|细化|承诺内容| +|commitment_due_date|承诺期限|Date|条件必填|承诺子记录|商务Owner|W24|到期前可触发提醒。|重命名/统一|期限| +|commitment_status|承诺状态|Enum|是|承诺子记录|商务Owner/系统|W24|未生效/生效/已兑现/逾期/取消。|重命名/统一|状态| +|fulfillment_record_ref|兑现记录引用|Link/URL|条件必填|承诺子记录|系统/履约|W24|回链D-01/D-09等执行事实。|重命名/统一|兑现记录| +|unresolved_risk_list|未决商务风险清单|LongText/Structure|条件必填|主记录|商务Owner|W24|报价前置风险;未关闭时可阻断审批。|重命名/细化|未决风险清单、商务风险| + +#### P-06 样品交付与芯片验证记录台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|validation_id|验证ID|Text/AutoNumber|是|验证主记录|系统|W05/W24|验证记录唯一标识。|保留/统一|Validation_ID、验证编号| +|request_id|关联需求编号|Link(P-01)|条件必填|验证主记录|系统/FAE|W05/W24|回链需求与验证目的。|新增/统一|需求号| +|project_id|关联项目ID|Link(P-05)|条件必填|验证主记录|系统/FAE|W05/W24|项目场景验证必填。|新增/统一|项目| +|customer_id|客户ID|Link(L-02)|条件必填|验证主记录|系统/FAE|W05/W24|客户验证必填。|新增/统一|客户ID| +|validation_internal_pn|验证内部料号|Link(P-08)|是|验证主记录|FAE|W05/W24|统一引用P-08.internal_pn。|重命名/统一|Validation_PN、验证PN| +|validation_type|验证类型|Enum|是|验证主记录|FAE|W05/W24|Pin-to-Pin/功能/可靠性/认证/复测。|新增/统一|验证类型| +|test_condition|测试条件|LongText/Structure|是|验证主记录|FAE/验证Owner|W05|环境、输入、负载、样品状态等。|重命名/统一|测试条件| +|test_parameters|测试参数|Structure|是|验证主记录|FAE/验证Owner|W05|参数名、值、单位、版本必须结构化。|重命名/统一|测试参数| +|test_result|测试结果|LongText/Structure|是|验证主记录|验证Owner|W05|实测数据与结论,必要时附原始数据。|重命名/统一|测试结果| +|validation_status|验证状态|Enum|是|验证主记录|FAE/验证Owner|W05/W24|未开始/进行中/通过/不通过/条件通过/待补证。|新增/统一|通过状态、验证状态| +|issue_risk|问题与风险|LongText/Structure|条件必填|验证主记录|AI/FAE|W05/W24|记录未关闭问题、风险等级、责任人和关闭条件。|重命名/统一|问题与风险| +|sample_version|样品版本|Text|条件必填|验证主记录|验证Owner|W05|样品追溯。|重命名/统一|样品版本| +|sample_lot|样品批次|Text|条件必填|验证主记录|验证Owner|W05|与D-07或样品出库记录回链。|重命名/统一|样品批次、批次| +|evidence_ref|验证证据引用|URL/Attachment/Structure|是|验证主记录|验证Owner/FAE|W05/W24|原始数据、照片、报告、资料页码等。|统一|证据引用| +|next_validation_action|后续验证动作|LongText/Structure|条件必填|验证主记录|FAE|W05/W24|UNKNOWN/CONDITIONAL时必须有关闭动作。|新增|验证建议/缺口| + +#### P-08 芯片主数据台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|internal_pn|内部料号|Text|是|物料主记录|SS02/FAE|W05/W24/SS03/11-A2|全链路料号统一基准;不可使用自由文本替代。|重命名/统一|标准型号PN、pn、mpn| +|customer_pn|客户料号|Text|条件必填|物料主记录|SS02/D-10映射|W24/SS03/11-A2|客户料号映射优先维护在D-10,P-08可作为别名索引。|新增/统一|customer_mpn| +|brand_name|品牌名称|Text/Link|是|物料主记录|FAE/数据Owner|W05/W24/SS03/11-A2|品牌统一引用;外部供应商品牌与原厂制造商分开。|重命名/统一|brand、manufacturer_brand、原厂或品牌| +|manufacturer_name|制造商名称|Text/Link|条件必填|物料主记录|数据Owner|W05/W24|品牌与制造商不一致时分开记录。|新增/统一|原厂| +|specification_description|规格描述|LongText|是|物料主记录|数据Owner/原厂资料|W05/W24|标准规格文本。|重命名/统一|Specification Description、产品描述| +|normalized_parameter_set|标准化参数集|Structure|W05必填;W24条件|物料主记录|FAE/数据Owner|W05/W24|参数名、值、单位、精度、版本,用于硬/软约束比较。|新增|参数名称、单位、版本| +|package|封装|Enum/Text|条件必填|物料主记录|数据Owner|W05/W24/SS03|影响硬约束和报价识别。|保留/细化|封装| +|process_technology|工艺|Text/Enum|条件必填|物料主记录|数据Owner|W05|硬约束比较项。|重命名/统一|工艺| +|lifecycle_status|生命周期状态|Enum|是|物料主记录|数据Owner|W05/W24/SS03|Active/NRND/EOL/停产/风险。|重命名/统一|生命周期状态、lifecycle_status、产品状态| +|eol_pcn_status|EOL/PCN状态|Enum/Structure|条件必填|物料主记录|数据Owner/原厂|W05/W24|记录通知状态、影响和生效时间。|新增/统一|EoL/PCN状态| +|datasheet_ref|Datasheet引用|URL/Link(P-10)|条件必填|物料主记录|P-10/原厂|W05/W24|官方资料链接。|重命名/统一|Datasheet链接| +|datasheet_version|Datasheet版本|Text|datasheet_ref有值时必填|物料主记录|P-10/原厂|W05/W24|用于证据版本回链。|重命名/统一|Datasheet版本/版本| +|application_scenarios|适配应用场景|MultiEnum/Structure|条件必填|物料主记录|FAE/数据Owner|W05/W24|判断物料实际适用性。|重命名/统一|应用场景、适配场景| +|pin_to_pin_relation_ref|Pin-to-Pin关系引用|Link(P-11)|条件必填|物料主记录|FAE|W05/W24|引用P-11关系记录,避免在P-08复制批准结论。|新增/统一|Pin-to-Pin替代关系| + +#### P-09 客户技术与机会档案 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|customer_id|客户ID|Link(L-02)|是|客户技术档案|SS02/FAE|W05/W24|区别于L-02的商务画像,技术档案以客户ID为主键。|保留/统一|客户 ID| +|technical_stack|客户技术栈|Structure|条件必填|客户技术档案|FAE|W05/W24|记录平台、架构、关键技术约束。|重命名/统一|技术栈| +|product_line_direction|产品线技术方向|Text/Structure|条件必填|客户技术档案|FAE/BDPM|W24|报价上下文与应用场景。|重命名/统一|产品线方向| +|bom_brand_usage|BOM品牌使用情况|Structure|条件必填|客户技术档案|FAE|W05/W24|记录客户当前BOM品牌、料号及替代空间。|重命名/统一|BOM品牌| +|validation_process|客户验证流程|LongText/Structure|条件必填|客户技术档案|FAE/客户|W05/W24|支持验证要求和报价前提判断。|重命名/统一|验证流程| +|technical_decision_maker_ref|技术决策人引用|Link(L-08)|条件必填|客户技术档案|FAE|W05/W24|引用联系人,不复制姓名。|重命名/统一|技术决策人| +|rd_capability_assessment|研发能力评估|Enum/LongText|条件必填|客户技术档案|FAE|W05/W24|用于技术支持与验证路径判断。|重命名/统一|研发能力| +|technical_preference_tags|技术偏好标签|MultiEnum|条件必填|客户技术档案|FAE|W05/W24|用于推荐和方案匹配。|重命名/统一|技术偏好标签| +|opportunity_id|机会ID|Text/AutoNumber|条件必填|机会子记录|系统/BDPM|W05/W24|需求或报价对应的机会对象。|新增/统一|机会记录| + +#### P-10 原厂技术资料库 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|document_id|资料ID|Text/AutoNumber|是|资料主记录|系统|W05/W24|资料唯一标识。|新增/统一|资料记录| +|document_type|资料类型|Enum|是|资料主记录|FAE|W05/W24|Datasheet/应用笔记/参考设计/白皮书/样片资料/其他。|新增/统一|资料类型| +|internal_pn|关联内部料号|Link(P-08)|条件必填|资料主记录|P-08/FAE|W05/W24|料号相关资料必须回链P-08。|新增/统一|PN| +|brand_name|品牌名称|Text/Link|是|资料主记录|数据Owner|W05/W24|统一引用P-08.brand_name。|重命名/统一|原厂| +|document_title|资料标题|Text|是|资料主记录|资料Owner|W05/W24|资料可识别名称。|新增|资料| +|document_version|资料版本|Text|是|资料主记录|资料Owner|W05/W24|评估必须回链版本。|重命名/统一|版本| +|document_date|资料发布日期|Date|条件必填|资料主记录|资料Owner|W05/W24|用于时效判断。|新增|更新记录| +|document_ref|资料文件链接|URL/Attachment|是|资料主记录|资料Owner|W05/W24|官方资料文件。|重命名/统一|Datasheet链接、资料| +|citation_locator|证据定位|Text/Structure|W05必填;W24条件|资料主记录|AI/FAE|W05/W24|页码、章节、表格或段落,支持参数证据引用。|新增|证据引用、页码或证据段落| +|source_contact_ref|原厂支持联系人|Link(L-08)/Text|条件必填|资料主记录|FAE|W05/W24|需要补证时的联系人。|重命名/统一|原厂支持联系人| +|document_status|资料状态|Enum|是|资料主记录|资料Owner|W05/W24|有效/过期/待核实/作废。|新增/统一|状态| + +#### P-11 替代选型与批准关系库 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|substitution_relation_id|替代关系ID|Text/AutoNumber|是|替代关系主记录|系统|W05/W24|原P-11-01与P-11-02统一后的关系主键。|新增/统一|P-11-01/P-11-02记录ID| +|original_internal_pn|原内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|评估基准物料。|重命名/统一|Original_PN、原PN、目标替代料号| +|candidate_internal_pn|候选内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|被评估物料。|重命名/统一|Candidate_PN、候选PN| +|substitution_type|替代类型|Enum|是|替代关系主记录|FAE|W05/W24|Pin-to-Pin/功能替代/参数替代/其他。|重命名/统一|替代类型、关系类型| +|recommendation_level|推荐等级|Enum|是|替代关系主记录|AI/FAE|W05/W24|推荐/条件推荐/备选/不推荐。|重命名/统一|替代等级、推荐| +|recommendation_reason|推荐理由|LongText|是|替代关系主记录|AI/FAE|W05/W24|说明技术和商务依据。|新增/统一|推荐理由| +|difference_summary|差异摘要|LongText|是|替代关系主记录|AI/FAE|W05|供P-01和后续流程消费。|新增/统一|评论、差异摘要| +|difference_item_id|差异明细ID|Text/AutoNumber|是|差异明细子表|系统|W05|一条参数差异一条记录。|新增|逐参数差异明细| +|parameter_name|标准参数名|Text/Link(GOV)|是|差异明细子表|AI/FAE|W05|参数名称标准化。|新增/统一|参数名称| +|original_value|原物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数A| +|candidate_value|候选物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数B| +|parameter_unit|参数单位|Text/Link(GOV)|条件必填|差异明细子表|AI/FAE|W05|统一单位后比较。|新增/统一|单位| +|constraint_type|约束类型|Enum|是|差异明细子表|GOV/AI|W05|硬约束/软约束。|新增/统一|硬/软约束标识| +|difference_status|差异状态|Enum|是|差异明细子表|AI/FAE|W05|MATCH/MISMATCH/UNKNOWN/CONDITIONAL。|新增/统一|Status、差异状态| +|impact_statement|影响说明|LongText|MISMATCH/CONDITIONAL必填|差异明细子表|AI/FAE|W05|说明对应用、验证、报价的影响。|新增|Impact、影响说明| +|evidence_ref|差异证据引用|URL/Attachment/Structure|是|差异明细子表|AI/FAE|W05|具体资料、页码、测试记录。|统一|证据引用| +|rule_version|差异规则版本|Text/Link(GOV)|是|差异明细子表|GOV/系统|W05|记录评估时采用的硬/软约束规则版本。|新增/统一|GOV规则版本| +|risk_level|替代风险等级|Enum|是|替代关系主记录|FAE/RI|W05/W24|低/中/高/关键。|统一|风险等级| +|verification_requirement|验证要求|LongText/Structure|CONDITIONAL/UNKNOWN必填|替代关系主记录|FAE|W05/W24|与P-06.validation_id或待验证任务回链。|统一|验证要求| +|validation_id|关联验证ID|Link(P-06)|条件必填|替代关系主记录|系统/FAE|W05/W24|验证完成后回链事实。|新增/统一|验证编号、验证结果| +|analysis_status|分析状态|Enum|是|替代关系主记录|系统/FAE|W05/W24|AI草稿/FAE确认/待客户验证/客户已批准/不批准/作废。|新增/统一|写入权限与时机、批准状态| +|customer_approval_status|客户批准状态|Enum|是|替代关系主记录|审批流程|W05/W24|未提交/待批准/已批准/不批准/有条件批准;不由差异评估自动产生。|重命名/统一|批准状态、客户批准| +|customer_approval_at|客户批准时间|DateTime|客户已批准时必填|替代关系主记录|审批流程|W05/W24|客户批准事实时间。|新增/统一|批准时间| +|customer_approval_ref|客户批准凭证|URL/Attachment|客户已批准时必填|替代关系主记录|审批流程|W05/W24|签核邮件、系统审批单或客户文件。|新增/统一|批准凭证| +|customer_feedback|客户反馈|LongText|条件必填|替代关系主记录|FAE/BDPM|W05/W24|记录客户对候选物料的反馈。|保留/细化|客户反馈| +|request_id|关联需求编号|Link(P-01)|条件必填|替代关系主记录|系统|W05/W24|明确候选关系适用的需求。|新增/统一|关联需求| +|project_id|关联项目ID|Link(P-05)|条件必填|替代关系主记录|系统|W05/W24|不同项目批准边界可不同。|新增/统一|关联项目| +|bom_position|关联BOM位|Text|条件必填|替代关系主记录|FAE|W05/W24|批准边界定位。|新增/统一|BOM位| +|analysis_report_ref|替代分析报告|URL/Attachment|条件必填|替代关系主记录|系统/FAE|W05|正式结构化结果或附件回链。|重命名/统一|替代分析报告、报告链接| +|confidence_score|证据可信度|Percent/Enum|条件必填|替代关系主记录|AI/FAE|W05|AI输出可信度,需与人工确认区分。|新增/统一|证据可信度| + +#### S-03 供应商主台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|supplier_id|供应商ID|Text/AutoNumber|是|供应商主记录|SS03|SS03-1/SS03-2|统一供应商主体键;临时供应商定点前必须转正式ID。|保留/统一|supplier_id、Supplier ID| +|supplier_name|供应商名称|Text|是|供应商主记录|采购/SS03|SS03-1/SS03-2|临时供应商可先占位,定点前补齐正式名称。|保留/统一|supplier_name、供应商名称| +|supplier_type|供应商分类|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|原厂/授权代理/贸易/分销/其他。|保留/细化|supplier_type、供应商分类| +|supplier_status|供应商状态|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|临时/正式/停用;临时状态不可定点。|新增/统一|supplier_status| +|temporary_supplier_flag|临时供应商标记|Boolean|是|供应商主记录|采购|SS03-1/SS03-2|临时新增立即可询价,但必须受定点门禁约束。|新增|临时供应商| +|sales_contact_ref|业务联系人引用|Link/Text|SS03-1必填|供应商主记录|采购/供应商Owner|SS03-1|至少一名联系人,用于邮件或即时通讯外发。|重命名/统一|sales_contact、联系人| +|contact_channel|首选联系渠道|Enum|条件必填|供应商主记录|供应商Owner|SS03-1|邮件/即时通讯/电话;需与外发记录一致。|新增/统一|send_channel、确认通道| +|rfq_product_scope|当前询价品牌/料号范围|Structure|临时供应商定点前必填|供应商主记录|采购|SS03-1/SS03-2|允许不预建完整品牌覆盖,但必须覆盖当前询价。|新增/统一|rfq_product_scope| +|authorized_brand_scope|授权品牌范围|Structure|代理/原厂条件必填|供应商主记录|SS03|SS03-1/SS03-2|判断品牌授权和可供范围。|新增/统一|品牌授权| +|agency_certificate_ref|代理证凭证|URL/Attachment|supplier_type=授权代理时必填|供应商主记录|供应商Owner/采购|SS03-2|无有效代理证不得提交定点审批。|保留/细化|agency_certificate_ref、代理证| +|agency_certificate_valid_to|代理证有效期至|Date|代理证有值时必填|供应商主记录|供应商Owner|SS03-2|定点前检查有效期。|新增|代理证| +|qualification_status|准入资质状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|待准入/合格/限制/不合格。|新增/统一|资质/准入| +|compliance_status|合规状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|通过/待核查/不通过。|新增/统一|合规| +|commercial_terms_ref|长期商务规则引用|Link/Structure|条件必填|供应商主记录|SS03|SS03-2|长期条款不在本次询价Activity维护,但应有引用。|新增/统一|商务规则、长期条款| +|supplier_health_score|供应商健康度|Percent/Enum|条件必填|供应商主记录|SS03/SS04|SS03-2|由履约数据计算,不作为单次询价事实覆盖。|新增/统一|供应商绩效/健康度| + +#### S-04 询价、报价、比价与采购决策记录台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|rfq_batch_id|询价批次号|Text/AutoNumber|是|RFQ层|系统/采购|SS03-1/SS03-2|同一批次可含多料号、多供应商;品牌或料号变化必须新开批次。|保留/统一|rfq_batch_id、询价批次号| +|rfq_line_id|询价行ID|Text/AutoNumber|是|RFQ层|系统|SS03-1/SS03-2|最小粒度为询价批次×供应商×内部料号。|保留/统一|rfq_line_id、询价行ID| +|source_type|需求来源类型|Enum|是|RFQ层|采购/上游台账|SS03-1/SS03-2|P-01客户需求/D-15采购计划/价格维护/异常补货。|统一|source_type| +|source_id|来源对象ID|Text/Link|条件必填|RFQ层|系统/上游台账|SS03-1/SS03-2|与source_type成对;P-01写request_id,D-15写purchase_plan_id。|统一|source_id| +|customer_id|客户ID|Link(L-02)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|customer_id| +|project_id|项目ID|Link(P-05)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|project_id| +|brand_name|询价品牌|Text/Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|与internal_pn精确匹配;品牌变化新开批次。|重命名/统一|brand、manufacturer_brand| +|internal_pn|询价内部料号|Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|统一引用P-08.internal_pn。|重命名/统一|pn、料号| +|target_qty|目标数量|Number|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01.target_qty或D-15.trigger_qty。|统一|target_qty、purchase_trigger_qty| +|target_delivery_date|目标交期|Date|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01或D-15.requirement_date。|统一|target_delivery_date、requirement_date| +|requested_price_types|所需价格类型|MultiEnum|是|RFQ层|P-01/采购|SS03-1|参考价/量产价/现货价/样品价。|重命名/统一|price_types_requested| +|quote_due_at|报价回复截止时间|DateTime|是|RFQ层|P-01/采购|SS03-1|供应商回复时效基准。|重命名/统一|quote_due_at、期望报价返回时间| +|target_price_internal|内部目标价|Currency|否|RFQ层|P-01|SS03-1/SS03-2|仅内部判断;如实际披露需留痕。|统一|target_price_internal| +|supplier_id|供应商ID|Link(S-03)|是|RFQ层|S-03/采购|SS03-1/SS03-2|临时供应商定点前转正式ID。|统一|supplier_id| +|send_channel|询价发送渠道|Enum|是|RFQ层|采购/系统|SS03-1|邮件/即时通讯/电话。|统一|send_channel| +|sent_at|询价发送时间|DateTime|是|RFQ层|系统|SS03-1|系统生成;未发送不得标记已发送。|保留/细化|sent_at| +|outbound_content_ref|外发内容引用|URL/Attachment|是|RFQ层|系统/采购|SS03-1|记录对外披露内容和附件。|新增/统一|对外披露内容、附件| +|sent_evidence_ref|外发凭证|URL/Attachment|是|RFQ层|系统|SS03-1|原始邮件、聊天发送记录等。|新增/统一|发送凭证、evidence| +|rfq_status|询价状态|Enum|是|RFQ层|系统/采购|SS03-1|草稿/已发送/部分回复/全部回复/已到期未回复/催询中/关闭。|保留/细化|rfq_status| +|reminder_count|催询次数|Integer|是|RFQ层|系统|SS03-1|自动提醒与人工催询均计数。|新增|跟进回复时效| +|last_reminded_at|最后催询时间|DateTime|条件必填|RFQ层|系统/采购|SS03-1|有催询时必填。|新增|催询| +|overdue_flag|询价超期标记|Boolean|是|RFQ层|系统|SS03-1|quote_due_at已过且未完成回复或未发出时触发。|新增|已到期未回复、发起超期| +|supplier_quote_id|供应商报价ID|Text/AutoNumber|是|Quote层|系统|SS03-2|同一询价行可有报价修订历史。|新增/统一|报价记录ID| +|quote_status|供应商报价状态|Enum|是|Quote层|采购|SS03-2|已报价/拒绝报价/未回复/待确认。|保留/细化|quote_status| +|price_type|报价价格类型|Enum|条件必填|Quote层|采购|SS03-2|必须与requested_price_types匹配。|保留/统一|price_type| +|unit_price|报价单价|Currency|已报价时必填|Quote层|采购|SS03-2|与currency_code成对。|重命名/统一|unit_price、价格| +|currency_code|报价币种|ISO 4217 Enum|已报价时必填|Quote层|采购|SS03-2|统一币种比较前保留原币种。|重命名/统一|currency| +|actual_qty|报价适用数量|Number|已报价时条件必填|Quote层|采购|SS03-2|判断与target_qty可比性。|重命名/统一|actual_qty| +|lead_time_days|报价交期天数|Number|已报价时条件必填|Quote层|采购|SS03-2|统一工作日/自然日口径;原始文字可放lead_time_note。|重命名/统一|lead_time、LT| +|lead_time_note|交期说明|LongText|条件必填|Quote层|采购|SS03-2|无法标准化时保留供应商原文。|新增|lead_time| +|moq_qty|MOQ|Number|条件必填|Quote层|采购|SS03-2|最小起订量。|拆分/统一|MOQ-SPQ、MOQ| +|spq_qty|SPQ|Number|条件必填|Quote层|采购|SS03-2|标准包装量。|拆分/统一|MOQ-SPQ、SPQ| +|quote_valid_until|报价有效期至|Date|条件必填|Quote层|采购|SS03-2|报价是否仍可用。|重命名/统一|valid_until、报价有效期| +|special_terms|本次特殊商务条款|LongText/Structure|否|Quote层|采购|SS03-2|一次性条款;长期供应商条款回链S-03。|重命名/细化|special_terms、特殊条款| +|response_at|实际回复时间|DateTime|已回复时必填|Quote层|系统/采购|SS03-2|回复时间事实。|保留/细化|response_at| +|no_quote_reason|未报价原因|LongText|拒绝/未回复时必填|Quote层|采购|SS03-2|拒绝报价或未回复必须填写。|保留/细化|no_quote_reason| +|quote_evidence_ref|原始报价凭证|URL/Attachment|报价/拒绝均必填|Quote层|系统/采购|SS03-2|邮件、聊天截图、供应商报价单。|重命名/统一|evidence、原始沟通凭证| +|quote_revision_no|同轮报价修订号|Integer|是|Quote层|系统/采购|SS03-2|同轮修改保留修订历史,当前报价可聚合展示。|新增|报价与版本| +|comparison_group_id|比价组ID|Text/AutoNumber|是|Decision层|系统|SS03-2|由rfq_batch_id+internal_pn+target_qty+target_delivery_date组成。|重命名/统一|comparison_group| +|comparability_status|可比性状态|Enum|是|Decision层|AI/采购|SS03-2|可直接比较/条件可比/不可比;不可比必须说明原因。|新增|正式比价组、不可直接比较| +|comparison_basis_snapshot_ref|比价基准快照|Structure/URL|是|Decision层|系统|SS03-2|固化目标数量、目标交期、价格类型、汇率和基准版本。|新增|比价组| +|system_score|系统评分|Number|是|Decision层|系统|SS03-2|按询价类型权重计算,0-100。|重命名/统一|score| +|system_recommendation|系统推荐|Enum|是|Decision层|系统|SS03-2|推荐/备选/不推荐。|重命名/统一|recommendation| +|procurement_confirm|采购确认意见|Enum|意见确认时必填|Decision层|采购|SS03-2|认可/调整/否决。|保留/细化|procurement_confirm| +|confirm_reason|确认或调整理由|LongText|与系统推荐不一致时必填|Decision层|采购|SS03-2|采购调整或否决系统建议时必填。|重命名/统一|confirm_reason| +|baseline_id|采购基准ID|Link(S-05)|有基准时必填|Decision层|系统|SS03-2|与品牌、料号、价格类型、数量和有效期匹配。|新增/统一|baseline_id| +|baseline_match_status|基准匹配状态|Enum|是|Decision层|系统|SS03-2|已匹配/无基准/条件匹配/不匹配。|新增|无基准可比、基准比较| +|baseline_unit_price|比较基准单价|Currency|有基准时必填|Decision层|系统|SS03-2|复制当次采用的S-05基准快照,避免历史重算漂移。|新增/统一|benchmark_unit_price| +|comparison_currency_code|比价统一币种|ISO 4217 Enum|跨币种时必填|Decision层|系统|SS03-2|统一币种并回链汇率。|新增|汇率/统一币种| +|conversion_rate|币种换算汇率|Number|跨币种时必填|Decision层|系统|SS03-2|记录换算基准和来源。|新增|汇率| +|price_variance_pct|相对基准涨幅|Percent|有基准时派生|Decision层|系统|SS03-2|(报价-基准)/基准;涨幅>10%标红。|重命名/统一|price_variance_pct、涨幅| +|variance_reason|涨价原因|LongText|涨幅>10%时必填|Decision层|采购|SS03-2|说明供应商涨价、市场、交期或其他原因。|保留/细化|variance_reason、涨价原因| +|selected_supplier_id|中选供应商ID|Link(S-03)|定点通过时必填|Decision层|系统/采购|SS03-2|审批通过的定点对象。|新增/统一|推荐供应商、中选供应商| +|selected_quote_id|中选报价ID|Link(S-04 Quote)|定点通过时必填|Decision层|系统|SS03-2|关联被选中的供应商报价。|新增/统一|定点结论| +|approval_status|定点审批状态|Enum|是|Decision层|审批流|SS03-2|待审批/已通过/已驳回。|重命名/统一|approval_status| +|approval_comment|定点审批意见|LongText|驳回/条件通过时必填|Decision层|审批人|SS03-2|审批意见。|重命名/统一|approval_comment| +|decision_status|定点结论状态|Enum|是|Decision层|系统/采购|SS03-2|待定点/已定点/无法定点/建议关闭/已变更。|重命名/统一|decision_status、定点结论| +|unselected_reason|未选/无法定点原因|LongText|无法定点或未选时必填|Decision层|采购|SS03-2|完整说明比较、准入或供应条件原因。|新增/统一|未选原因、无法定点原因| +|notification_status|结果通知状态|Enum|是|Decision层|系统|SS03-2|待通知/已通知/通知失败。|重命名/统一|notification_status| +|notification_recipient_ref|结果通知对象|Link/Text|定点结论产生时必填|Decision层|系统|SS03-2|按source_type通知P-01负责人、D-15负责人或其他发起人。|新增/统一|通知需求发起人| +|score_rule_version|评分规则版本|Text/Link(GOV)|是|Decision层|GOV/系统|SS03-2|记录不同询价类型的权重版本。|新增/统一|规则版本| + +#### S-05 采购基准与供应能力台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|baseline_id|采购基准ID|Text/AutoNumber|是|基准主记录|系统|SS03-1/SS03-2|基准唯一标识。|保留/统一|baseline_id| +|brand_name|基准品牌|Text/Link(P-08)|是|基准主记录|采购/SS03|SS03-1/SS03-2|与internal_pn精确匹配。|重命名/统一|brand| +|internal_pn|基准内部料号|Link(P-08)|是|基准主记录|P-08/采购|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn| +|supplier_id|基准供应商ID|Link(S-03)|是|基准主记录|S-03/采购|SS03-1/SS03-2|当前采购基准的供应商。|统一|supplier_id| +|price_type|基准价格类型|Enum|是|基准主记录|采购|SS03-2|必须与本次报价价格类型一致后比较。|统一|price_type| +|baseline_unit_price|采购基准单价|Currency|是|基准主记录|采购|SS03-2|历史/当前有效采购基准,不被单次询价直接覆盖。|重命名/统一|benchmark_unit_price| +|currency_code|基准币种|ISO 4217 Enum|是|基准主记录|采购|SS03-2|与单价成对。|重命名/统一|currency| +|quantity_tier|适用数量阶梯|Structure|是|基准主记录|采购|SS03-2|匹配目标数量。|拆分/结构化|applicable_qty、数量阶梯| +|valid_from|基准生效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_from| +|valid_to|基准失效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_to| +|lead_time_days|基准交期天数|Number|条件必填|基准主记录|采购|SS03-1/SS03-2|供应能力比较。|重命名/统一|lead_time、LT| +|moq_qty|基准MOQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|MOQ| +|spq_qty|基准SPQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|SPQ| +|availability_status|可供状态|Enum|是|基准主记录|采购|SS03-1/SS03-2|可供/部分可供/不可供/待确认。|重命名/统一|availability_status、可供状态| +|supply_risk_level|供应风险等级|Enum|条件必填|基准主记录|采购|SS03-2|低/中/高/关键。|新增/统一|供应风险| +|baseline_status|基准状态|Enum|是|基准主记录|采购|SS03-2|Active/暂停/过期/替代。|保留/细化|baseline_status| +|source_quote_ref|基准来源报价引用|Link(S-04/P-02)|条件必填|基准主记录|系统/采购|SS03-2|记录基准形成依据。|重命名/统一|来源报价| +|locked_at|基准锁定时间|DateTime|条件必填|基准主记录|采购|SS03-2|锁定后单次询价不得直接覆盖。|新增|报价锁定基准| + +#### D-15 采购计划与执行需求台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|purchase_plan_id|采购计划ID|Text/AutoNumber|是|采购计划主记录|SS04|SS03-1/SS03-2|内部采购需求唯一引用;写入S-04.source_id。|保留/统一|purchase_plan_id| +|customer_id|客户ID|Link(L-02)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户需求型采购必填。|统一|customer_id| +|project_id|项目ID|Link(P-05)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|项目型采购必填。|统一|project_id| +|internal_pn|内部料号|Link(P-08)|是|采购计划主记录|P-08/SS04|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn| +|forecast_version|Forecast版本|Text/Link(D-02)|条件必填|采购计划主记录|D-02/SS04|SS03-1/SS03-2|Forecast来源时必填。|统一|Forecast版本、版本号| +|customer_pickup_demand|客户提货需求|Number/Date/Structure|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户提货需求的数量和时间。|重命名/统一|提货需求| +|requirement_date|内部需求日期|Date|是|采购计划主记录|SS04|SS03-1/SS03-2|作为询价目标交期或内部需求时点。|重命名/统一|requirement_date、需求日期| +|inventory_ref|库存引用|Link(D-03)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|库存引用| +|in_transit_ref|在途引用|Link(D-03/D-16)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|在途引用| +|recommended_purchase_qty|建议采购量|Number|是|采购计划主记录|系统/采购|SS03-1/SS03-2|形成询价目标数量的依据。|新增/统一|建议采购量| +|trigger_qty|采购触发数量|Number|是|采购计划主记录|SS04|SS03-1/SS03-2|进入询价的正式数量输入。|重命名/统一|purchase_trigger_qty、触发数量| +|purchase_type|采购类型|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|量产/样品/现货/异常补货。|保留/细化|purchase_type| +|urgency_flag|紧急程度|Enum/Boolean|是|采购计划主记录|SS04|SS03-1/SS03-2|普通/紧急/特急;影响评分权重。|保留/细化|urgency_flag| +|plan_status|采购计划状态|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|有效/待执行/询价中/已满足/关闭。|保留/细化|plan_status| +|triggered_at|触发询价时间|DateTime|条件必填|采购计划主记录|系统|SS03-1|记录进入询价流程的时间。|新增/统一|触发时间| +|po_ref|关联采购订单引用|Link(D-16)|条件必填|采购计划主记录|系统|SS03-2|定点/执行后回链D-16。|重命名/统一|关联后续PO| + +#### L-02 客户主台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|customer_id|客户ID|Text/AutoNumber|是|客户主记录|SS01|W24/SS03/11-A2|所有客户侧台账统一锚点。|保留/统一|唯一ID、customer_id| +|customer_name|客户名称|Text|是|客户主记录|SS01|W05/W24/11-A2|展示名称;法律主体与展示名称如不同需分开。|新增/统一|客户名称、客户| +|legal_entity_name|客户法律主体|Text|条件必填|客户主记录|SS01|11-A2条件|单证、合同或制裁筛查需要时必填。|新增|主体信息| +|customer_status|客户状态|Enum|是|客户主记录|SS01|W24/11-A2|有效/暂停/终止。|新增/统一|状态机| +|default_contact_ref|默认联系人引用|Link(L-08)|条件必填|客户主记录|SS01|11-A2|与D-10提货联系人区分;D-10可覆盖为履约联系人。|新增/统一|联系人| + +#### D-01 交易与执行台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|order_id|订单号|Text/AutoNumber|是|订单主记录|SS04|11-A2/11-A3|订单执行主键。|保留/统一|order_id、订单号| +|request_id|需求编号|Link(P-01)|条件必填|订单主记录|系统|11-A2|回链P-01需求。|统一|需求编号| +|customer_id|客户ID|Link(L-02)|是|订单主记录|系统|11-A2/11-A3|客户名称通过L-02展示。|统一|customer_id| +|project_id|项目ID|Link(P-05)|条件必填|订单主记录|系统|11-A2|项目追溯。|统一|项目| +|internal_pn|内部料号|Link(P-08)|是|订单主记录|P-08/SS04|11-A2|内部料号统一引用P-08。|重命名/统一|mpn、料号| +|customer_pn|客户料号|Text/Link(D-10)|条件必填|订单主记录|D-10/SS04|11-A2|通过D-10映射获得。|重命名/统一|customer_mpn| +|order_qty|订单数量|Number|是|订单主记录|订单/系统|11-A2|交接实发数量核对基准。|保留/统一|order_qty| +|allocation_ref|分配批次引用|Link(D-03)|条件必填|订单主记录|D-03/SS04|11-A2|指向D-03批次分配记录。|新增/统一|inventory_lot| +|promised_delivery_date|承诺交期|Date|条件必填|订单主记录|P-04/SS04|11-A2|订单承诺交期。|重命名/统一|承诺交期| +|trade_term|贸易术语|Enum|条件必填|订单主记录|D-10/P-04|11-A2|EXW等;风险转移逻辑依赖。|新增/统一|BT类型、贸易术语| +|milestones|履约里程碑|Structure|是|订单主记录|系统/SS04|11-A2/11-A3|至少含交接;每个节点有状态、时间、来源、证据。|细化|milestones、里程碑| +|pickup_status|提货状态|Enum|是|提货交接事实层|PW/系统|11-A2|待提货/已预约/已提货/待重新预约/取消。|保留/细化|pickup_status| +|ship_date|发货日期|Date|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;由PW唯一录入。|保留/细化|ship_date| +|shipped_qty|实发数量|Number|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;与order_qty及D-03同步校验。|保留/细化|shipped_qty| +|handover_no|交接单号|Text|交接完成时必填|提货交接事实层|系统|11-A2|格式HD-YYYYMMDD-NNN。|保留/细化|handover_no| +|receiver_name|签收人姓名|Text|交接完成时必填|提货交接事实层|PW|11-A2|双方签署事实。|重命名/统一|receiver、签收人姓名| +|sign_time|交接签署时间|DateTime|交接完成时必填|提货交接事实层|PW|11-A2|EXW风险转移时点的事实基础。|保留/细化|sign_time、签署时间| +|risk_transfer_time|风险转移时点|DateTime|签署后派生|提货交接事实层|系统/AI/PW确认|11-A2/11-A3|EXW场景默认等于sign_time;作为开票和后续追踪锚点。|保留/细化|risk_transfer_time| +|handover_diff|交接差异|Enum/Structure|条件必填|提货交接事实层|PW/RI|11-A2|无差异/数量差异/型号差异/批次差异/包装差异/其他;关联差异单。|保留/细化|handover_diff| +|handover_file_ref|交接单影像/文件|URL/Attachment|交接完成时必填|提货交接事实层|PW|11-A2|必须区分草稿、已签署、已归档。|重命名/统一|handover_file| +|handover_doc_status|交接单文件状态|Enum|有handover_file_ref时必填|提货交接事实层|PW/系统|11-A2|草稿/已签署/已归档/作废;下游只消费已签署或已归档。|新增/统一|文档状态| +|handover_notes|交接注意事项|LongText|条件必填|提货交接事实层|D-10/PW|11-A2|模板或现场特殊注意事项。|新增|交接注意事项| +|logi_pickup|提货节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|保留/结构化|logi_pickup| +|logi_clearance|报关节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如报关受阻、查验。|保留/结构化|logi_clearance| +|logi_release|放行节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上。|保留/结构化|logi_release| +|logi_departure|离港节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如离港延误。|保留/结构化|logi_departure| +|track_note|物流跟踪/决策备注|LongText|条件必填|物流节点事实层|PW/RI|11-A3|货代不配合时记录降级为到货确认或销售协调等决策。|重命名/统一|track_note、决策记录| +|track_visible|物流信息可查状态|Boolean|四节点完成后必填|物流节点事实层|系统|11-A3|四节点实际事实齐备后为True;不是人工主观勾选。|保留/细化|track_visible| +|invoice_status|开票状态|Enum|是|订单状态层|系统/财务|11-A2|待开票/已触发/已开票/异常;risk_transfer_time为触发锚点。|新增/统一|开票状态| +|acceptance_status|验收状态|Enum|是|订单状态层|履约Owner|11-A2|待验收/已确认/争议/不适用。|新增/统一|验收状态| +|order_status|订单状态|Enum|是|订单状态层|系统/SS04|11-A2/11-A3|已放行/已交接/物流跟踪中/已完成/异常/关闭。|重命名/统一|订单状态机| +|closed_flag|关闭标记|Boolean|是|订单状态层|系统/SS04|11-A2/11-A3|所有必需节点或异常处置完结后关闭。|新增/统一|关闭状态| +|close_reason|关闭原因|LongText|关闭时必填|订单状态层|SS04|11-A2/11-A3|正常完成/取消/转异常流程等。|新增|关闭状态| +|pickup_appointment_time|预约提货时间|DateTime|分支2场景必填|提货交接事实层|D-10/PW|11-A2|用于预约提醒、未按预约提货判断及重新预约。|新增/统一|提货预约时间| +|physical_check_status|实物核对状态|Enum|交接前必填|提货交接事实层|PW|11-A2|未核对/已核对/不一致;核对数量、型号、批次标签和包装。|新增|实物一致性核对| +|diff_handling_result|差异处理结果|Enum/Structure|有差异时必填|提货交接事实层|RI/PW|11-A2|补齐后交接/按实发量交接并标注/暂停并转后续Activity。|新增/统一|差异处理结果| +|ri_gate_decision|RI门禁判断|Enum/LongText|差异或超时升级时必填|提货交接事实层|RI|11-A2/11-A3|放行/补齐后放行/暂停/转异常/降级追踪/联系客户核实。|新增/统一|RI门禁判断、决策记录| +|ri_escalated_at|RI升级时间|DateTime|发生升级时必填|提货交接事实层|系统|11-A2/11-A3|差异、预约超时或节点反馈超时的升级事实。|新增|升级时限| +|node_type|物流节点类型|Enum|是|物流节点子表(适用于logi_*)|系统|11-A3|提货/报关/放行/离港;对应logi_*字段。|新增/统一|四个关键节点| +|estimated_time|节点预计时间|DateTime|是|物流节点子表(适用于logi_*)|系统/AI|11-A3|到达预计时间后触发确认请求。|新增/统一|节点预计时间| +|actual_time|节点实际时间|DateTime|收到事实时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|收到反馈或人工电话确认的实际时间。|新增/统一|时间| +|source|节点事实来源|Text/Link|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复单位、系统或具体联系人;不得只写“外部”。|重命名/统一|来源、Source| +|collection_method|节点采集方式|Enum|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|邮件/微信/即时通讯/电话/人工录入。|重命名/统一|采集方式、Method| +|confirmation_status|节点确认状态|Enum|是|物流节点子表(适用于logi_*)|系统/PW|11-A3|待确认/已确认/超时/异常/已升级。|新增/统一|确认状态| +|anomaly_flag|节点异常标记|Boolean|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻、查验、离港延误等为True。|新增/统一|Anomaly Flag、异常节点标记| +|anomaly_type|节点异常类型|Enum/Text|anomaly_flag=True时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻/查验/离港延误/其他。|新增/统一|异常节点| +|node_evidence_ref|节点事实证据|URL/Attachment|已确认或异常时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复原文、电话记录或其他节点证据。|新增/统一|证据引用| +|reminder_count|节点提醒次数|Integer|是|物流节点子表(适用于logi_*)|系统|11-A3|按D-10最大提醒次数控制。|新增/统一|最大提醒次数| +|last_request_at|最后请求时间|DateTime|有请求时必填|物流节点子表(适用于logi_*)|系统|11-A3|支持间隔天数和超时判断。|新增|确认请求| +|node_ri_escalation_status|节点RI升级状态|Enum|超时/异常时必填|物流节点子表(适用于logi_*)|系统/RI|11-A3|未升级/已升级/已核实/已降级。|新增/统一|升级RI门禁| + +#### D-04 回款与信用台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|credit_record_id|信用记录ID|Text/AutoNumber|是|信用门禁主记录|系统|11-A2|回款信用门禁记录主键。|新增/统一|信用记录| +|customer_id|客户ID|Link(L-02)|是|信用门禁主记录|SS04|11-A2|关联客户信用主体。|统一|客户ID| +|payment_terms|账期条款|Text/Structure|是|信用门禁主记录|P-04/SS04|11-A2|从交易框架读取,作为信用判断依据。|重命名/统一|账期条款| +|credit_limit|信用额度|Currency|条件必填|信用门禁主记录|商务/财务|11-A2|与currency_code成对。|统一|信用额度| +|outstanding_amount|应收金额|Currency|是|信用门禁主记录|财务|11-A2|当前应收,需记录币种。|新增/统一|应收| +|paid_amount|已回款金额|Currency|是|信用门禁主记录|财务|11-A2|当前已回款,需记录币种。|新增/统一|已回款| +|overdue_days|逾期天数|Integer|是|信用门禁主记录|系统/财务|11-A2|信用预警触发依据。|保留/细化|逾期天数| +|credit_rating|信用评级|Enum|是|信用门禁主记录|财务/商务|11-A2|正常/关注/高风险/冻结。|保留/细化|信用评级| +|warning_status|信用预警状态|Enum|是|信用门禁主记录|系统/财务|11-A2|无预警/预警/拦截/已解除。|保留/细化|预警状态| + +#### D-03 库存与在途台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|inventory_record_id|库存记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|库存事实主键。|新增/统一|库存记录| +|internal_pn|内部料号|Link(P-08)|是|库存批次记录|P-08/SS04|11-A2|统一引用P-08。|重命名/统一|料号| +|manufacturer_lot|原厂批次|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|mfr_lot、lot| +|date_code|日期码DC|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|dc、Date Code| +|available_qty|可用数量|Number|是|库存批次记录|仓库|11-A2|可出库库存。|新增/统一|可用| +|inspection_qty|待检数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|待检| +|frozen_qty|冻结数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|冻结| +|in_transit_qty|在途数量|Number|是|库存批次记录|系统|11-A2|与D-16履约关联。|新增/统一|在途| +|allocation_id|分配记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|批次占用与订单绑定。|新增/统一|子表2| +|allocated_qty|已分配数量|Number|是|库存批次记录|系统|11-A2|与allocation_status配套。|新增/统一|分配数量| +|allocation_status|分配状态|Enum|是|库存批次记录|系统/PW确认|11-A2|已分配/部分出库/已出库/释放。|重命名/统一|alloc_status| +|shipped_qty|已出库数量|Number|是|库存批次记录|系统/PW确认|11-A2|与D-01.shipped_qty同步。|保留/细化|shipped_qty| +|last_ship_date|最后出库日期|Date|条件必填|库存批次记录|系统/PW确认|11-A2|该批次最后一次出库事实。|保留/细化|last_ship_date| +|moq_qty|库存批次MOQ|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货决策参考。|新增/统一|MOQ| +|lead_time_days|库存批次交期天数|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货与在途判断。|新增/统一|交期| +|stocking_decision|备货决策|Enum/Structure|条件必填|库存批次记录|履约Owner|11-A2|维持/补货/冻结/退出。|新增/统一|备货决策| +|exit_plan|退出方案|LongText/Structure|stocking_decision=退出时必填|库存批次记录|履约Owner|11-A2|异常PN或库存退出动作。|新增/统一|退出方案| + +#### D-10 客户履约主数据台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|execution_profile_id|履约参数档案ID|Text/AutoNumber|是|执行参数主记录|SS04|11-A2/11-A3|客户履约参数的版本化主键。|新增/统一|执行参数| +|customer_id|客户ID|Link(L-02)|是|执行参数主记录|SS04|11-A2/11-A3|履约参数按客户维护。|统一|客户| +|incoterms|贸易术语|Enum|是|执行参数主记录|履约Owner|11-A2|EXW等;影响风险转移规则。|新增/统一|贸易术语| +|packaging_requirements|包装要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|生成交接单和实物核对依据。|重命名/统一|包装| +|label_requirements|标签要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|实物核对依据。|重命名/统一|标签| +|document_requirements|单证要求|Structure|条件必填|执行参数主记录|履约Owner|11-A2|客户格式与单证清单要求。|重命名/统一|单证要求| +|logistics_preference|物流偏好|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点采集偏好。|重命名/统一|物流偏好| +|service_level|服务等级|Enum/Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点可见性和响应要求。|新增/统一|服务等级| +|alert_thresholds|预警阈值|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点超时、异常延误等阈值。|新增/统一|预警阈值| +|pickup_contact_ref|提货联系人引用|Link(L-08)/Text|是|执行参数主记录|履约Owner|11-A2/11-A3|用于交接单和未按预约提货提醒。|重命名/统一|pickup_contact| +|pickup_appointment_mode|预约提货方式|Enum|是|执行参数主记录|履约Owner|11-A2|预约/临时/固定窗口等。|重命名/统一|pickup_appt_mode| +|logistics_confirmation_channel|物流节点确认渠道|Enum|是|执行参数主记录|履约Owner|11-A3|邮件/微信/即时通讯/电话。|重命名/统一|logi_channel、确认通道| +|logistics_confirmation_interval_days|节点确认间隔天数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3天;可按客户配置。|重命名/统一|logi_interval| +|logistics_max_reminder_count|最大提醒次数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3次;达到后升级RI。|重命名/统一|logi_max_retry| +|handover_template_ref|交接单模板引用|URL/Attachment|是|执行参数主记录|履约Owner|11-A2|生成交接单草稿。|重命名/统一|handover_tpl| +|handover_notes|交接注意事项|LongText|条件必填|执行参数主记录|履约Owner|11-A2|客户特殊注意事项;生成交接单。|新增/统一|交接注意事项| +|mapping_id|料号映射ID|Text/AutoNumber|是|料号映射子表|系统|11-A2|客户料号与内部料号一对一版本记录。|新增|料号映射| +|customer_pn|客户料号|Text|是|料号映射子表|履约Owner|11-A2|客户侧料号。|重命名/统一|客户料号、customer_mpn| +|internal_pn|内部料号|Link(P-08)|是|料号映射子表|履约Owner|11-A2|内部统一料号。|重命名/统一|internal_pn| +|mapping_version|映射版本|Text|是|料号映射子表|履约Owner|11-A2|料号映射版本受控。|新增/统一|版本| +|mapping_status|映射状态|Enum|是|料号映射子表|履约Owner|11-A2|有效/待确认/过期/作废。|新增/统一|状态| +|difference_flag|映射差异标记|Boolean/Structure|是|料号映射子表|系统/履约Owner|11-A2|客户料号与内部料号无法一一对应时标记并说明。|新增/统一|差异标注| + +#### D-11 出口单证台账 + +|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称| +|---|---|---|---|---|---|---|---|---|---| +|document_set_id|单证包ID|Text/AutoNumber|是|单证包主记录|系统|11-A2|订单级单证包唯一标识。|新增/统一|证据包| +|order_id|订单号|Link(D-01)|是|单证包主记录|系统|11-A2|回链D-01订单。|新增/统一|订单号| +|invoice_ref|发票文件引用|URL/Attachment|条件必填|单证包主记录|履约/财务|11-A2|随货单证之一。|重命名/统一|发票| +|packing_list_ref|箱单文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|箱单| +|coc_ref|COC文件引用|URL/Attachment|条件必填|单证包主记录|履约/质量|11-A2|随货单证之一。|重命名/统一|COC| +|certificate_of_origin_ref|产地证文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|按客户或贸易场景要求。|重命名/统一|产地证| +|warranty_certificate_ref|质保书文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|质保书| +|required_document_types|应提供单证类型|MultiEnum|是|单证包主记录|D-10/履约|11-A2|根据D-10.document_requirements生成。|新增/统一|单证清单、客户格式要求| +|document_set_complete|单证包齐套状态|Enum|是|单证包主记录|系统/PW|11-A2|未齐套/已齐套;已齐套至少满足本订单required_document_types。|保留/细化|doc_set_complete| +|consistency_check_status|单证一致性审核状态|Enum|是|单证包主记录|系统/PW|11-A2|未审核/通过/不通过/待补正;核对订单、料号、数量、批次、客户信息。|新增/统一|一致性审核| +|consistency_check_result|单证一致性审核结果|LongText/Structure|审核后必填|单证包主记录|系统/PW|11-A2|记录差异项、责任人和补正动作。|新增/统一|一致性审核记录| +|document_archive_ref|单证包归档引用|URL/Attachment|归档时必填|单证包主记录|系统/履约|11-A2|正式证据包统一归档。|新增/统一|证据包| +|archive_due_date|归档期限|Date|条件必填|单证包主记录|履约Owner|11-A2|依据客户或合规要求。|新增/统一|归档期限| +|document_type|单证类型|Enum|是|单证明细子表|系统|11-A2|发票/箱单/COC/产地证/质保书/其他。|新增/统一|doc_list| +|document_ref|单证文件引用|URL/Attachment|是|单证明细子表|履约|11-A2|单证文件。|重命名/统一|doc_list| +|document_status|单证文件状态|Enum|是|单证明细子表|系统/履约|11-A2|草稿/已核验/已归档/作废。|新增/统一|文档状态| +|document_version|单证版本|Text|是|单证明细子表|系统/履约|11-A2|单证修改必须递增。|新增/统一|版本| + + +## 五、重叠字段与统一命名清单 + +下表列出不同Activity或台账中“含义相同但写法不同”“层级不同却使用同名”“同一字段承载多个语义”的主要问题,以及本次建议的统一口径。 + +|冲突主题|涉及台账/Activity|原有写法|统一字段名|统一口径| +|---|---|---|---|---| +|客户ID|P-01/P-02/P-04/P-06/P-09/S-04/D-01/D-10/L-02;W05/W24/SS03/11-A2|customer_id、客户ID、客户/项目、客户名称|customer_id;展示字段customer_name|ID只存L-02.customer_id;名称通过关联展示,不在业务台账重复维护。| +|项目关联|P-01/P-02/P-04/P-06/P-09/S-04/D-15/D-01;W05/W24/SS03/11-A2|project_id、客户/项目、项目|project_id|项目对象只保留ID关联;项目名称由P-05展示。| +|内部料号|P-01/P-02/P-03/P-06/P-08/P-11/S-04/S-05/D-15/D-01/D-03/D-10;W05/W24/SS03/11-A2|pn、mpn、标准型号PN、Validation_PN、Original_PN、Candidate_PN|internal_pn;原物料用original_internal_pn,候选物料用candidate_internal_pn|全链路只认P-08.internal_pn;不得将mpn、pn、型号混作同一字段。| +|客户料号|P-01/P-08/D-01/D-10;W05/W24/11-A2|customer_mpn、客户料号|customer_pn|客户料号与internal_pn分开;正式映射落D-10料号映射子表。| +|品牌/原厂|P-08/P-10/S-04/S-05/SS03|brand、manufacturer_brand、原厂或品牌|brand_name;制造商另用manufacturer_name|品牌、制造商、供应商不是同一对象;S-04/S-05引用brand_name与supplier_id。| +|数量|P-01/S-04/D-15/D-01/D-03/W24/SS03/11-A2|数量、target_qty、purchase_trigger_qty、order_qty、shipped_qty、actual_qty|target_qty、trigger_qty、order_qty、actual_qty、shipped_qty、allocated_qty|按业务语义分开;数量阶梯统一用quantity_tier子表,不用文本拼接。| +|交期/时间窗|P-01/S-04/D-15/P-02/S-05/W24/SS03|target_delivery_date、requirement_date、quote_return_due_at、quote_due_at、lead_time/LT|target_delivery_date、requirement_date、quote_due_at、lead_time_days|目标日期、需求日期、报价截止时间、供货时长四类概念分开;原始说明写lead_time_note。| +|价格类型|P-01/P-02/P-03/S-04/S-05/W24/SS03|price_types_default、price_types_requested、price_type、报价类型|requested_price_types(需求层);price_type(价格明细层);quote_type(报价主记录层)|需求要什么价格、这一行是什么价格、这份报价属于什么业务类型分别建字段。| +|价格/币种|P-01/P-02/P-03/S-04/S-05/W24/SS03|报价价格/币种、unit_price、benchmark_unit_price、currency|金额字段按对象命名,币种统一currency_code;跨币种另记comparison_currency_code/conversion_rate|金额与币种成对出现;currency不再作为模糊字段名。| +|有效期|P-02/P-03/S-04/S-05/W24/SS03|生效期、失效期、报价有效期、valid_until、effective_from/to|valid_from、valid_to;报价主记录用quote_valid_until|Price List/采购基准的有效区间用valid_from/valid_to;报价对外有效期用quote_valid_until。| +|状态|P-01/P-02/P-03/P-06/P-11/S-04/S-05/D-01/D-03/D-10/D-11/W05/W24/SS03/11-A2/11-A3|状态机、状态、approval_status、publish_status、doc_set_complete|对象业务状态与record_status分离;各对象保留*_status命名|record_status只管记录生命周期;quote_status、approval_status等只管业务流程。| +|版本|P-01/P-02/P-03/P-06/P-08/P-10/P-11/D-10/S-04/W05/W24/SS03/11-A2|版本、需求版本、Release版本、报价版本、Datasheet版本、规则版本|对象字段用requirement_version/quote_version/release_version/document_version/mapping_version;通用用record_version;规则用rule_version|版本必须绑定对象和用途,不再使用裸字段version。| +|证据/附件/链接|P-01/P-02/P-06/P-10/P-11/S-04/D-01/D-11/W05/W24/SS03/11-A2/11-A3|evidence、证据引用、输出文件链接、handover_file、doc_list|evidence_ref;对象文件用*_ref;输出用output_ref;交接单用handover_file_ref|证据引用可多值且必须可定位;业务对象文件链接按对象命名。| +|供应商主体/联系人|S-03/S-04/S-05/SS03|supplier_id、供应商ID、sales_contact、联系人|supplier_id;sales_contact_ref;contact_channel|供应商主体与联系人分离;联系渠道记录在供应商主数据,实际发送渠道记录在S-04.send_channel。| +|询价批次/比价组|S-04/SS03|rfq_batch_id、comparison_group|rfq_batch_id;comparison_group_id|批次是询价过程容器;比价组是同条件可比集合,两者不可互代。| +|MOQ/SPQ|P-02/S-04/S-05/D-03/W24/SS03/11-A2|MOQ-SPQ、MOQ、SPQ|moq_qty、spq_qty|拆分为两个数值字段,适用对象前缀由台账区分。| +|交期LT|S-04/S-05/P-02/SS03/W24|LT、lead_time、交期|lead_time_days;lead_time_note|标准化时长与原始说明分开;统一日历口径由GOV定义。| +|替代关系与批准|P-01/P-02/P-06/P-08/P-11/W05/W24|替代等级、推荐、批准状态、验证结果、P-11-01/P-11-02|recommendation_level、analysis_status、customer_approval_status、validation_status|推荐、分析、验证、客户批准四个阶段分开,P-11作为统一关系主对象。| +|风险等级|P-01/P-02/P-06/P-11/S-05/D-18/W05/W24/SS03|风险等级、供应风险、问题与风险|risk_level(通用决策风险);supply_risk_level(供应能力风险);issue_risk(问题明细)|不同风险对象不共用一个自由文本字段。| +|交接/物流节点|D-01/D-03/D-10/D-11/11-A2/11-A3|milestones、ship_date、logi_pickup、track_note、交接|milestones;交接事实字段;logi_*节点结构;track_note|交接是法律/责任事实;物流节点是后续过程事实,统一回链同一order_id但不混成一个字段。| + +## 六、必须落地的结构化规则 + +|标准项|标准定义|示例| +|---|---|---| +|ID命名|业务对象主键使用<对象>_id;跨台账引用保留同名ID并采用Link。|request_id、supplier_id、order_id| +|料号命名|internal_pn表示内部标准料号;customer_pn表示客户料号;original_internal_pn/candidate_internal_pn只用于替代关系上下文。|P-08.internal_pn、D-10.customer_pn| +|金额与币种|所有金额字段必须与currency_code成对;跨币种比较在决策记录中另记comparison_currency_code与conversion_rate。|unit_price + currency_code| +|数量阶梯|数量阶梯使用结构化子表,至少包含min_qty、max_qty、price或discount;禁止把多个阶梯拼接为自由文本。|P-02 QuoteLine.quantity_tier| +|时间字段|发生时点用*_at(DateTime),业务日期用*_date(Date),持续时长用*_days(Number)。|quote_due_at、target_delivery_date、lead_time_days| +|版本字段|不使用裸version;必须指明对象:requirement_version、quote_version、release_version、document_version、record_version、rule_version。|P-02.quote_version| +|状态字段|record_status管记录生命周期;对象业务流程用*_status;派生可见性用*_flag或*_visible。|record_status、approval_status、track_visible| +|证据字段|evidence_ref表示证据集合;对象文件按对象命名为*_ref;证据必须支持URL/附件/定位信息。|quote_evidence_ref、handover_file_ref、citation_locator| +|节点结构|物流节点统一包含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|D-01.logi_pickup| +|主记录与子表|主记录保存上下文、状态和汇总;逐PN/BOM位/阶梯/参数/单证的重复明细使用子表。|P-02 QuoteLine、P-11差异明细、D-11单证明细| + +## 七、实施优先级与待确认事项 + +**P0:**先落实P-01、P-02、P-11、S-04、D-01的主记录与子表结构,并建立统一ID、料号、版本、状态和证据字段。P-02如果不建立QuoteLine,PN×BOM位×数量阶梯无法稳定支撑报价和版本管理;P-11如果不建立差异明细,差异评估只能保留摘要,无法被AI复核或下游消费;S-04如果不拆RFQ/Quote/Decision三层,询价事实、供应商回复和定点结论会互相覆盖。 + +**P1:**落实条件必填与校验规则,包括代理证、涨价原因、跨币种换算、报价/基准有效期、风险转移时点、交接文件状态、物流节点异常及提醒次数。 + +**P1:**明确以下业务口径后固化到GOV规则:lead_time_days按自然日还是工作日;毛利计算公式和阈值;价格来源优先级;汇率来源与as_of;Incoterms及地点字段;客户批准与FAE分析结论的权限边界;D-10履约参数的客户级/物流公司级配置粒度。 + +**P2:**将S-05基准、D-03库存与D-16采购履约的数据同步关系做成系统接口;将D-01物流节点结构接入客户可查询视图,并将异常节点回链D-18或既有异常归集Activity。 + + +## 八、交付物说明 + +本Markdown用于评审和阅读;配套Excel提供可直接继续维护的“字段总表”“Activity覆盖矩阵”“重叠字段统一”“字段标准”四张工作表,并保留“说明”页作为使用口径。 + + +## 参考附件 + +[1]: /home/ubuntu/upload/SS01-02-03-04-台账体系全景-18Activity关联矩阵(2).html "SS01-02-03-04-台账体系全景:18 Activity关联矩阵" + +[2]: /home/ubuntu/upload/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-05 差异评估" + +[3]: /home/ubuntu/upload/SS02_ACT-WORK-24_报价方案制定_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-24 报价方案制定" + +[4]: /home/ubuntu/upload/SS03询价ActivityDemo(1).html "SS03 询价 Activity Demo" + +[5]: /home/ubuntu/upload/20260826-11-A2架构版(E04-I01v1.2).html "SS04 ACT-11-A2 提货交接与风险转移" + +[6]: /home/ubuntu/upload/20260826-SS04-SOP-11活动描述样板(11-A3).html "SS04 11-A3 物流执行信息跟踪活动描述样板" diff --git a/docs/img/cy/cy-cy.png b/docs/img/cy/cy-cy.png new file mode 100644 index 0000000000000000000000000000000000000000..cfed26f019f5b63e6f94f1a6adf4ab3d8ba5def9 GIT binary patch literal 69208 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�yX^DxXLQcM+V$Pu}XjC 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 F{{th?#J>Ol literal 0 HcmV?d00001 diff --git a/docs/img/cy/cy-qx.png b/docs/img/cy/cy-qx.png new file mode 100644 index 0000000000000000000000000000000000000000..923cf9711779ef9faf9a48fcd67dc2178df5251a GIT binary patch literal 68264 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

*EnmQc+S`-LXY0!BtPzM*H`Lqzcsa@77C# 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

-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

YL`zqRBzaQbp;sOb7S4A3JN|CJ>2@zd#RNs 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 literal 0 HcmV?d00001 diff --git a/docs/superpowers/plans/2026-08-26-act-work-05-part-substitution-assessment.md b/docs/superpowers/plans/2026-08-26-act-work-05-part-substitution-assessment.md new file mode 100644 index 0000000..5bb0c2f --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-act-work-05-part-substitution-assessment.md @@ -0,0 +1,3775 @@ +# ACT-WORK-05 差异评估(`part-substitution-assessment`)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 SS02 的 ACT-WORK-05「差异评估」做成本仓第三个真活动包:台账建进库、活动装进系统、脚本 e2e 五案例全绿,再在浏览器里把全流程全分支走一遍,每一轮的结论与修改记进 `docs/ACT-WORK-05-端到端测试记录.md`。 + +**Architecture:** 照 E04 / I01 已验证的骨架 —— 清单(契约七件 + 台账声明 + 样例)、案例模型(CMMN 8 项 + 说明书 + 逐步剧本 + 5 段进度)、两枚纯工具(逐参数比对 / 推荐定级,规则同时以 DMN 表随包冻结)、五张 HTML 模版(两张数据工单件 + 执行单件 + 两张验收件)。主数据(P-01/P-08/P-10/P-11/P-06)进平台基线 `MASTER_OBJECTS` 由安装通道物化到 `ledger` schema,活动清单只追加自己要用的列与标记;活动自持单据归 `sub_eval.*`。数据工单 = 零输入执行工单 + 工作区可增删行的件(每张单只对一张表);验收 01 = 决策卡循环;验收 02 = 终局放行。 + +**Tech Stack:** TypeScript(活动包只 import `@sdk` / `@contracts` / `@sfeel`)、vitest、node-postgres(ledger 动态 DDL)、Hono API、React 工作台、tools/e2e-a2.mjs(服务账号 e2e)、claude-in-chrome(浏览器走查)。 + +**Spec:** `docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md` · `ACT-WORK-05-一键启动入参说明.md` · `SS02-SS03-SS04_Demo台账字段细化与统一方案.md`(字段口径唯一权威)· 四份目标件 HTML · `ACT-WORK-05-全流程分支图.html`(18 条分支)。开发纪律:`docs/活动开发规范.md`、`docs/HTML报告生成规范.md`、`CLAUDE.md` 铁规矩。 + +## Global Constraints + +- 五道门禁以退出码验:`pnpm gates`(= gen:tiers → typecheck → lint → depcruise → api:check → test);不许以 echo 验。 +- §12.5 禁止清单:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO;不删测试;不动门禁配置。 +- 活动包**只准 import `@sdk` / `@contracts` / `@sfeel`**;产物文本黑名单(`process.env` / `fetch(` / `node:` / `eval(`);模版含 `_<值> / _distinct_<列> / tool_<名>_calls`);模型自写的旗只能占谓词一半。 +- 触达世界的项(交人核对 / 拍板 / 放行)标 `manualActivationRule` 且**不给 doneWhen**;`issueAcceptance` 一任务只能发一次且即终局 → 非终局审核用 `decide` 循环。 +- 执行卡**零输入**:不摆 `fields` / `blocks`,填报全在工作区的件上;一单同一张卡只发一次。 +- 报告模版**不算不判**:`data-slot` / `data-rows="mine:域.对象"` / `data-col` / `data-edit` / `data-class-of` / `data-blank`;主键列必挂 `data-col`;枚举列存 ASCII、人读名由样式表出。 +- 样例:第一条满载;样例名 ≤14 字。 +- 试跑一律用服务账号 `bpos-e2e`(`KEYCLOAK_E2E_CLIENT_ID/SECRET`),测完 `pnpm --filter @app/api reset-runs`;浏览器走查用当前 Chrome 已登录会话。 +- 提交只 `git add` 本任务点名的文件(工作树里有一批未提交的鉴权改动,别顺手带走);提交信息末尾带: + `Co-Authored-By: Claude Fable 5 ` 与 `Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh`。 +- 数据工单口径(用户 2026-08-26 定):**读数点概念作废,一张表一张单**;P-08 是一张表(参数集是它的一个字段);本活动最多 2 张数据工单(P-08 / P-10);S-03 / L-02(启动前)/ P-06(事实类)/ P-09 不派。 +- 本活动只答技术可替代性:不做价格 / 交期 / 数量判断;`customer_approval_status` 只读边界,活动不声明其写权。 + +--- + +## 0. 已锁定的设计决定(执行者不必再问;要改先改这里) + +| # | 决定 | 依据 | +|---|---|---| +| D1 | **守铁律②**:数据工单确认落**本单草稿层**,放行时并入正式表(试跑单 `landMaster` 默认开,放行后主数据真落库);本单后续步骤读得到本单草稿,所以「建了就能拿来评」不受影响。文档里「确认即正式入库、无草稿态」落成「无审核环节、放行并入」。 | `docs/活动开发规范.md:45` 铁律②;E04 录入卡同款 | +| D2 | **数据工单 = 零输入执行工单 + 件**(`data-edit` 表绑 `mine:parts.profile` / `mine:parts.document`,可加行,提交闸 = 声明的必填);卡标题以「数据工单 ·」开头;一张表一张单(P-08 一张、P-10 一张);对话区叫「数据卡」的专属卡皮记 debt。 | 现成机制零基座改动 | +| D3 | **P-08 一张表**:`normalized_parameter_set` 是 `parts.profile` 的 **text 列**,行语法 `参数名 \| 值 \| 单位 \| hard/soft \| 说明`(一行一参数),件上用 textarea 编辑;不建 `parts.parameter` 子表,不用 json(json 列对人填的字符串不归型,textarea 存进去是 JSON 字符串,读写两侧形状不一)。 | 用户 2026-08-26「都是一张表」;`engines.ts coerceToColumn` json 不归型 | +| D4 | datasheet 作**起单选填文件入参** `datasheets`(`format:'file'`,与 A1 `attachments` 同形);传了 AI 在数据工单件上预填,没传人全填(文档 0c:同为合法路径);「件上上传」记 debt。 | 平台文件入口只有起单 `format:'file'` | +| D5 | **一人兼 PW/FAE**:所有卡发给任务负责人;`roles: ['FAE/RI']`;派给第二个人记 debt。 | 平台一任务恰一名负责人 | +| D6 | 存在性检查 = S0 读 `parts.profile` 整表(剧本 `reads`)逐个候选核对 + **`refersTo` 墙**(`substitution.relation.original/candidate_internal_pn → parts.profile.internal_pn`;查无当场拒并指路);不另做基座读数。 | E04 contact_code 同款 | +| D7 | 主数据物化走**平台基线**(`seed-mvp-data.ts` 新增 4 域 6 表)+ 清单**追加声明**(必填 / tally / refersTo / requiredWhen 是活动的授权面);不写 SQL 迁移(ledger 表由声明动态物化)。 | `seed-activities.ts` 主数据先于活动物化 | +| D8 | 枚举列一律 ASCII tally,人读名由样式表出(推荐等级 `recommended/conditional/alternate/not_recommended`;差异状态 `match/mismatch/unknown/conditional`;约束 `hard/soft`;生命周期 `active/nrnd/eol/discontinued/risk`)。 | `data-class-of` 只认 ASCII | +| D9 | 验收 01 非终局 → `decide` 循环(E04 S4 同款);驳回意见可写 P-06「验证要求」行;验收 02 = `submit` 终局,件上摆落库预览(`touched:` 数据源)。 | `issueAcceptance` 只能一次 | +| D10 | GOV 规则 = 包内两张 DMN(文档)+ 两枚纯工具(求值),`rule_version = 'R-2026.07'` 随包冻结。 | quote-intake P-03 同款 | + +## 1. 现有数据库架构与「把台账建进库」的含义 + +**一库四 schema**(`db/init/001-schemas.sql`):`sys`(系统自持,Drizzle 迁移 0001–0033)· `ledger`(台账自生长,**唯一写者** `apps/api/src/modules/ledger/internal/adapter/ledger-store.ts`,动态 DDL)· `mastra` · `queue`。连接 `postgres://jcai:jcai@localhost:54329/jcai_wos`(容器 `jcai-postgres`,已在跑;本机没有 psql,一律 `docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc ""`)。 + +**台账怎么物化**(不写迁移,声明即建表): + +| 声明 | 物理落点 | +|---|---| +| `{domain, object}` | 表 `ledger.<域>__<对象>`(`tableNameOf`),表注释 = `label` | +| `fields[].type` text / number / integer / boolean / date / timestamp / json | text / double precision / bigint / boolean / date / timestamptz / jsonb;列注释 = `label` | +| `primaryKey: true` 各列 | `primary key (...)`;**主键一经建立不得由声明变更** | +| `kind` / `caseKey` | `ledger.__object_meta(domain, object, kind, case_key)`;`reset-runs` 只清 `kind <> 'master'` 的表 | +| 同名对象多份声明 | `schema-merge.ts` 最大化合并、只增不破坏(add-column / 放宽类型);`json` 与标量互斥即拦 | +| 任务草稿 | `ledger.__draft(task_id, domain, object, key_hash, key, patch, baseline, tombstone, updated_at)`;`taskView` = 正式版 + 本单草稿;放行 `releaseWrite` 合并进正式表(试跑单 `land_master=false` 时 master 行只留缓存) | + +**谁在什么时候调 `applyDeclarations`**:`installRealActivities()`(`apps/api/src/app/seed-activities.ts`,`pnpm activities:install` 的真身)先物化 `MASTER_OBJECTS`(平台基线,不挂活动),再逐包 `registry.install(manifest)`(活动声明合并进同名表 + 建自持表 + 写 `__object_meta`),最后 `systemWrite` 播 `MASTER_SAMPLE_ROWS`(按主键幂等)。 + +**现状(2026-08-26 实查)**:`ledger` 里 33 个对象;主数据四域 customer / project / supplier / market(`market.part` 是空占位的老料号表,主键 `pn`,与 P-08 的 `internal_pn` 口径不同,**不复用**,记 debt 待并);E04/I01 各自的 record 表 caseKey=`round_ref`;A1–A7 的表还在但已卸载。 + +**本活动要建进库的**(全部经上面这条路,零 SQL): + +| 全景 | 物理表 | kind | 主键 | 由谁物化 | +|---|---|---|---|---| +| P-08 | `ledger.parts__profile` | master | `internal_pn` | 基线 + 清单追加 | +| P-10 | `ledger.parts__document` | master | `internal_pn, doc_seq` | 基线 + 清单追加 | +| P-01 | `ledger.demand__request` | master | `request_id` | 基线 + 清单追加 | +| P-11 主 | `ledger.substitution__relation` | master | `substitution_relation_id` | 基线 + 清单追加 | +| P-11 子 | `ledger.substitution__difference_item` | master | `substitution_relation_id, difference_item_id` | 基线 + 清单追加(`compare_result` / `unknown_disposition` 两列只在清单) | +| P-06 | `ledger.validation__record` | master | `validation_id` | 基线 + 清单追加 | +| 自持 | `ledger.sub_eval__pn_check` / `__input_gap` / `__verdict` | record(caseKey `eval_ref`) | 见 Task 3 | 清单 | + +验收 SQL(Task 6 用): +```sql +select domain||'.'||object||' '||kind||' '||coalesce(case_key,'') from ledger.__object_meta + where domain in ('parts','demand','substitution','validation','sub_eval') order by 1; +select string_agg(column_name||':'||data_type, ', ' order by ordinal_position) + from information_schema.columns where table_schema='ledger' and table_name='parts__profile'; +select conname||' '||pg_get_constraintdef(oid) from pg_constraint where conrelid='ledger.substitution__difference_item'::regclass; +select internal_pn, brand_name, lifecycle_status from ledger.parts__profile order by 1; +``` + +## 2. 文件结构 + +**新建** +- `activities/part-substitution-assessment/package.json` · `src/index.ts` · `src/manifest.ts`(清单 + DMN)· `src/case-model.ts`(CMMN / NARRATIVE / STEPS / PHASES)· `src/skills.ts` · `src/views/prd-style.ts`(逐字节抄 I01)· `src/views/report.ts`(五张模版)· `__tests__/activity.test.ts` · `__tests__/steps.test.ts` +- `apps/api/src/app/__tests__/first-party-substitution.test.ts`(两枚纯工具) +- `docs/ACT-WORK-05-端到端测试记录.md`(Task 8 逐轮追加)· `docs/数据域字段说明-W05.md` + +**修改** +- `apps/api/src/app/seed-mvp-data.ts`(+4 域 6 表 + 样例料号)· `apps/api/src/app/__tests__/seed-mvp-data.test.ts`(四域 → 八域) +- `apps/api/src/app/tools/first-party.ts`(+2 handler)· `apps/api/src/app/seed-mvp.ts`(+2 工具契约) +- `tools/build-activities.ts`(PACKAGES)· `apps/api/src/app/seed-activities.ts`(REAL_PACKAGES) +- `tools/e2e-a2.mjs`(+`w05` 套件、`stageEdits`、`decide` 值) +- `CLAUDE.md`(现状快照 + 文档地图)· `docs/debt.md` + +--- + +### Task 0: 开分支、确认环境 + +**Files:** 无(只动 git 与环境) + +- [ ] **Step 1: 从 mvp-foundation 开功能分支**(工作树里已有的未提交改动跟着走,不碰它们) + +```bash +cd /Users/xcs/project2/tmp/BPOS-MVP +git checkout -b feat/act-work-05 +git status --short | wc -l # 记下这个数:本计划的提交只 add 自己点名的文件 +``` + +- [ ] **Step 2: 确认库与两套服务在跑** + +```bash +docker ps --format '{{.Names}} {{.Status}}' | grep jcai-postgres +lsof -nP -iTCP:8787 -iTCP:5173 -iTCP:8790 -iTCP:5180 -sTCP:LISTEN | awk '{print $1,$9}' | sort -u +``` +Expected: `jcai-postgres Up`;8787(dev api)与 5173(dev web)在;8790/5180 是 `./stable.sh` 起的稳定版(与 dev 连同一个库,推进器靠跨进程租约互斥 —— 跑 e2e / 浏览器走查时**先停它**,避免两台交替推同一单干扰观察:`./stable.sh stop`;走查完 `./stable.sh start`)。 + +- [ ] **Step 3: 确认 e2e 凭证在环境里**(值不打印) + +```bash +for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_E2E_CLIENT_ID KEYCLOAK_E2E_CLIENT_SECRET; do printf '%s=%s\n' "$k" "${(P)k:+set}"; done +``` +Expected: 四个都 `set`。缺了就从开发会话 / `.stable/env` 抄进当前 shell(`.stable/env` 只有 `KEYCLOAK_URL/REALM/CLIENT_ID`,E2E 那两个要问用户要;拿不到就停在这一步,e2e 跑不了)。 + +--- + +### Task 1: 主数据基线 —— P-08 / P-10 / P-01 / P-11 / P-06 进平台声明,加样例料号 + +**Files:** +- Modify: `apps/api/src/app/seed-mvp-data.ts`(`MASTER_OBJECTS` 末尾追加 6 表;`MASTER_DOMAINS` 8 域;`MASTER_SAMPLE_ROWS` 末尾追加 4 行) +- Modify: `apps/api/src/app/__tests__/seed-mvp-data.test.ts:18-19`(四域 → 八域) +- Test: `apps/api/src/app/__tests__/seed-mvp-data.test.ts` + +**Interfaces:** +- Produces: 物理表 `ledger.parts__profile` / `parts__document` / `demand__request` / `substitution__relation` / `substitution__difference_item` / `validation__record`(列名与类型见下);样例行 `parts.profile` × 2(`LM2596S-5.0`, `LM2596S-ADJ`)、`parts.document` × 2。Task 3 的清单只**追加**列,列名与类型必须与这里一字不差(同名异型会被 schema-merge 放宽或拦下)。 + +- [ ] **Step 1: 改测试 —— 八域** + +在 `apps/api/src/app/__tests__/seed-mvp-data.test.ts` 把 `'只有客户 / 项目 / 供应商 / 市场四个域'` 那条改成: + +```ts + it('八个域:客户 / 项目 / 供应商 / 市场 + 料件 / 需求 / 替代 / 验证(SS02 差异评估 2026-08-26 加后四域)', () => { + expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation']); + expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort()); + }); + + it('SS02 差异评估要读写的六张主数据表都在基线里,主键照《统一方案》', () => { + const pk = (domain: string, object: string) => + MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object)?.fields.filter((f) => f.primaryKey).map((f) => f.name); + expect(pk('parts', 'profile')).toEqual(['internal_pn']); + expect(pk('parts', 'document')).toEqual(['internal_pn', 'doc_seq']); + expect(pk('demand', 'request')).toEqual(['request_id']); + expect(pk('substitution', 'relation')).toEqual(['substitution_relation_id']); + expect(pk('substitution', 'difference_item')).toEqual(['substitution_relation_id', 'difference_item_id']); + expect(pk('validation', 'record')).toEqual(['validation_id']); + }); + + it('样例料号:原 PN 与同族可调版在库,候选 XL2596S-5.0 刻意不播(数据工单那条路要试得出来)', () => { + const parts = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile').map((r) => r.key['internal_pn']); + expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ']); + expect(parts).not.toContain('XL2596S-5.0'); + }); +``` +文件头 `import` 改成 `import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts';`,并把文件头注释里「只有这四个域,不多不少」改为「八个域,多一个就说明有人把单据塞进主数据了」。 + +- [ ] **Step 2: 跑测试确认它红** + +Run: `pnpm vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts` +Expected: FAIL(`MASTER_DOMAINS` 仍是四域;`pk('parts','profile')` 为 undefined)。 + +- [ ] **Step 3: 在 `seed-mvp-data.ts` 的 `MASTER_OBJECTS` 数组末尾(`market.quote_index` 之后、`];` 之前)追加六张表** + +```ts + /* ── 料件:P-08 芯片主数据 / P-10 原厂技术资料库(SS02 差异评估 2026-08-26 加)── + 字段名与《SS02-SS03-SS04_Demo台账字段细化与统一方案》§4.2 一字不差;全链路只认 internal_pn。 + 参数集是 P-08 的一个字段(用户定:一张表,不建子表),一行一参数: + `参数名 | 值 | 单位 | hard/soft | 说明`。老的 market.part(主键 pn)不复用,记 debt 待并。 */ + master('parts', 'profile', '芯片主数据(P-08)', [ + f('internal_pn', 'text', '内部料号', true), + f('customer_pn', 'text', '客户料号'), + f('brand_name', 'text', '品牌名称'), + f('manufacturer_name', 'text', '制造商名称'), + f('specification_description', 'text', '规格描述'), + f('normalized_parameter_set', 'text', '标准化参数集(一行一参数:参数名 | 值 | 单位 | hard/soft | 说明)'), + f('package', 'text', '封装'), + f('process_technology', 'text', '工艺'), + f('lifecycle_status', 'text', '生命周期状态'), + f('eol_pcn_status', 'text', 'EOL/PCN 状态'), + f('datasheet_ref', 'text', 'Datasheet 引用'), + f('datasheet_version', 'text', 'Datasheet 版本'), + f('application_scenarios', 'text', '适配应用场景'), + f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'), + ]), + master('parts', 'document', '原厂技术资料库(P-10)', [ + f('internal_pn', 'text', '关联内部料号', true), + f('doc_seq', 'text', '资料序号(同一料号下 01/02…)', true), + f('document_type', 'text', '资料类型'), + f('brand_name', 'text', '品牌名称'), + f('document_title', 'text', '资料标题'), + f('document_version', 'text', '资料版本'), + f('document_date', 'date', '资料发布日期'), + f('document_ref', 'text', '资料文件链接'), + f('citation_locator', 'text', '证据定位(页码/章节/表格)'), + f('source_contact_ref', 'text', '原厂支持联系人'), + f('document_status', 'text', '资料状态'), + ]), + + /* ── 需求:P-01 客户具体需求与项目任务主台账 ── */ + master('demand', 'request', '客户需求主记录(P-01)', [ + f('request_id', 'text', '需求编号', true), + f('customer_code', 'text', '客户编码'), + f('project_code', 'text', '项目编码'), + f('requirement_version', 'text', '需求版本'), + f('request_type', 'text', '需求类型'), + f('request_description', 'text', '需求描述'), + f('input_snapshot_ref', 'text', '输入快照'), + f('bom_id', 'text', 'BOM ID'), + f('bom_position', 'text', 'BOM 位'), + f('original_internal_pn', 'text', '原内部料号'), + f('customer_pn', 'text', '客户料号'), + f('application_scenario', 'text', '应用场景'), + f('technical_constraints', 'text', '技术约束'), + f('risk_level', 'text', '风险等级'), + f('verification_requirement', 'text', '验证/复测/确认要求'), + f('request_status', 'text', '需求状态'), + f('current_activity', 'text', '当前 Activity'), + f('current_stage', 'text', '当前阶段'), + f('next_action', 'text', '下一步动作'), + f('output_ref', 'text', '标准输出链接'), + ]), + + /* ── 替代:P-11 替代选型与批准关系库 —— 主记录 + 差异明细子表(《统一方案》§六:重复明细用子表)── */ + master('substitution', 'relation', '替代关系主记录(P-11)', [ + f('substitution_relation_id', 'text', '替代关系ID', true), + f('request_id', 'text', '关联需求编号'), + f('project_code', 'text', '关联项目编码'), + f('bom_position', 'text', '关联 BOM 位'), + f('original_internal_pn', 'text', '原内部料号'), + f('candidate_internal_pn', 'text', '候选内部料号'), + f('substitution_type', 'text', '替代类型'), + f('recommendation_level', 'text', '推荐等级'), + f('recommendation_reason', 'text', '推荐理由'), + f('difference_summary', 'text', '差异摘要'), + f('risk_level', 'text', '替代风险等级'), + f('verification_requirement', 'text', '验证要求'), + f('validation_id', 'text', '关联验证ID'), + f('analysis_status', 'text', '分析状态'), + /* 客户批准三列由独立审批流维护 —— 基线建列,差异评估活动**不声明写权** */ + f('customer_approval_status', 'text', '客户批准状态'), + f('customer_approval_at', 'timestamp', '客户批准时间'), + f('customer_approval_ref', 'text', '客户批准凭证'), + f('customer_feedback', 'text', '客户反馈'), + f('analysis_report_ref', 'text', '替代分析报告'), + f('confidence_score', 'number', '证据可信度'), + f('rule_version', 'text', '差异规则版本'), + ]), + master('substitution', 'difference_item', '差异明细(P-11 子表)', [ + f('substitution_relation_id', 'text', '替代关系ID', true), + f('difference_item_id', 'text', '差异明细ID', true), + f('parameter_name', 'text', '标准参数名'), + f('original_value', 'text', '原物料参数值'), + f('candidate_value', 'text', '候选物料参数值'), + f('parameter_unit', 'text', '参数单位'), + f('constraint_type', 'text', '约束类型'), + f('difference_status', 'text', '差异状态'), + f('impact_statement', 'text', '影响说明'), + f('risk_level', 'text', '风险等级'), + f('evidence_ref', 'text', '差异证据引用'), + f('confidence', 'number', 'AI 置信度'), + f('rule_version', 'text', '差异规则版本'), + ]), + + /* ── 验证:P-06 样品交付与芯片验证记录(事实类:只能实测产生,数据工单不许建)── */ + master('validation', 'record', '芯片验证记录(P-06)', [ + f('validation_id', 'text', '验证ID', true), + f('request_id', 'text', '关联需求编号'), + f('customer_code', 'text', '客户编码'), + f('validation_internal_pn', 'text', '验证内部料号'), + f('validation_type', 'text', '验证类型'), + f('test_condition', 'text', '测试条件'), + f('test_parameters', 'text', '测试参数'), + f('test_result', 'text', '测试结果'), + f('validation_status', 'text', '验证状态'), + f('issue_risk', 'text', '问题与风险'), + f('sample_version', 'text', '样品版本'), + f('sample_lot', 'text', '样品批次'), + f('evidence_ref', 'text', '验证证据引用'), + f('next_validation_action', 'text', '后续验证动作'), + ]), +``` + +并把 `MASTER_DOMAINS` 改为: + +```ts +/** 八域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 → 料件 → 需求 → 替代 → 验证 */ +export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation'] as const; +``` + +- [ ] **Step 4: 在 `MASTER_SAMPLE_ROWS` 数组末尾追加样例料号**(现有最后一个元素之后、`];` 之前) + +```ts + /* + * ── SS02 差异评估的样例料号(2026-08-26)── + * 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC + * **刻意不播** —— 「候选缺库 → 派数据工单」「硬约束不推荐」两条路要试得出来,库里得真没有。 + * 参数集一行一参数(参数名 | 值 | 单位 | hard/soft | 说明),与 P-08 声明的行语法同一份。 + */ + { + address: { domain: 'parts', object: 'profile' }, + key: { internal_pn: 'LM2596S-5.0' }, + values: { + internal_pn: 'LM2596S-5.0', + brand_name: 'TI', + manufacturer_name: 'Texas Instruments', + specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz', + normalized_parameter_set: [ + '输入电压范围 | 4.5~40 | V | hard | 客户输入上限 40V', + '输出电压 | 5.0 | V | hard | 固定版', + '输出电流 | 3 | A | hard | 满载能力', + '开关频率 | 150 | kHz | hard | 影响外围电感/电容', + '静态电流 | 5 | mA | soft | 待机功耗', + '工作温度 | -40~+85 | ℃ | hard | 工业温域', + '使能阈值 | 1.4 | V | hard | ON/OFF 引脚阈值', + ].join('\n'), + package: 'TO-263-5', + process_technology: 'BCD', + lifecycle_status: 'nrnd', + eol_pcn_status: 'PCN-2026-07:转 NRND,2027-01 起不接新设计', + datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf', + datasheet_version: 'Rev H (2023-09)', + application_scenarios: '工业电源;消费电源', + }, + }, + { + address: { domain: 'parts', object: 'profile' }, + key: { internal_pn: 'LM2596S-ADJ' }, + values: { + internal_pn: 'LM2596S-ADJ', + brand_name: 'TI', + manufacturer_name: 'Texas Instruments', + specification_description: '降压型 DCDC 稳压器,3A,可调输出 1.23~37V,150kHz(LM2596 同族)', + normalized_parameter_set: [ + '输入电压范围 | 4.5~40 | V | hard | 与固定版同', + '输出电压 | 1.23~37 | V | hard | 可调版,靠外围反馈网络定', + '输出电流 | 3 | A | hard | 满载能力', + '开关频率 | 150 | kHz | hard | 影响外围电感/电容', + '静态电流 | 5 | mA | soft | 待机功耗', + '工作温度 | -40~+85 | ℃ | hard | 工业温域', + '使能阈值 | 1.4 | V | hard | ON/OFF 引脚阈值', + ].join('\n'), + package: 'TO-263-5', + process_technology: 'BCD', + lifecycle_status: 'active', + datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf', + datasheet_version: 'Rev H (2023-09)', + application_scenarios: '工业电源;消费电源', + }, + }, + { + address: { domain: 'parts', object: 'document' }, + key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' }, + values: { + internal_pn: 'LM2596S-5.0', + doc_seq: '01', + document_type: 'datasheet', + brand_name: 'TI', + document_title: 'LM2596 SIMPLE SWITCHER Power Converter 150-kHz 3-A Step-Down Voltage Regulator', + document_version: 'Rev H (2023-09)', + document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf', + citation_locator: '§6.5 Electrical Characteristics 表 / §6.3 Recommended Operating Conditions', + document_status: 'valid', + }, + }, + { + address: { domain: 'parts', object: 'document' }, + key: { internal_pn: 'LM2596S-ADJ', doc_seq: '01' }, + values: { + internal_pn: 'LM2596S-ADJ', + doc_seq: '01', + document_type: 'datasheet', + brand_name: 'TI', + document_title: 'LM2596 SIMPLE SWITCHER Power Converter 150-kHz 3-A Step-Down Voltage Regulator', + document_version: 'Rev H (2023-09)', + document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf', + citation_locator: '§6.5 Electrical Characteristics 表(ADJ 列)', + document_status: 'valid', + }, + }, +``` + +同时把 `seed-activities.ts:143` 那行日志改成不依赖下标(新行插在数组末尾,`[1]` 仍是样例客户,不改也不炸;但下一个人再往前插就错)——改为: +```ts + log(` 样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`); +``` + +- [ ] **Step 5: 跑测试确认绿** + +Run: `pnpm vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts` +Expected: PASS(含新加 3 条)。 + +- [ ] **Step 6: 类型检查 + 提交** + +```bash +pnpm -s typecheck +git add apps/api/src/app/seed-mvp-data.ts apps/api/src/app/__tests__/seed-mvp-data.test.ts apps/api/src/app/seed-activities.ts +git commit -m "feat(w05): 主数据基线加料件/需求/替代/验证四域六表(P-08/P-10/P-01/P-11/P-06)与样例料号 + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` + +--- + +### Task 2: 两枚纯工具 —— 逐参数比对(compare-parameters)与推荐定级(recommend) + +**Files:** +- Modify: `apps/api/src/app/tools/first-party.ts`(在 `FIRST_PARTY_TOOLS` 之前加两段实现;数组末尾登记两条) +- Create: `apps/api/src/app/tools-w05.ts`(两条工具契约 + 试运行样例) +- Modify: `apps/api/src/app/seed-mvp.ts`(注册循环的数组加进 `W05_TOOLS`) +- Test: `apps/api/src/app/__tests__/first-party-substitution.test.ts`(新建) + +**Interfaces:** +- Produces 工具 `part-substitution-assessment.compare-parameters`: + 入参 `{ candidates: Array<{ candidateInternalPn: string; pairs: Array<{ parameterName: string; originalValue: string; candidateValue: string; unit?: string; constraintType: 'hard'|'soft'; requirement?: string; evidenceRef?: string }> }> }` + 出参 `{ ruleVersion: 'R-2026.07'; results: Array<{ candidateInternalPn; items: Array<{ parameterName; compareResult; differenceStatus: 'match'|'mismatch'|'unknown'|'conditional'; riskLevel: 'low'|'medium'|'high'; note }>; counts: { match; mismatch; unknown; conditional; hardMismatch; softMismatch; configurable } }> }` +- Produces 工具 `part-substitution-assessment.recommend`: + 入参 `{ candidates: Array<{ candidateInternalPn: string; counts: { match; mismatch; unknown; conditional; hardMismatch; softMismatch; configurable } }> }` + 出参 `{ ruleVersion; results: Array<{ candidateInternalPn; recommendationLevel: 'recommended'|'conditional'|'alternate'|'not_recommended'; riskLevel; reason }> }` +- 出参键与台账列同名之处:`differenceStatus → difference_status`、`riskLevel → risk_level`、`recommendationLevel → recommendation_level`(模型照抄,不必改名)。 + +- [ ] **Step 1: 写测试** + +`apps/api/src/app/__tests__/first-party-substitution.test.ts`: + +```ts +/** + * ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。 + * 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵: + * A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/ + * C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。 + */ +import { describe, expect, it } from 'vitest'; +import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts'; + +const handler = (name: string) => { + const found = FIRST_PARTY_TOOLS.find(([n]) => n === name); + if (found === undefined) throw new Error(`没有工具 ${name}`); + return found[1]; +}; +/** ToolHandler 的入参形状见 `apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts`:纯工具不用连接目标 */ +const call = async (name: string, input: Record) => { + const out = await handler(name)({ input, connection: {}, operation: undefined, idempotencyKey: undefined }); + if (!out.ok) throw new Error(`工具失败:${JSON.stringify(out)}`); + return out.output as Record; +}; + +type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string }; +type Result = { candidateInternalPn: string; items: Item[]; counts: Record }; + +const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record = {}) => ({ + parameterName, + originalValue, + candidateValue, + constraintType, + evidenceRef: 'P-08 · P-10 Rev H', + ...extra, +}); + +describe('compare-parameters:逐参数四态', () => { + it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => { + const out = await call('part-substitution-assessment.compare-parameters', { + candidates: [ + { + candidateInternalPn: 'XL2596S-5.0', + pairs: [ + pair('工作温度', '-40~+85', '-40~+125', 'hard', { unit: '℃' }), + pair('输出电压', '5.0', '5.0', 'hard', { unit: 'V' }), + pair('使能阈值', '1.4', '未披露', 'hard', { unit: 'V' }), + pair('静态电流', '5', '4.2', 'soft', { unit: 'mA' }), + ], + }, + { + candidateInternalPn: 'LM2596S-ADJ', + pairs: [pair('输出电压', '5.0', '1.23~37', 'hard', { unit: 'V' })], + }, + ], + }); + const [a, b] = out['results'] as Result[]; + expect(out['ruleVersion']).toBe('R-2026.07'); + const by = (r: Result, name: string) => r.items.find((i) => i.parameterName === name); + expect(by(a, '工作温度')).toMatchObject({ compareResult: 'covers', differenceStatus: 'match', riskLevel: 'low' }); + expect(by(a, '输出电压')).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' }); + expect(by(a, '使能阈值')).toMatchObject({ compareResult: 'unknown', differenceStatus: 'unknown', riskLevel: 'medium' }); + expect(by(a, '静态电流')).toMatchObject({ differenceStatus: 'conditional' }); // 软约束数值不同 → 条件 + expect(a.counts).toMatchObject({ match: 2, unknown: 1, conditional: 1, mismatch: 0, hardMismatch: 0 }); + expect(by(b, '输出电压')).toMatchObject({ compareResult: 'configurable', differenceStatus: 'conditional' }); + expect(b.counts['configurable']).toBe(1); + }); + + it('硬约束范围变窄 → MISMATCH 高风险;客户约束(requirement)不满足也是 MISMATCH', async () => { + const out = await call('part-substitution-assessment.compare-parameters', { + candidates: [ + { + candidateInternalPn: 'CN-DCDC-32V', + pairs: [ + pair('输入电压范围', '4.5~40', '4.5~32', 'hard', { unit: 'V', requirement: '>= 40' }), + pair('封装', 'TO-263-5', 'TO-263-5', 'hard'), + ], + }, + ], + }); + const [c] = out['results'] as Result[]; + expect(c.items[0]).toMatchObject({ compareResult: 'narrower', differenceStatus: 'mismatch', riskLevel: 'high' }); + expect(c.items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' }); + expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 }); + }); + + it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => { + const out = await call('part-substitution-assessment.compare-parameters', { + candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }], + }); + const [x] = out['results'] as Result[]; + expect(x.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'conditional' }); + }); + + it('数值带单位/括号注记也认得:「5.0(固定)」「3A」「150 kHz」;生命周期按活跃度比', async () => { + const out = await call('part-substitution-assessment.compare-parameters', { + candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0(固定)', '5.0 V', 'hard'), pair('输出电流', '3A', '3', 'hard'), pair('开关频率', '150 kHz', '150', 'hard'), pair('生命周期', 'nrnd', 'active', 'soft')] }], + }); + const [x] = out['results'] as Result[]; + // 生命周期不是数值:候选比原料更活(nrnd → active)算 covers → MATCH,替 NRND 料本就是为了这个 + expect(x.items.map((i) => i.differenceStatus)).toEqual(['match', 'match', 'match', 'match']); + expect(x.items[3]?.compareResult).toBe('covers'); + }); +}); + +describe('recommend:推荐等级四档', () => { + it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => { + const out = await call('part-substitution-assessment.recommend', { + candidates: [ + { candidateInternalPn: 'C', counts: { match: 5, mismatch: 1, unknown: 0, conditional: 0, hardMismatch: 1, softMismatch: 0, configurable: 0 } }, + { candidateInternalPn: 'A', counts: { match: 5, mismatch: 0, unknown: 1, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } }, + { candidateInternalPn: 'B', counts: { match: 6, mismatch: 0, unknown: 0, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }, + { candidateInternalPn: 'D', counts: { match: 7, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } }, + ], + }); + const results = out['results'] as Array<{ candidateInternalPn: string; recommendationLevel: string; riskLevel: string }>; + expect(results.map((r) => [r.candidateInternalPn, r.recommendationLevel, r.riskLevel])).toEqual([ + ['C', 'not_recommended', 'high'], + ['A', 'conditional', 'medium'], + ['B', 'alternate', 'medium'], + ['D', 'recommended', 'low'], + ]); + }); + + it('既有 UNKNOWN 又有 configurable:UNKNOWN 优先 → 条件推荐(验证闭环前不给备选)', async () => { + const out = await call('part-substitution-assessment.recommend', { + candidates: [{ candidateInternalPn: 'E', counts: { match: 1, mismatch: 0, unknown: 1, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }], + }); + expect((out['results'] as Array<{ recommendationLevel: string }>)[0]?.recommendationLevel).toBe('conditional'); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认红** + +Run: `pnpm vitest run apps/api/src/app/__tests__/first-party-substitution.test.ts` +Expected: FAIL(`没有工具 part-substitution-assessment.compare-parameters`)。 + +- [ ] **Step 3: 在 `first-party.ts` 的 `export const FIRST_PARTY_TOOLS` 之前加实现** + +```ts +/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)───────────────── + * + * 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份; + * 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。 + * + * 比对语义(按硬/软约束): + * equal / covers(候选范围 ⊇ 原范围) → match,低风险; + * configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围); + * narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险; + * 任一侧未披露 → unknown 中风险; + * 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH); + * requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。 + */ +export const SUBSTITUTION_RULE_VERSION = 'R-2026.07'; + +type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' }; +type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown'; +type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional'; +type RiskLevel = 'low' | 'medium' | 'high'; +type ParamPair = { + parameterName: string; + originalValue: string; + candidateValue: string; + unit?: string; + constraintType: 'hard' | 'soft'; + requirement?: string; + evidenceRef?: string; +}; +type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number }; + +const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i; +const NUM = '[-+]?\\d+(?:\\.\\d+)?'; +const RANGE_RE = new RegExp(`^(${NUM})\\s*(?:~|~|-|–|—|至|to)\\s*(${NUM})`, 'i'); +const NUM_RE = new RegExp(`^(${NUM})`); + +export function parseParamValue(raw: unknown): Parsed { + const s = String(raw ?? '').trim().replace(/\s+/g, ' '); + if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' }; + const r = RANGE_RE.exec(s); + if (r !== null) { + const lo = Number(r[1]); + const hi = Number(r[2]); + if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) }; + } + const n = NUM_RE.exec(s); + if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) }; + return { kind: 'text', value: s.toLowerCase() }; +} + +function compareParsed(o: Parsed, c: Parsed): CompareResult { + if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown'; + if (o.kind === 'range' && c.kind === 'range') { + if (c.lo === o.lo && c.hi === o.hi) return 'equal'; + if (c.lo <= o.lo && c.hi >= o.hi) return 'covers'; + if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower'; + return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial'; + } + if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs'; + if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs'; + if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs'; + if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs'; + return 'differs'; +} + +/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */ +function satisfiesRequirement(requirement: string | undefined, c: Parsed): boolean | undefined { + if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined; + const s = requirement.trim(); + const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s); + const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s); + const hi = c.kind === 'range' ? c.hi : c.value; + const lo = c.kind === 'range' ? c.lo : c.value; + if (ge !== null) return hi >= Number(ge[1]); + if (le !== null) return lo <= Number(le[1]); + const req = parseParamValue(s); + if (req.kind === 'range') return lo <= req.lo && hi >= req.hi; + if (req.kind === 'number') return lo <= req.value && hi >= req.value; + return undefined; +} + +/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */ +const LIFECYCLE_ORDER: Record = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 }; +function compareLifecycle(o: string, c: string): CompareResult | undefined { + const ro = LIFECYCLE_ORDER[o.trim().toLowerCase()]; + const rc = LIFECYCLE_ORDER[c.trim().toLowerCase()]; + if (ro === undefined || rc === undefined) return undefined; + return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs'; +} + +function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } { + const o = parseParamValue(p.originalValue); + const c = parseParamValue(p.candidateValue); + const hard = p.constraintType === 'hard'; + let compareResult = compareParsed(o, c); + if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult; + const req = satisfiesRequirement(p.requirement, c); + if (req === false) { + // 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险 + return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${p.requirement ?? ''}` }; + } + if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' }; + if (compareResult === 'equal' || compareResult === 'covers') { + if (hard && (p.evidenceRef === undefined || p.evidenceRef.trim() === '')) { + return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' }; + } + return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' }; + } + if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' }; + if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' }; + compareResult = compareResult === 'narrower' ? 'narrower' : 'differs'; + return hard + ? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' } + : { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' }; +} + +const compareParameters: ToolHandler = async ({ input }) => { + const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; pairs: ParamPair[] }>) : []; + const results = candidates.map((cand) => { + const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 }; + const items = (Array.isArray(cand.pairs) ? cand.pairs : []).map((p) => { + const j = judgePair(p); + counts[j.differenceStatus] += 1; + if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1; + if (j.compareResult === 'configurable') counts.configurable += 1; + return { parameterName: p.parameterName, unit: p.unit ?? '', constraintType: p.constraintType, ...j }; + }); + return { candidateInternalPn: cand.candidateInternalPn, items, counts }; + }); + return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } }; +}; + +/** + * 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环); + * 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。 + */ +const recommend: ToolHandler = async ({ input }) => { + const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial }>) : []; + const results = candidates.map((cand) => { + const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0); + if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` }; + if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` }; + if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` }; + if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` }; + return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` }; + }); + return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } }; +}; +``` + +在 `FIRST_PARTY_TOOLS` 数组末尾登记: + +```ts + ['part-substitution-assessment.compare-parameters', compareParameters], + ['part-substitution-assessment.recommend', recommend], +``` + +- [ ] **Step 4: 跑测试确认绿** + +Run: `pnpm vitest run apps/api/src/app/__tests__/first-party-substitution.test.ts` +Expected: PASS 6 条。 + +- [ ] **Step 5: 工具契约放独立文件 `apps/api/src/app/tools-w05.ts`(seed-mvp 与 seed-activities 都要用,放 seed-mvp 里会撞循环 import)** + +```ts +/** + * ACT-WORK-05 差异评估的两枚纯工具契约(活动私有作用域)。 + * 两处消费:`seed-mvp.ts`(db:seed 全套播种)与 `seed-activities.ts`(activities:install 装活动时顺带注册 —— + * 「安装即用」包含它声明过的纯工具能调得动;从前 E04 的工具只在 db:seed 里注册,开发机上只跑 + * activities:install 的人第一次跑活动就撞「工具不存在」)。 + */ +const PURE = { + effect: 'read' as const, + errors: [] as Array<{ code: string; retryable: boolean; message: string }>, + idempotent: true, + timeoutMs: 5_000, + retries: 0, + connectionKeys: [] as string[], + pure: true, + ownerActivityId: 'part-substitution-assessment', +}; + +/** 逐参数比对 —— 出参键与台账列同名(difference_status / risk_level),模型照抄 */ +export const COMPARE_PARAMETERS_TOOL = { + ...PURE, + name: 'part-substitution-assessment.compare-parameters', + displayName: '替代料逐参数比对', + version: '1.0.0', + description: + '按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' + + 'compare_result、risk_level 与各候选的计数。一次可比一批候选(candidates 数组),每对参数可带客户约束 requirement(如 ">= 40")与证据引用 evidenceRef;' + + '硬约束没有证据引用不判 match。规则版本 R-2026.07。', + input: { + type: 'object', + properties: { + candidates: { + type: 'array', + description: '[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}]', + }, + }, + }, + output: { + type: 'object', + properties: { + ruleVersion: { type: 'string' }, + results: { + type: 'array', + description: '[{candidateInternalPn, items:[{parameterName, unit, constraintType, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]', + }, + }, + }, +}; + +/** 推荐定级 —— 出参 recommendationLevel / riskLevel / reason 直接落 P-11 */ +export const RECOMMEND_TOOL = { + ...PURE, + name: 'part-substitution-assessment.recommend', + displayName: '替代推荐定级', + version: '1.0.0', + description: + '按每个候选的比对计数定推荐等级:硬约束 mismatch>0 → not_recommended;unknown>0 → conditional;' + + '只有可调配成(configurable)→ alternate;其余 conditional → conditional;全 match → recommended。', + input: { + type: 'object', + properties: { + candidates: { type: 'array', description: '[{candidateInternalPn, counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]' }, + }, + }, + output: { + type: 'object', + properties: { + ruleVersion: { type: 'string' }, + results: { type: 'array', description: '[{candidateInternalPn, recommendationLevel, riskLevel, reason}]' }, + }, + }, +}; + +export const W05_TOOLS = [COMPARE_PARAMETERS_TOOL, RECOMMEND_TOOL] as const; + +/** 试运行审的样例(两件共用一份:并集覆盖各家入参) */ +export const W05_TOOL_SAMPLE = { + input: { + candidates: [ + { + candidateInternalPn: 'XL2596S-5.0', + pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', unit: 'V', constraintType: 'hard', evidenceRef: 'P-08' }], + counts: { match: 1, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 }, + }, + ], + }, + expectShape: { type: 'object' }, +}; +``` + +然后在 `seed-mvp.ts`:顶部加 `import { W05_TOOLS } from './tools-w05.ts';`,把 `for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL])` 改为 `for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL, ...W05_TOOLS])`(那条「读类 1 · 写类 1 · 纯工具 4」的日志改成「纯工具 6」)。 + +- [ ] **Step 6: 门禁子集 + 提交** + +Run: `pnpm -s typecheck && pnpm -s lint && pnpm vitest run apps/api/src/app/__tests__` +Expected: 全绿。 + +```bash +git add apps/api/src/app/tools/first-party.ts apps/api/src/app/seed-mvp.ts apps/api/src/app/tools-w05.ts apps/api/src/app/__tests__/first-party-substitution.test.ts +git commit -m "feat(w05): 差异评估两枚纯工具 —— 逐参数四态比对与推荐定级(R-2026.07) + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` + +--- + +### Task 3: 活动包骨架 + 清单(manifest.ts)+ 结构自测 + +**Files:** +- Create: `activities/part-substitution-assessment/package.json` · `src/index.ts` · `src/constants.ts` · `src/skills.ts` · `src/views/prd-style.ts` · `src/manifest.ts` +- Create: `activities/part-substitution-assessment/__tests__/activity.test.ts` +- 依赖(本任务先以占位存在,Task 4/5 替换):`src/case-model.ts`、`src/views/report.ts` + +**Interfaces:** +- Produces:`manifest`(ActivityManifest)、常量 `RULE_VERSION = 'R-2026.07'`、`D = 'sub_eval'`;台账地址与列名(Task 4 剧本 / Task 5 模版 / Task 7 e2e 全部照这里抄): + - `customer.profile`(customer_code PK;name required;industry/region expect) + - `parts.profile`(internal_pn PK;brand_name★ specification_description★ normalized_parameter_set★ package★ lifecycle_status★ tally `active/nrnd/eol/discontinued/risk`;manufacturer_name / process_technology / eol_pcn_status / datasheet_ref / datasheet_version / application_scenarios expect) + - `parts.document`(internal_pn + doc_seq PK;document_type★ tally `datasheet/app_note/reference_design/white_paper/sample_doc/commitment_letter/other`;document_title★ document_version★ document_ref★ document_status★ tally `valid/expired/pending/void`;citation_locator / brand_name expect) + - `demand.request`(request_id PK;customer_code★ refersTo customer.profile.customer_code;requirement_version★ request_type★ request_description★ input_snapshot_ref★ original_internal_pn★ request_status★ tally `pending/responded/won/abandoned/closed`;application_scenario / bom_position expect;risk_level tally `low/medium/high/critical`、verification_requirement、output_ref、next_action、current_activity 均 `requiredWhen {current_stage:'gate03'}`;current_stage 自由文本) + - `substitution.relation`(substitution_relation_id PK;request_id★;original_internal_pn★ / candidate_internal_pn★ 均 refersTo parts.profile.internal_pn;substitution_type★ tally `pin_to_pin/functional/parametric/other`;recommendation_level★ tally `recommended/conditional/alternate/not_recommended`;recommendation_reason★ difference_summary★;risk_level★ tally `low/medium/high/critical`;verification_requirement requiredWhen {recommendation_level:'conditional'};validation_id;analysis_status★ tally `ai_draft/fae_confirmed/pending_customer/customer_approved/rejected/void`;bom_position / analysis_report_ref / confidence_score expect;rule_version★;eval_ref★) + - `substitution.difference_item`(substitution_relation_id PK distinct refersTo substitution.relation.substitution_relation_id;difference_item_id PK;parameter_name★ original_value★ candidate_value★;parameter_unit expect;constraint_type★ tally `hard/soft`;compare_result expect;difference_status★ tally `match/mismatch/unknown/conditional`;impact_statement★;unknown_disposition requiredWhen {difference_status:'unknown'};risk_level★ tally `low/medium/high`;evidence_ref★;confidence expect;rule_version★) + - `validation.record`(validation_id PK;request_id★;validation_internal_pn★ refersTo parts.profile.internal_pn;validation_type★ tally `pin_to_pin/functional/reliability/certification/retest`;validation_status★ tally `not_started/in_progress/passed/failed/conditional/pending_evidence`;next_validation_action★;test_condition / evidence_ref expect) + - `sub_eval.pn_check`(record,caseKey eval_ref;eval_ref + internal_pn PK;role★ tally `original/candidate`;exists_status★ tally `found/absent/created/abandoned`;abandon_reason requiredWhen {exists_status:'abandoned'};note) + - `sub_eval.input_gap`(record;eval_ref + item_key PK;input_item★;source_ledger expect;status★ tally `gap/default/ok`;detail expect;supplement requiredWhen {status:'gap'};origin) + - `sub_eval.verdict`(record;eval_ref PK;request_id、rule_version、original_internal_pn、candidate_total integer、input_confirmed_total、review_approved_total、landing_prepared_total、relation_total、recommended_total、conditional_total、alternate_total、not_recommended_total、summary) +- Produces 出口 `assessment-released`,载荷 `{ evalRef, requestId, relationTotal, recommendedTotal, conditionalTotal, alternateTotal, notRecommendedTotal }`(全部由 `sub_eval.verdict` 同名列承载)。 +- Produces 视图名:`p08-master-create` / `p10-document-create` / `eval-input-list` / `difference-review` / `landing-confirm`(Task 5 必须导出同名常量 `P08_MASTER_CREATE` / `P10_DOCUMENT_CREATE` / `EVAL_INPUT_LIST` / `DIFFERENCE_REVIEW` / `LANDING_CONFIRM`)。 +- Produces 计划项 id(Task 4 的 CMMN / STEPS 必须一字不差):`S0_bind` `S0d_master_data` `S1_input_confirm` `S2_normalize` `S3_assess` `S4_review` `S5_prepare_landing` `T_release`。 + +- [ ] **Step 1: 建包骨架** + +```bash +mkdir -p activities/part-substitution-assessment/src/views activities/part-substitution-assessment/__tests__ +cat > activities/part-substitution-assessment/package.json <<'EOF' +{ "name": "@activities/part-substitution-assessment", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } } +EOF +cat > activities/part-substitution-assessment/src/index.ts <<'EOF' +/** + * ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。 + * + * 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里: + * 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 五张 HTML 报告模版。 + * 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— + * 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。 + */ +import { defineActivity } from '@sdk'; + +export default defineActivity({ name: 'part-substitution-assessment' }); +EOF +# 共享常量单独一个文件:manifest.ts 与 case-model.ts 互相引用会撞 TDZ(E04 的 case-model 不 import manifest,这里两边都要用同一批常量) +cat > activities/part-substitution-assessment/src/constants.ts <<'EOF' +/** 清单与案例模型共用的常量 —— 单独成文件,免得 manifest ↔ case-model 循环 import 撞 TDZ。 */ +export const ACTIVITY_ID_TEXT = 'part-substitution-assessment'; +/** 活动自持单据的域名 */ +export const D = 'sub_eval'; +/** GOV 差异规则版本 —— 随包冻结,随每条结论落账(rule_version) */ +export const RULE_VERSION = 'R-2026.07'; +export const TOOL_COMPARE = 'part-substitution-assessment.compare-parameters'; +export const TOOL_RECOMMEND = 'part-substitution-assessment.recommend'; +EOF +# 视觉底逐字节抄 I01(两包之间没有代码通道,只能各持一份),只换头注 +cp activities/brand-content-toolkit/src/views/prd-style.ts activities/part-substitution-assessment/src/views/prd-style.ts +python3 - <<'PY' +p='activities/part-substitution-assessment/src/views/prd-style.ts' +s=open(p,encoding='utf-8').read() +head_end=s.index('export const PRD_CSS') +new_head='''/** + * ACT-WORK-05 差异评估 · 视觉底 —— 逐字节抄自 I01(`brand-content-toolkit/src/views/prd-style.ts`)。 + * + * 本活动的四份目标件(`docs/SS02-ACT-WORK-05-html/*.html`)与 E04 / I01 的目标件是同一套 + * 设计语言产出的(卡壳 / 卡头渐变 / 判据框 / 表格排版全同);`activities/*` 只准 import + * `@sdk` / `@contracts`,包之间没有代码通道,只能各自持有一份。改这份不要改口径: + * 数据驱动的开关(三档占位 / 缺项红带 / 状态徽 / 编辑闸)在 `report.ts` 的 `BIND_CSS`。 + */ + +''' +open(p,'w',encoding='utf-8').write(new_head+s[head_end:]) +PY +``` + +- [ ] **Step 2: 写技能 `src/skills.ts`** + +```ts +/** + * ACT-WORK-05 差异评估的技能 —— 载方法,不载权力。 + * 剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、拿不准时按哪条走」。 + */ +import { skillIdSchema } from '@contracts'; +import type { Skill } from '@contracts'; + +export const ASSESSMENT_SKILLS: Skill[] = [ + { + id: skillIdSchema.parse('substitution.normalize-parameters'), + label: '把多来源资料整成可比对的标准参数对照表', + applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。', + method: [ + '参数集在 `parts.profile.normalized_parameter_set` 里,一行一参数:`参数名 | 值 | 单位 | hard/soft | 说明`。', + '对照表按**原 PN 的参数名**为骨架:原 PN 有的参数,候选没有对应行 → 候选值写「未披露」(它会成为 UNKNOWN);', + '候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。', + '单位以原 PN 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。', + '约束类型以原 PN 那一行的 hard/soft 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。', + '**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。', + ].join('\n'), + crystallized: [], + knowledgeRefs: ['标准化参数集行语法', '硬/软约束口径'], + version: '1.0.0', + }, + { + id: skillIdSchema.parse('substitution.difference-judgement'), + label: '逐项四态由工具算,证据与影响由你写', + applicability: '对照表齐了,要出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 时。', + method: [ + '四态**不许自己判**:调 `part-substitution-assessment.compare-parameters`,把每个候选的全部参数对一次性传进去(candidates 数组,一次一批)。', + '每对参数都带 `evidenceRef`(值从哪份记录来:「P-08」「P-10 Rev H §6.5」「数据工单 <单号> · 确认人 <人>」)——硬约束没有证据引用,工具不判 MATCH。', + '客户在应用场景里给了边界(如「输入上限 40V」)就写进那一对的 `requirement`(`>= 40`),候选不满足直接 MISMATCH。', + '工具出参照抄进 `substitution.difference_item`:difference_status / compare_result / risk_level 一字不改;', + '`impact_statement` 你写:MATCH 写「无」,其余说清对应用 / 验证 / 报价的影响;UNKNOWN 必须写 `unknown_disposition`(挂验证任务号或补来源动作),不许悬空。', + '推荐等级同样由工具定(`part-substitution-assessment.recommend`,传每个候选的 counts),`recommendation_reason` 照工具的 reason 扩写,不许把不推荐改成推荐。', + ].join('\n'), + crystallized: [], + knowledgeRefs: ['四态口径', '推荐等级四档'], + version: '1.0.0', + }, + { + id: skillIdSchema.parse('substitution.landing'), + label: '落账只写分析层,客户批准不碰', + applicability: '审核通过之后拟 P-11 / 回写 P-01 时。', + method: [ + '`analysis_status` 从 `ai_draft` 推到 `fae_confirmed`;`customer_approval_status` 由独立审批流维护 —— 本活动**没有这一列的写权**,写了会被拒。', + 'P-01 回写五项:risk_level(取各候选里最高的)、verification_requirement(把所有验证任务号列全)、output_ref(替代分析报告号)、next_action(「提交 ACT-GATE-03 评审放行」)、current_stage 写 `gate03`。', + '规则版本随结论落账:每条 P-11 主记录与差异明细都写 rule_version(工具出参的 ruleVersion),中途规则更新不影响进行中的评估。', + '被打回重做时改的是**台账本身**(理由 / 依据 / 去向),不是把同一份原样再递一遍。', + ].join('\n'), + crystallized: [], + knowledgeRefs: ['analysis_status 与 customer_approval_status 分离'], + version: '1.0.0', + }, +]; +``` + +- [ ] **Step 3: 写清单 `src/manifest.ts`** + +```ts +/** + * 活动包清单 —— ACT-WORK-05 差异评估(案例活动)。 + * + * 业务出处:`docs/SS02-ACT-WORK-05-html/`(活动流程与工单说明 / 一键启动入参说明 / + * 《SS02-SS03-SS04_Demo台账字段细化与统一方案》/ 四份目标件 / 全流程分支图)。 + * 骨架照 E04 / I01 已验证的样子:双级入参 → 活动自持单据 + 共享主数据 → decide 循环做中间审核 → + * 终局放行。三处与前两个活动刻意不同,写在这里免得下一个读者当成疏漏: + * ① **主数据是六张(P-08/P-10/P-01/P-11 主+子/P-06)**,物理表由平台基线 + * (`apps/api/src/app/seed-mvp-data.ts`)物化,这里只**追加**本活动要用的列与标记 + * (required / tally / refersTo / requiredWhen 是活动的授权面;声明合并是纯 add-column)。 + * ② **数据工单**(PN 不在库时让人从零建整条主数据)= 零输入执行工单 + 工作区的件 + * (`mine:parts.profile` / `mine:parts.document` 可加行),确认落草稿层、放行并入正式表 + * (守铁律②;文档里「确认即入库」在本平台落成「无审核环节、放行并入」)。一张表一张单。 + * ③ 逐项四态与推荐等级**由两枚纯工具算**(规则同时以 D01/D02 两张 DMN 随包冻结当文档), + * 模型只负责对照表、证据与影响说明 —— 硬约束 MISMATCH 不可能被措辞改判。 + */ +import type { ActivityManifest, DmnModel } from '@contracts'; +import { activityIdSchema } from '@contracts'; +import { ASSESSMENT_SKILLS } from './skills.ts'; +import { CMMN_XML, NARRATIVE, ASSESSMENT_PHASES, ASSESSMENT_STEPS } from './case-model.ts'; +import { P08_MASTER_CREATE, P10_DOCUMENT_CREATE, EVAL_INPUT_LIST, DIFFERENCE_REVIEW, LANDING_CONFIRM } from './views/report.ts'; +import { ACTIVITY_ID_TEXT, D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts'; + +export { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts'; +const ACTIVITY_ID = activityIdSchema.parse(ACTIVITY_ID_TEXT); + +/** + * 两张判定表 —— **文档形态**(求值器在 `apps/api/src/app/tools/first-party.ts`,与这里同一份规则): + * D01 逐参数四态、D02 推荐等级。都是 FIRST:规则从上往下第一条命中即出结论,顺序就是优先级。 + */ +export const D01_DMN: DmnModel['tables'][number] = { + id: 'd01-difference-status', + label: '差异四态 D01(R-2026.07)', + hitPolicy: 'FIRST', + inputs: [ + { name: 'compare_result', expression: 'compare_result' }, + { name: 'constraint_type', expression: 'constraint_type' }, + { name: 'evidence_present', expression: 'evidence_present' }, + ], + outputs: [{ name: 'difference_status' }, { name: 'risk_level' }], + rules: [ + { id: 'd01-u', when: ['"unknown"', '-', '-'], then: ['unknown', 'medium'], annotation: '一侧未披露 → UNKNOWN,挂验证任务' }, + { id: 'd01-m0', when: ['"equal","covers"', '"hard"', 'false'], then: ['conditional', 'medium'], annotation: '硬约束缺证据不判 MATCH' }, + { id: 'd01-m', when: ['"equal","covers"', '-', '-'], then: ['match', 'low'], annotation: '一致或候选范围盖住原范围' }, + { id: 'd01-c', when: ['"configurable","partial"', '-', '-'], then: ['conditional', 'medium'], annotation: '可调配成 / 部分重叠 → 条件' }, + { id: 'd01-x', when: ['"narrower","differs"', '"hard"', '-'], then: ['mismatch', 'high'], annotation: '硬约束不一致' }, + { id: 'd01-s', when: ['"narrower","differs"', '"soft"', '-'], then: ['conditional', 'medium'], annotation: '软约束不一致 → 评估影响' }, + ], +}; + +export const D02_DMN: DmnModel['tables'][number] = { + id: 'd02-recommendation', + label: '推荐等级 D02(R-2026.07)', + hitPolicy: 'FIRST', + inputs: [ + { name: 'hard_mismatch_total', expression: 'hard_mismatch_total' }, + { name: 'unknown_total', expression: 'unknown_total' }, + { name: 'configurable_total', expression: 'configurable_total' }, + { name: 'conditional_total', expression: 'conditional_total' }, + ], + outputs: [{ name: 'recommendation_level' }], + rules: [ + { id: 'd02-n', when: ['> 0', '-', '-', '-'], then: ['not_recommended'], annotation: '硬约束失败直接不推荐' }, + { id: 'd02-u', when: ['0', '> 0', '-', '-'], then: ['conditional'], annotation: '有 UNKNOWN → 等验证闭环' }, + { id: 'd02-a', when: ['0', '0', '> 0', '-'], then: ['alternate'], annotation: '只靠改外围配得成 → 备选' }, + { id: 'd02-c', when: ['0', '0', '0', '> 0'], then: ['conditional'], annotation: '其余条件项 → 条件推荐' }, + { id: 'd02-r', when: ['0', '0', '0', '0'], then: ['recommended'], annotation: '全 MATCH → 推荐' }, + ], +}; + +export const ASSESSMENT_DMN: DmnModel = { + drd: [ + { decision: 'd01-difference-status', requires: [], inputs: ['compare_result', 'constraint_type', 'evidence_present'] }, + { decision: 'd02-recommendation', requires: [], inputs: ['hard_mismatch_total', 'unknown_total', 'configurable_total', 'conditional_total'] }, + ], + tables: [D01_DMN, D02_DMN], +}; + +/** 字段声明的短写(与 E04 同一套写法:显式 primaryKey / masked,其余按需) */ +type F = { name: string; type: 'text' | 'number' | 'integer' | 'boolean' | 'date' | 'timestamp' | 'json'; label: string; primaryKey?: boolean; required?: boolean; expect?: boolean; tally?: string[]; auto?: boolean; distinct?: boolean; refersTo?: string; requiredWhen?: { column: string; value: string } }; +const col = (f: F) => ({ primaryKey: false, masked: false, ...f }); + +export const manifest: ActivityManifest = { + identity: { + activityId: ACTIVITY_ID, + name: 'ACT-WORK-05 · 差异评估', + packageVersion: '1.0.0', + category: '需求分析与方案匹配', + sdkVersion: '0.1.0-m5', + checksum: 'dev-checksum-part-substitution-assessment', + }, + + /* ── 契约七件 ────────────────────────────────────────────────── */ + contract: { + activityId: ACTIVITY_ID, + version: '1.0.0', + input: { + /* + * 一键启动只收 4 项(文档《一键启动入参说明》极简原则):客户 / 客户需求 / 原 PN / 候选 PN 数组。 + * 应用场景、BOM 位、替代类型、规则版本、负责人期限**不是入参** —— 启动后在执行工单 01 里补。 + * datasheet 选填文件:传了 AI 在数据工单件上预填,没传人全填(文档 0c:同为合法路径)。 + * `customerName` 只在客户不在库(分支 0a)时用:S0 发数据录入卡建档,名字先摆上去让人核。 + */ + data: { + type: 'object', + required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'], + properties: { + customerCode: { type: 'string', title: '客户编码(L-02 客户主台账)', examples: ['CU-0142'] }, + customerName: { type: 'string', title: '客户名称(仅客户不在库时填,启动后先建档)', examples: ['深圳恒星电源有限公司'] }, + requestDescription: { + type: 'string', + title: '客户需求(原始描述 —— 据此新建 P-01 需求主记录 · 版本 V1)', + examples: ['工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料'], + }, + originalInternalPn: { type: 'string', title: '原内部料号(P-08)', examples: ['LM2596S-5.0'] }, + candidateInternalPns: { + type: 'array', + title: '候选内部料号(数组,≥1;不在库的启动后经数据工单创建)', + items: { type: 'string' }, + examples: [['XL2596S-5.0', 'LM2596S-ADJ']], + }, + datasheets: { + type: 'array', + format: 'file', + title: '候选 datasheet(选填;传了 AI 在数据工单里预填,没传人全填)', + items: { + type: 'object', + properties: { + name: { type: 'string', title: '文件名' }, + contentType: { type: 'string', title: '类型' }, + size: { type: 'integer', title: '字节数' }, + ref: { type: 'string', title: '存档号' }, + text: { type: 'string', title: '读出来的文本' }, + note: { type: 'string', title: '读取说明' }, + }, + }, + }, + }, + }, + messageAllowed: true, + messageHint: 'FAE 可在此说明客户的应用边界、验证条件或本轮评估的特殊要求', + }, + output: { + /* 单一必发口:本活动终点是 ACT-GATE-03 评审放行;下游 ACT-WORK-08 尚未实现,按「契约先立」声明 */ + mandatory: [ + { + name: 'assessment-released', + description: '差异评估结论已落账(P-11 替代关系主记录 + 差异明细、P-01 回写)—— 供 ACT-WORK-08 证据归集消费;当前无消费者,载荷 = 已落地事实', + payload: { + type: 'object', + required: ['evalRef', 'requestId', 'relationTotal'], + properties: { + evalRef: { type: 'string', title: '评估案号' }, + requestId: { type: 'string', title: '需求编号(P-01)' }, + relationTotal: { type: 'integer', title: '落账的替代关系条数' }, + recommendedTotal: { type: 'integer', title: '推荐' }, + conditionalTotal: { type: 'integer', title: '条件推荐' }, + alternateTotal: { type: 'integer', title: '备选' }, + notRecommendedTotal: { type: 'integer', title: '不推荐' }, + }, + }, + }, + ], + selective: [], + }, + requiredDeliverables: [ + { key: 'eval-input', label: '评估输入清单', ledger: `${D}.input_gap`, ledgers: [`${D}.input_gap`, `${D}.pn_check`, `${D}.verdict`], editableAtRelease: [] }, + { key: 'difference-items', label: '差异评估结论(P-11 差异明细)', ledger: 'substitution.difference_item', ledgers: ['substitution.difference_item', 'substitution.relation'], editableAtRelease: [] }, + { key: 'landing', label: '拟落账内容(P-11 主记录 + P-01 回写)', ledger: 'substitution.relation', ledgers: ['substitution.relation', 'demand.request'], editableAtRelease: [] }, + ], + /* + * 验收判据 —— L1/L2 只读基座读数;L4 三条是文档验收工单 01/02 里留给人的裁量; + * 「推荐等级与明细一致」「硬约束不判 MATCH」机器已由工具守住,这里仍留 L4 让签字的人过目。 + */ + acceptanceCriteria: [ + { + key: 'input-confirmed', + label: '评估输入已经 PW 补齐确认:执行工单 01 已交回,缺口行的补充内容都有,补不了的留了 UNKNOWN', + level: 'L1', + predicate: `input_confirmed_total > 0 and ${D}_input_gap_rows > 0 and ${D}_input_gap_missing = 0`, + }, + { + key: 'review-approved', + label: '差异评估结论已经 FAE/RI 审核通过:验收工单 01 已过,不处在驳回待改状态', + level: 'L1', + predicate: 'review_approved_total > 0 and substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0', + }, + { + key: 'every-candidate-assessed', + label: '每个未放弃的候选都有一条替代关系记录(N 候选 = N 条 P-11,并行独立落账)', + level: 'L2', + predicate: `substitution_relation_rows > 0 and substitution_relation_rows = count(candidateInternalPns) - ${D}_pn_check_exists_status_abandoned`, + }, + { + key: 'every-item-has-status', + label: '关键参数差异均有明确状态:每条明细都是 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 之一', + level: 'L2', + predicate: + 'substitution_difference_item_rows > 0 and substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + ' + + 'substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows', + }, + { + key: 'unknown-has-disposition', + label: 'UNKNOWN 项均有去向:每个 UNKNOWN 都挂了验证任务或补来源动作,影响说明与证据引用逐条齐全', + level: 'L2', + predicate: 'substitution_difference_item_missing = 0', + }, + { + key: 'judged-by-rule', + label: '四态与推荐等级由规则算出:逐参数比对工具与推荐定级工具都真调过', + level: 'L2', + predicate: `tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0`, + }, + { + key: 'analysis-status-confirmed', + label: '分析状态与客户批准状态分离:每条 P-11 只写分析层 analysis_status = FAE 确认,规则版本已锁定随结论落账', + level: 'L2', + predicate: 'substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and substitution_relation_missing = 0', + }, + { + key: 'p01-writeback-complete', + label: '回写 P-01 字段完整:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全', + level: 'L2', + predicate: 'demand_request_rows > 0 and demand_request_missing = 0', + }, + { key: 'evidence-credible', label: '证据链可信:每条结论都能追溯到 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入)', level: 'L4' }, + { key: 'hard-not-auto-match', label: '硬约束项未被自动判 MATCH:封装 / 工艺 / 电气边界等硬约束的 MATCH 都有证据支持', level: 'L4' }, + { key: 'recommendation-consistent', label: '推荐等级与差异明细一致:存在 UNKNOWN / CONDITIONAL 的候选没有给「推荐」', level: 'L4' }, + /* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */ + { key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-01 回写,结论供下游消费', level: 'L5' }, + ], + revisableAtRelease: [], + prohibitions: [ + { key: 'no-fabricated-parameter', statement: 'UNKNOWN 是一等公民:查不到的参数值必须显式写「未披露」并记去向,不得伪造、不得用估计值或空串冒充' }, + { key: 'no-override-hard-mismatch', statement: '硬约束 MISMATCH 不得被任何措辞改判 MATCH;要改只能补证据后重比' }, + { key: 'no-customer-approval-write', statement: '客户批准状态由独立审批流维护,本活动不写 customer_approval_status(没有这一列的写权)' }, + { key: 'no-commercial-judgement', statement: '本活动只答技术可替代性:不做数量、价格、交期、采购判断' }, + { key: 'no-rebind', statement: '启动绑定的 4 项(客户 / 需求 / 原 PN / 候选数组)工单内不可改;绑错只能退回触发方重新启动' }, + { key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' }, + ], + authorization: { + discretion: [], + preauthorizedActions: [], + /* + * 基线:数据工单 ≤2 + 候选放弃/创建决策 ≤2 + 新客户录入卡 1 + 执行单 01 + 审核决策 1 + 放行 1 = 8; + * 审核循环预留两轮(+4)= 12。cap 必须严格大于最后一次拍板时的实际次数(E04 实测栽过),留到 16。 + */ + humanCallBudget: 16, + }, + resources: { + tools: [{ name: TOOL_COMPARE }, { name: TOOL_RECOMMEND }], + skills: ASSESSMENT_SKILLS.map((s) => s.id), + models: ['deepseek-v4-flash'], + roles: ['FAE/RI'], + budget: { steps: 90, tokens: 600_000, activeMinutes: 120, humanCalls: 16 }, + }, + }, + + /* ── 声明 ────────────────────────────────────────────────────── */ + declarations: { + models: ['deepseek-v4-flash'], + tools: [{ name: TOOL_COMPARE }, { name: TOOL_RECOMMEND }], + ledger: [ + /* L-02 客户主台账:只在分支 0a(客户不在库)由数据录入卡建一行 —— 录入卡写的列都得声明 */ + { + domain: 'customer', + object: 'profile', + kind: 'master', + label: '客户档案', + fields: [ + col({ name: 'customer_code', type: 'text', label: '客户编码', primaryKey: true }), + col({ name: 'name', type: 'text', label: '客户名称', required: true }), + col({ name: 'industry', type: 'text', label: '所属行业', expect: true }), + col({ name: 'region', type: 'text', label: '区域', expect: true }), + ], + }, + /* P-08 芯片主数据:数据工单建整条;参数集是它的一个字段(一张表,不建子表) */ + { + domain: 'parts', + object: 'profile', + kind: 'master', + label: '芯片主数据(P-08)', + fields: [ + col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }), + col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }), + col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }), + col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }), + col({ name: 'normalized_parameter_set', type: 'text', label: '标准化参数集(一行一参数:参数名 | 值 | 单位 | hard/soft | 说明)', required: true }), + col({ name: 'package', type: 'text', label: '封装', required: true }), + col({ name: 'process_technology', type: 'text', label: '工艺', expect: true }), + col({ name: 'lifecycle_status', type: 'text', label: '生命周期状态', required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }), + col({ name: 'eol_pcn_status', type: 'text', label: 'EOL/PCN 状态', expect: true }), + col({ name: 'datasheet_ref', type: 'text', label: 'Datasheet 引用', expect: true }), + col({ name: 'datasheet_version', type: 'text', label: 'Datasheet 版本', expect: true }), + col({ name: 'application_scenarios', type: 'text', label: '适配应用场景', expect: true }), + ], + }, + /* P-10 原厂技术资料库:数据工单建;评估时只读做证据回链 */ + { + domain: 'parts', + object: 'document', + kind: 'master', + label: '原厂技术资料库(P-10)', + fields: [ + col({ name: 'internal_pn', type: 'text', label: '关联内部料号', primaryKey: true }), + col({ name: 'doc_seq', type: 'text', label: '资料序号(同一料号下 01/02…)', primaryKey: true }), + col({ name: 'document_type', type: 'text', label: '资料类型', required: true, tally: ['datasheet', 'app_note', 'reference_design', 'white_paper', 'sample_doc', 'commitment_letter', 'other'] }), + col({ name: 'brand_name', type: 'text', label: '品牌名称', expect: true }), + col({ name: 'document_title', type: 'text', label: '资料标题', required: true }), + col({ name: 'document_version', type: 'text', label: '资料版本', required: true }), + col({ name: 'document_ref', type: 'text', label: '资料文件链接', required: true }), + col({ name: 'citation_locator', type: 'text', label: '证据定位(页码/章节/表格)', expect: true }), + col({ name: 'document_status', type: 'text', label: '资料状态', required: true, tally: ['valid', 'expired', 'pending', 'void'] }), + ], + }, + /* P-01 需求主记录:S0 新建 V1,放行时回写五项(current_stage = gate03 那一刻五项必填) */ + { + domain: 'demand', + object: 'request', + kind: 'master', + label: '客户需求主记录(P-01)', + fields: [ + col({ name: 'request_id', type: 'text', label: '需求编号', primaryKey: true }), + col({ name: 'customer_code', type: 'text', label: '客户编码', required: true, refersTo: 'customer.profile.customer_code' }), + col({ name: 'requirement_version', type: 'text', label: '需求版本', required: true }), + col({ name: 'request_type', type: 'text', label: '需求类型', required: true }), + col({ name: 'request_description', type: 'text', label: '需求描述', required: true }), + col({ name: 'input_snapshot_ref', type: 'text', label: '输入快照', required: true }), + col({ name: 'original_internal_pn', type: 'text', label: '原内部料号', required: true }), + col({ name: 'bom_position', type: 'text', label: 'BOM 位', expect: true }), + col({ name: 'application_scenario', type: 'text', label: '应用场景', expect: true }), + col({ name: 'technical_constraints', type: 'text', label: '技术约束' }), + col({ name: 'risk_level', type: 'text', label: '风险等级', tally: ['low', 'medium', 'high', 'critical'], requiredWhen: { column: 'current_stage', value: 'gate03' } }), + col({ name: 'verification_requirement', type: 'text', label: '验证/复测/确认要求', requiredWhen: { column: 'current_stage', value: 'gate03' } }), + col({ name: 'request_status', type: 'text', label: '需求状态', required: true, tally: ['pending', 'responded', 'won', 'abandoned', 'closed'] }), + col({ name: 'current_activity', type: 'text', label: '当前 Activity', requiredWhen: { column: 'current_stage', value: 'gate03' } }), + col({ name: 'current_stage', type: 'text', label: '当前阶段' }), + col({ name: 'next_action', type: 'text', label: '下一步动作', requiredWhen: { column: 'current_stage', value: 'gate03' } }), + col({ name: 'output_ref', type: 'text', label: '标准输出链接', requiredWhen: { column: 'current_stage', value: 'gate03' } }), + ], + }, + /* P-11 替代关系主记录:一候选一条;两枚 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门) */ + { + domain: 'substitution', + object: 'relation', + kind: 'master', + label: '替代关系主记录(P-11)', + fields: [ + col({ name: 'substitution_relation_id', type: 'text', label: '替代关系ID', primaryKey: true }), + col({ name: 'eval_ref', type: 'text', label: '评估案号', required: true }), + col({ name: 'request_id', type: 'text', label: '关联需求编号', required: true }), + col({ name: 'bom_position', type: 'text', label: '关联 BOM 位', expect: true }), + col({ name: 'original_internal_pn', type: 'text', label: '原内部料号', required: true, refersTo: 'parts.profile.internal_pn' }), + col({ name: 'candidate_internal_pn', type: 'text', label: '候选内部料号', required: true, refersTo: 'parts.profile.internal_pn' }), + col({ name: 'substitution_type', type: 'text', label: '替代类型', required: true, tally: ['pin_to_pin', 'functional', 'parametric', 'other'] }), + col({ name: 'recommendation_level', type: 'text', label: '推荐等级', required: true, tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }), + col({ name: 'recommendation_reason', type: 'text', label: '推荐理由', required: true }), + col({ name: 'difference_summary', type: 'text', label: '差异摘要', required: true }), + col({ name: 'risk_level', type: 'text', label: '替代风险等级', required: true, tally: ['low', 'medium', 'high', 'critical'] }), + col({ name: 'verification_requirement', type: 'text', label: '验证要求', requiredWhen: { column: 'recommendation_level', value: 'conditional' } }), + col({ name: 'validation_id', type: 'text', label: '关联验证ID' }), + col({ name: 'analysis_status', type: 'text', label: '分析状态', required: true, tally: ['ai_draft', 'fae_confirmed', 'pending_customer', 'customer_approved', 'rejected', 'void'] }), + col({ name: 'analysis_report_ref', type: 'text', label: '替代分析报告', expect: true }), + col({ name: 'confidence_score', type: 'number', label: '证据可信度', expect: true }), + col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }), + ], + }, + /* P-11 差异明细子表:一条参数一条记录;relation id 上 distinct 让基座数「几条关系有明细」 */ + { + domain: 'substitution', + object: 'difference_item', + kind: 'master', + label: '差异明细(P-11 子表)', + fields: [ + col({ name: 'substitution_relation_id', type: 'text', label: '替代关系ID', primaryKey: true, distinct: true, refersTo: 'substitution.relation.substitution_relation_id' }), + col({ name: 'difference_item_id', type: 'text', label: '差异明细ID', primaryKey: true }), + col({ name: 'parameter_name', type: 'text', label: '标准参数名', required: true }), + col({ name: 'original_value', type: 'text', label: '原物料参数值', required: true }), + col({ name: 'candidate_value', type: 'text', label: '候选物料参数值', required: true }), + col({ name: 'parameter_unit', type: 'text', label: '参数单位', expect: true }), + col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }), + col({ name: 'compare_result', type: 'text', label: '比对结果(工具出参)', expect: true }), + col({ name: 'difference_status', type: 'text', label: '差异状态', required: true, tally: ['match', 'mismatch', 'unknown', 'conditional'] }), + col({ name: 'impact_statement', type: 'text', label: '影响说明(MATCH 写「无」)', required: true }), + col({ name: 'unknown_disposition', type: 'text', label: 'UNKNOWN 去向(验证任务号 / 补来源动作)', requiredWhen: { column: 'difference_status', value: 'unknown' } }), + col({ name: 'risk_level', type: 'text', label: '风险等级', required: true, tally: ['low', 'medium', 'high'] }), + col({ name: 'evidence_ref', type: 'text', label: '差异证据引用', required: true }), + col({ name: 'confidence', type: 'number', label: 'AI 置信度', expect: true }), + col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }), + ], + }, + /* P-06 验证记录:事实类 —— 本活动只写「验证要求」(状态 not_started),不经数据工单建 */ + { + domain: 'validation', + object: 'record', + kind: 'master', + label: '芯片验证记录(P-06)', + fields: [ + col({ name: 'validation_id', type: 'text', label: '验证ID', primaryKey: true }), + col({ name: 'request_id', type: 'text', label: '关联需求编号', required: true }), + col({ name: 'validation_internal_pn', type: 'text', label: '验证内部料号', required: true, refersTo: 'parts.profile.internal_pn' }), + col({ name: 'validation_type', type: 'text', label: '验证类型', required: true, tally: ['pin_to_pin', 'functional', 'reliability', 'certification', 'retest'] }), + col({ name: 'validation_status', type: 'text', label: '验证状态', required: true, tally: ['not_started', 'in_progress', 'passed', 'failed', 'conditional', 'pending_evidence'] }), + col({ name: 'test_condition', type: 'text', label: '测试条件', expect: true }), + col({ name: 'next_validation_action', type: 'text', label: '后续验证动作', required: true }), + col({ name: 'evidence_ref', type: 'text', label: '验证证据引用', expect: true }), + ], + }, + /* ── 活动自持单据(record,caseKey eval_ref)── */ + { + domain: D, + object: 'pn_check', + kind: 'record', + caseKey: 'eval_ref', + label: '料号存在性核对', + fields: [ + col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }), + col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }), + col({ name: 'role', type: 'text', label: '角色', required: true, tally: ['original', 'candidate'] }), + col({ name: 'exists_status', type: 'text', label: '在库状态', required: true, tally: ['found', 'absent', 'created', 'abandoned'] }), + col({ name: 'abandon_reason', type: 'text', label: '放弃原因', requiredWhen: { column: 'exists_status', value: 'abandoned' } }), + col({ name: 'note', type: 'text', label: '备注' }), + ], + }, + { + domain: D, + object: 'input_gap', + kind: 'record', + caseKey: 'eval_ref', + label: '评估输入清单', + fields: [ + col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }), + col({ name: 'item_key', type: 'text', label: '输入项键', primaryKey: true }), + col({ name: 'input_item', type: 'text', label: '输入项', required: true }), + col({ name: 'source_ledger', type: 'text', label: '来源台账', expect: true }), + col({ name: 'status', type: 'text', label: '状态', required: true, tally: ['gap', 'default', 'ok'] }), + col({ name: 'detail', type: 'text', label: '绑定值 / 缺口说明', expect: true }), + col({ name: 'supplement', type: 'text', label: '补充内容', requiredWhen: { column: 'status', value: 'gap' } }), + col({ name: 'origin', type: 'text', label: '来源(ai/human-added)' }), + ], + }, + { + domain: D, + object: 'verdict', + kind: 'record', + caseKey: 'eval_ref', + label: '本次评估结论', + fields: [ + col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }), + col({ name: 'request_id', type: 'text', label: '需求编号' }), + col({ name: 'rule_version', type: 'text', label: '规则版本' }), + col({ name: 'original_internal_pn', type: 'text', label: '原内部料号' }), + col({ name: 'candidate_total', type: 'integer', label: '候选数' }), + col({ name: 'input_confirmed_total', type: 'integer', label: '评估输入已确认' }), + col({ name: 'review_approved_total', type: 'integer', label: '差异结论已审核通过' }), + col({ name: 'landing_prepared_total', type: 'integer', label: '拟落账已备好' }), + col({ name: 'relation_total', type: 'integer', label: '替代关系条数' }), + col({ name: 'recommended_total', type: 'integer', label: '推荐' }), + col({ name: 'conditional_total', type: 'integer', label: '条件推荐' }), + col({ name: 'alternate_total', type: 'integer', label: '备选' }), + col({ name: 'not_recommended_total', type: 'integer', label: '不推荐' }), + col({ name: 'summary', type: 'text', label: '本次评估结论' }), + ], + }, + ], + listeners: [], + harness: { + memoryWindow: 24, + stallAfter: 'PT4H', + nudge: [{ after: 'PT2H', action: 'nudge' }], + retry: { times: 2, backoff: 'PT30S' }, + }, + }, + + /* ── 资产初始版 ──────────────────────────────────────────────── */ + assets: { + businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: ASSESSMENT_DMN, steps: ASSESSMENT_STEPS, phases: ASSESSMENT_PHASES }, + skills: ASSESSMENT_SKILLS, + /* + * 五个局面各一张: + * · `p08-master-create` —— S0d 摆,数据工单(P-08),件上可加行、可编辑; + * · `p10-document-create` —— S0d 摆,数据工单(P-10),同上; + * · `eval-input-list` —— S1 摆,评估输入清单:绑定区只读 + 缺口表可编辑; + * · `difference-review` —— S2/S3/S4 摆,差异明细 + 各候选推荐等级,只读; + * · `landing-confirm` —— S5/T_release 摆,拟写 P-11 / 拟回写 P-01,只读;放行后即归档件。 + */ + viewTemplates: [ + { name: 'p08-master-create', html: P08_MASTER_CREATE }, + { name: 'p10-document-create', html: P10_DOCUMENT_CREATE }, + { name: 'eval-input-list', html: EVAL_INPUT_LIST }, + { name: 'difference-review', html: DIFFERENCE_REVIEW }, + { name: 'landing-confirm', html: LANDING_CONFIRM }, + ], + cardSchemas: {}, + distillHooks: [{ on: 'terminal', prompt: '归纳本次差异评估:哪些候选、各自推荐等级与关键依据、UNKNOWN 挂了什么验证任务、审核是否被驳回过(为什么)、数据工单建了哪些主数据。' }], + }, + + /* + * ── 样例集:五条形状真不同(常备案例即验收案例)── + * 第一条满载且走「候选缺库 → 数据工单」那条最长的路(测试台落地即预填的是它);样例名 ≤14 字。 + * 库里只播了 LM2596S-5.0 / LM2596S-ADJ 两颗(见 seed-mvp-data.ts),XL2596S-5.0 / CN-DCDC-32V / LM2596S-3.3 刻意不在库。 + */ + samples: [ + { + name: '候选缺库·派数据工单', + input: { + customerCode: 'CU-0142', + requestDescription: '工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料;应用 12V→5V、工业温域、输入上限 40V', + originalInternalPn: 'LM2596S-5.0', + candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'], + }, + expect: { ports: ['assessment-released'] }, + }, + { + name: '都在库·常路', + input: { + customerCode: 'CU-0142', + requestDescription: 'LM2596S-5.0 转 NRND,客户先评估同族可调版 LM2596S-ADJ 能否替代(愿意改外围);应用 12V→5V、工业温域', + originalInternalPn: 'LM2596S-5.0', + candidateInternalPns: ['LM2596S-ADJ'], + }, + expect: { ports: ['assessment-released'] }, + }, + { + name: '原PN缺库·硬阻塞', + input: { + customerCode: 'CU-0142', + requestDescription: '客户另一块板在用 LM2596S-3.3(3.3V 固定版),同批 PCN,要求评估 LM2596S-ADJ 替代;应用 12V→3.3V', + originalInternalPn: 'LM2596S-3.3', + candidateInternalPns: ['LM2596S-ADJ'], + }, + expect: { ports: ['assessment-released'] }, + }, + { + name: '三候选·含硬约束不推荐', + input: { + customerCode: 'CU-0142', + requestDescription: '工业电源 12V→5V,输入上限 40V,工业温域;三个候选:XL2596S-5.0、LM2596S-ADJ、国产 CN-DCDC-32V(输入上限只有 32V)', + originalInternalPn: 'LM2596S-5.0', + candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ', 'CN-DCDC-32V'], + }, + expect: { ports: ['assessment-released'] }, + }, + { + name: '新客户·先建档', + input: { + customerCode: 'CU-9001', + customerName: '深圳恒星电源有限公司', + requestDescription: '新客户口述:工业电源 12V→5V 在用 LM2596S-5.0,想评估 LM2596S-ADJ', + originalInternalPn: 'LM2596S-5.0', + candidateInternalPns: ['LM2596S-ADJ'], + }, + expect: { ports: ['assessment-released'] }, + }, + ], + + entry: { file: 'index.js', checksum: 'dev-entry-part-substitution-assessment' }, +}; +``` + +- [ ] **Step 4: 先放两份占位(让 Task 3 的测试能跑;Task 4/5 整份替换)** + +`src/case-model.ts` 占位: +```ts +export const CMMN_XML = ''; +export const NARRATIVE = ''; +export const ASSESSMENT_STEPS: ReadonlyArray<{ item: string; intent: string; reads: string[]; writes: string[]; tools: string[]; view?: string; doneWhen?: string; guide: string }> = []; +export const ASSESSMENT_PHASES: ReadonlyArray<{ name: string; items: string[] }> = []; +``` +`src/views/report.ts` 占位: +```ts +export const P08_MASTER_CREATE = '

'; +export const P10_DOCUMENT_CREATE = '
'; +export const EVAL_INPUT_LIST = '
'; +export const DIFFERENCE_REVIEW = '
'; +export const LANDING_CONFIRM = '
'; +``` + +- [ ] **Step 5: 写结构自测 `__tests__/activity.test.ts`**(Task 4/5 落地前,案例模型与模版相关的用例会红 —— 这是预期,Task 5 收尾时全绿) + +```ts +/** + * ACT-WORK-05 差异评估的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。 + * 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐 + * (判据谓词 / 台账声明的授权面 / 决策卡循环骨架 / 报告模版的读写面),结构照 E04 / I01。 + */ +import { describe, expect, it } from 'vitest'; +import { activityManifestSchema } from '@contracts'; +import { createMockHost } from '@sdk'; +import { manifest, D, RULE_VERSION } from '../src/manifest.ts'; +import activity from '../src/index.ts'; + +const ITEMS = ['S0_bind', 'S0d_master_data', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release']; +const NO_DONE_WHEN = new Set(['S0d_master_data', 'S1_input_confirm', 'S4_review', 'T_release']); +const VIEWS = ['p08-master-create', 'p10-document-create', 'eval-input-list', 'difference-review', 'landing-confirm']; +const byAddress = (domain: string, object: string) => manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object); +const viewHtml = (name: string) => manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? ''; + +describe('清单本身合规', () => { + it('过 @contracts 的 schema', () => { + expect(() => activityManifestSchema.parse(manifest)).not.toThrow(); + }); + + it('没有静态监听(等外部回音一律落在执行工单上,listen 在本仓不可用)', () => { + expect(manifest.declarations.listeners).toEqual([]); + }); + + it('L1/L2 判据都给了谓词;L4 三条不给(裁量项);L5 恰一条', () => { + for (const c of manifest.contract.acceptanceCriteria) { + if (c.level === 'L1' || c.level === 'L2') expect(c.predicate, c.key).toBeDefined(); + if (c.level === 'L4') expect(c.predicate, c.key).toBeUndefined(); + } + expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4')).toHaveLength(3); + expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1); + }); + + it('机检谓词只读基座读数与记账旗:不出现活动自报的汇总列名', () => { + const forbidden = ['relation_total', 'candidate_total', 'recommended_total', 'not_recommended_total']; + for (const c of manifest.contract.acceptanceCriteria) { + for (const name of forbidden) expect(c.predicate ?? '', `${c.key} 读了自报数 ${name}`).not.toContain(name); + } + }); + + it('三件必交付物都有台账地址;人环预算严格大于「基线 8 + 两轮审核循环 4」', () => { + expect(manifest.contract.requiredDeliverables).toHaveLength(3); + for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined(); + expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(12); + expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(12); + }); + + it('起单只必填 4 项(客户 / 需求 / 原 PN / 候选数组);datasheet 是选填文件', () => { + expect(manifest.contract.input.data['required']).toEqual(['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']); + const properties = manifest.contract.input.data['properties'] as Record; + expect(properties['candidateInternalPns']?.type).toBe('array'); + expect(properties['datasheets']?.format).toBe('file'); + }); + + it('五条样例:第一条满载走数据工单那条路,名字 ≤14 字,形状各不相同', () => { + expect(manifest.samples).toHaveLength(5); + for (const s of manifest.samples) expect(s.name.length, s.name).toBeLessThanOrEqual(14); + expect(new Set(manifest.samples.map((s) => JSON.stringify(s.input))).size).toBe(5); + const first = manifest.samples[0]?.input as { candidateInternalPns?: string[] }; + expect(first.candidateInternalPns).toContain('XL2596S-5.0'); + const newCustomer = manifest.samples.find((s) => (s.input as { customerName?: string }).customerName !== undefined); + expect(newCustomer).toBeDefined(); + }); + + it('五张视图模版都声明了,剧本每一步引用的 view 都在其中', () => { + expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set(VIEWS)); + for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) { + if (step.view !== undefined) expect(VIEWS, step.view).toContain(step.view); + } + }); +}); + +describe('台账声明:授权面与《统一方案》口径', () => { + it('P-08 一张表:主键 internal_pn,参数集是它的一个必填字段,没有 parts.parameter 子表', () => { + const p08 = byAddress('parts', 'profile'); + expect(p08?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn']); + expect(p08?.fields.find((f) => f.name === 'normalized_parameter_set')?.required).toBe(true); + expect(byAddress('parts', 'parameter')).toBeUndefined(); + }); + + it('P-11 主记录的原 PN / 候选 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门)', () => { + const rel = byAddress('substitution', 'relation'); + for (const name of ['original_internal_pn', 'candidate_internal_pn']) { + expect(rel?.fields.find((f) => f.name === name)?.refersTo, name).toBe('parts.profile.internal_pn'); + } + }); + + it('客户批准状态**不在声明里**:本活动没有它的写权(只读边界)', () => { + const rel = byAddress('substitution', 'relation'); + expect(rel?.fields.map((f) => f.name)).not.toContain('customer_approval_status'); + }); + + it('UNKNOWN 必有去向:unknown_disposition 钉在 difference_status = unknown 的行上;四态是 ASCII tally', () => { + const item = byAddress('substitution', 'difference_item'); + expect(item?.fields.find((f) => f.name === 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' }); + expect(item?.fields.find((f) => f.name === 'difference_status')?.tally).toEqual(['match', 'mismatch', 'unknown', 'conditional']); + expect(item?.fields.find((f) => f.name === 'substitution_relation_id')?.distinct).toBe(true); + }); + + it('P-01 回写五项钉在 current_stage = gate03 那一刻(放行前不拦,放行门 L2 数得出来)', () => { + const p01 = byAddress('demand', 'request'); + for (const name of ['risk_level', 'verification_requirement', 'output_ref', 'next_action', 'current_activity']) { + expect(p01?.fields.find((f) => f.name === name)?.requiredWhen, name).toEqual({ column: 'current_stage', value: 'gate03' }); + } + }); + + it('自持单据都带 caseKey eval_ref 且在主键里;放弃候选必须写原因', () => { + for (const object of ['pn_check', 'input_gap', 'verdict']) { + const t = byAddress(D, object); + expect(t?.kind, object).toBe('record'); + expect(t?.caseKey, object).toBe('eval_ref'); + expect(t?.fields.find((f) => f.name === 'eval_ref')?.primaryKey, object).toBe(true); + } + expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' }); + }); + + it('规则版本常量随包冻结', () => { + expect(RULE_VERSION).toBe('R-2026.07'); + expect(manifest.assets.businessModel.dmn.tables.map((t) => t.id)).toEqual(['d01-difference-status', 'd02-recommendation']); + }); +}); + +describe('案例模型与清单的对齐', () => { + it('八个计划项,剧本 item 与 CMMN humanTask id 一一对应', () => { + const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/ m[1] ?? ''); + expect(cmmnIds).toEqual(ITEMS); + expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS); + }); + + it('四处不给 doneWhen(数据工单 / 输入确认 / 审核 / 放行),其余四处都给', () => { + for (const s of manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>) { + if (NO_DONE_WHEN.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined(); + else expect(s.doneWhen, s.item).toBeDefined(); + } + }); + + it('那四项在 CMMN 里都标了人工激活', () => { + for (const id of NO_DONE_WHEN) { + const block = new RegExp(``).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? ''; + expect(block, id).toContain('manualActivationRule'); + } + }); + + it('除放行外每一项恰好属于一个阶段;5 段 + 放行 = x/6,与文档 0.5/1/2/3/4/5 六步对齐', () => { + const phases = manifest.assets.businessModel.phases ?? []; + expect(phases).toHaveLength(5); + const phaseItems = phases.flatMap((p) => p.items); + expect([...phaseItems].sort()).toEqual([...ITEMS.filter((i) => i !== 'T_release')].sort()); + expect(new Set(phaseItems).size).toBe(phaseItems.length); + }); + + it('说明书点名的计划项都真实存在', () => { + const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d[a-z]?_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? ''); + for (const name of mentioned) expect(ITEMS, `说明书提到但不存在的计划项: ${name}`).toContain(name); + }); + + it('两种决策卡骨架都在说明书里:候选放弃/创建(create/abandon)与审核(approve/reject/custom:)', () => { + const n = manifest.assets.businessModel.narrative; + expect(n).toContain('"kind":"decide","itemId":"S0d_master_data"'); + expect(n).toContain('"value":"create"'); + expect(n).toContain('"value":"abandon"'); + expect(n).toContain('"kind":"decide","itemId":"S4_review"'); + expect(n).toContain('"approve"'); + expect(n).toContain('"reject"'); + expect(n).toContain('"custom:"'); + }); + + it('T_release 剧本里 judge criterion 的原文逐字对得上 acceptanceCriteria 的 label', () => { + const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find((s) => s.item === 'T_release'); + const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? ''); + expect(quoted).toHaveLength(3); + const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label)); + for (const q of quoted) expect(labels.has(q), q).toBe(true); + }); +}); + +describe('报告模版:编辑面与只读面各就各位', () => { + it('两张数据工单件可加行可编辑:data-edit / data-edit-add 与主键 data-col 都在', () => { + const p08 = viewHtml('p08-master-create'); + expect(p08).toContain('data-edit="parts.profile"'); + expect(p08).toContain('data-edit-add="parts.profile"'); + expect(p08).toContain('data-col="internal_pn"'); + expect(p08).toContain('data-col="normalized_parameter_set"'); + const p10 = viewHtml('p10-document-create'); + expect(p10).toContain('data-edit="parts.document"'); + expect(p10).toContain('data-col="doc_seq"'); + }); + + it('评估输入清单:缺口表可编辑(supplement),绑定区只读', () => { + const html = viewHtml('eval-input-list'); + expect(html).toContain(`data-edit="${D}.input_gap"`); + expect(html).toContain('data-col="supplement"'); + expect(html).toContain(`data-rows="mine:${D}.pn_check"`); + }); + + it('差异审核与落账确认只读:没有任何 data-edit / data-col', () => { + for (const name of ['difference-review', 'landing-confirm']) { + expect(viewHtml(name), name).not.toContain('data-edit='); + expect(viewHtml(name), name).not.toContain('data-col='); + } + }); + + it('每个可编辑格子的 data-col 都在对应台账对象的声明里', () => { + const cases: Array<[string, string, string]> = [ + ['p08-master-create', 'parts', 'profile'], + ['p10-document-create', 'parts', 'document'], + ['eval-input-list', D, 'input_gap'], + ]; + for (const [view, domain, object] of cases) { + const cols = new Set([...viewHtml(view).matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? '')); + const declared = new Set(byAddress(domain, object)?.fields.map((f) => f.name)); + for (const c of cols) expect(declared.has(c), `${view}: ${c}`).toBe(true); + } + }); + + it('枚举列只用 data-class-of 投影,不摆 ASCII 原值', () => { + expect(viewHtml('difference-review')).toContain('data-class-of="difference_status"'); + expect(viewHtml('difference-review')).toContain('data-class-of="recommendation_level"'); + expect(viewHtml('difference-review')).not.toContain('data-slot="difference_status"'); + }); + + it('宿主拒推含脚本的模版', async () => { + const host = createMockHost({ contract: CONTRACT }); + await expect(host.base.interaction.pushHtml('
')).rejects.toThrow(/无脚本执行权/); + }); +}); + +describe('活动入口', () => { + it('没有 run —— 行为全在清单里', () => { + expect(activity.run).toBeUndefined(); + }); +}); + +const CONTRACT = { + activityId: 'part-substitution-assessment', + version: '1.0.0', + data: {}, + message: undefined, + outputPorts: { mandatory: ['assessment-released'], selective: [] }, + prohibitions: [], + discretion: [], +} as const; +``` + +- [ ] **Step 6: 跑一遍,记下哪些红(只许是案例模型 / 模版相关的那几条)** + +Run: `pnpm vitest run activities/part-substitution-assessment` +Expected:「清单本身合规」「台账声明」两组绿;「案例模型」「报告模版」组红(占位为空)。若「过 @contracts 的 schema」红,先修清单再往下。 + +- [ ] **Step 7: 提交骨架** + +```bash +git add activities/part-substitution-assessment +git commit -m "feat(w05): 差异评估活动包骨架与清单 —— 六张主数据追加声明、三张自持单据、两张 DMN、五条样例 + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` + +--- + +### Task 4: 案例模型 —— CMMN 八项、说明书、逐步剧本、五段进度 + 剧本自测 + +**Files:** +- Replace: `activities/part-substitution-assessment/src/case-model.ts`(整份替换 Task 3 的占位) +- Create: `activities/part-substitution-assessment/__tests__/steps.test.ts` + +**Interfaces:** +- Consumes:Task 3 的台账地址 / 列名 / tally 值、工具名 `TOOL_COMPARE` / `TOOL_RECOMMEND`、视图名。 +- Produces:`CMMN_XML`、`NARRATIVE`、`ASSESSMENT_STEPS`(8 条,`item` 顺序 = CMMN humanTask 顺序)、`ASSESSMENT_PHASES`(5 段)。 +- 案号约定(剧本与 e2e 共用):`eval_ref = 'W05-' + case:openedAt 去分隔符`;`request_id = 'REQ-' + 同一串数字`;`input_snapshot_ref = 'SNAP-' + eval_ref`;关系 id `REL-<数字串>-01/02…`(按候选数组顺序);明细 id `<关系id>-P01/P02…`;验证 id `VAL-<数字串>-01/02…`;报告号 `RPT-<数字串>`。 +- 卡标题约定(e2e 按标题对号):数据工单 P-08 以「数据工单 · 芯片主数据创建」开头;P-10 以「数据工单 · 原厂技术资料创建」开头;执行单以「评估输入与资料补齐确认」开头。 + +- [ ] **Step 1: 写 `src/case-model.ts`** + +```ts +/** + * ACT-WORK-05 差异评估的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 + * + * 业务出处:`docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md`(步骤 0.5 + 5 步, + * 1 数据(条件)+ 1 执行 + 2 验收)与《SS02-SS03-SS04_Demo台账字段细化与统一方案》(字段口径)。 + * + * 文档六步收成 8 个案例模型项: + * 启动期 → S0_bind(AI:铸案号、建 P-01 V1、锁规则版本、逐个 PN 查库、写存在性核对表) + * 0.5 等待数据 → S0d_master_data(人:数据工单 —— P-08 一张、P-10 一张;候选 ≥2 缺库先决策卡; + * 库里都有就 disable 这一项) + * 1 建立评估上下文 → S1_input_confirm(人:执行工单 01,评估输入清单,件上补缺口) + * 2 读取并标准化 → S2_normalize(AI:P-11 主记录骨架 + 差异明细对照行) + * 3 逐项差异评估 → S3_assess(AI + 两枚工具:四态 / 风险 / 证据 / 影响 / 推荐等级 / 验证任务) + * 4 FAE/RI 审核 → S4_review(人:验收工单 01 = 决策卡循环,驳回则 AI 改台账再发) + * 5 提交评审放行 → S5_prepare_landing(AI:analysis_status 推 fae_confirmed、回写 P-01)+ T_release(人:验收工单 02,终局放行) + * + * 四处不给 doneWhen:S0d(触达人)、S1(交人补缺口)、S4(拍板且可循环)、T_release(签下去)。 + */ +import { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts'; + +export const CMMN_XML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update + demand_request_rows > 0 and sub_eval_pn_check_rows > 0 + + + + update + sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_document_missing = 0 + + + + update + input_confirmed_total > 0 and sub_eval_input_gap_rows > 0 and sub_eval_input_gap_missing = 0 + + + + update + substitution_relation_rows > 0 and substitution_relation_rows = count(candidateInternalPns) - sub_eval_pn_check_exists_status_abandoned and substitution_difference_item_rows > 0 and substitution_difference_item_distinct_substitution_relation_id = substitution_relation_rows + + + + update + substitution_difference_item_missing = 0 and substitution_relation_missing = 0 and substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows and tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0 + + + + update + review_approved_total > 0 and substitution_difference_item_missing = 0 + + + + update + landing_prepared_total > 0 and substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and substitution_relation_missing = 0 and demand_request_missing = 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +/** + * 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本)。 + * 卡骨架住在这里而不是剧本里(模型会压拍,压过之后只有说明书还在 —— A1/A6/I01 同一课)。 + */ +export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能替代原料号,逐参数给出可复核的差异结论,审核后落账**。 +入参: +· \`customerCode\` —— 客户编码(L-02);\`customerName\` 只在客户不在库时给。 +· \`requestDescription\` —— 客户需求原话,据它新建 P-01 需求主记录(版本 V1)。 +· \`originalInternalPn\` —— 原内部料号;\`candidateInternalPns\` —— 候选内部料号数组(≥1,逐个评估、各一条 P-11)。 +· \`datasheets\` —— 选填的真文件,基座已读成文本(每件 \`{name, text?, note}\`);有就拿它在数据工单里预填,没有就人全填。 + +**本活动只回答一件事:技术上哪种料能替**。不做数量 / 价格 / 交期 / 采购判断;客户批准状态由审批流维护,你没有那一列的写权。 + +**四项绑定后不可改**(客户 / 需求 / 原 PN / 候选数组):绑错只能退回启动人重新启动。 + +**能算的不问人也不自己判**:四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)与推荐等级 +(推荐 / 条件推荐 / 备选 / 不推荐)全由两枚工具算 —— \`${TOOL_COMPARE}\`、\`${TOOL_RECOMMEND}\`, +一批一次。你负责对照表、证据引用、影响说明、UNKNOWN 的去向。**规则版本 \`${RULE_VERSION}\` 随每条结论落账**。 + +**UNKNOWN 是一等公民**:查不到的参数值写「未披露」,不许估、不许留空串冒充;每个 UNKNOWN 必须写去向 +(挂验证任务号或补来源动作)。硬约束 MISMATCH 不许改判,要改只能补证据后重比。 + +**存在性检查与数据工单**:启动时逐个料号在 \`parts.profile\` 里查(原 PN + 每个候选)。查无记录 = 「完全没有」, +要人从零建整条主数据 —— 发**数据工单**(一张表一张单:P-08 一张、P-10 一张);PN 在库但个别字段缺 = 「有而不全」, +归执行工单 01 补。原 PN 缺 = 硬阻塞必须建;候选缺:候选 ≥2 时先问人放弃还是创建,候选 = 1 时必须建。 +**库里都有就把 S0d_master_data disable 掉**(此单不走这条),别发空单。 + +**六步走**:绑定与存在性核对 → (等待数据)→ 人补评估输入 → 标准化对照表 → 逐项评估 → 人审结论(可循环)→ 拟落账 → 人放行。 + +【本单会用到的卡,全部照下面的骨架发,别自己另编】 + +① 新客户建档(只在 \`customerCode\` 在 \`customer.profile\` 里查无时,S0_bind 里发;编码用入参给的那个,人核名): +{"kind":"entry","itemId":"S0_bind","ask":"客户 不在客户档案里,请核对建档信息后确认", + "ledger":"customer.profile","key":{"customer_code":""}, + "fields":[ + {"key":"name","label":"客户名称","value":"","required":true}, + {"key":"industry","label":"所属行业"}, + {"key":"region","label":"区域"} + ]} +人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。 + +② 候选不在库、候选 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选只有一颗时不问、直接建): +{"kind":"decide","itemId":"S0d_master_data", + "ask":"候选 在芯片主数据里查无记录。是从零建它的主数据(派数据工单),还是本轮放弃这颗候选?", + "options":[{"value":"create","label":"创建主数据(派数据工单)","recommended":true}, + {"value":"abandon","label":"放弃这颗候选"}],"allowCustom":true} +\`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因; +点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。 + +③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上): +{"kind":"human","itemId":"S0d_master_data","workOrder":"execution","card":{ + "title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>", + "ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。", + "steps":["每一行:品牌、规格描述、封装、生命周期状态、标准化参数集为必填,缺一格确认不了", + "标准化参数集一行一参数:参数名 | 值 | 单位 | hard/soft | 说明;原厂未披露的值写「未披露」,它会进入 UNKNOWN 并挂验证任务", + "有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写", + "改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"], + "standard":"每颗料号一行,必填列全齐;参数集至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项。", + "basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"}, + {"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}} + +④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发): +{"kind":"human","itemId":"S0d_master_data","workOrder":"execution","card":{ + "title":"数据工单 · 原厂技术资料创建(P-10):<料号列表>", + "ask":"请把这些料号的 datasheet / 承诺函等资料记录补进原厂技术资料库(一份资料一行,可加行)。", + "steps":["资料类型、标题、版本、文件链接、资料状态为必填;证据定位写页码或章节,差异评估的证据引用会指向它", + "同一料号多份资料就多加几行,序号 01/02… 自己续", + "改完保存,回这张卡确认"], + "standard":"每颗料号至少一行 datasheet 记录,必填列全齐。", + "basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}} + +⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入): +{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{ + "title":"评估输入与资料补齐确认:<原 PN> → 个候选", + "ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。", + "steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)", + "原厂未披露的参数(如使能阈值)补不到就写「挂验证任务」或「留 UNKNOWN」—— 补不了也是合法答案,不拦流程", + "替代类型 / 规则版本 / 负责人期限三行是系统默认,可改可不改", + "加一行 = 补充 AI 没列出的资料项;删一行 = 该项不适用;已绑定的四项不可改", + "改完保存,回这张卡提交"], + "standard":"每一条「待补」行的补充内容都有;补不了的写明理由。", + "basis":[{"k":"已绑定","v":"客户 · 需求 V1 · 原 PN <原 PN> · 候选 颗"}, + {"k":"缺口","v":" 项待补 / 待确认"}]}} + +⑥ 验收工单 01 · 差异评估结论审核(S4_review;决策卡循环,不是终局验收)—— 发之前先在轨迹里找有没有已答过、还没处理的 \`[card-reply]\`: +· \`value\` 是 \`"approve"\` → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项,别再发卡; +· \`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头 → 都算驳回:\`"reject"\` 没写意见,**不许再发卡追问**;\`"custom:"\` 的 \`note\` 就是他的意见, + 按意见改 \`substitution.difference_item\` / \`substitution.relation\`(同 PK 覆盖写,不开新行),意见要挂验证的写一行 \`validation.record\`, + 然后**换一种问法**再发一张(措辞不换会被当成同一张卡静默吞掉); +· 没有 → 才发第一张: +{"kind":"decide","itemId":"S4_review","allowCustom":true, + "ask":"差异评估结论已算出: 个候选、共 条参数比对(MATCH a / MISMATCH b / UNKNOWN c / CONDITIONAL d),推荐等级 <逐候选一句>。请审核。", + "options":[{"value":"approve","label":"通过","recommended":true}, + {"value":"reject","label":"驳回(不写具体意见)"}]} +三个 \`value\` 只有 \`"approve"\` / \`"reject"\` / \`"custom:"\` 开头三种,别造第四种。 + +【一路上要说话】每一拍带一句 say,说业务的话:查了几颗料、缺几颗、对照了几项、四态各几条、推荐等级各是什么。到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。用件上那三个词(必填 / 建议 / 选填)说缺口,只说事实。 + +【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告(有 doneWhen 的项引擎自己收)。 +动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍;tool 之后同拍其余动作作废。 +**常路 9–12 拍收口**(候选缺库那条路多两张数据工单、多两拍)。 + +§Z 结案:FAE/RI 在验收单上签字,系统把草稿合并进 P-08 / P-10 / P-01 / P-11 / P-06 正式版并发射 \`assessment-released\` 口。`; + +/** + * 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。 + * 四处不给 doneWhen:S0d(发数据工单触达人)、S1(交人补缺口)、S4(拍板且循环)、T_release(签下去)。 + */ +export const ASSESSMENT_STEPS = [ + { + item: 'S0_bind', + intent: '铸案号、建需求 V1、锁规则版本、逐个料号查库、写存在性核对表', + writes: ['demand.request', `${D}.pn_check`, `${D}.verdict`, 'customer.profile'], + reads: ['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets', 'case:openedAt', 'customer.profile', 'parts.profile'], + view: 'eval-input-list', + tools: [], + /* + * 「该有几行」也进判据:原 PN + 每个候选各一行核对(count(candidateInternalPns) + 1); + * 少一行 = 一颗料从这一单里凭空消失。count() 对未绑定名回 1 —— candidateInternalPns 是必填入参,恒绑定。 + */ + doneWhen: + `demand_request_rows > 0 and demand_request_missing = 0 and ${D}_verdict_rows > 0 and ` + + `${D}_pn_check_rows = count(candidateInternalPns) + 1 and ${D}_pn_check_missing = 0`, + guide: `第一件事:**把这一单的上下文立起来**,一拍连做(客户不在库时多一张录入卡、多一拍)。 + +【先铸案号】\`eval_ref = 'W05-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`(\`case:openedAt\` 形如 "2026-08-26 21:03:15",去掉分隔符即得); +\`request_id = 'REQ-' + 同一串数字\`;\`input_snapshot_ref = 'SNAP-' + eval_ref\`。**不许从正式表里别单的案号里挑**,整单只有这一个 eval_ref。 + +【客户在不在库】在 \`customer.profile\` 里找 \`customerCode\`:在 → 直接用;不在 → 照说明书①发数据录入卡(名字用 \`customerName\`), +发完这一拍结束;人确认后这家客户就在草稿层里了,下一拍接着做。**客户不在库也不许自己 write customer.profile**。 + +【写 \`demand.request\` 一行(V1)】request_id、customer_code(= customerCode)、requirement_version = "V1"、request_type = "技术需求"、 +request_description(入参原话,不改写)、input_snapshot_ref、original_internal_pn、request_status = "pending"、current_activity = "ACT-WORK-05"、 +current_stage = "assessing"。application_scenario / bom_position **这一步不写**(启动时未收,S1 由人补)。 + +【逐个料号查库,写 \`${D}.pn_check\`】\`parts.profile\` 整表在你的作用域里,按 \`internal_pn\` 逐个核对:原 PN 一行(role = "original"), +每个候选一行(role = "candidate");在 → exists_status = "found";不在 → exists_status = "absent"(**这一格先写 absent,等数据工单确认后再改**)。 +一颗都不能少:数组里点了几颗候选,就写几行 + 原 PN 一行。note 选填(如「PCN 转 NRND」)。 + +【写 \`${D}.verdict\` 一行】eval_ref、request_id、rule_version = "${RULE_VERSION}"、original_internal_pn、candidate_total = 候选数组长度。 + +写完引擎自己收这一项。收完先看 \`${D}.pn_check\`:有 absent 的 → 下一项是 S0d_master_data(数据工单);一个 absent 都没有 → +**disable S0d_master_data**(reason 写「原 PN 与全部候选都在库,无需建主数据」),然后等 S1 那一项 Enabled。`, + }, + { + item: 'S0d_master_data', + intent: '料号不在库:派数据工单让人从零建整条主数据(P-08 一张、P-10 一张);候选 ≥2 先问放弃还是创建', + writes: ['parts.profile', 'parts.document', `${D}.pn_check`], + reads: [`${D}.pn_check`, 'candidateInternalPns', 'originalInternalPn', 'datasheets', 'parts.profile', 'parts.document'], + view: 'p08-master-create', + tools: [], + /* 触达人,不给 doneWhen;人工激活:发卡就算启动 */ + guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走;都在库就 disable 它(见 S0 剧本)。**先分岔,再动作**: + +【先看轨迹】这一项里已经发过哪些卡、人答了什么(【当前这一步】下面那行引擎事实说得清楚): +· 原 PN absent → **硬阻塞**,不问人,直接进入「建」的名单; +· 候选 absent 且候选数 ≥2 → 每颗 absent 的候选照说明书②各发一张决策卡(一次一张,按数组顺序);人选 "abandon"/"custom:" 的, + 把那一行 \`${D}.pn_check\` 改成 exists_status = "abandoned" + abandon_reason(同 PK 覆盖写),这颗不再评估;选 "create" 的进「建」的名单; +· 候选 absent 且候选只有 1 颗 → 不问,必须建。 +**别把同一颗候选问两遍**:决策卡答过就按答案办。 + +【「建」的名单定了 → 先写骨架行,再发 P-08 数据工单】 +1. 给名单里每颗料写一行 \`parts.profile\` 骨架:key {internal_pn},patch 只带你确有依据的列 —— 有 \`datasheets[].text\` 就从里面读 + brand_name / specification_description / package / lifecycle_status(active/nrnd/eol/discontinued/risk 之一)/ normalized_parameter_set + (一行一参数:参数名 | 值 | 单位 | hard/soft | 说明,原厂没写的值写「未披露」)/ datasheet_ref + datasheet_version;没有 datasheet 就只写 internal_pn + 这一列(其余留给人填,**别编**)。 +2. 同一拍给每颗料写一行 \`parts.document\` 骨架:key {internal_pn, doc_seq: "01"},patch {document_type: "datasheet", document_status: "pending", + brand_name 有就写}。 +3. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。 + +【P-08 那张确认回来了】看轨迹里的 [card-reply]:再发说明书④那张 P-10 数据工单(只发一次),发完这一拍结束。 + +【P-10 那张也确认回来了】把名单里每颗料的 \`${D}.pn_check\` 改成 exists_status = "created"(同 PK 覆盖写),然后 finish 这一项。 +这时 S1_input_confirm 的入场前提(全部料号落定、本单建的主数据不缺必填)自己就成立了 —— 缺必填由提交闸兜着,卡回来时一定是齐的。 + +【这两张卡一单各只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`, + }, + { + item: 'S1_input_confirm', + intent: '评估输入清单:绑定四项只读核对,缺口行交 PW 在件上补', + writes: [`${D}.input_gap`, `${D}.verdict`, 'demand.request'], + reads: [`${D}.input_gap`, `${D}.pn_check`, 'demand.request', 'parts.profile', 'parts.document', 'candidateInternalPns', 'originalInternalPn'], + view: 'eval-input-list', + tools: [], + /* 交人补缺口,不给 doneWhen;人工激活:发卡就算启动 */ + guide: `料号全部落定了,**该 PW 补评估输入了** —— 这是本活动唯一的执行工单,零输入。**先分岔**:【当前这一步】那行引擎事实说 +「从没给它发过卡」→ 走【发卡】;说「人已经被问过了」→ 走【收口】。 + +【发卡】先把评估输入清单写下来(\`${D}.input_gap\`,一行一项,item_key 用英文短键,input_item 用人读名),一拍连做: +· 已绑定四项(status = "ok",detail 写绑定值,origin = "ai"):item_key = customer / request / original_pn / candidates + (候选多颗就 candidate_1、candidate_2… 一颗一行,detail 写料号与在库状态); +· 待补两项(status = "gap",detail 写「启动时未收」):application_scenario「应用场景」(source_ledger = "P-01")、bom_position「BOM 位」(source_ledger = "P-09"); +· 默认可改三项(status = "default",detail 写默认值):substitution_type「替代类型」= "Pin-to-Pin"、rule_version「规则版本」= "${RULE_VERSION}"、owner「负责人 / 期限」= "按客户线默认 · 1 个工作日"; +· 待确认(status = "gap"):逐颗候选看 \`parts.profile.normalized_parameter_set\`,值是「未披露」的参数各一行(item_key = unknown_<料号>_<序号>, + input_item 如「XL2596S-5.0 使能阈值」,source_ledger = "P-08",detail 写「原厂未披露(有而不全)」); +· 齐备项(status = "ok"):原 PN 资料版本(source_ledger = "P-10",detail 写 datasheet 版本)。 +写完同一拍照说明书⑤发执行工单,发完这一拍结束(挂起)。 + +【收口】人交回之后只做三件事,然后 finish: +① 读 \`${D}.input_gap\` 现值:把 application_scenario / bom_position 两行的 supplement 抄进 \`demand.request\`(同 PK 覆盖写这两列; + supplement 写「补不了」「UNKNOWN」之类的,这两列不写);人加的行(origin = "human-added")同等作数,写进 technical_constraints 一句汇总。 +② 写 \`${D}.verdict.input_confirmed_total = 1\`。 +③ finish 这一项。**别在这一拍做对照表** —— 那是下一步的活。 + +【这张卡一单只发一次】人交回来之后不许再发;缺必填有提交闸兜着。`, + }, + { + item: 'S2_normalize', + intent: '读取并标准化:每个未放弃的候选一条 P-11 主记录骨架 + 逐参数对照行', + writes: ['substitution.relation', 'substitution.difference_item'], + reads: ['parts.profile', 'parts.document', 'demand.request', `${D}.pn_check`, `${D}.input_gap`, `${D}.verdict`, 'candidateInternalPns', 'originalInternalPn'], + view: 'difference-review', + tools: [], + doneWhen: + `substitution_relation_rows > 0 and substitution_relation_rows = count(candidateInternalPns) - ${D}_pn_check_exists_status_abandoned and ` + + 'substitution_difference_item_rows > 0 and substitution_difference_item_distinct_substitution_relation_id = substitution_relation_rows', + guide: `输入齐了,**把多来源资料整成可比对的对照表**,一拍连做(先写关系,再写明细 —— 明细引用关系 id,顺序反了会被拒)。 + +【每个未放弃的候选一条 \`substitution.relation\` 骨架】(\`${D}.pn_check\` 里 exists_status 是 found / created 的候选;abandoned 的不写) +substitution_relation_id = "REL-<案号数字串>-01/02…"(按候选数组顺序)、eval_ref、request_id、original_internal_pn、candidate_internal_pn、 +substitution_type = "pin_to_pin"(人在评估输入清单里改了替代类型就照改:功能替代 → functional、参数替代 → parametric)、 +analysis_status = "ai_draft"、rule_version = "${RULE_VERSION}"、bom_position(照 demand.request 现值抄,没有不写)。 +recommendation_level / recommendation_reason / difference_summary / risk_level **这一步一格都不写**(S3 的活)。 + +【每条关系逐参数写 \`substitution.difference_item\` 对照行】骨架按**原 PN 的参数集**(\`parts.profile.normalized_parameter_set\`,一行一参数 +「参数名 | 值 | 单位 | hard/soft | 说明」):原 PN 有的每个参数一行 —— difference_item_id = "<关系id>-P01/P02…"、parameter_name、 +original_value、candidate_value(候选参数集里同名那一行的值;候选没有这个参数 → 写「未披露」)、parameter_unit(以原 PN 为准;候选单位不同先换算, +换算不了原值照写、evidence_ref 里注明)、constraint_type(原 PN 那一行的 hard/soft;没标的按口径:封装 / 工艺 / 电压 / 电流 / 温度 / 阈值 hard,其余 soft)、 +rule_version = "${RULE_VERSION}"、evidence_ref(值从哪来:「P-08 · P-10 <版本>」;本单数据工单建的料号写「人工录入 · 数据工单 · P-08」)。 +另加两行**封装**与**生命周期**(constraint_type = hard / soft,值分别取 \`package\` 与 \`lifecycle_status\`)。 +difference_status / compare_result / risk_level / impact_statement / unknown_disposition **这一步不写**。 + +**缺参数不得伪造**:候选那一侧查不到就写「未披露」。写完引擎自己收这一项(每条关系都有明细行才算)。`, + }, + { + item: 'S3_assess', + intent: '逐项差异评估:四态与推荐等级由工具算,证据 / 影响 / UNKNOWN 去向由你写', + writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`], + reads: ['substitution.relation', 'substitution.difference_item', 'demand.request', `${D}.input_gap`, `${D}.verdict`], + view: 'difference-review', + tools: [TOOL_COMPARE, TOOL_RECOMMEND], + doneWhen: + 'substitution_difference_item_missing = 0 and substitution_relation_missing = 0 and ' + + 'substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + ' + + 'substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows and ' + + 'tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0', + guide: `对照表铺好了,**固定三拍**:比对 → 定级 → 写盘。**工具调用后同拍其余动作作废**,别把工具和写台账串在一起;两枚工具各只调一次。 + +【第一拍:只调 \`${TOOL_COMPARE}\`,别的什么都不写】把**全部**关系一次打包: +{"candidates":[{"candidateInternalPn":"<候选>","pairs":[{"parameterName":"输入电压范围","originalValue":"4.5~40","candidateValue":"4.5~32", + "unit":"V","constraintType":"hard","requirement":">= 40","evidenceRef":"P-08 · P-10 Rev H"}, …]}, …]} +· pairs 逐条照 \`substitution.difference_item\` 抄(parameterName / originalValue / candidateValue / unit / constraintType / evidenceRef); +· **客户在应用场景里给了边界的参数才写 requirement**(如「输入上限 40V」→ 输入电压范围那一对写 ">= 40";「工业温域」→ 工作温度写 "-40~85"); +· 每一对都带 evidenceRef —— 硬约束没有证据引用工具不判 MATCH,那正是要的。 +调完这一拍结束。 + +【第二拍:只调 \`${TOOL_RECOMMEND}\`】把第一拍出参里每个候选的 counts 原样传:{"candidates":[{"candidateInternalPn":"…","counts":{…}}, …]}。调完结束。 + +【第三拍:写盘,一拍连做,一次都不再调工具】 +① 逐条 \`substitution.difference_item\` 同 PK 覆盖写:difference_status / compare_result / risk_level 照第一拍出参**一字不改**; + impact_statement(MATCH 写「无」;其余说清对应用 / 验证 / 报价的影响,如「输入上限 32V < 客户 40V,不能上板」); + difference_status = unknown 的行**必须**写 unknown_disposition(「挂验证任务 VAL-<案号数字串>-01:实测使能阈值」或「向原厂补来源」);confidence(0~1)。 +② 每个 UNKNOWN 写一行 \`validation.record\`:validation_id = "VAL-<案号数字串>-01/02…"、request_id、validation_internal_pn(候选)、 + validation_type = "pin_to_pin"、validation_status = "not_started"、next_validation_action(要测什么、什么条件)、test_condition。 +③ 逐条 \`substitution.relation\` 同 PK 覆盖写:recommendation_level / risk_level 照第二拍出参、recommendation_reason(工具 reason 扩写成人话, + 引具体参数)、difference_summary(「7 项比对:4 MATCH / 1 MISMATCH / 1 UNKNOWN / 1 CONDITIONAL」)、analysis_report_ref = "RPT-<案号数字串>"、 + confidence_score(取该候选各行 confidence 的最小值);recommendation_level = conditional 的行**必须**写 verification_requirement(列出 VAL 号)、validation_id。 +④ \`${D}.verdict\` 同 PK 覆盖写:relation_total、recommended_total / conditional_total / alternate_total / not_recommended_total(数关系表)。 +写完引擎自己收这一项;下一项是审核(S4),**这一拍不要发卡**。 + +**不许把工具的结论改成别的**:不推荐就是不推荐;要改只能补证据后重比。`, + }, + { + item: 'S4_review', + intent: '差异评估结论审核:决策卡循环,通过或驳回 + AI 按意见改台账重发', + writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`], + reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`], + view: 'difference-review', + tools: [], + /* 要人拍板,且这一项内部可能循环 —— 不给 doneWhen */ + guide: `结论算出来了,**该 FAE/RI 审核了** —— 这是验收工单 01,走决策卡(不是终局验收)。**先分岔,再动作**: +轨迹里找这一项已答过、还没处理的 [card-reply]: +· 没有 → 照说明书⑥发第一张决策卡(ask 里把候选数、比对条数、四态各几条、逐候选推荐等级说全),发完这一拍结束。 +· "approve" → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项。别再发卡。 +· "reject" / "custom:…" → **驳回**。按意见改台账(同 PK 覆盖写,不开新行、不改行数): + · 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`; + · 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级不许改 —— 那是工具算的;要改先回 S3 补证据重比,本活动不回头,写清楚为什么维持); + · 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**; + 然后**换一种问法**再发一张决策卡(把改了什么写进 ask),发完结束。 +**基座对着字面比对意见与草稿**:观察里点名了哪一行,就改那一行,不挪到别的行头上。`, + }, + { + item: 'S5_prepare_landing', + intent: '拟落账:P-11 分析状态推到 FAE 确认,回写 P-01 五项', + writes: ['substitution.relation', 'demand.request', `${D}.verdict`], + reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`], + view: 'landing-confirm', + tools: [], + doneWhen: + 'landing_prepared_total > 0 and substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and ' + + 'substitution_relation_missing = 0 and demand_request_missing = 0', + guide: `审核通过了,**把拟落账的内容备好**,一拍连做: +① 每条 \`substitution.relation\` 同 PK 覆盖写 analysis_status = "fae_confirmed"(只推分析层;客户批准状态你没有写权,别碰)。 +② \`demand.request\` 同 PK 覆盖写五项 + 状态:risk_level(取各候选 risk_level 里最高的一档)、verification_requirement(把所有 VAL 号与要测什么列全; + 没有 UNKNOWN 写「无需验证」)、output_ref = "RPT-<案号数字串>"、next_action = "提交 ACT-GATE-03 评审放行"、current_activity = "ACT-WORK-05"、 + current_stage = "gate03"、request_status = "responded"。 +③ \`${D}.verdict\` 同 PK 覆盖写:landing_prepared_total = 1、summary 一句人话(几个候选各什么等级、挂了几个验证任务)。 +写完引擎自己收这一项,下一项是放行。**这一拍不要 submit**。`, + }, + { + item: 'T_release', + intent: '拟落账内容已备好,提交 ACT-GATE-03 交 FAE/RI 放行;被打回就改台账再交', + /* 被打回重做时要改的就是这几张 —— 不声明就写不进去 */ + writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`], + reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`], + view: 'landing-confirm', + tools: [], + /* 放行触达世界,必须人按一下 —— 引擎不自动收它 */ + guide: `落账确认那一屏摆着拟写 P-11 主记录与拟回写 P-01。**先看轨迹里有没有「验收被打回重做」**: +有 → 这一拍不是再提交一遍。照意见改**台账本身**(同 PK 覆盖写,不开新行): +· 他说推荐等级与明细不符 → 核对 \`substitution.relation.recommendation_level\` 与该关系明细的四态计数,不符的把 recommendation_reason 改实、把漏挂的验证要求补上; +· 他说 P-01 回写不全 → 补 \`demand.request\` 那五项; +· 他说证据不可信 → 改那几行 \`substitution.difference_item.evidence_ref\`(指到具体资料版本与页码); +· 没写具体意见 → 自己挑最站不住的一处改实,至少改对一处; +改完这一拍再 judge + submit。**基座那道闸:打回之后台账一个字没改就不许再提交。** + +没被打回(或已改过)→ 先裁三条裁量判据,然后 submit,同一拍连做: +· judge criterion="证据链可信:每条结论都能追溯到 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入)" —— 逐条看 evidence_ref 指不指得到具体记录; +· judge criterion="硬约束项未被自动判 MATCH:封装 / 工艺 / 电气边界等硬约束的 MATCH 都有证据支持" —— 看 hard 且 match 的行 evidence_ref 是否非空且具体; +· judge criterion="推荐等级与差异明细一致:存在 UNKNOWN / CONDITIONAL 的候选没有给「推荐」" —— 逐候选核对; +**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄。 +这一单没有分支去向,只发一口 \`assessment-released\`,不必你选出口。终局唯放行:你不能自己宣布完成。`, + }, +]; + +/** + * 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段; + * 放行那一格由基座统一加,人看到的是 **x/6** —— 与文档的步骤 0.5 / 1 / 2 / 3 / 4 / 5 六步逐格对齐: + * 0.5 启动与存在性检查(等待数据)· 1 建立评估上下文 · 2 读取并标准化 · 3 逐项差异评估 · 4 FAE 审核与补证(含拟落账)· 5 提交评审放行(基座那格) + */ +export const ASSESSMENT_PHASES = [ + { name: '启动与存在性检查', items: ['S0_bind', 'S0d_master_data'] }, + { name: '建立评估上下文', items: ['S1_input_confirm'] }, + { name: '读取并标准化资料', items: ['S2_normalize'] }, + { name: '逐项差异评估', items: ['S3_assess'] }, + { name: 'FAE 审核与补证', items: ['S4_review', 'S5_prepare_landing'] }, +]; +``` + +- [ ] **Step 2: 写剧本自测 `__tests__/steps.test.ts`** + +```ts +/** + * 步骤剧本与呈现面 —— 这段错了不报错,只是活动开始转圈 / 界面开始撒谎(与 E04 / I01 同一套惯例)。 + */ +import { describe, expect, it } from 'vitest'; +import { evaluateCondition } from '@sfeel'; +import { ASSESSMENT_PHASES, ASSESSMENT_STEPS, CMMN_XML, NARRATIVE } from '../src/case-model.ts'; +import { manifest, D } from '../src/manifest.ts'; + +const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map((m) => m[1] ?? ''); + +function doneOf(item: string): string { + const found = ASSESSMENT_STEPS.find((s) => s.item === item)?.doneWhen; + if (found === undefined) throw new Error(`${item} 没有 doneWhen`); + return found; +} +/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */ +function safeEval(expression: string, scope: Record): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} +/** 开局局面:入参 + 所有基座读数归零 */ +const fresh = (input: Record): Record => ({ + ...input, + demand_request_rows: 0, + demand_request_missing: 0, + [`${D}_verdict_rows`]: 0, + [`${D}_pn_check_rows`]: 0, + [`${D}_pn_check_missing`]: 0, + [`${D}_pn_check_exists_status_absent`]: 0, + [`${D}_pn_check_exists_status_abandoned`]: 0, + [`${D}_input_gap_rows`]: 0, + [`${D}_input_gap_missing`]: 0, + substitution_relation_rows: 0, + substitution_relation_missing: 0, + substitution_relation_analysis_status_fae_confirmed: 0, + substitution_difference_item_rows: 0, + substitution_difference_item_missing: 0, + substitution_difference_item_distinct_substitution_relation_id: 0, + substitution_difference_item_difference_status_match: 0, + substitution_difference_item_difference_status_mismatch: 0, + substitution_difference_item_difference_status_unknown: 0, + substitution_difference_item_difference_status_conditional: 0, + tool_part_substitution_assessment_compare_parameters_calls: 0, + tool_part_substitution_assessment_recommend_calls: 0, +}); + +describe('剧本与案例模型对得上', () => { + it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => { + for (const step of ASSESSMENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item); + for (const id of ITEM_IDS) expect(ASSESSMENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id); + }); + + it('清单里挂的就是这份剧本与这份阶段', () => { + expect(manifest.assets.businessModel.steps).toBe(ASSESSMENT_STEPS); + expect(manifest.assets.businessModel.phases).toBe(ASSESSMENT_PHASES); + }); +}); + +describe('剧本声明的读写面 ⊆ 清单声明', () => { + const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`)); + const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name)); + + it('steps[].writes / reads(台账那几个)全落在声明过的对象上', () => { + for (const step of ASSESSMENT_STEPS) { + for (const address of step.writes) expect(declaredAddresses.has(address), `${step.item} 要写 ${address}`).toBe(true); + for (const address of step.reads.filter((r) => r.includes('.') && !r.startsWith('case:'))) { + expect(declaredAddresses.has(address), `${step.item} 要读 ${address}`).toBe(true); + } + } + }); + + it('steps[].tools 全部是声明过的工具;只有 S3 调工具', () => { + for (const step of ASSESSMENT_STEPS) { + for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true); + if (step.item !== 'S3_assess') expect(step.tools, step.item).toEqual([]); + } + expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.tools).toHaveLength(2); + }); + + it('customer_approval_status 在任何剧本里都不许写(只读边界)', () => { + for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(/customer_approval_status\s*=/); + }); +}); + +describe('doneWhen —— 开局不许真,干完必须真', () => { + it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => { + for (const sample of manifest.samples) { + for (const step of ASSESSMENT_STEPS) { + if (step.doneWhen === undefined) continue; + expect(safeEval(step.doneWhen, fresh(sample.input as Record)), `样例「${sample.name}」下 ${step.item} 开局就为真`).toBe(false); + } + } + }); + + it('S0:两颗候选 + 原 PN = 3 行核对齐、需求与结论都写了 → 收得掉;少写一颗 → 收不掉', () => { + const base = { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'], demand_request_rows: 1, demand_request_missing: 0, [`${D}_verdict_rows`]: 1, [`${D}_pn_check_missing`]: 0 }; + expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 3 })).toBe(true); + expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 2 })).toBe(false); + }); + + it('S2:未放弃的候选各一条关系且每条都有明细 → 收得掉;放弃一颗后关系数随之减一', () => { + const two = { candidateInternalPns: ['A', 'B'], [`${D}_pn_check_exists_status_abandoned`]: 0, substitution_difference_item_rows: 14 }; + expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 2 })).toBe(true); + expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(false); + expect(safeEval(doneOf('S2_normalize'), { ...two, [`${D}_pn_check_exists_status_abandoned`]: 1, substitution_relation_rows: 1, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(true); + }); + + it('S3:四态之和 = 明细行数、必填齐、两枚工具都调过 → 收得掉;工具没调过 → 收不掉', () => { + const ok = { + substitution_difference_item_missing: 0, + substitution_relation_missing: 0, + substitution_difference_item_rows: 7, + substitution_difference_item_difference_status_match: 4, + substitution_difference_item_difference_status_mismatch: 1, + substitution_difference_item_difference_status_unknown: 1, + substitution_difference_item_difference_status_conditional: 1, + tool_part_substitution_assessment_compare_parameters_calls: 1, + tool_part_substitution_assessment_recommend_calls: 1, + }; + expect(safeEval(doneOf('S3_assess'), ok)).toBe(true); + expect(safeEval(doneOf('S3_assess'), { ...ok, tool_part_substitution_assessment_recommend_calls: 0 })).toBe(false); + expect(safeEval(doneOf('S3_assess'), { ...ok, substitution_difference_item_difference_status_match: 3 })).toBe(false); + }); + + it('S5:旗 + 全部关系 FAE 确认 + P-01 回写齐 → 收得掉;缺一头都收不掉', () => { + const ok = { landing_prepared_total: 1, substitution_relation_analysis_status_fae_confirmed: 2, substitution_relation_rows: 2, substitution_relation_missing: 0, demand_request_missing: 0 }; + expect(safeEval(doneOf('S5_prepare_landing'), ok)).toBe(true); + expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, substitution_relation_analysis_status_fae_confirmed: 1 })).toBe(false); + expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, demand_request_missing: 1 })).toBe(false); + }); +}); + +describe('说明书与剧本的骨架', () => { + it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => { + expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"'); + expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_master_data"'); + expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)'); + expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)'); + expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认'); + expect(NARRATIVE).toContain('"kind":"decide","itemId":"S4_review"'); + }); + + it('剧本里不引反例原话;S3 固定三拍且写明「工具后同拍作废」', () => { + const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? ''; + expect(s3).toContain('第一拍'); + expect(s3).toContain('第二拍'); + expect(s3).toContain('第三拍'); + expect(s3).toContain('工具调用后同拍其余动作作废'); + }); + + it('S0 剧本写明库里都有就 disable S0d;S0d 剧本写明候选 ≥2 才问、=1 必建、原 PN 硬阻塞', () => { + const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? ''; + expect(s0).toContain('disable S0d_master_data'); + const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_master_data')?.guide ?? ''; + expect(s0d).toContain('硬阻塞'); + expect(s0d).toContain('候选只有 1 颗'); + }); + + it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => { + expect(ASSESSMENT_PHASES).toHaveLength(5); + for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10); + const all = ASSESSMENT_PHASES.flatMap((p) => p.items); + expect(new Set(all).size).toBe(all.length); + expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort()); + }); +}); +``` + +- [ ] **Step 3: 跑包内测试** + +Run: `pnpm vitest run activities/part-substitution-assessment` +Expected:steps.test 全绿;activity.test 里除「报告模版」组外全绿(模版仍是占位)。 + +- [ ] **Step 4: 提交** + +```bash +git add activities/part-substitution-assessment +git commit -m "feat(w05): 差异评估案例模型 —— CMMN 八项七哨兵、说明书五卡骨架、八条剧本、五段进度 + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` + +--- + +### Task 5: 五张 HTML 模版(目标件平移) + +**Files:** +- Replace: `activities/part-substitution-assessment/src/views/report.ts`(整份替换 Task 3 的占位) +- Test: `activities/part-substitution-assessment/__tests__/activity.test.ts`(Task 3 已写好的「报告模版」组此时转绿) + +**Interfaces:** +- Consumes:Task 3 的台账列名 / tally 值;基座读数名 `parts_profile_rows / parts_profile_missing / parts_profile_missing_keys`、`parts_document_*`、`sub_eval_input_gap_*`、`substitution_relation_rows`、`substitution_difference_item_rows`、四态计数;引擎注入 `case:owner` / `case:taskId`;摊平列 `eval_ref` / `request_id` / `rule_version` / `original_internal_pn`;入参 `customerCode` / `requestDescription` / `originalInternalPn`。 +- Produces:`P08_MASTER_CREATE` / `P10_DOCUMENT_CREATE` / `EVAL_INPUT_LIST` / `DIFFERENCE_REVIEW` / `LANDING_CONFIRM`(字符串)。 +- 平移法(《HTML报告生成规范》§7):目标件 `docs/SS02-ACT-WORK-05-html/*.html` 的版面语言原样搬(它与 E04/I01 目标件同一套壳,`prd-style.ts` 已逐字节持有);把设计稿记号换成平台绑定:`data-rows="in.*/out.*"` → `data-rows="mine:域.对象"`;`data-field` → `data-slot` + 可改格加 `data-col`;`meta.task.owner/id` → `case:owner/case:taskId`;`sd.*` 读数 → 基座读数;`card.deadline`(本仓没有工单期限这个事实)与 `card.id` 单号**不摆**;目标件里 JS 造的行 / 算的计数一律换成 `data-rows` + 基座读数。 + +- [ ] **Step 1: 写 `src/views/report.ts`** + +先把 I01 `activities/brand-content-toolkit/src/views/report.ts` 里 `const BIND_CSS = \`` 到它的闭合反引号(`/** 三层壳照目标件 */` 之前)整段**原样复制**过来作为本文件的 `BIND_CSS`(数据驱动的开关全在那里:三档占位 / 缺项红带 / 编辑闸 / 增删行 / 状态徽基座);然后按下面写其余内容: + +```ts +/** + * ACT-WORK-05 差异评估的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。 + * + * 五个局面各一张: + * · `p08-master-create` —— S0d 摆,数据工单(P-08 芯片主数据),可加行、可编辑; + * · `p10-document-create` —— S0d 摆,数据工单(P-10 原厂技术资料),可加行、可编辑; + * · `eval-input-list` —— S0/S1 摆,评估输入清单:绑定区只读 + 缺口表可编辑; + * · `difference-review` —— S2/S3/S4 摆,各候选推荐等级 + 差异明细,**只读**(通过/驳回在对话区决策卡); + * · `landing-confirm` —— S5/T_release 摆,拟写 P-11 / 拟回写 P-01,**只读**,放行后即归档件。 + * + * 版面语言逐字量自 `docs/SS02-ACT-WORK-05-html/` 四份目标件;取值三纪律:顶层只取汇总列与单值列, + * 逐条走 `mine:域.对象`,列名逐字照 `declarations.ledger` 抄。**模版不算不判**。 + */ +import { PRD_CSS } from './prd-style.ts'; +import { D } from '../constants.ts'; + +/* ==================== 绑定期补的样式(从 I01 report.ts 的 BIND_CSS 原样复制)==================== */ +const BIND_CSS = `…(I01 那一段,原样)…`; + +/* ==================== 本活动的枚举徽标与参数集格子 ==================== */ +const EXTRA_CSS = ` +/* 参数集一行一参数:只读那一份按行换行显示 */ +.proto-root .jc-pre { white-space: pre-line; font-family: var(--mono); font-size: 12px; } +.proto-root textarea.jc-cell { min-height: 120px; font-family: var(--mono); font-size: 12px; } +/* 徽标按列作用域取人读名(同一个 v-conditional 在推荐等级与四态里是两个词) */ +.proto-root .jc-tag.t-role.v-original::after { content: "原 PN"; } +.proto-root .jc-tag.t-role.v-candidate::after { content: "候选"; } +.proto-root .jc-tag.t-exist.v-found::after { content: "在库"; } +.proto-root .jc-tag.t-exist.v-absent::after { content: "不在库"; } +.proto-root .jc-tag.t-exist.v-created::after { content: "本单新建"; } +.proto-root .jc-tag.t-exist.v-abandoned::after { content: "已放弃"; } +.proto-root .jc-tag.t-exist.v-absent { color: var(--danger); background: var(--danger-soft); } +.proto-root .jc-tag.t-exist.v-created { color: var(--ok); background: var(--ok-soft); } +.proto-root .jc-tag.t-gap.v-gap::after { content: "待补*"; } +.proto-root .jc-tag.t-gap.v-default::after { content: "默认·可改"; } +.proto-root .jc-tag.t-gap.v-ok::after { content: "齐备"; } +.proto-root .jc-tag.t-gap.v-gap { color: var(--danger); background: var(--danger-soft); } +.proto-root .jc-tag.t-gap.v-ok { color: var(--ok); background: var(--ok-soft); } +.proto-root .jc-tag.t-life.v-active::after { content: "Active"; } +.proto-root .jc-tag.t-life.v-nrnd::after { content: "NRND"; } +.proto-root .jc-tag.t-life.v-eol::after { content: "EOL"; } +.proto-root .jc-tag.t-life.v-discontinued::after { content: "停产"; } +.proto-root .jc-tag.t-life.v-risk::after { content: "风险"; } +.proto-root .jc-tag.t-doc.v-datasheet::after { content: "Datasheet"; } +.proto-root .jc-tag.t-doc.v-app_note::after { content: "应用笔记"; } +.proto-root .jc-tag.t-doc.v-reference_design::after { content: "参考设计"; } +.proto-root .jc-tag.t-doc.v-white_paper::after { content: "白皮书"; } +.proto-root .jc-tag.t-doc.v-sample_doc::after { content: "样片资料"; } +.proto-root .jc-tag.t-doc.v-commitment_letter::after { content: "承诺函"; } +.proto-root .jc-tag.t-doc.v-other::after { content: "其他"; } +.proto-root .jc-tag.t-docst.v-valid::after { content: "有效"; } +.proto-root .jc-tag.t-docst.v-expired::after { content: "过期"; } +.proto-root .jc-tag.t-docst.v-pending::after { content: "待核实"; } +.proto-root .jc-tag.t-docst.v-void::after { content: "作废"; } +.proto-root .jc-tag.t-sub.v-pin_to_pin::after { content: "Pin-to-Pin"; } +.proto-root .jc-tag.t-sub.v-functional::after { content: "功能替代"; } +.proto-root .jc-tag.t-sub.v-parametric::after { content: "参数替代"; } +.proto-root .jc-tag.t-sub.v-other::after { content: "其他"; } +.proto-root .jc-tag.t-rec.v-recommended::after { content: "推荐"; } +.proto-root .jc-tag.t-rec.v-conditional::after { content: "条件推荐"; } +.proto-root .jc-tag.t-rec.v-alternate::after { content: "备选"; } +.proto-root .jc-tag.t-rec.v-not_recommended::after { content: "不推荐"; } +.proto-root .jc-tag.t-rec.v-recommended { color: var(--ok); background: var(--ok-soft); } +.proto-root .jc-tag.t-rec.v-conditional { color: var(--warn-accent); background: var(--warn-soft); } +.proto-root .jc-tag.t-rec.v-not_recommended { color: var(--danger); background: var(--danger-soft); } +.proto-root .jc-tag.t-anal.v-ai_draft::after { content: "AI 草稿"; } +.proto-root .jc-tag.t-anal.v-fae_confirmed::after { content: "FAE 确认"; } +.proto-root .jc-tag.t-anal.v-pending_customer::after { content: "待客户验证"; } +.proto-root .jc-tag.t-anal.v-customer_approved::after { content: "客户已批准"; } +.proto-root .jc-tag.t-anal.v-rejected::after { content: "不批准"; } +.proto-root .jc-tag.t-anal.v-void::after { content: "作废"; } +.proto-root .jc-tag.t-anal.v-fae_confirmed { color: var(--ok); background: var(--ok-soft); } +.proto-root .jc-tag.t-diff.v-match::after { content: "MATCH"; } +.proto-root .jc-tag.t-diff.v-mismatch::after { content: "MISMATCH"; } +.proto-root .jc-tag.t-diff.v-unknown::after { content: "UNKNOWN"; } +.proto-root .jc-tag.t-diff.v-conditional::after { content: "CONDITIONAL"; } +.proto-root .jc-tag.t-diff.v-match { color: var(--ok); background: var(--ok-soft); } +.proto-root .jc-tag.t-diff.v-mismatch { color: var(--danger); background: var(--danger-soft); } +.proto-root .jc-tag.t-diff.v-unknown { color: var(--ink-3); background: var(--soft); } +.proto-root .jc-tag.t-diff.v-conditional { color: var(--warn-accent); background: var(--warn-soft); } +.proto-root .jc-tag.t-con.v-hard::after { content: "硬约束"; } +.proto-root .jc-tag.t-con.v-soft::after { content: "软约束"; } +.proto-root .jc-tag.t-con.v-hard { color: var(--danger); background: var(--danger-soft); } +.proto-root .jc-tag.t-risk.v-low::after { content: "低"; } +.proto-root .jc-tag.t-risk.v-medium::after { content: "中"; } +.proto-root .jc-tag.t-risk.v-high::after { content: "高"; } +.proto-root .jc-tag.t-risk.v-critical::after { content: "关键"; } +.proto-root .jc-tag.t-risk.v-low { color: var(--ok); background: var(--ok-soft); } +.proto-root .jc-tag.t-risk.v-medium { color: var(--warn-accent); background: var(--warn-soft); } +.proto-root .jc-tag.t-risk.v-high, .proto-root .jc-tag.t-risk.v-critical { color: var(--danger); background: var(--danger-soft); } +/* 绑定区:四项锁定的灰底只读行 */ +.proto-root .jc-bound { display: grid; grid-template-columns: 140px 1fr; gap: 6px 14px; padding: 10px 14px; margin: 8px 0 14px; background: var(--soft); border: 1px solid var(--line-soft); border-radius: 9px; font-size: 13px; } +.proto-root .jc-bound b { color: var(--ink-3); font-weight: 600; } +`; + +/** 三层壳照目标件(收件人 / 关联任务两格由引擎注入;目标件的「最迟交回」与单号本仓没有真数据源,不摆) */ +const doc = (input: { tone: 'human' | 'ledger'; badge: string; tag: string; kicker: string; title: string; step: string; state: string; body: string; foot: string }): string => + ` +
+
+
+
${input.badge}
+
+ ${input.kicker} +

${input.title}

+
收件人: · 关联任务:
+
+
+ ${input.tag} + ${input.step} + ${input.state} +
+
+
${input.body}
+
${input.foot}
+
+
`; + +const part = (label: string, aside = ''): string => `

【${label}】${aside === '' ? '' : ` —— ${aside}`}

`; +const criteria = (title: string, items: readonly string[]): string => + `
${title}
    ${items.map((i) => `
  • ${i}
  • `).join('')}
`; +const flow = (text: string): string => `
${text}
`; +/** 页脚右侧「工单类型:× · <案号>」 */ +const sign = (note: string, tag: string): string => `${note}${tag} · `; +const editGate = (address: string, label: string): string => `
+ ${label} + + + +
`; +const gapbar = (object: string, what: string): string => `
+ 还差 行${what}没填 —— 。按「编辑」开表,红框那几格就是要填的。补齐了才确认得了。 +
`; +const tag = (scope: string, col: string): string => ``; +const cell = (col: string, cls = ''): string => ``; +const ro = (col: string, cls = ''): string => ``; +const select = (col: string, options: ReadonlyArray, required = false): string => + ``; + +/* ==================== 局面 0 · 数据工单 P-08 ==================== */ + +const P08_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
内部料号品牌制造商规格描述封装工艺生命周期EOL / PCNDatasheet 引用Datasheet 版本适配场景标准化参数集(一行一参数:参数名 | 值 | 单位 | hard/soft | 说明)
${cell('internal_pn', 'mono')}${cell('brand_name')}${cell('manufacturer_name')}${cell('specification_description')}${cell('package', 'mono')}${cell('process_technology')}${select('lifecycle_status', [['active', 'Active'], ['nrnd', 'NRND'], ['eol', 'EOL'], ['discontinued', '停产'], ['risk', '风险']], true)}${tag('t-life', 'lifecycle_status')}${cell('eol_pcn_status')}${cell('datasheet_ref', 'mono')}${cell('datasheet_version')}${cell('application_scenarios')}
`; + +export const P08_MASTER_CREATE = doc({ + tone: 'human', + badge: '数据', + tag: '数据工单', + kicker: 'SS02 · ACT-WORK-05 差异评估 · 数据工单(P-08)', + title: '芯片主数据创建', + step: '步骤 0.5 · 等待数据', + state: `待建 / 行`, + body: + part('做什么', '批量创建 P-08 主数据记录') + + `
+

模板定位:本单是「数据工单」的实例 —— 一单一类(可多条):目标台账 = 芯片主数据(P-08); + 字段即表头、每行一条新记录;标准化参数集是这条记录的一个字段(一行一参数)。

+

触发原因:系统按内部料号查芯片主数据查无记录 —— 是「完全没有」,不是字段缺失(那归执行工单)。

+

AI 预填:上传了 datasheet 的料号 AI 已按资料预填,标着值的格子请核对;没上传的料号全部手填,同为合法路径。

+

纪律:必填列全齐才允许确认;确认后这批记录进本单草稿,放行时并入正式库并留痕(确认人 = 数据责任人);只建主数据,验证记录等事实类不在此建。

+
` + + part('创建什么', '主数据记录(每行一条,*为必填列)') + + gapbar('parts_profile', '必填') + + editGate('parts.profile', `待建 条记录,点「编辑」核对预填、补齐空缺;「+ 加一行」= 再建一条`) + + `
必填列(*)全齐才允许确认;原厂未披露的参数值请直接写「未披露」,它会进入 UNKNOWN 并挂验证任务;删一行 = 该记录不建。改动只落任务草稿层,保存不等于放行。
` + + P08_TABLE + + `
+ + 新行的内部料号必须写(它是这一行的钥匙);其余必填列全部填写后才允许确认。 +
` + + flow('提交 / 确认:本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'), + foot: sign('SS02 · ACT-WORK-05 差异评估 · 数据工单 · 芯片主数据创建(P-08)', '工单类型:数据'), +}); + +/* ==================== 局面 0′ · 数据工单 P-10 ==================== */ + +const P10_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + +
关联料号序号资料类型品牌资料标题版本文件链接证据定位状态
${cell('internal_pn', 'mono')}${cell('doc_seq', 'mono')}${select('document_type', [['datasheet', 'Datasheet'], ['app_note', '应用笔记'], ['reference_design', '参考设计'], ['white_paper', '白皮书'], ['sample_doc', '样片资料'], ['commitment_letter', '承诺函'], ['other', '其他']], true)}${tag('t-doc', 'document_type')}${cell('brand_name')}${cell('document_title')}${cell('document_version')}${cell('document_ref', 'mono')}${cell('citation_locator')}${select('document_status', [['valid', '有效'], ['expired', '过期'], ['pending', '待核实'], ['void', '作废']], true)}${tag('t-docst', 'document_status')}
`; + +export const P10_DOCUMENT_CREATE = doc({ + tone: 'human', + badge: '数据', + tag: '数据工单', + kicker: 'SS02 · ACT-WORK-05 差异评估 · 数据工单(P-10)', + title: '原厂技术资料创建', + step: '步骤 0.5 · 等待数据', + state: `待建 / 行`, + body: + part('做什么', '为本单新建的料号登记原厂资料(P-10)') + + `
+

触发原因:按内部料号查原厂技术资料库查无记录。一份资料一行;同一料号多份资料序号 01/02… 自己续。

+

为什么要它:差异明细的证据引用要指到具体资料的版本与页码 —— 没有这一行,结论就追溯不到来源。

+
` + + part('创建什么', '资料记录(每行一条,*为必填列)') + + gapbar('parts_document', '必填') + + editGate('parts.document', `待建 条资料记录,点「编辑」补齐`) + + `
资料类型、标题、版本、文件链接、状态为必填;证据定位写页码或章节。改动只落任务草稿层,保存不等于放行。
` + + P10_TABLE + + `
+ + 关联料号与序号是这一行的钥匙,新行都要写;必填列全齐才允许确认。 +
` + + flow('提交 / 确认:本工单只承载内容;确认请在对话区的【执行卡】中进行。'), + foot: sign('SS02 · ACT-WORK-05 差异评估 · 数据工单 · 原厂技术资料创建(P-10)', '工单类型:数据'), +}); + +/* ==================== 局面 1 · 评估输入清单(S0/S1)==================== */ + +const PN_CHECK_TABLE = `
+ + + + + + + + + + + + + + +
料号角色在库状态备注 / 放弃原因
${ro('internal_pn', 'mono')}${tag('t-role', 'role')}${tag('t-exist', 'exists_status')}
`; + +const GAP_TABLE = `
+ + + + + + + + + + + + + + + + + + +
键输入项来源状态绑定值 / 缺口说明补充内容
${cell('item_key', 'mono')}${cell('input_item')}${cell('source_ledger', 'mono')}${select('status', [['gap', '待补'], ['default', '默认·可改'], ['ok', '齐备']])}${tag('t-gap', 'status')}${cell('detail')}
`; + +export const EVAL_INPUT_LIST = doc({ + tone: 'human', + badge: '执行', + tag: '执行工单', + kicker: 'SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1', + title: '评估输入与资料补齐确认', + step: '步骤 1/5 · 建立评估上下文', + state: `待补 项`, + body: + part('做什么', '启动 4 项已绑定,其余信息在工单里补齐') + + `
+

AI 已完成:系统已按启动传入的 4 项完成绑定 —— 客户、客户需求(新建 P-01 · 版本 V1)、原 PN、候选 PN(数组,逐个评估)—— 并生成输入快照。

+

需要你补齐:启动时未收的信息(应用场景、BOM 位)直接在下表补;默认值(替代类型 / 规则版本 / 负责人期限)可确认可改。本单缺口只覆盖「有而不全」;「完全没有」(料号不在库)已由数据工单创建。未补齐的缺口项将按 UNKNOWN 进入差异评估。

+
` + + part('已绑定', '启动时冻结,工单内不可改 —— 绑错请退回启动人重新启动') + + `
+ 客户 + 客户需求 · V1 · + 原内部料号 + 规则版本 +
` + + PN_CHECK_TABLE + + part('交回什么', '评估输入清单(已绑定项只读核对,缺口项补齐,*为必填)') + + gapbar(`${D}_input_gap`, '补充内容') + + editGate(`${D}.input_gap`, `还有 项待补 / 待确认,点「编辑」在「补充内容」列填入`) + + `
加一行 = 补充 AI 未列出的资料项(新行的「输入项」与「补充内容」必填);删一行 = 该输入项不适用;补不了的写「留 UNKNOWN」或「挂验证任务」—— 那也是一个答案。改动只落任务草稿层,保存不等于放行。
` + + GAP_TABLE + + `
+ + 键写一个英文短名(如 thermal_note);「绑定值 / 缺口说明」栏写清这项资料是什么、AI 为什么拿不到。 +
` + + flow('审批 / 提交:本工单只承载内容;「提交」请在对话区的【执行卡】中进行。提交后去向:进入步骤 2 · AI 读取与标准化,仍缺失的项将按 UNKNOWN 进入差异评估。'), + foot: sign('SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1 · 评估输入与资料补齐确认', '工单类型:执行'), +}); + +/* ==================== 局面 2 · 差异评估结论审核(S2/S3/S4,只读)==================== */ + +const RELATION_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + +
候选料号替代类型推荐等级推荐理由差异摘要风险验证要求置信度
${ro('candidate_internal_pn', 'mono')}${tag('t-sub', 'substitution_type')}${tag('t-rec', 'recommendation_level')}${ro('recommendation_reason')}${ro('difference_summary')}${tag('t-risk', 'risk_level')}${ro('verification_requirement')}${ro('confidence_score', 'mono')}
`; + +const ITEM_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
关系参数项原 PN 值候选 PN 值单位约束类型结论风险证据引用影响说明UNKNOWN 去向置信度
${ro('substitution_relation_id', 'mono')}${ro('original_value', 'mono')}${ro('candidate_value', 'mono')}${ro('parameter_unit')}${tag('t-con', 'constraint_type')}${tag('t-diff', 'difference_status')}${tag('t-risk', 'risk_level')}${ro('evidence_ref', 'mono')}${ro('impact_statement')}${ro('unknown_disposition')}${ro('confidence', 'mono')}
`; + +export const DIFFERENCE_REVIEW = doc({ + tone: 'ledger', + badge: '验收', + tag: '验收工单', + kicker: 'SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2', + title: '差异评估结论审核', + step: '步骤 3~4/5 · 逐项差异评估 + FAE/RI 审核', + state: `共 个候选 · 条比对`, + body: + part('AI 结论说明', '结论是怎么算出来的') + + `
+

AI 已完成的计算:按 GOV 评估规则(锁定版本 ),对原 PN + 与每个候选逐项比对,四态结论 —— MATCH / + MISMATCH / + UNKNOWN / + CONDITIONAL ,并给出风险等级、证据引用与置信度。 + 差异明细 = P-11 差异明细子表(一条参数一条记录);当前为 AI 草稿,审核通过前不落账。本单数据工单创建的主数据,其参数证据标注「人工录入」。

+
` + + part('验收判据', '你据此判断「通过 / 驳回」') + + criteria('判断这份差异评估结论是否可信,请逐项核对', [ + '关键参数差异均有明确状态:逐项结论无漏项,每个参数都有四态之一的判定;', + 'UNKNOWN 项均有去向:每个 UNKNOWN 都挂了验证任务或补来源动作,不允许悬空;', + '证据链完整:每项结论可追溯至 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入);', + '硬约束项未被自动判 MATCH:封装 / 工艺等硬约束未经证据支持不得判 MATCH。', + ]) + + part('各候选推荐等级', '只读展示 · 每个候选一条 P-11 主记录草稿') + + RELATION_TABLE + + part('AI 产出的差异明细', '只读展示 · P-11 差异明细子表草稿') + + ITEM_TABLE + + flow('审批:本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。审核意见与补充条件写入 P-06(验证要求);通过后去向:进入步骤 5 · 评审放行与落账确认。'), + foot: sign('SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2 · 差异评估结论审核', '工单类型:验收'), +}); + +/* ==================== 局面 3 · 评审放行与落账确认(S5/T_release,只读)==================== */ + +const P11_TABLE = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
替代关系ID替代类型原内部料号候选内部料号推荐等级推荐理由差异摘要替代分析报告分析状态客户批准状态风险验证要求证据可信度规则版本
${ro('substitution_relation_id', 'mono')}${tag('t-sub', 'substitution_type')}${ro('original_internal_pn', 'mono')}${ro('candidate_internal_pn', 'mono')}${tag('t-rec', 'recommendation_level')}${ro('recommendation_reason')}${ro('difference_summary')}${ro('analysis_report_ref', 'mono')}${tag('t-anal', 'analysis_status')}未提交 由审批流程维护,本活动不产生${tag('t-risk', 'risk_level')}${ro('verification_requirement')}${ro('confidence_score', 'mono')}${ro('rule_version', 'mono')}
`; + +const P01_TABLE = `
+ + + + + + + + + + + + + + + + + + + + +
需求编号风险等级验证要求输出链接下一步动作当前阶段需求状态
${ro('request_id', 'mono')}${tag('t-risk', 'risk_level')}${ro('verification_requirement')}${ro('output_ref', 'mono')}${ro('next_action')}${ro('current_stage', 'mono')}${ro('request_status', 'mono')}
`; + +export const LANDING_CONFIRM = doc({ + tone: 'ledger', + badge: '验收', + tag: '验收工单', + kicker: 'SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2', + title: '评审放行与落账确认', + step: '步骤 5/5 · 提交 ACT-GATE-03', + state: `拟落账 条 P-11`, + body: + part('AI 结论说明', '本单确认什么') + + `
+

本活动以 ACT-GATE-03 评审放行为终点。本单确认拟落账内容:评估结论层写 P-11 替代关系主记录(每个候选一条)与差异明细子表,需求状态回写 P-01(风险等级 / 验证要求 / 输出链接 / 下一步动作 / 当前阶段)。 + 客户批准不属于本活动 —— 客户批准状态由审批流程维护,本单只写分析层(分析状态 = FAE 确认)。

+
` + + part('验收判据', '你据此判断「通过 / 驳回」') + + criteria('放行与落账前,请逐项核对', [ + '推荐等级与差异明细一致:存在 UNKNOWN / CONDITIONAL 的候选,推荐等级不得给「推荐」;', + '分析状态与客户批准状态分离:本单只写分析层(FAE 确认),客户批准状态保持「未提交」;', + 'GOV 规则版本已锁定:规则版本随结论一并落账;', + '回写 P-01 字段完整:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全。', + ]) + + part('拟写 P-11 · 替代关系主记录', '只读展示') + + P11_TABLE + + part('拟回写 P-01 · 需求状态', '只读展示') + + P01_TABLE + + flow('审批:本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后去向:P-11 落账、P-01 回写、放行;结论供 ACT-WORK-08 证据归集消费。'), + foot: sign('SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2 · 评审放行与落账确认', '工单类型:验收'), +}); +``` + +- [ ] **Step 2: 跑包内测试,全绿** + +Run: `pnpm vitest run activities/part-substitution-assessment` +Expected: activity.test + steps.test 全部 PASS(含「报告模版」组 7 条)。 + +- [ ] **Step 3: 全仓门禁子集 + 提交** + +Run: `pnpm -s typecheck && pnpm -s lint && pnpm -s depcruise` +Expected: 全绿(活动包只 import `@sdk`/`@contracts`/`@sfeel` 与包内文件)。 + +```bash +git add activities/part-substitution-assessment +git commit -m "feat(w05): 五张报告模版 —— 两张数据工单件、评估输入清单、差异审核件、落账确认件(目标件平移) + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` + +--- + +### Task 6: 登记进构建与安装通道、装进库、库内验收、测试台冒烟 + +**Files:** +- Modify: `tools/build-activities.ts`(import + `PACKAGES` 加一条) +- Modify: `apps/api/src/app/seed-activities.ts`(`REAL_PACKAGES` 加一条;装包之前注册 `W05_TOOLS`) +- Test: `apps/api/src/app/__tests__/seed-mvp-data.test.ts`(已过);`docker exec … psql` 验收 SQL + +**Interfaces:** +- Consumes:Task 2 的 `W05_TOOLS` / `W05_TOOL_SAMPLE`;Task 3–5 的活动包。 +- Produces:库里装好的 `part-substitution-assessment@1.0.0`、六张主数据表 + 三张自持表、样例料号;测试台「活动」页签里能选到它并起单。 + +- [ ] **Step 1: 登记到构建脚本** + +在 `tools/build-activities.ts` 的 import 区加: +```ts +import { manifest as partSubstitutionAssessment } from '../activities/part-substitution-assessment/src/manifest.ts'; +``` +`PACKAGES` 数组末尾加: +```ts + { dir: 'part-substitution-assessment', manifest: partSubstitutionAssessment }, +``` + +- [ ] **Step 2: 登记到安装通道并顺带注册纯工具** + +`apps/api/src/app/seed-activities.ts`: +```ts +const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const; +``` +顶部加 `import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';`,在 `installRealActivities` 里 `const root = activitiesRoot();` 之前插入: +```ts + /* + * **纯工具随活动一起注册**(2026-08-26):从前工具契约只在 db:seed 里注册,只跑 activities:install 的 + * 开发机上,活动清单声明了工具、白名单也发了,`sys.tool` 里却没有那件工具 —— 第一次跑就撞「工具不存在」。 + * 「安装即用」包含它声明过的纯工具能调得动。register 按 name+version 幂等,重复装是 no-op。 + */ + for (const contract of W05_TOOLS) { + const verdict = await container.toolbox.register({ contract, samples: [W05_TOOL_SAMPLE] }); + if (!verdict.accepted) log(` 工具 ${contract.name} 未过两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`); + } +``` +同文件那句 `主数据四域已物化:${MASTER_OBJECTS.length} 张表` 改成 `主数据 ${MASTER_DOMAINS.length} 域已物化:${MASTER_OBJECTS.length} 张表`(import 里把 `MASTER_DOMAINS` 一并从 `./seed-mvp-data.ts` 引进来)—— 日志说「四域」而库里八域,就是一处会撒谎的话。 + +- [ ] **Step 3: 构建 + 装包(这一步同时物化六张主数据表、三张自持表,并播样例料号)** + +```bash +pnpm activities:install 2>&1 | tail -20 +``` +Expected 日志里有:`主数据 8 域已物化:17 张表`(原 11 张 + 6)、`工具 …` 无「未过两审」、`活动包已装:ACT-WORK-05 · 差异评估(part-substitution-assessment@1.0.0) · 已授权工具 2 件`、静态审**无 error**。若出现 `静态审提示:[error] …`: +- `predicate-unbound` 指名 `candidateInternalPns` → 把该谓词里的 `count(candidateInternalPns)` 换成读 `sub_eval.verdict.candidate_total`(模型写)并在 doneWhen/哨兵里保留 `_rows` 那一半;升版本重装; +- `reference-target-unbound` → 检查 `refersTo` 的三个目标列是否与清单里声明的列名一字不差; +- `ledger-kind-invalid` → 自持表的 `caseKey` 必须在主键里。 + +- [ ] **Step 4: 库内验收(表、主键、性质、注释、样例行)** + +```bash +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select domain||'.'||object||' | '||kind||' | '||coalesce(case_key,'-') from ledger.__object_meta where domain in ('parts','demand','substitution','validation','sub_eval') order by 1" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select conrelid::regclass||' '||pg_get_constraintdef(oid) from pg_constraint where contype='p' and conrelid::regclass::text ~ '^ledger\\.(parts|demand|substitution|validation|sub_eval)__' order by 1" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select string_agg(column_name||':'||data_type, ', ' order by ordinal_position) from information_schema.columns where table_schema='ledger' and table_name='substitution__difference_item'" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select obj_description('ledger.parts__profile'::regclass)||' / '||col_description('ledger.parts__profile'::regclass, 6)" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select internal_pn||' | '||brand_name||' | '||lifecycle_status||' | '||left(normalized_parameter_set, 40) from ledger.parts__profile order by 1" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select internal_pn||'/'||doc_seq||' '||document_type||' '||document_version from ledger.parts__document order by 1" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select activity_id||' @'||version||' '||state from sys.activity_package where activity_id='part-substitution-assessment'" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select name||' v'||version from sys.tool where name like 'part-substitution-assessment.%'" +``` +Expected: +- `__object_meta` 9 行:`parts.profile master`、`parts.document master`、`demand.request master`、`substitution.relation master`、`substitution.difference_item master`、`validation.record master`、`sub_eval.pn_check record eval_ref`、`sub_eval.input_gap record eval_ref`、`sub_eval.verdict record eval_ref`; +- 主键:`parts__profile (internal_pn)`、`parts__document (internal_pn, doc_seq)`、`substitution__relation (substitution_relation_id)`、`substitution__difference_item (substitution_relation_id, difference_item_id)`、`sub_eval__pn_check (eval_ref, internal_pn)`、`sub_eval__input_gap (eval_ref, item_key)`、`sub_eval__verdict (eval_ref)`; +- `substitution__difference_item` 的列里含 `compare_result:text` 与 `unknown_disposition:text`(清单追加的两列真长出来了); +- 表注释 `芯片主数据(P-08)`,第 6 列注释是参数集的人读名; +- `parts__profile` 两行(`LM2596S-5.0 | TI | nrnd | 输入电压范围 | 4.5~40 …`、`LM2596S-ADJ | TI | active | …`);`parts__document` 两行; +- `sys.activity_package` 有 `part-substitution-assessment @1.0.0 installed`(state 值以库里 E04 那一行为准,同样即可);`sys.tool` 两件 `v1.0.0`。 +把这几条输出原样贴进 `docs/ACT-WORK-05-端到端测试记录.md` 的「0. 库内验收」节(Task 8 Step 1 建文件时一起补)。 + +- [ ] **Step 5: 测试台冒烟(不起单,只看装没装对)** + +用 claude-in-chrome 打开 `http://localhost:5173/design#/design/testbed/act`:活动下拉里有「ACT-WORK-05 · 差异评估」;选中后表单预填的是样例 0(客户 CU-0142、候选 `["XL2596S-5.0","LM2596S-ADJ"]`),样例钮五枚都显示名字(没有退成「样例 N」),「落真库」勾着。再打开 `http://localhost:5173/design#/design/ledgers` 数据域那一屏,按活动分组里能看到 ACT-WORK-05 名下 9 张表、`parts.profile` 正式 2 行。截图存 `docs/img/w05-testbed-smoke.png`(用 computer 工具 screenshot 后保存)。 + +- [ ] **Step 6: 门禁子集 + 提交** + +Run: `pnpm -s typecheck && pnpm -s lint && pnpm -s depcruise && pnpm vitest run apps/api/src/app/__tests__ activities/part-substitution-assessment` +Expected: 全绿。 + +```bash +git add tools/build-activities.ts apps/api/src/app/seed-activities.ts activities/part-substitution-assessment/dist/manifest.json activities/part-substitution-assessment/dist/index.js +git commit -m "feat(w05): 差异评估活动登记进构建与安装通道;装包时顺带注册纯工具 + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` +(`dist/` 是否入库以 E04 现状为准:`git ls-files activities/crm-relationship-coverage/dist` 有输出就一起 add,没有就不 add。) + +--- + +### Task 7: 脚本 e2e 套件 `w05`(五案例) + +**Files:** +- Modify: `tools/e2e-a2.mjs`(`SUITES` 加 `w05`;`buildEdits` 加 `stageEdits` / `fillWhere`;`expectGateBlocks` 认 `fillWhere`;`main()` 套件名单加 `w05`) + +**Interfaces:** +- Consumes:Task 4 的卡标题约定(P-08 单 `数据工单 · 芯片主数据创建`、P-10 单 `数据工单 · 原厂技术资料创建`、执行单 `评估输入与资料补齐确认`)、决策卡 value(`create` / `abandon` / `approve` / `reject` / `custom:`)、样例序号 0–4。 +- Produces:`node tools/e2e-a2.mjs w05` 5/5 通过;每案例 `released`、被拒 0。 + +- [ ] **Step 1: 扩 `buildEdits` —— 案例级按表的编辑(`stageEdits`)与按条件整行扫空(`fillWhere`)** + +把 `function buildEdits(stage, drafts) {` 改为 `function buildEdits(stage, drafts, kase = {}) {`,并把里面 +`for (const [i, edit] of (stage.edits ?? []).entries()) {` 改为 +```js + // 案例级按表的编辑叠在套件级之上(同一套件不同案例要建的料号不同 —— 参数集不能是同一份) + const edits = [...(stage.edits ?? []), ...((kase.stageEdits ?? {})[stage.address] ?? [])]; + for (const [i, edit] of edits.entries()) { +``` +在 `if (stage.fillAllBlank) { … }` 之后加: +```js + /* + * 按条件整行扫空:只给某一档状态的行填(评估输入清单里只有 status = gap 的行要补充内容, + * default / ok 的行本就不该填 —— fillAllBlank 会把它们也填上,那是替人说了他没说的话)。 + */ + if (stage.fillWhere) { + const { column, equals, values } = stage.fillWhere; + for (const r of rows) { + if (String(r[column] ?? '') === equals && !taken.has(String(r[rowKey]))) { + lines.push({ [rowKey]: String(r[rowKey]), ...values }); + } + } + } +``` +`runCase` 里 `const lines = buildEdits(stage, body.drafts ?? []);` 改为 `const lines = buildEdits(stage, body.drafts ?? [], kase);`。 + +`expectGateBlocks` 里 +```js + const rows = draftRows(body.drafts ?? [], stage.address); + const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length; +``` +改为 +```js + const rows = draftRows(body.drafts ?? [], stage.address); + // 有 fillWhere 的段只数该档的行:default / ok 行空着是本分,不是「闸没拦」 + const scoped = stage.fillWhere ? rows.filter((r) => String(r[stage.fillWhere.column] ?? '') === stage.fillWhere.equals) : rows; + const blank = scoped.filter((r) => String(r[column] ?? '').trim() === '').length; +``` + +- [ ] **Step 2: 加套件(放在 `i01: {…}` 之后、`};` 之前)** + +```js + /* + * ACT-WORK-05 差异评估 —— 1 数据(条件,P-08 / P-10 各一张)+ 1 执行 + 2 验收(审核走决策卡循环、放行终局)。 + * 库里只播了 LM2596S-5.0 / LM2596S-ADJ:XL2596S-5.0、CN-DCDC-32V、LM2596S-3.3 刻意不在, + * 「候选缺库 / 原 PN 缺库 / 硬约束不推荐」三条路靠它们。e2e 关落真库,主数据不落正式表,案例可反复跑。 + */ + w05: { + activityId: 'part-substitution-assessment', + address: 'sub_eval.input_gap', + blankCol: 'supplement', + stages: [ + { + match: /数据工单 · 芯片主数据创建/, + address: 'parts.profile', + rowKey: 'internal_pn', + blankCol: 'brand_name', + expectGateBlock: true, + edits: [], + // 案例没点名的料号兜底(不该走到这里 —— 每个案例都在 stageEdits 里逐颗给了) + fillAllBlank: { + brand_name: '兜底品牌', + specification_description: '兜底规格(案例没给参数集)', + package: 'TO-263-5', + lifecycle_status: 'active', + normalized_parameter_set: '输入电压范围 | 4.5~40 | V | hard | 兜底', + }, + }, + { + match: /数据工单 · 原厂技术资料创建/, + address: 'parts.document', + rowKey: 'internal_pn', + blankCol: 'document_title', + expectGateBlock: true, + edits: [], + fillAllBlank: { + doc_seq: '01', + document_type: 'datasheet', + document_title: 'Datasheet(e2e 兜底)', + document_version: 'Rev A', + document_ref: 'https://example.com/datasheet.pdf', + citation_locator: 'p.1', + document_status: 'valid', + }, + }, + { + match: /评估输入与资料补齐确认/, + address: 'sub_eval.input_gap', + rowKey: 'item_key', + blankCol: 'supplement', + expectGateBlock: true, + edits: [ + { item_key: 'application_scenario', supplement: '12V→5V、工业温域(-40~85℃)、输入上限 40V' }, + { item_key: 'bom_position', supplement: 'U3 电源位' }, + ], + // 其余 gap 行(原厂未披露的参数)统一「留 UNKNOWN」—— 补不了也是合法答案 + fillWhere: { column: 'status', equals: 'gap', values: { supplement: '原厂未披露,留 UNKNOWN 并挂验证任务' } }, + }, + ], + cases: [ + { + sample: 0, + name: '候选缺库 · XL2596S-5.0 派数据工单(P-08 + P-10)· 审核先点名驳回再通过', + // 决策卡序:候选 ≥2 缺库先问 → create;审核第一张点名意见驳回;第二张通过 + decideSeq: ['create', 'custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'], + expectDecision: true, + expectPort: 'assessment-released', + stageEdits: { + 'parts.profile': [ + { + internal_pn: 'XL2596S-5.0', + brand_name: 'XLSEMI', + manufacturer_name: 'XLSEMI', + specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz', + package: 'TO-263-5', + process_technology: 'BCD', + lifecycle_status: 'active', + datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', + datasheet_version: 'Rev C', + application_scenarios: '工业电源;消费电源', + normalized_parameter_set: [ + '输入电压范围 | 4.5~40 | V | hard | 客户上限 40V', + '输出电压 | 5.0 | V | hard | 固定版', + '输出电流 | 3 | A | hard | 满载能力', + '开关频率 | 150 | kHz | hard | 影响外围', + '静态电流 | 4.2 | mA | soft | 待机功耗', + '工作温度 | -40~+125 | ℃ | hard | 工业温域', + '使能阈值 | 未披露 | V | hard | 原厂未给', + ].join('\n'), + }, + ], + 'parts.document': [ + { + internal_pn: 'XL2596S-5.0', + doc_seq: '01', + document_type: 'datasheet', + brand_name: 'XLSEMI', + document_title: 'XL2596S 150KHz 3A Buck DC/DC Converter', + document_version: 'Rev C', + document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', + citation_locator: 'p.3 Electrical Characteristics', + document_status: 'valid', + }, + ], + }, + // 使能阈值未披露 → UNKNOWN → 条件推荐(工具算的,不是模型说的) + expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' }, + }, + { + sample: 1, + name: '都在库 · 常路 · 可调版 → 备选 · 审核一次通过', + decide: 'approve', + expectPort: 'assessment-released', + expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'LM2596S-ADJ', column: 'recommendation_level', contains: 'alternate' }, + }, + { + sample: 2, + name: '原 PN 缺库 · 硬阻塞建档(不问人)· 审核通过', + decide: 'approve', + expectPort: 'assessment-released', + stageEdits: { + 'parts.profile': [ + { + internal_pn: 'LM2596S-3.3', + brand_name: 'TI', + manufacturer_name: 'Texas Instruments', + specification_description: '降压型 DCDC 稳压器,3A,固定 3.3V,150kHz', + package: 'TO-263-5', + process_technology: 'BCD', + lifecycle_status: 'nrnd', + datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf', + datasheet_version: 'Rev H (2023-09)', + normalized_parameter_set: [ + '输入电压范围 | 4.5~40 | V | hard | 客户上限 40V', + '输出电压 | 3.3 | V | hard | 固定版', + '输出电流 | 3 | A | hard | 满载能力', + '开关频率 | 150 | kHz | hard | 影响外围', + '静态电流 | 5 | mA | soft | 待机功耗', + '工作温度 | -40~+85 | ℃ | hard | 工业温域', + '使能阈值 | 1.4 | V | hard | ON/OFF 阈值', + ].join('\n'), + }, + ], + 'parts.document': [ + { + internal_pn: 'LM2596S-3.3', + doc_seq: '01', + document_type: 'datasheet', + brand_name: 'TI', + document_title: 'LM2596 SIMPLE SWITCHER Power Converter', + document_version: 'Rev H (2023-09)', + document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf', + citation_locator: '§6.5 Electrical Characteristics(3.3V 列)', + document_status: 'valid', + }, + ], + }, + }, + { + sample: 3, + name: '三候选 · 两颗缺库各问一次 · 国产 32V 硬约束不推荐', + decideSeq: ['create', 'create', 'approve'], + expectDecision: true, + expectPort: 'assessment-released', + stageEdits: { + 'parts.profile': [ + { + internal_pn: 'XL2596S-5.0', + brand_name: 'XLSEMI', + specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz', + package: 'TO-263-5', + lifecycle_status: 'active', + normalized_parameter_set: [ + '输入电压范围 | 4.5~40 | V | hard | 客户上限 40V', + '输出电压 | 5.0 | V | hard | 固定版', + '输出电流 | 3 | A | hard | 满载能力', + '开关频率 | 150 | kHz | hard | 影响外围', + '静态电流 | 4.2 | mA | soft | 待机功耗', + '工作温度 | -40~+125 | ℃ | hard | 工业温域', + '使能阈值 | 未披露 | V | hard | 原厂未给', + ].join('\n'), + }, + { + internal_pn: 'CN-DCDC-32V', + brand_name: '国产某厂', + specification_description: '降压型 DCDC,3A,固定 5.0V,输入上限 32V', + package: 'TO-263-5', + lifecycle_status: 'active', + normalized_parameter_set: [ + '输入电压范围 | 4.5~32 | V | hard | 上限 32V', + '输出电压 | 5.0 | V | hard | 固定版', + '输出电流 | 3 | A | hard | 满载能力', + '开关频率 | 150 | kHz | hard | 影响外围', + '静态电流 | 6 | mA | soft | 待机功耗', + '工作温度 | -40~+85 | ℃ | hard | 工业温域', + '使能阈值 | 1.3 | V | hard | ON/OFF 阈值', + ].join('\n'), + }, + ], + 'parts.document': [ + { internal_pn: 'XL2596S-5.0', doc_seq: '01', document_type: 'datasheet', document_title: 'XL2596S Datasheet', document_version: 'Rev C', document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', citation_locator: 'p.3', document_status: 'valid' }, + { internal_pn: 'CN-DCDC-32V', doc_seq: '01', document_type: 'datasheet', document_title: 'CN-DCDC-32V Datasheet', document_version: 'V1.0', document_ref: 'https://example.com/cn-dcdc-32v.pdf', citation_locator: 'p.2', document_status: 'valid' }, + ], + }, + expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'CN-DCDC-32V', column: 'recommendation_level', contains: 'not_recommended' }, + }, + { + sample: 4, + name: '新客户 · 录入卡先建档 · 审核通过', + entryValues: { name: '深圳恒星电源有限公司', industry: '工业电源', region: '广东' }, + decide: 'approve', + expectPort: 'assessment-released', + }, + ], + }, +``` + +`main()` 里 `['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01']` 改为 `['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01', 'w05']`。 + +- [ ] **Step 3: 跑套件(先停稳定版,免得两台后端交替推同一单)** + +```bash +./stable.sh stop +pnpm --filter @app/api reset-runs +node tools/e2e-a2.mjs w05 2>&1 | tee /tmp/w05-e2e-round1.log | tail -60 +``` +Expected: `5/5 通过`;每案例一行 `→ released · 被拒 0 · N 拍 / Mk token · 决策卡 K 张 · 发口 [assessment-released]`,案例 0/3 的 `闸拦住空手交卷(400)—— 如设计` 至少各出现两次(P-08 / P-10 / 执行单),案例 4 有 `录入卡代答`。 +**红了怎么办**(这是设计欠账不是模型的错,修清单 / 剧本 / 说明书,升 `packageVersion` 重装再跑;每一轮都记进 Task 8 的测试记录): +- `熔断交人` / 被拒 > 0 → 看 `轨迹末 12 条`:拒回指向哪一步 — `写了等于没写` 多半是剧本让它重写已有的值;`要先…` 类是墙,照设计律「墙要配路」改;`查无此值`(refersTo)= 存在性核对没做对,看 S0/S0d 剧本; +- `需要你判断下一步` 兜底卡 → 模型输出不成形两次,看 agent-runner 重问日志,多半是 S3 第三拍写盘太长(考虑把 ① 明细与 ③ 关系拆成两拍写); +- `认不出这张执行卡属于哪一段` → 卡标题没按说明书骨架,改说明书③④⑤的 title 或 `stages[].match`; +- `期望口 assessment-released 未在发射名单里` → 放行门某条 L2 没绿,看验收卡判据哪条红(测试台观测面 `cards` 里的判据表)。 + +- [ ] **Step 4: 再跑一遍确认稳定(两轮都 5/5 才算),然后清运行数据、提交** + +```bash +node tools/e2e-a2.mjs w05 2>&1 | tee /tmp/w05-e2e-round2.log | tail -20 +pnpm --filter @app/api reset-runs +git add tools/e2e-a2.mjs +git commit -m "test(w05): e2e 套件 —— 五案例(候选缺库 / 常路 / 原 PN 硬阻塞 / 三候选含不推荐 / 新客户建档) + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` +(活动包若在本任务里改过版本,`git add activities/part-substitution-assessment` 一并提交。) + +--- + +### Task 8: 浏览器全流程全分支走查 + 测试记录文档 + +**Files:** +- Create: `docs/ACT-WORK-05-端到端测试记录.md`(每一轮追加一节;Task 6 的库内验收与 Task 7 的两轮脚本结果也补进来) +- 可能 Modify: `activities/part-substitution-assessment/src/*`(走查逼出的修改,每改必升 `packageVersion` 并 `pnpm activities:install`) + +**Interfaces:** +- Consumes:测试台 `http://localhost:5173/design#/design/testbed/act`(活动页签;「起单」钮;样例钮;「落真库」勾选框默认勾着;「日志」页签看逐拍轨迹)、工作台 `http://localhost:5173/`(左栏任务列表 → 中间工作区的件 → 右侧对话区卡;执行卡钮「提交」,验收卡「重做」/「确认通过」,录入卡「确认」,决策卡按钮 = 选项文案;件上「编辑 / 取消 / 保存 / + 加一行 / ×」)、Chrome 当前已登录会话。 +- Produces:五轮走查记录、18 条分支的覆盖表、逼出的修改清单。 + +- [ ] **Step 1: 建记录文档 + 走查前置** + +```bash +cat > docs/ACT-WORK-05-端到端测试记录.md <<'EOF' +# ACT-WORK-05 差异评估 · 端到端测试记录 + +> 活动包 `part-substitution-assessment`;实施计划 `docs/superpowers/plans/2026-08-26-act-work-05-part-substitution-assessment.md`。 +> 每一轮一节:**日期 · 案例 · 走的路径 · 结果(终态 / 被拒 / 拍数 / token / 人环次数)· 观察到的问题 · 修改(文件 @ 版本)· 复测结论**。 +> 脚本 e2e 与浏览器走查都记;「界面不撒谎」:没跑到的分支写「未覆盖」并说原因,不写「通过」。 + +## 0. 库内验收(2026-08-26) + +(Task 6 Step 4 的 SQL 输出原样贴这里) + +## 1. 脚本 e2e(`node tools/e2e-a2.mjs w05`) + +### 第 1 轮 +(贴 tail -60;每案例一行结论;红了写原因与修改) + +### 第 2 轮(稳定性复跑) + +## 2. 浏览器全流程全分支走查 + +### 分支覆盖表 + +| 分支 | 触发条件 | 覆盖轮次 | 结果 | 备注 | +|---|---|---|---|---| +| 0a | 启动期:新客户无档案 | R5 | | 数据录入卡建档 | +| 0b / 1d | 候选无 P-08 主数据 → 派数据工单 | R2 / R3 | | P-08 + P-10 各一张 | +| 0c | 未传 datasheet → 人全填 | R2 / R3 / R4 | | R2b 另试「传了 datasheet → AI 预填」 | +| 1a | 绑定参数有误 → 退回重启 | R1 | | 工单内四项不可改(件上没有编辑面) | +| 1b | 输入存在缺口(有而不全) | R1 / R2 | | 应用场景 / BOM 位 / 未披露参数 | +| 1c | 缺口补不了 → 留 UNKNOWN | R2 | | 补充内容写「留 UNKNOWN」 | +| 1d(原 PN) | 原 PN 无数据 → 硬阻塞必须建 | R4 | | 不问人 | +| 1d(放弃) | 候选 ≥2 缺库 → 放弃并记原因 | R3 | | | +| 2a | 单位冲突 → 阻断回步骤 1 | — | 未覆盖 | 需要冲突单位的夹具料号;记 debt | +| 2b | 证据版本冲突 → 人工复核 | — | 未覆盖 | 同上 | +| 3a | 硬约束 MISMATCH → 不推荐 | R3 | | CN-DCDC-32V | +| 4a | UNKNOWN 无去向 → 驳回补证 | R2 | | 审核卡点名驳回 | +| 4b | 证据链不全 → 驳回 | R2 | | 同一张卡的第二种意见 | +| 5a | GATE-03 放行 | R1–R5 | | | +| 5b | GATE-03 驳回 → REWORK | R4 | | 验收卡「重做」 | +| 6a | 至少一个推荐 / 备选 | R1 | | 备选 | +| 6b | 全是条件推荐 | R2 | | XL 条件推荐 | +| 6c | 全不推荐 | — | 未覆盖 | 需要一颗全硬约束失败的原 PN 组合;记 debt | +| 6d | 客户不批准 | — | 范围外 | 独立审批流 | + +### R1 · 都在库 · 常路(样例 1) +### R2 · 候选缺库 · 数据工单 · 审核驳回循环(样例 0) +### R2b · 传了 datasheet → AI 预填(样例 0 + 上传) +### R3 · 三候选 · 一颗放弃一颗新建 · 硬约束不推荐(样例 3) +### R4 · 原 PN 缺库 · 硬阻塞 · 放行驳回重做(样例 2) +### R5 · 新客户 · 录入卡建档(样例 4) + +## 3. 结论与欠账 +EOF +./stable.sh stop # 走查期间只留 dev(8787 / 5173),避免两台交替推同一单 +pnpm --filter @app/api reset-runs +``` +用 claude-in-chrome:`ToolSearch "select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,mcp__claude-in-chrome__computer,mcp__claude-in-chrome__read_page,mcp__claude-in-chrome__find,mcp__claude-in-chrome__form_input,mcp__claude-in-chrome__get_page_text,mcp__claude-in-chrome__tabs_create_mcp,mcp__claude-in-chrome__file_upload,mcp__claude-in-chrome__read_console_messages"` → `tabs_context_mcp` → 新开一页 `http://localhost:5173/`。**出现 Keycloak 登录页就停下来请用户在浏览器里登录一次**(凭证只有他有),登录后继续。每轮之间跑「轮间清场」: + +```bash +pnpm --filter @app/api reset-runs +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -c " +delete from ledger.substitution__difference_item where substitution_relation_id like 'REL-%'; +delete from ledger.substitution__relation where substitution_relation_id like 'REL-%'; +delete from ledger.validation__record where validation_id like 'VAL-%'; +delete from ledger.demand__request where request_id like 'REQ-%'; +delete from ledger.parts__document where internal_pn in ('XL2596S-5.0','CN-DCDC-32V','LM2596S-3.3'); +delete from ledger.parts__profile where internal_pn in ('XL2596S-5.0','CN-DCDC-32V','LM2596S-3.3'); +delete from ledger.customer__profile where customer_code = 'CU-9001';" +``` +(浏览器走查「落真库」默认勾着,放行后主数据真落正式表 —— 不清掉,下一轮「候选缺库」那条路就试不出来。) + +**每一轮记录的固定项**:起单时刻与 taskId(测试台起单后地址栏 / 观测面顶部);进度条刻度(应是 x/6,每个人环时条上有格);每张卡的文案是否人话、指导书是否显示;件上编辑 → 保存 → 闸 / 红带是否同源(空着必填按「提交」要被拦并点名行列);对话区 say 是否一轮一句;工作成果快照份数(执行单 / 验收单各一份 + 决策卡);终态与 `被拒 / 拍数 / token`(测试台「日志」页签或 `curl -s -H "authorization: Bearer $TOKEN" http://localhost:8787/api/gov/testbed/activity/ | jq '{terminal,used,rejects:[.trail[]|select(.kind=="action-rejected")|.title]}'`,token 用 e2e 的 `login()` 同款 client_credentials 取);落库验证 SQL(见各轮)。发现问题 → 改清单 / 剧本 / 模版 → 升 `packageVersion` → `pnpm activities:install` → 记录「修改」→ 复跑该轮直到 released 且被拒 0。 + +- [ ] **Step 2: R1 · 都在库 · 常路(样例 1)** + +1. 测试台 → 活动页签 → 活动「ACT-WORK-05 · 差异评估」→ 样例「都在库·常路」→「起单」。记 taskId。 +2. 工作台:左栏点开这一单。等 AI 跑 S0(对话区应出一句「已绑定…原 PN 与候选都在库,不建主数据」之类的话;进度条 1/6 → 2/6)。**分支 1a**:确认工作区「已绑定」区四项灰底只读、没有编辑钮。 +3. 执行卡「评估输入与资料补齐确认」到达:看指导书(五条 steps)与依据;工作区件:料号核对表两行(原 PN 在库 / 候选 在库 徽标),评估输入清单里 `application_scenario` / `bom_position` 两行状态「待补*」、三行「默认·可改」、齐备行。**先按「提交」不填** → 应被闸拦,提示点名缺哪几行(记原文)。 +4. 件上「编辑」→ 两行补充内容各填一句(`12V→5V、工业温域、40V 输入上限` / `U3 电源位`)→「保存」→ 红带消失、闸文案变;再「提交」→ 通过;对话区出现「人已交回」痕。 +5. AI 跑 S2/S3(三拍:比对 → 定级 → 写盘)。工作区换成差异审核件:关系表 1 行(`LM2596S-ADJ` 推荐等级「备选」),明细表 ≥8 行(输出电压 CONDITIONAL,其余 MATCH)。**分支 6a**。 +6. 审核决策卡到达 →「通过」。AI 跑 S5 → 工作区换成落账确认件(拟写 P-11 一行 analysis_status「FAE 确认」,拟回写 P-01 一行 current_stage `gate03`)→ 验收卡到达:判据表 L1/L2 全绿、L4 三条带 AI 裁断与理由。 +7.「确认通过」→ 中间态「字已签下,正在落地」→ 落地结果屏(进了哪几张表各几行、转出 `assessment-released`)→ 终态 released。**分支 5a**。 +8. 工作成果:定稿两份(执行单快照 / 验收单快照)+ 决策卡快照,翻份翻页正常。 +9. 落库验证: +```bash +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select substitution_relation_id||' '||candidate_internal_pn||' '||recommendation_level||' '||analysis_status||' '||rule_version from ledger.substitution__relation" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select count(*)||' 条明细;UNKNOWN '||sum(case when difference_status='unknown' then 1 else 0 end) from ledger.substitution__difference_item" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select request_id||' '||request_status||' '||current_stage||' '||coalesce(risk_level,'')||' '||coalesce(output_ref,'') from ledger.demand__request" +``` +Expected:一条关系 `alternate fae_confirmed R-2026.07`;P-01 一行 `responded gate03 … RPT-…`。 +10. 记进 R1 节;有问题按固定流程修、升版、复跑。 + +- [ ] **Step 3: R2 · 候选缺库 · 数据工单 · 审核驳回循环(样例 0)** + +轮间清场 → 起单样例「候选缺库·派数据工单」。 +1. S0 后对话区应说「XL2596S-5.0 不在库」;进度条停在 1/6「启动与存在性检查」。决策卡「候选 XL2596S-5.0 在芯片主数据里查无记录…」→ 点「创建主数据(派数据工单)」。**分支 0b / 1d**。 +2. 执行卡「数据工单 · 芯片主数据创建(P-08):XL2596S-5.0」到达;工作区 = P-08 件,一行骨架只有料号(**分支 0c 人全填**)。空手「提交」→ 闸拦并点名(记原文)。「编辑」→ 填品牌 / 规格 / 封装 / 生命周期(下拉 Active)/ 参数集(textarea 七行,使能阈值写「未披露」)/ Datasheet 引用与版本 →「保存」→ 红带收 →「+ 加一行」加一颗假料再「×」撤掉(增删行链)→「保存」→「提交」。 +3. 执行卡「数据工单 · 原厂技术资料创建(P-10):XL2596S-5.0」到达;件上一行骨架(类型 Datasheet、状态待核实);填标题 / 版本 / 链接 / 证据定位、状态改「有效」→ 保存 → 提交。进度条进 2/6。 +4. 执行卡「评估输入与资料补齐确认」:清单里多一行「XL2596S-5.0 使能阈值」待补(**分支 1b**);应用场景 / BOM 位填值,使能阈值那行补充内容写「原厂未披露,留 UNKNOWN 挂验证任务」(**分支 1c**)→ 保存 → 提交。 +5. S2/S3 后审核件:两条关系(XL 条件推荐 / ADJ 备选),明细里「使能阈值 UNKNOWN」那行 UNKNOWN 去向写着 VAL 号。**分支 6b**。 +6. 审核决策卡 → 在输入框写意见「使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件」提交(= custom 驳回,**分支 4a**)。看:对话区观察「意见按字面点名对上了…」,AI 改那一行(unknown_disposition / validation.record 的 test_condition),换问法再发一张卡;件上那一行确实变了。再驳回一次,意见「证据引用请指到 datasheet 的页码」(**分支 4b**),看 evidence_ref 变化;第三张卡「通过」。 +7. 拟落账 → 验收卡 → 确认通过 → released。落库验证: +```bash +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select internal_pn||' '||brand_name||' '||lifecycle_status from ledger.parts__profile where internal_pn='XL2596S-5.0'" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select validation_id||' '||validation_internal_pn||' '||validation_status||' '||left(next_validation_action,40) from ledger.validation__record" +docker exec -i jcai-postgres psql -U jcai -d jcai_wos -Atc "select candidate_internal_pn||' '||recommendation_level||' '||coalesce(verification_requirement,'') from ledger.substitution__relation order by 1" +``` +Expected:XL 真落了 P-08;一条 VAL `not_started`;XL `conditional` 且验证要求含 VAL 号;ADJ `alternate`。 +8. 记录:人环次数(决策 3 + 数据 2 + 执行 1 + 放行 1 = 7)、拍数、被拒。 + +- [ ] **Step 4: R2b · 传了 datasheet → AI 预填(样例 0 + 上传)** + +轮间清场。先造一份文本 datasheet: +```bash +cat > "$TMPDIR/XL2596S-5.0-datasheet.txt" <<'EOF' +XL2596S-5.0 XLSEMI 150KHz 3A Buck DC/DC Converter Rev C +Package TO-263-5, BCD process, Active +Input voltage range 4.5V to 40V +Output voltage 5.0V fixed, output current 3A +Switching frequency 150kHz, quiescent current 4.2mA +Operating temperature -40 to +125 C +Enable threshold: not specified +EOF +``` +测试台样例 0 → 「候选 datasheet」文件选择器上传这份 txt(claude-in-chrome `file_upload`)→ 起单。看 P-08 数据工单的件:品牌 / 规格 / 封装 / 生命周期 / 参数集**已预填**(参数集里使能阈值是「未披露」),卡上依据写「已按 datasheet 预填 N 项」。核对后直接提交(闸应判「无缺」放行)。其余同 R2,审核直接通过。记录预填了几格、有没有编造(**分支 0c 的另一半**)。 + +- [ ] **Step 5: R3 · 三候选 · 一颗放弃一颗新建 · 硬约束不推荐(样例 3)** + +轮间清场 → 起单样例「三候选·含硬约束不推荐」。 +1. 两张决策卡按数组顺序来:XL2596S-5.0 → 点「放弃这颗候选」(不写原因,看 `abandon_reason` 是否落了「负责人本轮放弃(未注明原因)」)(**分支 1d 放弃**);CN-DCDC-32V →「创建主数据」。 +2. P-08 数据工单只有 CN 一行 → 填(输入电压范围 `4.5~32`)→ 提交;P-10 同。 +3. 执行单 → 补两行 → 提交。审核件:两条关系(ADJ 备选、CN **不推荐**),CN 的「输入电压范围」行 MISMATCH 高风险、影响说明写着 32V < 40V(**分支 3a**);XL 不出现在关系表里、核对表里标「已放弃」。 +4. 审核通过 → 放行 → released。落库:`select candidate_internal_pn||' '||recommendation_level from ledger.substitution__relation order by 1` → `CN-DCDC-32V not_recommended` + `LM2596S-ADJ alternate`;`select internal_pn||' '||exists_status from ledger.sub_eval__pn_check` 在放行前查(record 表放行后仍在,reset-runs 才清)→ XL `abandoned`。 + +- [ ] **Step 6: R4 · 原 PN 缺库 · 硬阻塞 · 放行驳回重做(样例 2)** + +轮间清场 → 起单样例「原PN缺库·硬阻塞」。 +1. S0 后**不出决策卡**,直接来 P-08 数据工单「LM2596S-3.3」(**分支 1d 原 PN**);填(输出电压 3.3)→ 提交;P-10 → 提交。 +2. 执行单 → 审核通过 → 落账确认件 → 验收卡:**点「重做」**,意见写「P-01 的验证要求请把测试条件也写进去」(**分支 5b**)。看:对话区「验收被打回重做:…」,AI 改 `demand.request.verification_requirement`(件上那一格变了),再次提交 → 新验收卡 →「确认通过」→ released。 +3. 若 AI 原样重提(基座闸「打回之后台账一个字没改」拒回)→ 这是剧本 T_release 的欠账,改剧本、升版、复跑。 +4. 落库:`select original_internal_pn||' → '||candidate_internal_pn||' '||recommendation_level from ledger.substitution__relation`;`select internal_pn from ledger.parts__profile where internal_pn='LM2596S-3.3'`。 + +- [ ] **Step 7: R5 · 新客户 · 录入卡建档(样例 4)** + +轮间清场 → 起单样例「新客户·先建档」。 +1. S0 发数据录入卡「客户 CU-9001 不在客户档案里…」:名称预填「深圳恒星电源有限公司」,行业 / 区域空 → 填「工业电源 / 广东」→「确认」(**分支 0a**)。对话区痕「已写入客户档案」;AI 接着写 P-01(customer_code 引用墙这时应过)。 +2. 其余同 R1 → released。落库:`select customer_code||' '||name||' '||coalesce(industry,'') from ledger.customer__profile where customer_code='CU-9001'` → 一行。 +3. 轮间清场(把 CU-9001 删掉)。 + +- [ ] **Step 8: 收口记录** + +每轮结束把「结果 / 问题 / 修改 / 复测」填进对应节;分支覆盖表逐行填「结果」(通过 / 未覆盖 + 原因);「3. 结论与欠账」写:五轮终态与被拒数汇总、逼出的修改列表(文件 @ 版本)、未覆盖分支(2a / 2b / 6c)与建议夹具、留给 Task 9 的 debt。走查中改过的活动包随本任务提交: +```bash +git add activities/part-substitution-assessment docs/ACT-WORK-05-端到端测试记录.md +git commit -m "test(w05): 浏览器全流程全分支走查五轮(R1–R5 + R2b)与测试记录;走查逼出的活动包修订 + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +``` + +--- + +### Task 9: 文档、欠账、全量门禁、收尾 + +**Files:** +- Create: `docs/数据域字段说明-W05.md` +- Modify: `CLAUDE.md`(现状快照加一条;文档权威地图加两行)· `docs/debt.md`(追加一节) + +- [ ] **Step 1: 写 `docs/数据域字段说明-W05.md`**(口径与 `docs/数据域字段说明-E04-I01.md` 同一套:一览表 + 每张表「重点字段(按重要程度)」+「数据结构」;★ = 重点,【人填】= 人在件上 / 录入卡填) + +```markdown +# 数据域字段说明 —— ACT-WORK-05 差异评估 + +> 对应设计中心 → 数据域「ACT-WORK-05 · 差异评估」分组。字段以活动清单的台账声明为准 +> (`activities/part-substitution-assessment/src/manifest.ts` 的 `declarations.ledger`); +> 主数据六张的**物理结构**由平台基线 `apps/api/src/app/seed-mvp-data.ts` 物化(《SS02-SS03-SS04_Demo台账字段细化与统一方案》§4.2 全列), +> 活动只追加自己要用的列与标记。约束词表见 `docs/数据域字段说明-E04-I01.md`「怎么读这份文档」。 + +### 一览 + +| 全景 | 台账地址 | 人读名 | 性质 | 本活动用到的列 | 人填列 | +|---|---|---|---|---|---| +| L-02 | `customer.profile` | 客户档案 | 主数据 · 共享 | 4 | 3(录入卡,仅新客户) | +| P-08 | `parts.profile` | 芯片主数据 | 主数据 · 共享 | 12 | 12(数据工单件上) | +| P-10 | `parts.document` | 原厂技术资料库 | 主数据 · 共享 | 9 | 9(数据工单件上) | +| P-01 | `demand.request` | 客户需求主记录 | 主数据 · 共享 | 17 | 0(应用场景 / BOM 位经执行单由 AI 抄入) | +| P-11 | `substitution.relation` | 替代关系主记录 | 主数据 · 共享 | 17 | 0(只审不改) | +| P-11 子 | `substitution.difference_item` | 差异明细 | 主数据 · 共享 | 15 | 0 | +| P-06 | `validation.record` | 芯片验证记录 | 主数据 · 共享 | 8 | 0(只写验证要求) | +| — | `sub_eval.pn_check` | 料号存在性核对 | 本活动工作台账 | 6 | 0(决策卡定放弃) | +| — | `sub_eval.input_gap` | 评估输入清单 | 本活动工作台账 | 8 | 3(补充内容 / 加行) | +| — | `sub_eval.verdict` | 本次评估结论 | 本活动工作台账 | 14 | 0 | + +## 一、`parts.profile` 芯片主数据(P-08) + +一张表:料号一行,标准化参数集是它的一个字段(一行一参数 `参数名 | 值 | 单位 | hard/soft | 说明`)。 +库里查无记录 → 数据工单,人在件上从零建整条(可加行);确认落草稿层,放行并入正式表。 + +**重点字段(按重要程度)** +1. `internal_pn` ★【人填】主键 —— 全链路只认它,不可自由文本 +2. `normalized_parameter_set` ★【人填】必填 —— 差异评估逐项比对的唯一输入;「未披露」进 UNKNOWN +3. `lifecycle_status` ★【人填】必填 · 取值域 `active/nrnd/eol/discontinued/risk` +4. `package` ★【人填】必填 —— 硬约束 +5. `specification_description` ★【人填】必填 +6. `brand_name` ★【人填】必填 +7. `datasheet_ref` / `datasheet_version` ★【人填】建议填(成对)—— 证据回链 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `internal_pn` ★ | 内部料号 | text | 主键 | 人(件上)/ 种子 | | +| `brand_name` ★ | 品牌名称 | text | 必填 | 人(件上)/ AI 预填 | | +| `manufacturer_name` | 制造商名称 | text | 建议填 | 人(件上) | 与品牌不一致时分开记 | +| `specification_description` ★ | 规格描述 | text | 必填 | 人(件上)/ AI 预填 | | +| `normalized_parameter_set` ★ | 标准化参数集 | text | 必填 | 人(件上)/ AI 预填 | 一行一参数 | +| `package` ★ | 封装 | text | 必填 | 人(件上)/ AI 预填 | 硬约束 | +| `process_technology` | 工艺 | text | 建议填 | 人(件上) | 硬约束 | +| `lifecycle_status` ★ | 生命周期状态 | text | 必填 · 取值域 | 人(件上,下拉) | | +| `eol_pcn_status` | EOL/PCN 状态 | text | 建议填 | 人(件上) | | +| `datasheet_ref` ★ | Datasheet 引用 | text | 建议填 | 人(件上)/ AI 预填 | | +| `datasheet_version` ★ | Datasheet 版本 | text | 建议填 | 人(件上)/ AI 预填 | 有引用就该有版本 | +| `application_scenarios` | 适配应用场景 | text | 建议填 | 人(件上) | | +``` +(其余九张表照这个格式写全:列名逐字照清单抄,「写入者」按剧本 —— S0 写 P-01 / pn_check / verdict;S0d 人建 P-08 / P-10;S1 人补 input_gap、AI 抄两列进 P-01;S2/S3 写 relation / difference_item / validation;S5 写 relation.analysis_status 与 P-01 五项。`customer_approval_status` 一栏写「审批流维护,本活动无写权」。) + +- [ ] **Step 2: CLAUDE.md** + +「现状快照」末尾加一条(照既有条目的写法:事实 + 账): +```markdown +- **ACT-WORK-05 差异评估补齐到验收线**(2026-08-26,`part-substitution-assessment@<最终版本>`):本仓第三个真活动包, + 也是第一个把 SS02 主数据(P-08 / P-10 / P-01 / P-11 主+子 / P-06)建进库的活动 —— 六张表进平台基线 + `MASTER_OBJECTS`(四域 → 八域),活动只追加列与标记;P-08 一张表(参数集是一个 text 字段,一行一参数)。 + **数据工单** = 零输入执行工单 + 件上可加行(一张表一张单:P-08 / P-10),确认落草稿、放行并入(守铁律②)。 + 四态与推荐等级由两枚纯工具算(`compare-parameters` / `recommend`,规则 R-2026.07 同时以 DMN 随包冻结); + 审核走决策卡循环,放行终局。**e2e 五案例 5/5 released、被拒 0**;浏览器五轮(R1–R5 + R2b)走查见 + `docs/ACT-WORK-05-端到端测试记录.md`(18 条分支覆盖表:2a / 2b / 6c 未覆盖,记 debt)。 + <走查逼出的修改各一句> +``` +「文档权威地图」表里加两行:`docs/数据域字段说明-W05.md`(ACT-WORK-05 十张表字段口径)与 `docs/ACT-WORK-05-端到端测试记录.md`(逐轮测试与修改结论)。 + +- [ ] **Step 3: `docs/debt.md` 追加一节** + +```markdown +## 2026-08-26 · ACT-WORK-05 差异评估落地时留下的欠账 + +- **数据工单还没有自己的卡皮**:对话区里它是一张标题带「数据工单 ·」的执行卡;文档要的「数据卡」 + (`workOrder:'data'`)要走契约四步 + 前端卡皮,MVP 先不做。 +- **datasheet 只能在起单时上传**:平台文件入口只有起单 `format:'file'`;文档说「在数据工单里上传」, + 件上 / 卡上的上传控件是新基座能力。 +- **PW 与 FAE 一人兼**:平台一任务恰一名负责人,执行单与验收单都发给 owner;派给第二个人是新能力。 +- **参数集是 text 列的行语法**,不是《统一方案》字面上的 Structure / 子表:件上用 textarea 一行一参数; + 要按行编辑就得物理拆 `parts.parameter`(用户定「一张表」,先不拆)。 +- **`market.part`(老料号占位表,主键 `pn`)与 `parts.profile`(P-08,主键 `internal_pn`)重叠**: + 前者空且无人读写,待并或退役。 +- **分支 2a 单位冲突 / 2b 证据版本冲突 / 6c 全不推荐 未覆盖**:需要冲突单位与冲突版本的夹具料号、 + 以及一颗全硬约束失败的组合;补夹具后加 e2e 案例。 +- **存在性检查靠模型读整表 + refersTo 墙**:没有「这颗料在不在库」的基座读数;料号表大了整表进上下文会撑爆 + 截断阈值,届时要补一枚 `parts.exists` 读工具或基座读数。 +- **`customer_approval_status` 不在活动声明里**,所以件上那一格是静态文案「未提交」而不是取值 —— 审批流做起来后改成 data-slot。 +``` + +- [ ] **Step 4: 全量门禁(退出码)** + +```bash +pnpm gates; echo "gates exit=$?" +``` +Expected: `gates exit=0`。红了逐个修(常见:lint 的 §12.5 禁止项、depcruise 活动包越界 import、api:check —— 本计划没改 `packages/contracts`,api-report 不该变;若变了说明改了契约,补 `pnpm api:check --update && git add api-report`)。 + +- [ ] **Step 5: 提交、收尾** + +```bash +git add docs/数据域字段说明-W05.md docs/debt.md CLAUDE.md +git commit -m "docs(w05): 数据域字段说明、现状快照、欠账登记 + +Co-Authored-By: Claude Fable 5 +Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh" +git log --oneline mvp-foundation..HEAD +./stable.sh start # 走查完把稳定版起回来(要它带上新活动就 ./stable.sh update) +``` +合并与推送按用户的规矩由用户在终端做(主支发起合并再推主支;本会话无 TTY 推不了 Gitea)—— 收尾时把分支名 `feat/act-work-05` 与提交列表报给用户。 + +--- + +## 自检清单(写完计划后过一遍) + +- [x] 规格覆盖:启动 4 项 / 双通道无感知(渠道落 `origin`)/ 存在性检查 / 数据工单 P-08 + P-10 一表一单 / 候选 ≥2 放弃决策 / 原 PN 硬阻塞 / 执行单 01 缺口补齐与 UNKNOWN 合法放行 / 步骤 2–3 纯 AI 不派单 / 四态 + 风险 + 证据 + 置信度 / 推荐四档 / 验收 01 四判据(决策卡循环、意见写 P-06)/ 验收 02 四判据(终局放行)/ P-11 主 + 子落账 / P-01 回写五项 / analysis_status 与 customer_approval_status 分离 / 规则版本锁定 / 多候选并行各一条 P-11 / 台账建进库(基线 + 清单)/ e2e 五案例 / 浏览器五轮 + 分支覆盖表 / 测试记录文档 —— 各有任务落点。 +- [x] 未覆盖且已明示:2a / 2b / 6c(夹具缺)、6d(范围外)、数据卡卡皮、件上上传、双负责人。 +- [x] 名字一致性:活动 id `part-substitution-assessment`;域 `sub_eval` / `parts` / `demand` / `substitution` / `validation`;视图名五个;计划项八个;工具两个;出口 `assessment-released`;读数名与 tally 值在 Task 3/4/5/7 之间逐字相同(`exists_status: found/absent/created/abandoned`,`status: gap/default/ok`,四态 `match/mismatch/unknown/conditional`,推荐 `recommended/conditional/alternate/not_recommended`)。 +- [x] 每个代码步骤给了完整代码;每个验证步骤给了命令与期望输出;没有「TBD / 后补 / 类似 Task N」。 From 8556432dbafd8d0bbd10b084c02e49c229d76957 Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 11:44:36 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(org):=20=E6=88=90=E5=91=98=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20=E2=80=94=E2=80=94=20Keycloak=20=E8=AF=BB=E5=86=99?= =?UTF-8?q?=E9=9D=A2=E7=A9=BF=E9=80=8F=E3=80=81=E6=B2=BB=E7=90=86=E5=8F=B0?= =?UTF-8?q?=E6=88=90=E5=91=98/=E7=BB=84=E7=BB=87/=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=B8=89=E9=A1=B5=E3=80=81=E5=A7=93=E5=9C=A8=E5=89=8D=20person?= =?UTF-8?q?Name=E3=80=81Staff.kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端:`KeycloakDirectory` 端口(users / groups / roles 读写;写直接穿透 Keycloak —— 这里建的人立刻能登录、 删的人立刻消失,停用与收权之后 logout 踢掉会话)、`keycloak-directory.ts` + `keycloak-admin-http.ts` 适配器、 org-directory 契约与 /api/gov 路由、`sync-roster` CLI(ops 路由够不着时整册照过来), 容器按 provisioning 服务账号有无决定注不注入(没配就如实说不可用)。 `personName` 姓在前:汉字直拼「柴浩」、否则留一个空格「Admin Amy」,token 与名册同步两条路同一把尺; `staffKindOf` 按 Keycloak 固定前缀 service-account- 认服务账号,员工那一屏不摆它(`Staff.kind` 不落库,按账号名现判)。 前端:员工那一屏重做成成员 / 组织 / 权限三页签(member-page 下钻),页签栏右端一句话说清这一格管什么。 契约变更:contracts org.ts / api/http.ts / api/org-directory.ts;api-report 已刷新;org CONTRACT.md 与 ADR-012 同步。 2026-08-26 做的,一直留在工作树没入库。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu --- .../apps/api/src/modules/org/contract.d.ts | 74 +- .../apps/api/src/modules/org/index.d.ts | 10 +- api-report/apps/api/src/modules/org/port.d.ts | 75 +- .../contracts/src/api/governance.d.ts | 12 + .../packages/contracts/src/api/http.d.ts | 2 +- .../packages/contracts/src/api/index.d.ts | 1 + .../contracts/src/api/org-directory.d.ts | 701 ++++++++++++++++++ .../packages/contracts/src/api/workbench.d.ts | 16 + api-report/packages/contracts/src/org.d.ts | 28 + apps/api/package.json | 3 +- .../src/app/__tests__/auth-middleware.test.ts | 2 +- .../__tests__/org-directory-registry.test.ts | 36 + apps/api/src/app/container.ts | 17 +- apps/api/src/app/routes/org-directory.ts | 196 +++++ apps/api/src/app/server.ts | 2 + apps/api/src/app/sync-roster.ts | 46 ++ apps/api/src/modules/org/CONTRACT.md | 19 + .../src/modules/org/__tests__/claims.test.ts | 57 +- .../org/__tests__/directory-service.test.ts | 313 ++++++++ .../modules/org/__tests__/directory.test.ts | 159 ++++ .../__tests__/keycloak-admin-roster.test.ts | 10 +- .../__tests__/keycloak-token-verifier.test.ts | 18 + .../modules/org/__tests__/org-service.test.ts | 16 + .../modules/org/__tests__/roster-diff.test.ts | 5 +- apps/api/src/modules/org/contract.ts | 77 +- apps/api/src/modules/org/index.ts | 24 + .../internal/adapter/keycloak-admin-http.ts | 143 ++++ .../internal/adapter/keycloak-admin-roster.ts | 13 +- .../internal/adapter/keycloak-directory.ts | 262 +++++++ .../adapter/keycloak-token-verifier.ts | 5 + .../src/modules/org/internal/domain/claims.ts | 56 +- .../modules/org/internal/domain/directory.ts | 121 +++ .../org/internal/domain/roster-diff.ts | 10 +- .../org/internal/usecase/org-service.ts | 320 +++++++- apps/api/src/modules/org/port.ts | 77 +- apps/api/tsup.config.ts | 2 + apps/web/src/app/gov-shell.tsx | 7 +- apps/web/src/app/styles/console.css | 17 + .../gov-staff/internal/member-page.tsx | 255 +++++++ .../features/gov-staff/internal/members.tsx | 188 +++++ .../src/features/gov-staff/internal/orgs.tsx | 276 +++++++ .../src/features/gov-staff/internal/panel.tsx | 372 +--------- .../src/features/gov-staff/internal/perms.tsx | 204 +++++ apps/web/src/shared/gov/gov-api.ts | 69 ++ apps/web/src/shared/gov/gov-route.ts | 21 +- docs/adr/ADR-012-keycloak-identity.md | 23 + packages/contracts/src/api/http.ts | 7 + packages/contracts/src/api/index.ts | 1 + packages/contracts/src/api/org-directory.ts | 260 +++++++ packages/contracts/src/org.ts | 16 + 50 files changed, 4242 insertions(+), 402 deletions(-) create mode 100644 api-report/packages/contracts/src/api/org-directory.d.ts create mode 100644 apps/api/src/app/__tests__/org-directory-registry.test.ts create mode 100644 apps/api/src/app/routes/org-directory.ts create mode 100644 apps/api/src/app/sync-roster.ts create mode 100644 apps/api/src/modules/org/__tests__/directory-service.test.ts create mode 100644 apps/api/src/modules/org/__tests__/directory.test.ts create mode 100644 apps/api/src/modules/org/internal/adapter/keycloak-admin-http.ts create mode 100644 apps/api/src/modules/org/internal/adapter/keycloak-directory.ts create mode 100644 apps/api/src/modules/org/internal/domain/directory.ts create mode 100644 apps/web/src/features/gov-staff/internal/member-page.tsx create mode 100644 apps/web/src/features/gov-staff/internal/members.tsx create mode 100644 apps/web/src/features/gov-staff/internal/orgs.tsx create mode 100644 apps/web/src/features/gov-staff/internal/perms.tsx create mode 100644 packages/contracts/src/api/org-directory.ts diff --git a/api-report/apps/api/src/modules/org/contract.d.ts b/api-report/apps/api/src/modules/org/contract.d.ts index aa1575b..9d48630 100644 --- a/api-report/apps/api/src/modules/org/contract.d.ts +++ b/api-report/apps/api/src/modules/org/contract.d.ts @@ -5,8 +5,27 @@ * 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。 * 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。 */ -import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts'; -export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +import type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts'; +export type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +/** + * 目录写面的两个入参 —— 住在 contract.ts 而不是 port.ts:它们是**服务出口的形状** + * (路由照它填),port 只是恰好也要用。反过来放会让 port ↔ contract 成环。 + */ +export type CreateDirectoryUser = { + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + /** 临时口令;不给就建一个还登不进来的壳 */ + readonly password?: string; + /** 一并授的 realm 角色 */ + readonly roles?: readonly string[]; +}; +export type DirectoryUserPatch = { + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; /** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ export type StaffWithPosition = Staff & { readonly position: Position; @@ -47,6 +66,24 @@ export declare class AccountDisabledError extends Error { export declare class AccessLockoutError extends Error { constructor(message?: string); } +/** + * 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。 + * **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。 + */ +export declare class DirectoryUnavailableError extends Error { + /** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */ + readonly misconfigured: boolean; + constructor(message: string, misconfigured: boolean); +} +/** + * 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`), + * 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。 + * 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。 + */ +export declare class ServiceAccountProtectedError extends Error { + readonly account: string; + constructor(account: string); +} export type OrgService = { /** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */ authenticate(token: string): Promise; @@ -75,6 +112,39 @@ export type OrgService = { deleteRoleAccess(role: string): Promise; /** 能进该端的活跃员工 —— 治理台通知的收件人 */ staffWithEnd(surface: Surface): Promise; + /** + * 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError` + * (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。 + * 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。 + */ + directoryUsers(query: { + q?: string; + includeDisabled?: boolean; + }): Promise<{ + users: OrgUser[]; + }>; + directoryUser(sub: string): Promise; + createDirectoryUser(input: CreateDirectoryUser): Promise; + patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise; + /** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */ + setDirectoryUserEnabled(sub: string, enabled: boolean): Promise; + resetDirectoryUserPassword(sub: string, password: string): Promise; + deleteDirectoryUser(sub: string): Promise; + directoryRoles(): Promise<{ + roles: OrgRole[]; + }>; + /** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */ + setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise; + directoryGroups(): Promise<{ + groups: OrgGroup[]; + }>; + directoryGroupMembers(groupId: string): Promise<{ + members: OrgUser[]; + }>; + createDirectoryGroup(name: string, parentId?: string): Promise; + renameDirectoryGroup(groupId: string, name: string): Promise; + deleteDirectoryGroup(groupId: string): Promise; + setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise; /** 建组 / 改组;id 留空即新建 */ saveWorkGroup(input: { id?: string; diff --git a/api-report/apps/api/src/modules/org/index.d.ts b/api-report/apps/api/src/modules/org/index.d.ts index 2a126d4..383966a 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -1,18 +1,22 @@ /** * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ -export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts'; -export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts'; -export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts'; +export type { CreateDirectoryUser, DirectoryUserPatch, Identity, InFlightWork, OrgGroup, OrgRole, OrgService, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts'; +export { AccessLockoutError, AccountDisabledError, DirectoryUnavailableError, PositionNotFoundError, ServiceAccountProtectedError, StaffNotFoundError, TokenRejectedError, } from './contract.ts'; +export type { Clock, DirectoryGroup, DirectoryRole, DirectoryUser, IdGenerator, IdentityPatch, InFlightWorkProbe, KeycloakDirectory, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts'; export type { OrgServiceDeps } from './internal/usecase/org-service.ts'; export { createOrgService } from './internal/usecase/org-service.ts'; export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { displayNameOf, filterOrgUsers, isServiceAccount, sortOrgUsers, toOrgGroups, toOrgRoles, toOrgUser, } from './internal/domain/directory.ts'; export { buildInFlightWork } from './internal/domain/in-flight.ts'; export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +export { createKeycloakDirectory } from './internal/adapter/keycloak-directory.ts'; +export { createAdminHttp, KeycloakMisconfiguredError, KeycloakRejectedError, KeycloakUnavailableError } from './internal/adapter/keycloak-admin-http.ts'; +export type { AdminHttp, KeycloakAdminOptions } from './internal/adapter/keycloak-admin-http.ts'; export { createPositionRepository, createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, } from './internal/adapter/org-repository.ts'; export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; diff --git a/api-report/apps/api/src/modules/org/port.d.ts b/api-report/apps/api/src/modules/org/port.d.ts index f0b9ff1..8b59759 100644 --- a/api-report/apps/api/src/modules/org/port.d.ts +++ b/api-report/apps/api/src/modules/org/port.d.ts @@ -3,7 +3,7 @@ * adapter 实现之,usecase 依赖之,app 装配之。 */ import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; -import type { Identity } from './contract.ts'; +import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts'; export type Clock = { now(): Date; }; @@ -11,7 +11,12 @@ export type Clock = { export type VerifiedClaims = { readonly sub: string; readonly preferred_username: string; + /** Keycloak 拼好的 `firstName lastName` —— 西式顺序,只在拆不开时才用它 */ readonly name?: string; + /** 名(firstName) */ + readonly given_name?: string; + /** 姓(lastName)—— 我们按「姓在前」自己拼,见 `personName` */ + readonly family_name?: string; readonly email?: string; readonly realm_access?: { readonly roles?: readonly string[]; @@ -28,6 +33,65 @@ export type RosterUser = Identity & { export type RosterSource = { listUsers(): Promise; }; +/** + * Keycloak 那一侧的一个用户 —— **原样的形状**,不掺本地字段。 + * 与 `RosterUser` 的区别:名册只要「同步要比对的那几列」,目录要摆给人看的全部。 + */ +export type DirectoryUser = { + readonly sub: string; + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + readonly enabled: boolean; + readonly emailVerified: boolean; + /** realm 角色(家具角色已滤) */ + readonly roles: readonly string[]; +}; +/** 组树里的一行 —— 平表,层级看 `parentId` / `depth`(契约同款,理由见 org-directory.ts) */ +export type DirectoryGroup = { + readonly id: string; + readonly name: string; + readonly path: string; + readonly parentId?: string; + readonly depth: number; + readonly memberCount: number; +}; +export type DirectoryRole = { + readonly name: string; + readonly description?: string; + readonly composite: boolean; +}; +/** + * 成员目录 —— app 未配 provisioning 服务账号时不注入(与 `RosterSource` 同款)。 + * 读是投影,写**直接穿透**到 Keycloak:这里建的人立刻能登录,这里删的人从 Keycloak 消失。 + */ +export type KeycloakDirectory = { + listUsers(): Promise; + getUser(sub: string): Promise; + }>; + createUser(input: CreateDirectoryUser): Promise; + updateUser(sub: string, patch: DirectoryUserPatch): Promise; + setEnabled(sub: string, enabled: boolean): Promise; + resetPassword(sub: string, password: string): Promise; + deleteUser(sub: string): Promise; + /** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */ + logout(sub: string): Promise; + listRoles(): Promise; + setRole(sub: string, role: string, granted: boolean): Promise; + listGroups(): Promise; + groupMembers(groupId: string): Promise; + /** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */ + groupMemberUsernames(groupId: string): Promise; + createGroup(name: string, parentId?: string): Promise; + renameGroup(groupId: string, name: string): Promise; + deleteGroup(groupId: string): Promise; + setUserGroup(sub: string, groupId: string, member: boolean): Promise; +}; /** 名册即时更新 / 同步要改的那几列 */ export type IdentityPatch = { readonly account: string; @@ -45,7 +109,12 @@ export type PositionRepository = { }): Promise; }; /** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ -export type StaffRecord = Staff & { +/** + * 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`), + * 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。 + */ +export type StoredStaff = Omit; +export type StaffRecord = StoredStaff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date; }; @@ -55,7 +124,7 @@ export type StaffRepository = { /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ findByAccount(account: string): Promise; listByPosition(positionId: PositionId): Promise; - insert(staff: Staff, marks: { + insert(staff: StoredStaff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date; }): Promise; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 8a8de12..bc1569e 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -19,6 +19,10 @@ export declare const listStaffRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; position: z.ZodObject<{ id: z.core.$ZodBranded; code: z.ZodString; @@ -55,6 +59,10 @@ export declare const createStaffRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; }; /** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */ @@ -266,6 +274,10 @@ export declare const setStaffPositionRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; position: z.ZodObject<{ id: z.core.$ZodBranded; code: z.ZodString; diff --git a/api-report/packages/contracts/src/api/http.d.ts b/api-report/packages/contracts/src/api/http.d.ts index 75dc9ae..75213ff 100644 --- a/api-report/packages/contracts/src/api/http.d.ts +++ b/api-report/packages/contracts/src/api/http.d.ts @@ -41,7 +41,7 @@ export type RouteContract = z.infer; export type RouteBody = R['body'] extends z.ZodTypeAny ? z.infer : never; /** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ -export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone"]; +export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone", "keycloak-misconfigured", "keycloak-unavailable"]; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; export type ApiError = { readonly code: ApiErrorCode; diff --git a/api-report/packages/contracts/src/api/index.d.ts b/api-report/packages/contracts/src/api/index.d.ts index 297cfdd..4b5edcb 100644 --- a/api-report/packages/contracts/src/api/index.d.ts +++ b/api-report/packages/contracts/src/api/index.d.ts @@ -1,5 +1,6 @@ export * from './http.ts'; export * from './workbench.ts'; export * from './governance.ts'; +export * from './org-directory.ts'; export * from './ingest.ts'; export * from './mail.ts'; diff --git a/api-report/packages/contracts/src/api/org-directory.d.ts b/api-report/packages/contracts/src/api/org-directory.d.ts new file mode 100644 index 0000000..41f1440 --- /dev/null +++ b/api-report/packages/contracts/src/api/org-directory.d.ts @@ -0,0 +1,701 @@ +/** + * 成员与组织(ADR-012 · 运营中心「员工」那一屏的三个页签)。 + * + * **真源是 Keycloak**:读接口是它的投影,写接口**直接穿透**到 Admin API —— + * 这里建的人立刻能登录、这里停的人立刻登不上、这里删的人从 Keycloak 消失。 + * 没有「先存本地草稿再同步」这回事,**也没有撤销**;界面上凡写必二次确认。 + * + * 与本地名册的关系:`sys.staff` 是这份目录的投影 + 一列本地岗位(见 org/CONTRACT.md)。 + * 涉及身份的写(停用 / 删除 / 授收角色)之后,本地那一行当场跟手 —— 两屏不许说两句话。 + * + * 三处口径与 Keycloak 的形状有关,写在这儿免得每条接口重复: + * ① **服务账号一律不可见、不可写**:目录读剔除 `service-account-*`(它是身份不是人, + * 见 `staffKindSchema`);写面对着服务账号的 sub 一律 403。 + * ② **组树是平表**:一行一个组,靠 `parentId` / `depth` 表达层级(按 `path` 升序已排好)。 + * 契约里不放递归结构 —— 递归 schema 到了声明文件那一层只会变成一坨 any。 + * ③ **列表无分页**:realm 不大,一次拉全;Keycloak 那一侧的 100 条上限由适配器分页兜住。 + */ +import { z } from 'zod'; +export declare const orgUserSchema: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; +}, z.core.$strip>; +export type OrgUser = z.infer; +export declare const orgUserDetailSchema: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + emailVerified: z.ZodBoolean; + groups: z.ZodArray>; +}, z.core.$strip>; +export type OrgUserDetail = z.infer; +export declare const listOrgUsersRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + users: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const getOrgUserRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + emailVerified: z.ZodBoolean; + groups: z.ZodArray>; + }, z.core.$strip>; +}; +/** + * 新建成员 —— 建完立刻能登录。口令是**临时**的:本人首登必须改 + * (`temporary:true`,手册 §14.2);我们不存它,也不回显。 + */ +export declare const createOrgUserRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + username: z.ZodString; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + password: z.ZodOptional; + roles: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +/** 改资料(姓名 / 邮箱)—— 用户名建后不可改,不在这里 */ +export declare const patchOrgUserRoute: { + readonly method: "PATCH"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +/** + * 启用 / 停用。停用**立刻踢掉该用户全部会话** —— 不然他攥着的 token + * 还能用到过期为止,而「已停用」这三个字在界面上已经写出去了(界面不撒谎)。 + */ +export declare const setOrgUserEnabledRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/enabled"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + enabled: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +/** 重置口令 —— 一律临时口令,本人首登必须改;服务端不回显、不留痕 */ +export declare const resetOrgUserPasswordRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/reset-password"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + password: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** 删人 —— 从 Keycloak 消失,不可撤销;本地名册那一行同批停用(留痕与在途还挂着他) */ +export declare const deleteOrgUserRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +export declare const orgRoleSchema: z.ZodObject<{ + name: z.ZodString; + description: z.ZodOptional; + composite: z.ZodBoolean; + ends: z.ZodArray>; + holders: z.ZodNumber; +}, z.core.$strip>; +export type OrgRole = z.infer; +export declare const listOrgRolesRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/roles"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + roles: z.ZodArray; + composite: z.ZodBoolean; + ends: z.ZodArray>; + holders: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** + * 授 / 收一个 realm 角色。收权**立刻踢掉该用户全部会话**(手册 §17.3)—— + * 否则他手上那枚 token 还带着旧角色,收权要等到 token 过期才生效。 + * 不能收自己的最后一条进运营中心的路(409)—— 收完没人进得来,这扇门就锁死了。 + */ +export declare const setOrgUserRoleRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + role: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + granted: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +export declare const orgGroupSchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; +}, z.core.$strip>; +export type OrgGroup = z.infer; +/** 组树平表,按 `path` 升序 —— 层级看 `parentId` / `depth` */ +export declare const listOrgGroupsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + groups: z.ZodArray; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 组的直属成员(含被停用的人 —— 这一屏管的是归属,不是能不能登录) */ +export declare const listOrgGroupMembersRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/groups/:id/members"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + members: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 建组:`parentId` 给了就是子组,不给就是顶层组。同级不得重名(Keycloak 那一侧拦,409) */ +export declare const createOrgGroupRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + name: z.ZodString; + parentId: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}; +export declare const renameOrgGroupRoute: { + readonly method: "PATCH"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + name: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}; +/** 删组 —— 连同子组一起没(Keycloak 语义);人不会被删,只是不再属于这个组 */ +export declare const deleteOrgGroupRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** 成员进 / 出组 */ +export declare const setOrgUserGroupRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/groups/:groupId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + groupId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + member: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** + * 这一屏的全部路由 —— 归属一处列全,测试拿它逐条断言。 + * 逐个 import 去断言的写法,新增一条只要忘了加进名单就悄悄漏过去了。 + */ +export declare const ORG_DIRECTORY_ROUTES: readonly [{ + readonly method: "GET"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + users: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + emailVerified: z.ZodBoolean; + groups: z.ZodArray>; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + username: z.ZodString; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + password: z.ZodOptional; + roles: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "PATCH"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/enabled"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + enabled: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/reset-password"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + password: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}, { + readonly method: "DELETE"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/roles"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + roles: z.ZodArray; + composite: z.ZodBoolean; + ends: z.ZodArray>; + holders: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + role: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + granted: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + groups: z.ZodArray; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/groups/:id/members"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + members: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + name: z.ZodString; + parentId: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}, { + readonly method: "PATCH"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + name: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}, { + readonly method: "DELETE"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/groups/:groupId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + groupId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + member: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}]; diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index b120b40..e85fe04 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -20,6 +20,10 @@ export declare const sessionRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -80,6 +84,10 @@ export declare const loginRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -158,6 +166,10 @@ export declare const meRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -891,6 +903,7 @@ export declare const taskDetailSchema: z.ZodObject<{ cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional; export type StaffState = z.infer; +/** + * 人,还是服务账号。 + * + * Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为 + * `service-account-` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在 + * 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。 + * + * 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**: + * 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。 + * 这条判断归后端 —— 界面不该去猜账号名长什么样。 + */ +export declare const staffKindSchema: z.ZodEnum<{ + person: "person"; + service: "service"; +}>; +export type StaffKind = z.infer; export declare const staffSchema: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; @@ -30,6 +46,10 @@ export declare const staffSchema: z.ZodObject<{ disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; export type Staff = z.infer; export declare const principalSchema: z.ZodObject<{ @@ -44,6 +64,10 @@ export declare const principalSchema: z.ZodObject<{ disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -226,6 +250,10 @@ export declare const sessionSchema: z.ZodObject<{ disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; diff --git a/apps/api/package.json b/apps/api/package.json index 3036521..75b137e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,8 @@ "seed": "node dist/seed.js", "migrate": "node dist/migrate.js", "reset-runs": "node dist/reset-runs.js", - "install-activities": "node dist/install-activities.js" + "install-activities": "node dist/install-activities.js", + "sync-roster": "node dist/sync-roster.js" }, "dependencies": { "@ai-sdk/openai-compatible": "^3.0.14", diff --git a/apps/api/src/app/__tests__/auth-middleware.test.ts b/apps/api/src/app/__tests__/auth-middleware.test.ts index 5881c31..5df5ae9 100644 --- a/apps/api/src/app/__tests__/auth-middleware.test.ts +++ b/apps/api/src/app/__tests__/auth-middleware.test.ts @@ -11,7 +11,7 @@ import { createTicketIssuer } from '../ticket.ts'; const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' }; const principal = (ends: Surface[], roles: string[]): Principal => ({ - staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles }, + staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles, kind: 'person' }, position, roles, ends, diff --git a/apps/api/src/app/__tests__/org-directory-registry.test.ts b/apps/api/src/app/__tests__/org-directory-registry.test.ts new file mode 100644 index 0000000..3c4a60f --- /dev/null +++ b/apps/api/src/app/__tests__/org-directory-registry.test.ts @@ -0,0 +1,36 @@ +/** + * 成员与组织的路由归属(ADR-012):15 条全部进契约、全部注册、全部归运营中心。 + * + * 归属写在契约的 `access` 上,鉴权中间件一处按注册表判 —— 所以**漏标一条** + * 就会落进 `?? 'any'` 兜底:只要登录过的人都能调。这条测试就是拦那个的。 + */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { ORG_DIRECTORY_ROUTES } from '@contracts'; +import { appRouteRegistry } from '../server.ts'; +import { buildRouteRegistry, routesOf } from '../route-registry.ts'; + +describe('成员与组织路由契约', () => { + it('15 条全在契约命名空间里,注册表建得起来', () => { + const registry = buildRouteRegistry(routesOf(Object.values(contracts))); + expect(ORG_DIRECTORY_ROUTES).toHaveLength(15); + for (const route of ORG_DIRECTORY_ROUTES) { + expect(registry.has(route.method, route.path)).toBe(true); + } + }); + + it('读写一律归运营中心 —— 成员目录没有设计端的消费方', () => { + for (const route of ORG_DIRECTORY_ROUTES) { + expect([route.path, route.access]).toEqual([route.path, 'ops']); + } + }); + + it('查得到的归属就是 ops,不落兜底 —— 兜底等于「登录了就能调」', () => { + const registry = appRouteRegistry(); + for (const route of ORG_DIRECTORY_ROUTES) { + // 拿一条**具体**路径去查(带 :param 的模版要真能匹配上,不然线上每次都 miss) + const concrete = route.path.replace(/:[A-Za-z_][\w]*/g, 'X'); + expect([concrete, registry.lookup(route.method, concrete)]).toEqual([concrete, 'ops']); + } + }); +}); diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 83ff4eb..8a9448d 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -16,8 +16,9 @@ import { createEventService, createEventStore } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import type { LoopEngineService } from '@modules/loop-engine'; import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger'; -import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org'; +import type { InFlightWorkProbe, KeycloakDirectory, OrgService, RosterSource, TokenVerifier } from '@modules/org'; import { + createKeycloakDirectory, createKeycloakRoster, createKeycloakTokenVerifier, createOrgService, @@ -185,6 +186,7 @@ const sfeelEvaluator = (expression: string, scope: Readonly { @@ -256,9 +258,22 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve secret: keycloak.provisioning.secret, })); + /* 成员目录与名册同步同一枚 provisioning 服务账号 —— 没配就都不注入,界面如实说不可用 */ + const directory: KeycloakDirectory | undefined = + overrides.directory ?? + (keycloak?.provisioning === undefined + ? undefined + : createKeycloakDirectory({ + url: keycloak.url, + realm: keycloak.realm, + clientId: keycloak.provisioning.clientId, + secret: keycloak.provisioning.secret, + })); + const org = createOrgService({ verifier, ...(roster === undefined ? {} : { roster }), + ...(directory === undefined ? {} : { directory }), positions: createPositionRepository(db), staff: createStaffRepository(db), roleAccess: createRoleAccessRepository(db), diff --git a/apps/api/src/app/routes/org-directory.ts b/apps/api/src/app/routes/org-directory.ts new file mode 100644 index 0000000..efecfe4 --- /dev/null +++ b/apps/api/src/app/routes/org-directory.ts @@ -0,0 +1,196 @@ +/** + * 成员与组织的路由组装(ADR-012)—— 运营中心「员工」那一屏的三个页签。 + * 端级授权由 server.ts 的鉴权中间件按契约上的 `access: 'ops'` 统一判,这里不写守卫。 + * + * **写是穿透**:成功之后 Keycloak 已经变了,没有撤销。所以这一层只做三件事 —— + * 把入参校出来、把模块的错误翻成 HTTP、把写完的那一行原样回给界面(界面照它重画,不自己拼)。 + * + * 错误分档(手册 §15 + BPOS §17.0): + * 502 `keycloak_misconfigured` 服务账号缺角色 / 没配 —— **重试无益**,去补权限; + * 502 `keycloak_unavailable` 暂时够不着 —— 可退避重试; + * 409 `conflict` 同级重名、收权会锁死运营中心; + * 403 `forbidden` 对着服务账号下手。 + * 两种 502 分开说,是因为**人的下一步不同**:一个去找管理员,一个等一会儿再点。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { + createOrgGroupRoute, + createOrgUserRoute, + deleteOrgGroupRoute, + deleteOrgUserRoute, + getOrgUserRoute, + listOrgGroupMembersRoute, + listOrgGroupsRoute, + listOrgRolesRoute, + listOrgUsersRoute, + patchOrgUserRoute, + renameOrgGroupRoute, + resetOrgUserPasswordRoute, + setOrgUserEnabledRoute, + setOrgUserGroupRoute, + setOrgUserRoleRoute, +} from '@contracts'; +import { + AccessLockoutError, + DirectoryUnavailableError, + ServiceAccountProtectedError, +} from '@modules/org'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; + +/** 出错时统一翻译 —— 每条路由各写一遍 try/catch 只会漏掉其中几条 */ +async function answer(run: () => Promise): Promise<{ body: T } | { error: ApiError; status: 403 | 404 | 409 | 502 }> { + try { + return { body: await run() }; + } catch (error) { + if (error instanceof DirectoryUnavailableError) { + return { + error: { code: error.misconfigured ? 'keycloak-misconfigured' : 'keycloak-unavailable', message: error.message }, + status: 502, + }; + } + if (error instanceof ServiceAccountProtectedError) { + return { error: { code: 'forbidden', message: error.message }, status: 403 }; + } + if (error instanceof AccessLockoutError) { + return { error: { code: 'conflict', message: error.message }, status: 409 }; + } + // Keycloak 明确拒绝(重名 / 约束不过)—— 原话带给人看,别自己编一句更含糊的 + if (error instanceof Error && error.name === 'KeycloakRejectedError') { + const status = (error as Error & { status?: number }).status; + return { error: { code: status === 404 ? 'not-found' : 'conflict', message: error.message }, status: status === 404 ? 404 : 409 }; + } + throw error; + } +} + +export function registerOrgDirectoryRoutes(app: Hono, container: Container): void { + /** + * zod 出来的可选字段是 `string | undefined`,而入参类型在 `exactOptionalPropertyTypes` + * 下要的是「要么有、要么这个键不存在」—— 显式的 undefined 在这儿不算「没填」。 + * 全仓同一个惯用法:条件展开。 + */ +function defined>(input: T): { [K in keyof T]: Exclude } { + const out: Record = {}; + for (const [k, v] of Object.entries(input)) if (v !== undefined) out[k] = v; + return out as { [K in keyof T]: Exclude }; +} + +/** 校验 body,不合就 400 —— 与治理台其余路由同一套话 */ + const bad = (detail: unknown): ApiError => ({ code: 'validation-failed', message: '入参不合法', detail }); + + /* ── 成员 ───────────────────────────────────────────────────────── */ + + app.get(listOrgUsersRoute.path, async (c) => { + const q = c.req.query('q') ?? ''; + // 只有 `1` 算真 —— `true` / `yes` 这类一律当假,免得两处理解不同(与 BPOS §1.1 同款) + const includeDisabled = c.req.query('includeDisabled') === '1'; + const out = await answer(() => container.org.directoryUsers({ q, includeDisabled })); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.get(getOrgUserRoute.path, async (c) => { + const out = await answer(() => container.org.directoryUser(c.req.param('sub'))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(createOrgUserRoute.path, async (c) => { + const parsed = createOrgUserRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.createDirectoryUser(defined(parsed.data))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.patch(patchOrgUserRoute.path, async (c) => { + const parsed = patchOrgUserRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.patchDirectoryUser(c.req.param('sub'), defined(parsed.data))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(setOrgUserEnabledRoute.path, async (c) => { + const parsed = setOrgUserEnabledRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.setDirectoryUserEnabled(c.req.param('sub'), parsed.data.enabled)); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(resetOrgUserPasswordRoute.path, async (c) => { + const parsed = resetOrgUserPasswordRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(async () => { + await container.org.resetDirectoryUserPassword(c.req.param('sub'), parsed.data.password); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.delete(deleteOrgUserRoute.path, async (c) => { + const out = await answer(async () => { + await container.org.deleteDirectoryUser(c.req.param('sub')); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + /* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ───────────────────── */ + + app.get(listOrgRolesRoute.path, async (c) => { + const out = await answer(() => container.org.directoryRoles()); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(setOrgUserRoleRoute.path, async (c) => { + const parsed = setOrgUserRoleRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => + container.org.setDirectoryUserRole(c.req.param('sub'), c.req.param('role'), parsed.data.granted), + ); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + /* ── 组织 ───────────────────────────────────────────────────────── */ + + app.get(listOrgGroupsRoute.path, async (c) => { + const out = await answer(() => container.org.directoryGroups()); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.get(listOrgGroupMembersRoute.path, async (c) => { + const out = await answer(() => container.org.directoryGroupMembers(c.req.param('id'))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(createOrgGroupRoute.path, async (c) => { + const parsed = createOrgGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.createDirectoryGroup(parsed.data.name, parsed.data.parentId)); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.patch(renameOrgGroupRoute.path, async (c) => { + const parsed = renameOrgGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.renameDirectoryGroup(c.req.param('id'), parsed.data.name)); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.delete(deleteOrgGroupRoute.path, async (c) => { + const out = await answer(async () => { + await container.org.deleteDirectoryGroup(c.req.param('id')); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(setOrgUserGroupRoute.path, async (c) => { + const parsed = setOrgUserGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(async () => { + await container.org.setDirectoryUserGroup(c.req.param('sub'), c.req.param('groupId'), parsed.data.member); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); +} diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index 6e6e8a0..7922af1 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -28,6 +28,7 @@ import { registerRegistrationRoutes } from './routes/registration.ts'; import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts'; import { registerGovernanceRoutes } from './routes/governance.ts'; import { registerIngestRoutes } from './routes/ingest.ts'; +import { registerOrgDirectoryRoutes } from './routes/org-directory.ts'; import { registerMailRoutes } from './routes/mail.ts'; import { registerStreamRoute } from './routes/stream.ts'; import { registerWorkbenchRoutes } from './routes/workbench.ts'; @@ -115,6 +116,7 @@ export function createServer(container: Container): Hono { registerWorkbenchRoutes(app, container); registerGovernanceRoutes(app, container); + registerOrgDirectoryRoutes(app, container); registerDeskRoutes(app, container); registerDeskElementRoutes(app, container); registerDeskApprovalRoutes(app, container); diff --git a/apps/api/src/app/sync-roster.ts b/apps/api/src/app/sync-roster.ts new file mode 100644 index 0000000..124216e --- /dev/null +++ b/apps/api/src/app/sync-roster.ts @@ -0,0 +1,46 @@ +/** + * 名册同步 —— 从 Keycloak Admin API 把整册照过来(ADR-012)。 + * + * pnpm --filter @app/api sync-roster + * + * 与 `POST /api/gov/staff/sync` 是**同一段用例**(`org.syncRoster`),只是换了个入口: + * 那条路由要 `ops`,而运维手上常常只有 shell —— 服务账号进不了运营中心, + * 前端也未必摆着那枚钮。首登建档(JIT)覆盖的是「在用的人」,这条补的是「还没登录过的人」。 + * + * 三个数说的是**真落进库的条数**;`failed` 单列 —— 半途出事的同步不许报成一排绿数字。 + */ +import { loadConfig } from './config.ts'; +import { createContainer } from './container.ts'; + +export async function syncRoster(): Promise { + const container = await createContainer(loadConfig()); + try { + const outcome = await container.org.syncRoster(); + if (!outcome.configured) { + process.stderr.write(`✗ ${outcome.message}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + `同步完成:远端 ${outcome.total} 人 · 新建 ${outcome.inserted} · 更新 ${outcome.updated} · 停用 ${outcome.disabled}\n`, + ); + for (const f of outcome.failed) process.stderr.write(` ✗ ${f.account}:${f.reason}\n`); + if (outcome.failed.length > 0) process.exitCode = 1; + + const { staff } = await container.org.listStaff(); + process.stdout.write(`\n名册 ${staff.length} 人:\n`); + for (const s of staff) { + // 标出人 / 服务账号 —— 服务账号在 Keycloak 管理台的 Users 列表里也看不到,这里如实说 + const kind = s.kind === 'service' ? '服务账号' : '人 '; + process.stdout.write( + ` ${kind} ${s.account.padEnd(32)} ${s.name.padEnd(14)} ${s.position.code.padEnd(5)} ${s.state.padEnd(9)} ${s.roles.join(',')}\n`, + ); + } + } finally { + await container.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await syncRoster(); +} diff --git a/apps/api/src/modules/org/CONTRACT.md b/apps/api/src/modules/org/CONTRACT.md index 3b1d849..5396557 100644 --- a/apps/api/src/modules/org/CONTRACT.md +++ b/apps/api/src/modules/org/CONTRACT.md @@ -32,6 +32,15 @@ 不变量: - `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键 + - **姓在前、名在后**(2026-08-26 用户定):`Staff.name` 由 `personName` 一处拼 —— + token 那条路取 `family_name` / `given_name`,名册同步取 `lastName` / `firstName`; + **不吃 Keycloak 拼好的 `name` claim**(那是 `firstName lastName`,西式序)。 + 两边都是汉字直拼(「柴浩」),否则留一个空格(「Admin Amy」);皆空回落用户名。 + 拆不开的 realm(没配 profile scope)才退回 `name` claim —— 重排不了的东西如实用它。 + - `Staff.kind` 由**账号名现判**、不落库:`service-account-` 是 Keycloak 自己的命名, + 开了 `serviceAccountsEnabled` 的 client 一律配一个。服务账号是**真身份**(试跑与 e2e 用它跑单), + 但不是人 —— 员工管理那一屏据此不摆它,试跑台的负责人下拉照旧摆(铁规矩 6)。 + 按名字认不按角色认:角色是各 realm 自己配的,这个前缀由 Keycloak 保证 - `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError, 有效 token 不能把它翻回 active(撤权在同步之后即时生效) - **停用判定走在 JIT 写之前**:已停用的人攥着有效 token 也驱动不了 `updateIdentity`; @@ -45,6 +54,16 @@ ③落库之前按差异算一遍「同步后还有谁能进运营中心」,不过就整体拒绝(AccessLockoutError → 409),一条都不写 - 同步逐条落库:**单条失败不停在半路**,失败的人点名进 `RosterSyncOutcome.failed`; 三个计数说的是真落下的条数,不是打算写几条 + - **成员目录**(`directory*`,ADR-012):没配 provisioning 就**整个不可用**(抛 + `DirectoryUnavailableError` → 502),不给「读得了写不了」的半开状态 —— 那样人会以为是自己点错了。 + 读是投影、写**穿透**到 Keycloak(没有本地草稿,也没有撤销)。四条不变量: + ①**服务账号一律不可见不可写**(目录读剔干净、写面抛 `ServiceAccountProtectedError` → 403); + ②**停用与收权当场踢会话** —— 否则那枚 token 到期前照样通行,而屏上已经写了「已停用」; + ③**收权先算自锁**:收完没人进得了运营中心就整体拒(409),与改「角色→端」同一道闸、同一个理由; + ④**删人本地只停不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。 + - 目录读有 **60 秒进程内缓存**,**任一写(成功或失败)之后整体清空**:写完立刻重拉一定看得到新结果, + 前端不必延时重试。失败也清 —— 半途失败之后那份缓存说不清停在哪一边。 + 唯一滞后:**别的**管理员在另一个后端实例上写,这边最多滞后 60 秒。 - `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 [] - `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖 - 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核 diff --git a/apps/api/src/modules/org/__tests__/claims.test.ts b/apps/api/src/modules/org/__tests__/claims.test.ts index 559c3c9..8180d50 100644 --- a/apps/api/src/modules/org/__tests__/claims.test.ts +++ b/apps/api/src/modules/org/__tests__/claims.test.ts @@ -1,6 +1,6 @@ /** domain 测试零 mock(编码规范 §16) */ import { describe, expect, it } from 'vitest'; -import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts'; +import { identityOf, isNoiseRole, normalizeRoles, personName, staffKindOf } from '../internal/domain/claims.ts'; describe('identityOf —— claims 归一成一个人', () => { it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => { @@ -43,3 +43,58 @@ describe('噪声角色', () => { expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']); }); }); + +describe('staffKindOf —— 人还是服务账号', () => { + it('Keycloak 的 service-account- 认作服务账号', () => { + expect(staffKindOf('service-account-task-dispatcher')).toBe('service'); + }); + it('大小写与前后空格不影响判断', () => { + expect(staffKindOf(' Service-Account-Provisioning ')).toBe('service'); + }); + it('真人一律 person —— 名字里带 service 也不算', () => { + expect(staffKindOf('chaihao')).toBe('person'); + expect(staffKindOf('ai-test')).toBe('person'); + expect(staffKindOf('service-desk')).toBe('person'); + expect(staffKindOf('')).toBe('person'); + }); +}); + +describe('personName —— 姓在前、名在后', () => { + it('中文名直拼,不留空格', () => { + expect(personName({ familyName: '柴', givenName: '浩', fallback: 'chaihao' })).toBe('柴浩'); + expect(personName({ familyName: '丁', givenName: '子乔', fallback: 'x' })).toBe('丁子乔'); + }); + it('非汉字留一个空格 —— 拼在一起会连成一个词', () => { + expect(personName({ familyName: 'Admin', givenName: 'Amy', fallback: 'amy' })).toBe('Admin Amy'); + }); + it('一边混着字母就留空格(拼死了读不出来是两截)', () => { + expect(personName({ familyName: '李', givenName: 'Ken', fallback: 'x' })).toBe('李 Ken'); + }); + it('只有一半就摆那一半,不留孤零零的空格', () => { + expect(personName({ familyName: '柴', fallback: 'chaihao' })).toBe('柴'); + expect(personName({ givenName: '浩', fallback: 'chaihao' })).toBe('浩'); + }); + it('两边都空(或只有空白)→ 回落,名字这一格不许空着', () => { + expect(personName({ fallback: 'chaihao' })).toBe('chaihao'); + expect(personName({ familyName: ' ', givenName: '', fallback: 'chaihao' })).toBe('chaihao'); + }); +}); + +describe('identityOf 的名字:自己拼,不吃 Keycloak 拼好的那个', () => { + it('有 family_name / given_name 就按姓在前拼 —— name claim 是西式序,不用它', () => { + const id = identityOf({ + sub: 's', + preferred_username: 'chaihao', + name: '浩 柴', + given_name: '浩', + family_name: '柴', + }); + expect(id.name).toBe('柴浩'); + }); + it('realm 没配 profile scope(拆不开)→ 如实用 name claim', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob', name: 'Bob Lee' }).name).toBe('Bob Lee'); + }); + it('两样都没有 → 回落用户名', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob' }).name).toBe('bob'); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/directory-service.test.ts b/apps/api/src/modules/org/__tests__/directory-service.test.ts new file mode 100644 index 0000000..0b67b92 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/directory-service.test.ts @@ -0,0 +1,313 @@ +/** + * 成员目录的用例层 —— 两道闸与一处缓存(ADR-012)。 + * Keycloak 是外界,给它一个记账的替身(编码规范 §16:只有外界能 mock)。 + */ +import { describe, expect, it } from 'vitest'; +import type { Position, RoleAccess, WorkGroup } from '@contracts'; +import { positionIdSchema } from '@contracts'; +import { AccessLockoutError, DirectoryUnavailableError, ServiceAccountProtectedError } from '../contract.ts'; +import type { DirectoryUser, KeycloakDirectory, OrgServiceDeps, StaffRecord } from '../index.ts'; +import { createOrgService } from '../index.ts'; + +const PEOPLE: DirectoryUser[] = [ + { sub: 'sub-admin', username: 'chaihao', enabled: true, emailVerified: true, roles: ['admin', 'user'] }, + { sub: 'sub-user', username: 'linpeixuan', enabled: true, emailVerified: false, roles: ['user'] }, + { sub: 'sub-svc', username: 'service-account-task-dispatcher', enabled: true, emailVerified: false, roles: ['service'] }, +]; + +type Log = string[]; + +/** 记账替身 —— 只回预置的形状,把调过什么记下来 */ +function fakeDirectory(log: Log, users = PEOPLE): KeycloakDirectory { + const find = (sub: string): DirectoryUser => { + const u = users.find((x) => x.sub === sub); + if (u === undefined) throw new Error(`没有这个人:${sub}`); + return u; + }; + return { + listUsers: async () => { + log.push('listUsers'); + return users; + }, + getUser: async (sub) => ({ ...find(sub), groups: [] }), + createUser: async (input) => { + log.push(`createUser:${input.username}`); + return 'sub-new'; + }, + updateUser: async (sub) => void log.push(`updateUser:${sub}`), + setEnabled: async (sub, enabled) => void log.push(`setEnabled:${sub}:${String(enabled)}`), + resetPassword: async (sub) => void log.push(`resetPassword:${sub}`), + deleteUser: async (sub) => void log.push(`deleteUser:${sub}`), + logout: async (sub) => void log.push(`logout:${sub}`), + listRoles: async () => [ + { name: 'admin', composite: false }, + { name: 'user', composite: false }, + { name: 'default-roles-corp', composite: true }, + ], + setRole: async (sub, role, granted) => void log.push(`setRole:${sub}:${role}:${String(granted)}`), + listGroups: async () => { + log.push('listGroups'); + return [{ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 }]; + }, + groupMembers: async () => users, + groupMemberUsernames: async () => users.map((u) => u.username), + createGroup: async (name) => { + log.push(`createGroup:${name}`); + return { id: 'g2', name, path: `/${name}`, depth: 0, memberCount: 0 }; + }, + renameGroup: async (id, name) => { + log.push(`renameGroup:${id}:${name}`); + return { id, name, path: `/${name}`, depth: 0, memberCount: 0 }; + }, + deleteGroup: async (id) => void log.push(`deleteGroup:${id}`), + setUserGroup: async (sub, gid, member) => void log.push(`setUserGroup:${sub}:${gid}:${String(member)}`), + }; +} + +function harness(opts: { directory?: KeycloakDirectory } = {}) { + const positions = new Map(); + const staff = new Map(); + const roleAccess = new Map([ + ['admin', { role: 'admin', ends: ['design', 'ops', 'workbench'] }], + ['user', { role: 'user', ends: ['workbench'] }], + ]); + const groups = new Map(); + let nowMs = Date.parse('2026-08-26T09:00:00Z'); + + const deps: OrgServiceDeps = { + verifier: { verify: async () => ({ sub: 'x', preferred_username: 'x' }) }, + ...(opts.directory === undefined ? {} : { directory: opts.directory }), + positions: { + list: async () => [...positions.values()], + findByCode: async (code) => [...positions.values()].find((p) => p.code === code), + findById: async (id) => positions.get(id), + upsert: async (p) => { + const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase()); + const row: Position = { id, code: p.code, name: p.name }; + positions.set(id, row); + return row; + }, + }, + staff: { + list: async () => [...staff.values()], + findById: async (id) => staff.get(id), + findByAccount: async (a) => [...staff.values()].find((s) => s.account === a), + listByPosition: async () => [], + insert: async (s, marks) => { + const row: StaffRecord = { ...s, ...marks }; + staff.set(row.id, row); + return row; + }, + updateIdentity: async () => undefined, + setPosition: async () => undefined, + setState: async (id, state) => { + const row = staff.get(id); + if (row !== undefined) staff.set(id, { ...row, state }); + }, + }, + roleAccess: { + list: async () => [...roleAccess.values()], + upsert: async (row) => { + roleAccess.set(row.role, row); + return row; + }, + remove: async (role) => void roleAccess.delete(role), + }, + workGroups: { + listByOwner: async () => [], + upsert: async (g) => { + groups.set(g.id, g); + return g; + }, + }, + inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] }, + clock: { now: () => new Date(nowMs) }, + ids: { next: () => 'id' }, + testAccounts: [], + }; + return { org: createOrgService(deps), staff, tick: (ms: number) => (nowMs += ms) }; +} + +describe('没配 provisioning —— 目录整个不可用,不给半开状态', () => { + it('读也不行,写也不行;话里说清缺什么', async () => { + const h = harness(); + await expect(h.org.directoryUsers({})).rejects.toBeInstanceOf(DirectoryUnavailableError); + await expect(h.org.directoryGroups()).rejects.toBeInstanceOf(DirectoryUnavailableError); + await h.org.directoryUsers({}).catch((e: unknown) => { + expect((e as Error).message).toContain('KEYCLOAK_PROVISIONING_CLIENT_ID'); + expect((e as DirectoryUnavailableError).misconfigured).toBe(true); + }); + }); +}); + +describe('服务账号:是身份,不是人', () => { + it('目录读里一个都不出现', async () => { + const h = harness({ directory: fakeDirectory([]) }); + const { users } = await h.org.directoryUsers({ includeDisabled: true }); + expect(users.map((u) => u.username)).toEqual(['chaihao', 'linpeixuan']); + }); + + it('对着它下手一律拒 —— 停掉它,试跑与 e2e 第二天全趴下', async () => { + const h = harness({ directory: fakeDirectory([]) }); + await expect(h.org.setDirectoryUserEnabled('sub-svc', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError); + await expect(h.org.deleteDirectoryUser('sub-svc')).rejects.toBeInstanceOf(ServiceAccountProtectedError); + await expect(h.org.setDirectoryUserRole('sub-svc', 'admin', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError); + }); + + it('连建一个叫这名字的都不许 —— 免得从正门造出一个假服务账号', async () => { + const h = harness({ directory: fakeDirectory([]) }); + await expect( + h.org.createDirectoryUser({ username: 'service-account-fake' }), + ).rejects.toBeInstanceOf(ServiceAccountProtectedError); + }); + + it('组成员里也剔干净', async () => { + const h = harness({ directory: fakeDirectory([]) }); + const { members } = await h.org.directoryGroupMembers('g1'); + expect(members.map((m) => m.username)).toEqual(['chaihao', 'linpeixuan']); + }); +}); + +describe('收权不能把最后一个管理员关在门外', () => { + it('收掉唯一 admin 的 admin → 拒,一个字都没写出去', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log, [PEOPLE[0]!, PEOPLE[1]!]) }); + await expect(h.org.setDirectoryUserRole('sub-admin', 'admin', false)).rejects.toBeInstanceOf(AccessLockoutError); + expect(log.filter((l) => l.startsWith('setRole'))).toEqual([]); + }); + + it('还有另一个 admin 在 → 放行,并**当场踢掉他的会话**', async () => { + const log: Log = []; + const two: DirectoryUser[] = [ + PEOPLE[0]!, + { sub: 'sub-admin2', username: 'amy', enabled: true, emailVerified: false, roles: ['admin', 'user'] }, + ]; + const h = harness({ directory: fakeDirectory(log, two) }); + await h.org.setDirectoryUserRole('sub-admin', 'admin', false); + expect(log).toContain('setRole:sub-admin:admin:false'); + // 不踢的话他手上那枚 token 到期前还带着 admin —— 收权要等过期才生效 + expect(log).toContain('logout:sub-admin'); + }); + + it('授权那一路不查自锁(只会更宽,锁不死)', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log, [PEOPLE[1]!]) }); + await h.org.setDirectoryUserRole('sub-user', 'admin', true); + expect(log).toContain('setRole:sub-user:admin:true'); + expect(log).not.toContain('logout:sub-user'); + }); +}); + +describe('停用与删除:本地那一行跟手', () => { + it('停用 → 踢会话 + 本地置 disabled(两屏不许说两句话)', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] }); + await h.org.setDirectoryUserEnabled('sub-user', false); + expect(log).toContain('setEnabled:sub-user:false'); + expect(log).toContain('logout:sub-user'); + expect(h.staff.get('sub-user')?.state).toBe('disabled'); + }); + + it('启用不踢会话(他本来就登不上,踢什么)', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.setDirectoryUserEnabled('sub-user', true); + expect(log).not.toContain('logout:sub-user'); + }); + + it('删人 → 本地那一行**停用不删**:留痕、在途、台账都还指着这个 sub', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] }); + await h.org.deleteDirectoryUser('sub-user'); + expect(log).toContain('deleteUser:sub-user'); + expect(h.staff.get('sub-user')?.state).toBe('disabled'); + expect(h.staff.has('sub-user')).toBe(true); + }); +}); + +describe('60 秒读缓存 —— 写完立刻清,不必延时重试', () => { + it('60 秒内不再问 Keycloak', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryUsers({ q: 'chai' }); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(1); + }); + + it('过了 60 秒自己重取', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + h.tick(61_000); + await h.org.directoryUsers({}); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(2); + }); + + /* + * 清缓存**按写真正碰到的东西清**。全清的代价看得见:删一个组之后下一次读要把 + * 「全量成员的角色」那趟 N+1 重付一遍,屏上那棵树因此多钉着已经没了的组好几秒。 + */ + it('建组只清组那一份 —— 人的角色与端没变,别陪着重付一趟 N+1', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + await h.org.createDirectoryGroup('新组'); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(1); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(2); + }); + + it('改资料只清人那一份 —— 组的成员数没变', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + await h.org.patchDirectoryUser('sub-user', { firstName: '佩萱' }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(2); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(1); + }); + + it('删人两边都清 —— 人没了,他在的那些组也少一个人', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + await h.org.deleteDirectoryUser('sub-user'); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(2); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(2); + }); + + it('写**失败**了也清 —— 半途失败之后那份缓存说不清是哪一边', async () => { + const log: Log = []; + const dir = fakeDirectory(log); + const h = harness({ + directory: { ...dir, createGroup: async () => { throw new Error('同级重名'); } }, + }); + await h.org.directoryGroups(); + await expect(h.org.createDirectoryGroup('技术部')).rejects.toThrow('同级重名'); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(2); + }); +}); + +describe('角色清单', () => { + it('家具角色不摆(授收它没有意义),持有数按生效角色数', async () => { + const h = harness({ directory: fakeDirectory([]) }); + const { roles } = await h.org.directoryRoles(); + expect(roles.map((r) => r.name)).toEqual(['admin', 'user']); + // service-account 已剔,所以 user 是 2 不是 3 + expect(roles.find((r) => r.name === 'user')?.holders).toBe(2); + expect(roles.find((r) => r.name === 'admin')?.holders).toBe(1); + expect(roles.find((r) => r.name === 'admin')?.ends).toEqual(['design', 'ops', 'workbench']); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/directory.test.ts b/apps/api/src/modules/org/__tests__/directory.test.ts new file mode 100644 index 0000000..44c0736 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/directory.test.ts @@ -0,0 +1,159 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { RoleAccess } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../port.ts'; +import { + displayNameOf, + filterOrgUsers, + isServiceAccount, + localMarks, + sortOrgUsers, + toOrgGroups, + toOrgRoles, + toOrgUser, +} from '../internal/domain/directory.ts'; + +const TABLE: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; + +const kc = (over: Partial = {}): DirectoryUser => ({ + sub: 'sub-1', + username: 'chaihao', + enabled: true, + emailVerified: false, + roles: ['admin', 'user'], + ...over, +}); + +describe('服务账号:是身份,不是人', () => { + it('Keycloak 的 service-account- 认得出', () => { + expect(isServiceAccount('service-account-task-dispatcher')).toBe(true); + expect(isServiceAccount('chaihao')).toBe(false); + // 名字里带 service 不算 —— 认的是 Keycloak 那个固定前缀,不是关键词 + expect(isServiceAccount('service-desk')).toBe(false); + }); +}); + +describe('displayNameOf', () => { + it('姓在前、名在后 —— 中文名直拼', () => { + expect(displayNameOf({ username: 'a', firstName: '浩', lastName: '柴' })).toBe('柴浩'); + }); + it('只有一半就摆那一半', () => { + expect(displayNameOf({ username: 'a', lastName: '柴' })).toBe('柴'); + }); + it('两个都空 → 回落用户名,不回空串', () => { + expect(displayNameOf({ username: 'amy', firstName: ' ', lastName: '' })).toBe('amy'); + }); +}); + +describe('toOrgUser —— Keycloak 那份 + 本地那两列', () => { + it('ends 由本地表算,不是 Keycloak 的字段', () => { + expect(toOrgUser(kc(), TABLE).ends).toEqual(['design', 'ops', 'workbench']); + expect(toOrgUser(kc({ roles: ['user'] }), TABLE).ends).toEqual(['workbench']); + }); + it('角色一行都没命中 → 无端可进(不是报错,是事实)', () => { + expect(toOrgUser(kc({ roles: ['service'] }), TABLE).ends).toEqual([]); + }); + it('本地两列合进来;没有就整个键不出现', () => { + const withLocal = toOrgUser(kc(), TABLE, { lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'TEST' }); + expect(withLocal.lastSeenAt).toBe('2026-08-26T07:00:00.000Z'); + expect(withLocal.positionCode).toBe('TEST'); + const bare = toOrgUser(kc(), TABLE); + expect('lastSeenAt' in bare).toBe(false); + expect('positionCode' in bare).toBe(false); + }); +}); + +describe('sortOrgUsers —— 来过的在前', () => { + it('来过的按时刻倒序在前,没来过的按用户名在后', () => { + const rows = [ + toOrgUser(kc({ sub: 'c', username: 'cathy' }), TABLE), + toOrgUser(kc({ sub: 'a', username: 'amy' }), TABLE, { lastSeenAt: new Date('2026-08-01T00:00:00Z') }), + toOrgUser(kc({ sub: 'b', username: 'bob' }), TABLE), + toOrgUser(kc({ sub: 'd', username: 'dan' }), TABLE, { lastSeenAt: new Date('2026-08-20T00:00:00Z') }), + ]; + expect(sortOrgUsers(rows).map((u) => u.username)).toEqual(['dan', 'amy', 'bob', 'cathy']); + }); +}); + +describe('filterOrgUsers —— 检索与「含停用」', () => { + const rows = [ + toOrgUser(kc({ sub: '1', username: 'chaihao', firstName: '浩', lastName: '柴', email: 'a@x.com' }), TABLE), + toOrgUser(kc({ sub: '2', username: 'amy', enabled: false }), TABLE), + ]; + it('缺省不摆停用的人', () => { + expect(filterOrgUsers(rows, '', false).map((u) => u.username)).toEqual(['chaihao']); + }); + it('含停用 → 都摆', () => { + expect(filterOrgUsers(rows, '', true)).toHaveLength(2); + }); + it('用户名 / 姓名 / 邮箱任一含关键词即命中,不区分大小写', () => { + expect(filterOrgUsers(rows, 'CHAI', true)).toHaveLength(1); + expect(filterOrgUsers(rows, '柴', true)).toHaveLength(1); + expect(filterOrgUsers(rows, 'a@x', true)).toHaveLength(1); + }); + it('**含在中间**也搜得到 —— 这正是不转给 Keycloak 的 search= 的理由(那个是前缀式)', () => { + expect(filterOrgUsers(rows, 'aiha', true).map((u) => u.username)).toEqual(['chaihao']); + }); + it('搜不到就是空,不是全量', () => { + expect(filterOrgUsers(rows, '查无此人', true)).toEqual([]); + }); +}); + +describe('toOrgRoles', () => { + const roles: DirectoryRole[] = [ + { name: 'admin', composite: false }, + { name: 'user', composite: false, description: '员工' }, + ]; + it('每个角色配上本地算的 ends 与调用方数好的持有数', () => { + const out = toOrgRoles(roles, TABLE, [5, 8]); + expect(out[0]).toMatchObject({ name: 'admin', ends: ['design', 'ops', 'workbench'], holders: 5 }); + expect(out[1]).toMatchObject({ name: 'user', ends: ['workbench'], holders: 8, description: '员工' }); + }); + it('持有数没给到那一位 → 0,不是 undefined', () => { + expect(toOrgRoles(roles, TABLE, [3])[1]?.holders).toBe(0); + }); +}); + +describe('toOrgGroups —— 平表 + 成员数', () => { + const groups: DirectoryGroup[] = [ + { id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 }, + { id: 'g2', name: '平台组', path: '/技术部/平台组', parentId: 'g1', depth: 1, memberCount: 0 }, + ]; + it('成员数由调用方现数;顶层组没有 parentId 那个键', () => { + const out = toOrgGroups(groups, new Map([['g1', 3]])); + expect(out[0]).toEqual({ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 3 }); + expect(out[1]?.parentId).toBe('g1'); + }); + it('没数过的组是 0 —— 不给 undefined 让界面摆一个空格', () => { + expect(toOrgGroups(groups, new Map())[1]?.memberCount).toBe(0); + }); +}); + +describe('localMarks', () => { + const none = positionIdSchema.parse('pos-none'); + const staff: Array = [ + { + id: staffIdSchema.parse('sub-1'), + account: 'chaihao', + name: '柴浩', + positionId: none, + state: 'active', + roles: ['admin'], + lastSeenAt: new Date('2026-08-26T07:00:00Z'), + }, + { id: staffIdSchema.parse('sub-2'), account: 'amy', name: 'Amy', positionId: none, state: 'active', roles: [] }, + ]; + it('按 sub 索引;岗位查不到就不摆那个键', () => { + const marks = localMarks(staff, (id) => (id === none ? 'NONE' : undefined)); + expect(marks.get('sub-1')).toEqual({ lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'NONE' }); + expect('lastSeenAt' in (marks.get('sub-2') ?? {})).toBe(false); + }); + it('岗位表里没有那一行 → 键不出现,不摆空串', () => { + const marks = localMarks(staff, () => undefined); + expect('positionCode' in (marks.get('sub-1') ?? {})).toBe(false); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts index 8689740..355f6a7 100644 --- a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts +++ b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts @@ -8,8 +8,9 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { id: `sub-${i}`, username: `u${i}`, enabled: i !== 3, - firstName: i === 0 ? '' : '名', - lastName: i === 0 ? '' : `${i}`, + // 第 0 位两边都空 —— 试的是「名字这一格不许空着,回落用户名」那一支 + firstName: i === 0 ? '' : '浩', + lastName: i === 0 ? '' : '柴', email: `u${i}@x.test`, })); const json = (body: unknown, status = 200): Response => @@ -42,13 +43,14 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { } describe('Keycloak Admin API 名册', () => { - it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { + it('分页拉全(100 + 5)、姓在前名在后、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { const kc = fakeKeycloak(); const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch }); const users = await roster.listUsers(); expect(users).toHaveLength(105); expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true }); - expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); + // 姓在前:lastName「柴」+ firstName「浩」= 柴浩(中文直拼,不留空格) + expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '柴浩', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); expect(users[3]?.enabled).toBe(false); expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2); }); diff --git a/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts index 85187f3..f1e6cd2 100644 --- a/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts +++ b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts @@ -62,6 +62,24 @@ describe('Keycloak 验签', () => { expect(claims.realm_access?.roles).toContain('user'); }); + /* + * 验签这一层是**挑字段**拼 `VerifiedClaims` 的 —— 类型上加了字段而这儿忘了挑, + * 编译一声不吭,下游拿到的永远是 undefined。实测栽过一次: + * 姓名改「姓在前」之后每个请求还是把名字写回西式序,查了两台后端才发现漏在这儿。 + * 所以这条钉的是**整条链**:真 token 的形状进去,`identityOf` 认得出的形状出来。 + */ + it('given_name / family_name 要原样带出来 —— 姓在前的拼法全靠它俩', async () => { + const claims = await verifier.verify(await mint({ claims: { given_name: '浩', family_name: '柴' } })); + expect(claims.given_name).toBe('浩'); + expect(claims.family_name).toBe('柴'); + }); + + it('token 里没有那两个 claim → 键干脆不出现(不是 undefined)', async () => { + const claims = await verifier.verify(await mint()); + expect('given_name' in claims).toBe(false); + expect('family_name' in claims).toBe(false); + }); + it('过期 30 秒内(时钟容差)仍通过', async () => { await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined(); }); diff --git a/apps/api/src/modules/org/__tests__/org-service.test.ts b/apps/api/src/modules/org/__tests__/org-service.test.ts index 3124742..4be338a 100644 --- a/apps/api/src/modules/org/__tests__/org-service.test.ts +++ b/apps/api/src/modules/org/__tests__/org-service.test.ts @@ -243,6 +243,22 @@ describe('名册与岗位', () => { expect(await h.org.staffOfPosition('NONE')).toEqual([]); }); + it('listStaff 给每行标 kind —— 服务账号是身份不是人(员工管理那一屏据此不摆它)', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'p', account: 'chaihao', name: '柴浩', roles: ['admin'] }); + await h.org.registerIdentity({ + sub: 's', + account: 'service-account-task-dispatcher', + name: 'service-account-task-dispatcher', + roles: ['service'], + }); + const { staff } = await h.org.listStaff(); + const kinds = Object.fromEntries(staff.map((x) => [x.account, x.kind])); + expect(kinds['chaihao']).toBe('person'); + expect(kinds['service-account-task-dispatcher']).toBe('service'); + }); + it('syncRoster 未配置 → configured:false', async () => { const h = harness(); await h.org.ensureSeeded(); diff --git a/apps/api/src/modules/org/__tests__/roster-diff.test.ts b/apps/api/src/modules/org/__tests__/roster-diff.test.ts index 03efa13..f3f2a71 100644 --- a/apps/api/src/modules/org/__tests__/roster-diff.test.ts +++ b/apps/api/src/modules/org/__tests__/roster-diff.test.ts @@ -1,12 +1,11 @@ /** domain 测试零 mock(编码规范 §16) */ import { describe, expect, it } from 'vitest'; -import type { Staff } from '@contracts'; import { positionIdSchema, staffIdSchema } from '@contracts'; import { activeRolesAfter, diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts'; -import type { RosterUser } from '../port.ts'; +import type { RosterUser, StoredStaff } from '../port.ts'; const none = positionIdSchema.parse('none'); -const local = (sub: string, over: Partial = {}): Staff => ({ +const local = (sub: string, over: Partial = {}): StoredStaff => ({ id: staffIdSchema.parse(sub), account: sub, name: sub, diff --git a/apps/api/src/modules/org/contract.ts b/apps/api/src/modules/org/contract.ts index e17e299..09fc8c2 100644 --- a/apps/api/src/modules/org/contract.ts +++ b/apps/api/src/modules/org/contract.ts @@ -7,6 +7,10 @@ */ import type { InFlightWork, + OrgGroup, + OrgRole, + OrgUser, + OrgUserDetail, Position, PositionId, Principal, @@ -19,7 +23,27 @@ import type { WorkGroupBinding, } from '@contracts'; -export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +export type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +/** + * 目录写面的两个入参 —— 住在 contract.ts 而不是 port.ts:它们是**服务出口的形状** + * (路由照它填),port 只是恰好也要用。反过来放会让 port ↔ contract 成环。 + */ +export type CreateDirectoryUser = { + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + /** 临时口令;不给就建一个还登不进来的壳 */ + readonly password?: string; + /** 一并授的 realm 角色 */ + readonly roles?: readonly string[]; +}; + +export type DirectoryUserPatch = { + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; /** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ export type StaffWithPosition = Staff & { readonly position: Position }; @@ -78,6 +102,32 @@ export class AccessLockoutError extends Error { } } +/** + * 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。 + * **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。 + */ +export class DirectoryUnavailableError extends Error { + /** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */ + readonly misconfigured: boolean; + constructor(message: string, misconfigured: boolean) { + super(message); + this.name = 'DirectoryUnavailableError'; + this.misconfigured = misconfigured; + } +} + +/** + * 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`), + * 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。 + * 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。 + */ +export class ServiceAccountProtectedError extends Error { + constructor(readonly account: string) { + super(`「${account}」是服务账号,不是人 —— 成员目录不管它;要停它去 Keycloak 停对应的 client`); + this.name = 'ServiceAccountProtectedError'; + } +} + export type OrgService = { /* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */ @@ -109,6 +159,31 @@ export type OrgService = { /** 能进该端的活跃员工 —— 治理台通知的收件人 */ staffWithEnd(surface: Surface): Promise; + /* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */ + + /** + * 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError` + * (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。 + * 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。 + */ + directoryUsers(query: { q?: string; includeDisabled?: boolean }): Promise<{ users: OrgUser[] }>; + directoryUser(sub: string): Promise; + createDirectoryUser(input: CreateDirectoryUser): Promise; + patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise; + /** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */ + setDirectoryUserEnabled(sub: string, enabled: boolean): Promise; + resetDirectoryUserPassword(sub: string, password: string): Promise; + deleteDirectoryUser(sub: string): Promise; + directoryRoles(): Promise<{ roles: OrgRole[] }>; + /** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */ + setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise; + directoryGroups(): Promise<{ groups: OrgGroup[] }>; + directoryGroupMembers(groupId: string): Promise<{ members: OrgUser[] }>; + createDirectoryGroup(name: string, parentId?: string): Promise; + renameDirectoryGroup(groupId: string, name: string): Promise; + deleteDirectoryGroup(groupId: string): Promise; + setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise; + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */ /** 建组 / 改组;id 留空即新建 */ diff --git a/apps/api/src/modules/org/index.ts b/apps/api/src/modules/org/index.ts index f920fb0..c599f3e 100644 --- a/apps/api/src/modules/org/index.ts +++ b/apps/api/src/modules/org/index.ts @@ -2,9 +2,15 @@ * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ export type { + CreateDirectoryUser, + DirectoryUserPatch, Identity, InFlightWork, + OrgGroup, + OrgRole, OrgService, + OrgUser, + OrgUserDetail, Position, Principal, RoleAccess, @@ -18,16 +24,22 @@ export type { export { AccessLockoutError, AccountDisabledError, + DirectoryUnavailableError, PositionNotFoundError, + ServiceAccountProtectedError, StaffNotFoundError, TokenRejectedError, } from './contract.ts'; export type { Clock, + DirectoryGroup, + DirectoryRole, + DirectoryUser, IdGenerator, IdentityPatch, InFlightWorkProbe, + KeycloakDirectory, PositionRepository, RoleAccessRepository, RosterSource, @@ -44,6 +56,15 @@ export { createOrgService } from './internal/usecase/org-service.ts'; export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { + displayNameOf, + filterOrgUsers, + isServiceAccount, + sortOrgUsers, + toOrgGroups, + toOrgRoles, + toOrgUser, +} from './internal/domain/directory.ts'; export { buildInFlightWork } from './internal/domain/in-flight.ts'; export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; @@ -51,6 +72,9 @@ export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapte export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +export { createKeycloakDirectory } from './internal/adapter/keycloak-directory.ts'; +export { createAdminHttp, KeycloakMisconfiguredError, KeycloakRejectedError, KeycloakUnavailableError } from './internal/adapter/keycloak-admin-http.ts'; +export type { AdminHttp, KeycloakAdminOptions } from './internal/adapter/keycloak-admin-http.ts'; export { createPositionRepository, createRoleAccessRepository, diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-http.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-http.ts new file mode 100644 index 0000000..8954e8a --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-http.ts @@ -0,0 +1,143 @@ +/** + * Keycloak Admin API 的 HTTP 底座(手册 §11 / §14;ADR-012)。 + * + * 名册同步与成员目录**共用同一枚管理 token** —— 从前只有名册那一条路, + * token 缓存写在它里面;目录接上来之后各缓存一份就是两条独立的续期时钟, + * 边界上谁先过期谁自己去换,白烧两次握手,还没人说得清当下在用哪一枚。 + * + * 三条路径规则照手册 §14.1:`{client-uuid}` 不是 clientId、创建返回 201 空体 + * (新 id 在 `Location` 末段)、列表默认 `max=100` 必须分页。 + * + * 错误分两种说 —— **重试有没有意义,决定了界面该说什么话**: + * `KeycloakMisconfiguredError`(401/403)= 服务账号缺角色或凭证不对,重试无益; + * `KeycloakUnavailableError`(网络 / 5xx)= 暂时够不着,过会儿再来。 + */ + +/** 服务账号缺 `realm-management` 角色,或凭证不对 —— 重试无益,去补权限 */ +export class KeycloakMisconfiguredError extends Error { + constructor(message: string) { + super(message); + this.name = 'KeycloakMisconfiguredError'; + } +} + +/** Keycloak 暂时够不着(网络 / 5xx)—— 可退避重试 */ +export class KeycloakUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'KeycloakUnavailableError'; + } +} + +/** Keycloak 明确拒绝了这次写(重名 / 约束不过)—— 原话带出去给人看 */ +export class KeycloakRejectedError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = 'KeycloakRejectedError'; + this.status = status; + } +} + +export type KeycloakAdminOptions = { + readonly url: string; + readonly realm: string; + readonly clientId: string; + readonly secret: string; + /** 测试注入;缺省全局 fetch */ + readonly fetch?: typeof fetch; + /** 毫秒时刻;测试注入 */ + readonly now?: () => number; +}; + +export type AdminHttp = { + get(path: string): Promise; + /** 建资源:成功 201 空体,回 `Location` 末段(新 id);Keycloak 不给就回 undefined */ + post(path: string, body?: unknown): Promise; + put(path: string, body?: unknown): Promise; + /** `body` 可选是因为**撤 realm 角色是带 body 的 DELETE**(手册 §14.4),不是笔误 */ + delete(path: string, body?: unknown): Promise; +}; + +/** 列表接口默认 `max=100`(手册 §14.1 第三条),分页取全 */ +export const ADMIN_PAGE = 100; + +export function createAdminHttp(options: KeycloakAdminOptions): AdminHttp { + const fetchImpl = options.fetch ?? fetch; + const now = options.now ?? ((): number => Date.now()); + const base = options.url.replace(/\/+$/, ''); + const realmBase = `${base}/admin/realms/${options.realm}`; + let cached: { token: string; expiresAt: number } | undefined; + + async function call(path: string, init: RequestInit): Promise { + try { + return await fetchImpl(path, init); + } catch (error) { + throw new KeycloakUnavailableError(`够不着 Keycloak:${error instanceof Error ? error.message : String(error)}`); + } + } + + async function adminToken(): Promise { + if (cached !== undefined && now() < cached.expiresAt) return cached.token; + const resp = await call(`${base}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: options.clientId, + client_secret: options.secret, + }), + }); + if (!resp.ok) { + const text = await resp.text(); + // 401/400 在这条路上一律是凭证不对(client_id / secret)—— 重试一百次也是同一句 + if (resp.status < 500) throw new KeycloakMisconfiguredError(`取管理 token 失败:${resp.status} ${text}`); + throw new KeycloakUnavailableError(`取管理 token 失败:${resp.status} ${text}`); + } + const data = (await resp.json()) as { access_token: string; expires_in: number }; + // 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调 + cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 }; + return cached.token; + } + + async function send(method: string, path: string, body?: unknown): Promise { + const token = await adminToken(); + const resp = await call(`${realmBase}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (resp.ok) return resp; + const text = await resp.text(); + const where = `${method} ${path}`; + if (resp.status === 401 || resp.status === 403) { + // 手册 §14.6:403 就是服务账号缺 realm-management 角色 —— 说清缺在哪一步 + throw new KeycloakMisconfiguredError( + `${where} 被拒(${resp.status})—— provisioning 服务账号缺 realm-management 角色(手册 §14.6);原话:${text}`, + ); + } + if (resp.status >= 500) throw new KeycloakUnavailableError(`${where} 失败(${resp.status}):${text}`); + throw new KeycloakRejectedError(resp.status, `${where} 被拒(${resp.status}):${text}`); + } + + return { + async get(path: string): Promise { + return (await (await send('GET', path)).json()) as T; + }, + async post(path: string, body?: unknown): Promise { + const resp = await send('POST', path, body); + // 手册 §14.1 第二条:新资源 id 在 Location 末段,响应体是空的 + const location = resp.headers.get('location'); + return location === null ? undefined : location.split('/').pop(); + }, + async put(path: string, body?: unknown): Promise { + await send('PUT', path, body); + }, + async delete(path: string, body?: unknown): Promise { + await send('DELETE', path, body); + }, + }; +} diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts index bffb86b..c20de69 100644 --- a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts @@ -7,7 +7,7 @@ * 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。 */ import type { RosterSource, RosterUser } from '../../port.ts'; -import { normalizeRoles } from '../domain/claims.ts'; +import { normalizeRoles, personName } from '../domain/claims.ts'; export type KeycloakRosterOptions = { readonly url: string; @@ -76,14 +76,15 @@ export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSour const out: RosterUser[] = []; for (const u of users) { const roles = await adminGet(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`); - const name = [u.firstName, u.lastName] - .filter((s): s is string => s !== undefined && s.trim() !== '') - .join(' ') - .trim(); out.push({ sub: u.id, account: u.username, - name: name === '' ? u.username : name, + // 姓在前、名在后 —— 与 token 那条路同一把尺(`personName`),两处理解不许分叉 + name: personName({ + ...(u.lastName === undefined ? {} : { familyName: u.lastName }), + ...(u.firstName === undefined ? {} : { givenName: u.firstName }), + fallback: u.username, + }), // 与 token 那条路同一把尺 —— 归一自己再写一遍,两处理解一分叉, // `identityChanged` 的逐字比对就会天天判「变了」(或天天判「没变」) roles: normalizeRoles(roles.map((r) => r.name)), diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-directory.ts b/apps/api/src/modules/org/internal/adapter/keycloak-directory.ts new file mode 100644 index 0000000..961983d --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-directory.ts @@ -0,0 +1,262 @@ +/** + * 成员目录 —— Keycloak Admin API 的读写面(手册 §14;ADR-012)。 + * + * 与名册同步(`keycloak-admin-roster.ts`)共用 `createAdminHttp` 的那枚管理 token。 + * 这里**不做过滤、不做归并**:剔服务账号、拼 `ends`、合本地岗位都归 domain 与 usecase —— + * 适配器只把 Keycloak 的形状原样翻过来,翻错了一眼看得出。 + */ +import type { + AdminHttp, + KeycloakAdminOptions, +} from './keycloak-admin-http.ts'; +import { ADMIN_PAGE, createAdminHttp } from './keycloak-admin-http.ts'; +import type { CreateDirectoryUser, DirectoryUserPatch } from '../../contract.ts'; +import type { + DirectoryGroup, + DirectoryRole, + DirectoryUser, + KeycloakDirectory, +} from '../../port.ts'; +import { normalizeRoles } from '../domain/claims.ts'; + +type KcUser = { + readonly id: string; + readonly username: string; + readonly enabled: boolean; + readonly emailVerified?: boolean; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; +type KcRole = { readonly name: string; readonly description?: string; readonly composite?: boolean }; +type KcGroup = { + readonly id: string; + readonly name: string; + readonly path: string; + readonly subGroups?: readonly KcGroup[]; +}; + +/** 空串与 undefined 是同一件事:Keycloak 那边没填,别在契约里变成一个空格子 */ +function opt(value: string | undefined): { [k: string]: string } { + return value === undefined || value.trim() === '' ? {} : { v: value.trim() }; +} + +function toUser(u: KcUser, roles: readonly string[]): DirectoryUser { + const first = opt(u.firstName); + const last = opt(u.lastName); + const mail = opt(u.email); + return { + sub: u.id, + username: u.username, + enabled: u.enabled, + emailVerified: u.emailVerified === true, + roles: normalizeRoles([...roles]), + ...(first.v === undefined ? {} : { firstName: first.v }), + ...(last.v === undefined ? {} : { lastName: last.v }), + ...(mail.v === undefined ? {} : { email: mail.v }), + }; +} + +const enc = encodeURIComponent; + +/** + * 有限并发地跑一批 —— Keycloak 没有「一次拿回全部人的角色」的接口(手册 §14.4 逐人查), + * 串行跑 8 个人就是 8 趟远程往返(实测整页 37 秒)。并发上限压着, + * 免得一屏把管理端打成一次小型压测。 + */ +async function mapLimit(items: readonly T[], limit: number, run: (item: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = cursor; + cursor += 1; + if (i >= items.length) return; + out[i] = await run(items[i]!); + } + }); + await Promise.all(workers); + return out; +} + +const ROLE_FETCH_CONCURRENCY = 8; + +export function createKeycloakDirectory(options: KeycloakAdminOptions, http?: AdminHttp): KeycloakDirectory { + const api = http ?? createAdminHttp(options); + + /** 实际生效的 realm 角色 —— 排查权限永远看 `/composite`(手册 §14.4) */ + async function rolesOf(sub: string): Promise { + const roles = await api.get(`/users/${enc(sub)}/role-mappings/realm/composite`); + return roles.map((r) => r.name); + } + + async function allUsers(): Promise { + const out: KcUser[] = []; + for (let first = 0; ; first += ADMIN_PAGE) { + const page = await api.get(`/users?first=${first}&max=${ADMIN_PAGE}&briefRepresentation=false`); + out.push(...page); + if (page.length < ADMIN_PAGE) break; + } + return out; + } + + /** 一行一个组;`subGroups` 递归摊平,顺手记住谁是谁的爹 */ + function flatten(nodes: readonly KcGroup[], parentId: string | undefined, depth: number, into: DirectoryGroup[]): void { + for (const g of nodes) { + into.push({ + id: g.id, + name: g.name, + path: g.path, + depth, + memberCount: 0, // 由 usecase 按组成员现数 —— Keycloak 的组表不带这个数 + ...(parentId === undefined ? {} : { parentId }), + }); + flatten(g.subGroups ?? [], g.id, depth + 1, into); + } + } + + /** 口令一律**临时**:本人首登必须改(手册 §14.2);我们不存、不回显、不留痕 */ + async function resetPassword(sub: string, password: string): Promise { + await api.put(`/users/${enc(sub)}/reset-password`, { type: 'password', value: password, temporary: true }); + } + + async function setRole(sub: string, role: string, granted: boolean): Promise { + // 授/撤的 body 是**角色对象数组**(手册 §14.4)—— 光给名字 Keycloak 不认 + const all = await api.get>('/roles'); + const target = all.find((r) => r.name === role); + if (target === undefined) throw new Error(`realm 里没有角色「${role}」`); + const body = [{ id: target.id, name: target.name }]; + const path = `/users/${enc(sub)}/role-mappings/realm`; + if (granted) await api.post(path, body); + else await api.delete(path, body); + } + + async function rawGroupMembers(groupId: string): Promise { + const members: KcUser[] = []; + for (let first = 0; ; first += ADMIN_PAGE) { + const page = await api.get(`/groups/${enc(groupId)}/members?first=${first}&max=${ADMIN_PAGE}`); + members.push(...page); + if (page.length < ADMIN_PAGE) break; + } + return members; + } + + async function groupById(groupId: string): Promise { + const g = await api.get(`/groups/${enc(groupId)}`); + const segments = g.path.split('/').filter((s) => s !== ''); + return { id: g.id, name: g.name, path: g.path, depth: segments.length - 1, memberCount: 0 }; + } + + return { + async listUsers() { + const users = await allUsers(); + return mapLimit(users, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id))); + }, + + async getUser(sub) { + const u = await api.get(`/users/${enc(sub)}`); + const [roles, groups] = await Promise.all([ + rolesOf(sub), + api.get>(`/users/${enc(sub)}/groups`), + ]); + return { ...toUser(u, roles), groups: groups.map((g) => ({ id: g.id, path: g.path })) }; + }, + + async createUser(input: CreateDirectoryUser) { + const first = opt(input.firstName); + const last = opt(input.lastName); + const mail = opt(input.email); + const sub = await api.post('/users', { + username: input.username, + enabled: true, + ...(first.v === undefined ? {} : { firstName: first.v }), + ...(last.v === undefined ? {} : { lastName: last.v }), + ...(mail.v === undefined ? {} : { email: mail.v }), + }); + if (sub === undefined) throw new Error('Keycloak 建用户成功但没回 Location —— 拿不到新 id,别往下走'); + if (input.password !== undefined) await resetPassword(sub, input.password); + for (const role of input.roles ?? []) await setRole(sub, role, true); + return sub; + }, + + async updateUser(sub, patch: DirectoryUserPatch) { + // 手册 §14.2:PUT 是全量覆盖 —— 先 GET 再改再 PUT,不然没带的字段会被清空 + const current = await api.get(`/users/${enc(sub)}`); + const first = opt(patch.firstName ?? current.firstName); + const last = opt(patch.lastName ?? current.lastName); + const mail = opt(patch.email ?? current.email); + await api.put(`/users/${enc(sub)}`, { + ...current, + firstName: first.v ?? '', + lastName: last.v ?? '', + email: mail.v ?? '', + }); + }, + + async setEnabled(sub, enabled) { + const current = await api.get(`/users/${enc(sub)}`); + await api.put(`/users/${enc(sub)}`, { ...current, enabled }); + }, + + resetPassword, + + async deleteUser(sub) { + await api.delete(`/users/${enc(sub)}`); + }, + + async logout(sub) { + await api.post(`/users/${enc(sub)}/logout`); + }, + + async listRoles(): Promise { + const roles = await api.get('/roles'); + return roles.map((r) => ({ + name: r.name, + composite: r.composite === true, + ...(r.description === undefined || r.description.trim() === '' ? {} : { description: r.description.trim() }), + })); + }, + + setRole, + + async listGroups() { + const tree = await api.get('/groups?briefRepresentation=false'); + const flat: DirectoryGroup[] = []; + flatten(tree, undefined, 0, flat); + return flat.sort((a, b) => a.path.localeCompare(b.path)); + }, + + async groupMembers(groupId) { + const members = await rawGroupMembers(groupId); + return mapLimit(members, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id))); + }, + + async groupMemberUsernames(groupId) { + return (await rawGroupMembers(groupId)).map((u) => u.username); + }, + + + async createGroup(name, parentId) { + const path = parentId === undefined ? '/groups' : `/groups/${enc(parentId)}/children`; + const id = await api.post(path, { name }); + if (id === undefined) throw new Error('Keycloak 建组成功但没回 Location —— 拿不到新 id,别往下走'); + return groupById(id); + }, + + async renameGroup(groupId, name) { + const current = await api.get(`/groups/${enc(groupId)}`); + await api.put(`/groups/${enc(groupId)}`, { ...current, name }); + return groupById(groupId); + }, + + async deleteGroup(groupId) { + await api.delete(`/groups/${enc(groupId)}`); + }, + + async setUserGroup(sub, groupId, member) { + const path = `/users/${enc(sub)}/groups/${enc(groupId)}`; + if (member) await api.put(path); + else await api.delete(path); + }, + }; +} diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts index 06c6795..4e32b32 100644 --- a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts +++ b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts @@ -24,6 +24,9 @@ const claimsSchema = z.object({ sub: z.string().min(1), preferred_username: z.string().min(1), name: z.string().optional(), + /* 姓与名分开的两个 claim —— 我们按「姓在前」自己拼(`personName`),不吃拼好的 `name` */ + given_name: z.string().optional(), + family_name: z.string().optional(), email: z.string().optional(), realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(), }); @@ -39,6 +42,8 @@ function toVerifiedClaims(claims: z.infer): VerifiedClaims sub: claims.sub, preferred_username: claims.preferred_username, ...(claims.name === undefined ? {} : { name: claims.name }), + ...(claims.given_name === undefined ? {} : { given_name: claims.given_name }), + ...(claims.family_name === undefined ? {} : { family_name: claims.family_name }), ...(claims.email === undefined ? {} : { email: claims.email }), ...(claims.realm_access === undefined ? {} diff --git a/apps/api/src/modules/org/internal/domain/claims.ts b/apps/api/src/modules/org/internal/domain/claims.ts index 0015c65..617e432 100644 --- a/apps/api/src/modules/org/internal/domain/claims.ts +++ b/apps/api/src/modules/org/internal/domain/claims.ts @@ -5,11 +5,51 @@ * Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉: * `default-roles-` / `offline_access` / `uma_authorization`。 */ +import type { StaffKind } from '@contracts'; import type { Identity } from '../../contract.ts'; import type { VerifiedClaims } from '../../port.ts'; const NOISE_ROLES: ReadonlySet = new Set(['offline_access', 'uma_authorization']); +/** + * Keycloak 给服务账号用户的固定命名 —— **它自己定的**,不是我们的约定: + * 开了 `serviceAccountsEnabled` 的 client 一律配一个 `service-account-`。 + * 按名字认而不按角色认,是因为角色是每个 realm 自己配的(这边叫 `service`,别处未必), + * 而这个前缀由 Keycloak 保证。 + */ +const SERVICE_ACCOUNT_PREFIX = 'service-account-'; + +/** + * 姓在前、名在后(2026-08-26 用户定)。 + * + * Keycloak 自己拼的 `name` claim 是 `firstName lastName` —— 西式顺序,中文名读出来是「浩 柴」。 + * 所以**不吃那个 claim**,拿分开的 `family_name` / `given_name` 自己拼; + * 名册同步那条路同理,拿 `lastName` / `firstName`。 + * + * 中间那个空格按**字形**决定,不按语言设置:两边都是汉字就直拼(「柴浩」), + * 否则留一个空格(「Admin Amy」)—— 中文名里塞个空格,读的人会以为那是两个词。 + */ +const CJK = /^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]+$/u; + +export function personName(input: { + readonly familyName?: string; + readonly givenName?: string; + /** 两边都空时回落到这个(通常是用户名)—— 名字这一格永远不许是空的 */ + readonly fallback: string; +}): string { + const family = (input.familyName ?? '').trim(); + const given = (input.givenName ?? '').trim(); + if (family === '' && given === '') return input.fallback; + if (family === '') return given; + if (given === '') return family; + return CJK.test(family) && CJK.test(given) ? `${family}${given}` : `${family} ${given}`; +} + +/** 这个账号是人还是服务账号 —— 员工管理那一屏据此不摆服务账号(它是身份,不是人) */ +export function staffKindOf(account: string): StaffKind { + return account.trim().toLowerCase().startsWith(SERVICE_ACCOUNT_PREFIX) ? 'service' : 'person'; +} + export function isNoiseRole(role: string): boolean { return NOISE_ROLES.has(role) || role.startsWith('default-roles-'); } @@ -21,7 +61,21 @@ export function normalizeRoles(roles: readonly string[]): string[] { export function identityOf(claims: VerifiedClaims): Identity { const account = claims.preferred_username; - const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + /* + * 先按姓在前自己拼;`family_name` / `given_name` 都没有的 realm(没配 profile scope) + * 才退回 Keycloak 拼好的 `name` —— 拆不开的东西没法重排,如实用它。 + */ + const composed = personName({ + ...(claims.family_name === undefined ? {} : { familyName: claims.family_name }), + ...(claims.given_name === undefined ? {} : { givenName: claims.given_name }), + fallback: '', + }); + const name = + composed !== '' + ? composed + : claims.name === undefined || claims.name.trim() === '' + ? account + : claims.name.trim(); const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); return { sub: claims.sub, diff --git a/apps/api/src/modules/org/internal/domain/directory.ts b/apps/api/src/modules/org/internal/domain/directory.ts new file mode 100644 index 0000000..9908a12 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/directory.ts @@ -0,0 +1,121 @@ +/** + * 成员目录的归一(纯函数,零 IO;ADR-012)。 + * + * Keycloak 那一份(身份 · 角色 · 组)与本地那一份(岗位 · 几时来过)在这里合成一行 —— + * **两份事实各有各的主人**:改身份去 Keycloak,改岗位在本地;这段只负责摆在一起给人看。 + * + * 服务账号在这一层就剔掉:它是真身份、跑得了单,但**不是人**(见 `staffKindSchema`), + * 而这三个页签管的是人。剔在 domain 而不是各路由自己记得滤 —— 漏一处就是一处泄漏, + * 而泄漏的后果是有人在界面上把 e2e 账号停用了,试跑第二天全趴下。 + */ +import type { OrgGroup, OrgRole, OrgUser, RoleAccess } from '@contracts'; +import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../../port.ts'; +import { personName, staffKindOf } from './claims.ts'; +import { endsOf } from './ends.ts'; + +/** 本地名册里跟这个人有关的那两列 —— 目录只读它们,不写 */ +export type LocalMark = { readonly lastSeenAt?: Date; readonly positionCode?: string }; + +export function isServiceAccount(username: string): boolean { + return staffKindOf(username) === 'service'; +} + +/** 姓在前、名在后;皆空回落 username —— 名字这一格不会是空的。拼法归 `personName` 一处 */ +export function displayNameOf(user: Pick): string { + return personName({ + ...(user.lastName === undefined ? {} : { familyName: user.lastName }), + ...(user.firstName === undefined ? {} : { givenName: user.firstName }), + fallback: user.username, + }); +} + +export function toOrgUser(user: DirectoryUser, table: readonly RoleAccess[], local?: LocalMark): OrgUser { + return { + sub: user.sub, + username: user.username, + displayName: displayNameOf(user), + enabled: user.enabled, + roles: [...user.roles], + ends: endsOf(user.roles, table), + ...(user.email === undefined ? {} : { email: user.email }), + ...(local?.lastSeenAt === undefined ? {} : { lastSeenAt: local.lastSeenAt.toISOString() }), + ...(local?.positionCode === undefined ? {} : { positionCode: local.positionCode }), + }; +} + +/** + * 排序:来过的在前(倒序),没来过的在后(按用户名升序)。 + * 「没有 `lastSeenAt`」说的是**还没拿这枚身份访问过本系统**,不等于「从未登录 Keycloak」—— + * 名册同步照过来的人天生就是这一档。 + */ +export function sortOrgUsers(users: readonly OrgUser[]): OrgUser[] { + return [...users].sort((a, b) => { + if (a.lastSeenAt !== undefined && b.lastSeenAt !== undefined) { + return a.lastSeenAt === b.lastSeenAt ? a.username.localeCompare(b.username) : b.lastSeenAt.localeCompare(a.lastSeenAt); + } + if (a.lastSeenAt !== undefined) return -1; + if (b.lastSeenAt !== undefined) return 1; + return a.username.localeCompare(b.username); + }); +} + +/** + * 检索:用户名 / 姓名 / 邮箱任一含关键词(不区分大小写)。 + * 在**我们这一侧**过滤而不是转给 Keycloak 的 `search=` —— 那个是前缀式, + * 「按姓名搜中间那两个字」搜不出来,而人会以为这个人不在。realm 不大,全量在手上。 + */ +export function filterOrgUsers(users: readonly OrgUser[], q: string, includeDisabled: boolean): OrgUser[] { + const needle = q.trim().toLowerCase(); + return users.filter((u) => { + if (!includeDisabled && !u.enabled) return false; + if (needle === '') return true; + return [u.username, u.displayName, u.email ?? ''].some((f) => f.toLowerCase().includes(needle)); + }); +} + +/** + * 每个角色配上「能进哪几个端」与「几个人持有」—— 前者本地说了算,后者 Keycloak 说了算。 + * `holders` 由调用方按角色现数后传进来(每个角色一趟),不在这儿从全量用户里筛 —— + * 那样等于逼调用方先把每个人的角色都拉一遍。 + */ +export function toOrgRoles( + roles: readonly DirectoryRole[], + table: readonly RoleAccess[], + holders: readonly number[], +): OrgRole[] { + return roles.map((r, i) => ({ + name: r.name, + composite: r.composite, + ends: endsOf([r.name], table), + holders: holders[i] ?? 0, + ...(r.description === undefined ? {} : { description: r.description }), + })); +} + +/** 组树平表配上直属成员数(已剔服务账号)—— Keycloak 的组表本身不带这个数 */ +export function toOrgGroups(groups: readonly DirectoryGroup[], counts: ReadonlyMap): OrgGroup[] { + return groups.map((g) => ({ + id: g.id, + name: g.name, + path: g.path, + depth: g.depth, + memberCount: counts.get(g.id) ?? 0, + ...(g.parentId === undefined ? {} : { parentId: g.parentId }), + })); +} + +/** 本地名册 → 按 sub 取那两列 */ +export function localMarks( + staff: ReadonlyArray, + positionCodeOf: (positionId: string) => string | undefined, +): Map { + const out = new Map(); + for (const s of staff) { + const code = positionCodeOf(s.positionId); + out.set(s.id, { + ...(s.lastSeenAt === undefined ? {} : { lastSeenAt: s.lastSeenAt }), + ...(code === undefined ? {} : { positionCode: code }), + }); + } + return out; +} diff --git a/apps/api/src/modules/org/internal/domain/roster-diff.ts b/apps/api/src/modules/org/internal/domain/roster-diff.ts index d7d55bc..b4c0841 100644 --- a/apps/api/src/modules/org/internal/domain/roster-diff.ts +++ b/apps/api/src/modules/org/internal/domain/roster-diff.ts @@ -10,7 +10,7 @@ */ import type { Staff, StaffId } from '@contracts'; import type { Identity } from '../../contract.ts'; -import type { IdentityPatch, RosterUser } from '../../port.ts'; +import type { IdentityPatch, RosterUser, StoredStaff } from '../../port.ts'; import { isTestAccount } from './test-account.ts'; export type RosterDiff = { @@ -27,7 +27,7 @@ export function sameRoles(a: readonly string[], b: readonly string[]): boolean { } /** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */ -export function identityChanged(staff: Staff, identity: Identity): boolean { +export function identityChanged(staff: StoredStaff, identity: Identity): boolean { return ( staff.account !== identity.account || staff.name !== identity.name || @@ -51,10 +51,10 @@ export function patchOf(identity: Identity): IdentityPatch { */ export function diffRoster( remote: readonly RosterUser[], - local: readonly Staff[], + local: readonly StoredStaff[], testAccounts: readonly string[], ): RosterDiff { - const bySub = new Map(local.map((s) => [s.id, s])); + const bySub = new Map(local.map((s) => [s.id, s])); const diff: RosterDiff = { insert: [], update: [], setState: [] }; const seen = new Set(); for (const user of remote) { @@ -90,7 +90,7 @@ export function diffRoster( * 一次同步能停很多人;要是把最后一个能进运营中心的人也停了,那张表与同步按钮就都没人点得动了, * 只剩去库里手改 `state`。所以要**在落 setState 之前**先按差异算一遍结果。 */ -export function activeRolesAfter(local: readonly Staff[], diff: RosterDiff): string[][] { +export function activeRolesAfter(local: readonly StoredStaff[], diff: RosterDiff): string[][] { const state = new Map(local.map((s) => [s.id, s.state])); const roles = new Map(local.map((s) => [s.id, s.roles])); for (const u of diff.update) roles.set(u.id, u.patch.roles); diff --git a/apps/api/src/modules/org/internal/usecase/org-service.ts b/apps/api/src/modules/org/internal/usecase/org-service.ts index 248b531..8f13cb0 100644 --- a/apps/api/src/modules/org/internal/usecase/org-service.ts +++ b/apps/api/src/modules/org/internal/usecase/org-service.ts @@ -6,15 +6,30 @@ * `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 —— * 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。 */ -import type { Position, Principal, RoleAccess, Staff } from '@contracts'; +import type { OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, Staff } from '@contracts'; import { positionIdSchema, staffIdSchema } from '@contracts'; -import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts'; -import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts'; +import type { + CreateDirectoryUser, + DirectoryUserPatch, + Identity, + OrgService, + RosterSyncOutcome, + StaffWithPosition, +} from '../../contract.ts'; +import { + AccountDisabledError, + DirectoryUnavailableError, + PositionNotFoundError, + ServiceAccountProtectedError, + StaffNotFoundError, +} from '../../contract.ts'; import type { Clock, + DirectoryUser, IdGenerator, InFlightWorkProbe, PositionRepository, + KeycloakDirectory, RoleAccessRepository, RosterSource, StaffRecord, @@ -22,7 +37,17 @@ import type { TokenVerifier, WorkGroupRepository, } from '../../port.ts'; -import { identityOf } from '../domain/claims.ts'; +import { identityOf, isNoiseRole, staffKindOf } from '../domain/claims.ts'; +import type { LocalMark } from '../domain/directory.ts'; +import { + filterOrgUsers, + isServiceAccount, + localMarks, + sortOrgUsers, + toOrgGroups, + toOrgRoles, + toOrgUser, +} from '../domain/directory.ts'; import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts'; import { buildInFlightWork } from '../domain/in-flight.ts'; import { activeRolesAfter, diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts'; @@ -33,6 +58,8 @@ export type OrgServiceDeps = { readonly verifier: TokenVerifier; /** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */ readonly roster?: RosterSource; + /** 同上;缺省时成员目录整个不可用(抛 DirectoryUnavailableError → 502) */ + readonly directory?: KeycloakDirectory; readonly positions: PositionRepository; readonly staff: StaffRepository; readonly roleAccess: RoleAccessRepository; @@ -47,10 +74,19 @@ export type OrgServiceDeps = { /** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */ const LAST_SEEN_REWRITE_MS = 60_000; +/** + * 成员目录读缓存的寿命。Keycloak 没有「一次拿回全部人的生效角色」的接口(手册 §14.4 逐人查), + * 一屏就是十几趟远程往返 —— 实测 37 秒。缓存之后: + * **本进程写完立刻整体清空** → 写后马上重拉一定看得到新结果,前端不必延时重试; + * 唯一的滞后是**别的**管理员在另一个后端实例上写,这边最多滞后这么久。 + */ +const DIRECTORY_TTL_MS = 60_000; + /** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */ function toStaff(record: StaffRecord): Staff { const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record; - return staff; + // `kind` 由账号名现判,不落库 —— 它是 Keycloak 命名的事实,不是我们存的一列状态 + return { ...staff, kind: staffKindOf(staff.account) }; } export function createOrgService(deps: OrgServiceDeps): OrgService { @@ -147,6 +183,117 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { return toStaff(fresh ?? created); } + /* ── 成员目录的几个共用件 ─────────────────────────────────────────── */ + + /** 只缓存**贵的那两趟**(全量成员 / 组树);详情与成员单查照旧现取 */ + let usersCache: { at: number; value: OrgUser[] } | undefined; + let groupsCache: { at: number; value: OrgGroup[] } | undefined; + + /** + * 写完清缓存 —— **按这次写真正碰到的东西清**,不是一律全清。 + * 全清的代价看得见:删一个组之后,下一次读要把「全量成员的角色」那趟 N+1 重付一遍, + * 屏上那棵树因此多钉着已经没了的组好几秒(实测删 4 秒 + 重取 5.5 秒)。 + * + * 'users' 改的是人本身(资料 / 口令)—— 组的成员数没变 + * 'groups' 改的是组(建 / 改名 / 删)—— 人的角色与端没变 + * 'all' 两边都动了(删人 = 人没了 + 他所在的组少一个人;进出组同理) + */ + function bustDirectoryCache(scope: 'users' | 'groups' | 'all'): void { + if (scope !== 'groups') usersCache = undefined; + if (scope !== 'users') groupsCache = undefined; + } + + function fresh(entry: { at: number; value: T } | undefined): T | undefined { + if (entry === undefined) return undefined; + return deps.clock.now().getTime() - entry.at < DIRECTORY_TTL_MS ? entry.value : undefined; + } + + function needDirectory(): KeycloakDirectory { + if (deps.directory === undefined) { + throw new DirectoryUnavailableError( + '未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 成员与组织的真源是 Keycloak,读写都要经它的 Admin API', + true, + ); + } + return deps.directory; + } + + /** + * 把适配器抛的两类 Keycloak 故障翻成模块自己的错误 —— **重试有没有意义**是分界: + * 配错了(缺角色 / 凭证不对)重试一百次也是同一句;暂时够不着才值得过会儿再来。 + * 别的错(重名、约束不过)原样往上抛,路由译成 4xx 并带原话。 + */ + async function guardKeycloak(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof Error && error.name === 'KeycloakMisconfiguredError') { + throw new DirectoryUnavailableError(error.message, true); + } + if (error instanceof Error && error.name === 'KeycloakUnavailableError') { + throw new DirectoryUnavailableError(error.message, false); + } + throw error; + } + } + + /** 本地名册那两列,按 sub 索引 —— 目录只读它们 */ + async function localMarkMap(): Promise> { + const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]); + const codeOf = new Map(positions.map((p) => [p.id as string, p.code])); + return localMarks(rows, (id) => codeOf.get(id)); + } + + /** 目录全量(已剔服务账号、已配好端与本地两列、已排序)—— 检索在这之后做 */ + async function allDirectoryUsers(): Promise { + const dir = needDirectory(); + const hit = fresh(usersCache); + if (hit !== undefined) return hit; + const [raw, table, marks] = await Promise.all([ + guardKeycloak(() => dir.listUsers()), + deps.roleAccess.list(), + localMarkMap(), + ]); + const people = raw.filter((u) => !isServiceAccount(u.username)); + const value = sortOrgUsers(people.map((u) => toOrgUser(u, table, marks.get(u.sub)))); + usersCache = { at: deps.clock.now().getTime(), value }; + return value; + } + + /** + * 一次穿透写 —— 跑完**一律清缓存**。 + * 包起来而不是各写面自己记得调:漏一处就是一处「写完看不到」, + * 而那种漏不报错,只是画面比事实旧(与治理台写路由统一广播同一个道理)。 + */ + async function writeThrough(scope: 'users' | 'groups' | 'all', run: () => Promise): Promise { + try { + return await guardKeycloak(run); + } finally { + bustDirectoryCache(scope); + } + } + + /** 一次写之后回那一行的当下样子 —— 界面照它重画,不必自己拼 */ + async function oneUser(sub: string): Promise { + const dir = needDirectory(); + const [raw, table, marks] = await Promise.all([ + guardKeycloak(() => dir.getUser(sub)), + deps.roleAccess.list(), + localMarkMap(), + ]); + return toOrgUser(raw, table, marks.get(sub)); + } + + function assertPerson(user: Pick): void { + if (isServiceAccount(user.username)) throw new ServiceAccountProtectedError(user.username); + } + + /** 写面开工之前先问一句「这个 sub 是人吗」—— 服务账号一律不许动 */ + async function assertPersonBySub(sub: string): Promise { + const dir = needDirectory(); + assertPerson(await guardKeycloak(() => dir.getUser(sub))); + } + const service: OrgService = { async authenticate(token) { const claims = await deps.verifier.verify(token); @@ -308,6 +455,169 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface)); }, + /* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ──────────────── */ + + async directoryUsers(query) { + const users = await allDirectoryUsers(); + const q = query.q ?? ''; + return { users: filterOrgUsers(users, q, query.includeDisabled === true) }; + }, + + async directoryUser(sub): Promise { + const dir = needDirectory(); + const raw = await guardKeycloak(() => dir.getUser(sub)); + assertPerson(raw); + const [table, marks] = await Promise.all([deps.roleAccess.list(), localMarkMap()]); + return { + ...toOrgUser(raw, table, marks.get(raw.sub)), + emailVerified: raw.emailVerified, + groups: raw.groups.map((g) => ({ ...g })), + ...(raw.firstName === undefined ? {} : { firstName: raw.firstName }), + ...(raw.lastName === undefined ? {} : { lastName: raw.lastName }), + }; + }, + + async createDirectoryUser(input: CreateDirectoryUser) { + const dir = needDirectory(); + if (isServiceAccount(input.username)) throw new ServiceAccountProtectedError(input.username); + const sub = await writeThrough('all', () => dir.createUser(input)); + return oneUser(sub); + }, + + async patchDirectoryUser(sub, patch: DirectoryUserPatch) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('users', () => dir.updateUser(sub, patch)); + return oneUser(sub); + }, + + async setDirectoryUserEnabled(sub, enabled) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('users', () => dir.setEnabled(sub, enabled)); + /* + * 停用之后**踢会话** —— 界面上已经写了「已停用」,而他手上那枚 token 到期前照样通行。 + * 这是「界面不撒谎」在鉴权上的样子:说停了就得真的进不来。 + */ + if (!enabled) await writeThrough('users', () => dir.logout(sub)); + // 本地那一行跟手:两屏说的是同一件事(名册同步也会做,但那要等人去按) + const local = await deps.staff.findById(staffIdSchema.parse(sub)); + if (local !== undefined) await deps.staff.setState(local.id, enabled ? 'active' : 'disabled', deps.clock.now()); + return oneUser(sub); + }, + + async resetDirectoryUserPassword(sub, password) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('users', () => dir.resetPassword(sub, password)); + }, + + async deleteDirectoryUser(sub) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('all', () => dir.deleteUser(sub)); + /* + * 本地那一行**停用,不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。 + * 删了那些地方就成了悬空的 id,而历史是只增不改的。 + */ + const local = await deps.staff.findById(staffIdSchema.parse(sub)); + if (local !== undefined) await deps.staff.setState(local.id, 'disabled', deps.clock.now()); + }, + + async directoryRoles(): Promise<{ roles: OrgRole[] }> { + const dir = needDirectory(); + const [roles, table, users] = await Promise.all([ + guardKeycloak(() => dir.listRoles()), + deps.roleAccess.list(), + allDirectoryUsers(), + ]); + // 家具角色不摆:它们是 Keycloak 给每个用户自动挂的,授收都没有意义(见 isNoiseRole) + const real = roles.filter((r) => !isNoiseRole(r.name)); + /* + * 持有数从**生效角色**里数(每人的 `/composite`),不用 `GET /roles/{name}/users` —— + * 那个只回**直接授予**的:`user` 是经 `default-roles-corp` 继承来的, + * 那条路会报「持有 0 人」而实际人人都有。快了却是错的,不要。 + * 速度靠 `allDirectoryUsers` 的 60 秒缓存拿。 + */ + const counts = real.map((r) => users.filter((u) => u.roles.includes(r.name)).length); + return { roles: toOrgRoles(real, table, counts) }; + }, + + async setDirectoryUserRole(sub, role, granted) { + const dir = needDirectory(); + await assertPersonBySub(sub); + /* + * 收权之前先算一遍「收完还有谁进得了运营中心」—— 与改「角色→端」那条同一道闸, + * 理由也同一个:这两条路都能把最后一个管理员关在门外,而**恢复动作本身要 ops**。 + */ + if (!granted) { + const [table, users] = await Promise.all([deps.roleAccess.list(), allDirectoryUsers()]); + const after = users + .filter((u) => u.enabled) + .map((u) => (u.sub === sub ? u.roles.filter((r) => r !== role) : u.roles)); + assertNotLockedOut( + table, + after, + `收掉「${role}」之后就没有人能进运营中心了 —— 这扇门会锁死(恢复动作本身要运营中心),拒绝`, + ); + } + await writeThrough('users', () => dir.setRole(sub, role, granted)); + // 收权立刻踢会话:否则他手上那枚 token 还带着旧角色,收权要等到过期才生效 + if (!granted) await writeThrough('users', () => dir.logout(sub)); + return oneUser(sub); + }, + + async directoryGroups(): Promise<{ groups: OrgGroup[] }> { + const dir = needDirectory(); + const hit = fresh(groupsCache); + if (hit !== undefined) return { groups: hit }; + const groups = await guardKeycloak(() => dir.listGroups()); + // 数数就只数数 —— 别为了一个数字把每个成员的角色都拉一遍(N+1 的第二处) + const pairs = await Promise.all( + groups.map(async (g) => { + const names = await guardKeycloak(() => dir.groupMemberUsernames(g.id)); + return [g.id, names.filter((n) => !isServiceAccount(n)).length] as const; + }), + ); + const value = toOrgGroups(groups, new Map(pairs)); + groupsCache = { at: deps.clock.now().getTime(), value }; + return { groups: value }; + }, + + async directoryGroupMembers(groupId) { + const dir = needDirectory(); + const [raw, table, marks] = await Promise.all([ + guardKeycloak(() => dir.groupMembers(groupId)), + deps.roleAccess.list(), + localMarkMap(), + ]); + const members = raw.filter((u) => !isServiceAccount(u.username)).map((u) => toOrgUser(u, table, marks.get(u.sub))); + return { members: sortOrgUsers(members) }; + }, + + async createDirectoryGroup(name, parentId) { + const dir = needDirectory(); + const g = await writeThrough('groups', () => dir.createGroup(name, parentId)); + return toOrgGroups([g], new Map())[0]!; + }, + + async renameDirectoryGroup(groupId, name) { + const dir = needDirectory(); + const g = await writeThrough('groups', () => dir.renameGroup(groupId, name)); + return toOrgGroups([g], new Map())[0]!; + }, + + async deleteDirectoryGroup(groupId) { + const dir = needDirectory(); + await writeThrough('groups', () => dir.deleteGroup(groupId)); + }, + + async setDirectoryUserGroup(sub, groupId, member) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('groups', () => dir.setUserGroup(sub, groupId, member)); + }, + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */ async saveWorkGroup(input) { diff --git a/apps/api/src/modules/org/port.ts b/apps/api/src/modules/org/port.ts index b438108..8350e4d 100644 --- a/apps/api/src/modules/org/port.ts +++ b/apps/api/src/modules/org/port.ts @@ -3,7 +3,7 @@ * adapter 实现之,usecase 依赖之,app 装配之。 */ import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; -import type { Identity } from './contract.ts'; +import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts'; export type Clock = { now(): Date }; @@ -13,7 +13,12 @@ export type Clock = { now(): Date }; export type VerifiedClaims = { readonly sub: string; readonly preferred_username: string; + /** Keycloak 拼好的 `firstName lastName` —— 西式顺序,只在拆不开时才用它 */ readonly name?: string; + /** 名(firstName) */ + readonly given_name?: string; + /** 姓(lastName)—— 我们按「姓在前」自己拼,见 `personName` */ + readonly family_name?: string; readonly email?: string; readonly realm_access?: { readonly roles?: readonly string[] }; }; @@ -25,6 +30,66 @@ export type TokenVerifier = { verify(token: string): Promise }; export type RosterUser = Identity & { readonly enabled: boolean }; export type RosterSource = { listUsers(): Promise }; +/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */ + +/** + * Keycloak 那一侧的一个用户 —— **原样的形状**,不掺本地字段。 + * 与 `RosterUser` 的区别:名册只要「同步要比对的那几列」,目录要摆给人看的全部。 + */ +export type DirectoryUser = { + readonly sub: string; + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + readonly enabled: boolean; + readonly emailVerified: boolean; + /** realm 角色(家具角色已滤) */ + readonly roles: readonly string[]; +}; + +/** 组树里的一行 —— 平表,层级看 `parentId` / `depth`(契约同款,理由见 org-directory.ts) */ +export type DirectoryGroup = { + readonly id: string; + readonly name: string; + readonly path: string; + readonly parentId?: string; + readonly depth: number; + readonly memberCount: number; +}; + +export type DirectoryRole = { + readonly name: string; + readonly description?: string; + readonly composite: boolean; +}; + +/** + * 成员目录 —— app 未配 provisioning 服务账号时不注入(与 `RosterSource` 同款)。 + * 读是投影,写**直接穿透**到 Keycloak:这里建的人立刻能登录,这里删的人从 Keycloak 消失。 + */ +export type KeycloakDirectory = { + listUsers(): Promise; + getUser(sub: string): Promise }>; + createUser(input: CreateDirectoryUser): Promise; + updateUser(sub: string, patch: DirectoryUserPatch): Promise; + setEnabled(sub: string, enabled: boolean): Promise; + resetPassword(sub: string, password: string): Promise; + deleteUser(sub: string): Promise; + /** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */ + logout(sub: string): Promise; + listRoles(): Promise; + setRole(sub: string, role: string, granted: boolean): Promise; + listGroups(): Promise; + groupMembers(groupId: string): Promise; + /** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */ + groupMemberUsernames(groupId: string): Promise; + createGroup(name: string, parentId?: string): Promise; + renameGroup(groupId: string, name: string): Promise; + deleteGroup(groupId: string): Promise; + setUserGroup(sub: string, groupId: string, member: boolean): Promise; +}; + /** 名册即时更新 / 同步要改的那几列 */ export type IdentityPatch = { readonly account: string; @@ -44,7 +109,13 @@ export type PositionRepository = { }; /** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ -export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; +/** + * 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`), + * 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。 + */ +export type StoredStaff = Omit; + +export type StaffRecord = StoredStaff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; export type StaffRepository = { list(): Promise; @@ -52,7 +123,7 @@ export type StaffRepository = { /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ findByAccount(account: string): Promise; listByPosition(positionId: PositionId): Promise; - insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; + insert(staff: StoredStaff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; updateIdentity(id: StaffId, patch: IdentityPatch): Promise; setPosition(id: StaffId, positionId: PositionId): Promise; setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index d5c2a54..e530baa 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ 'reset-runs': 'src/app/reset-runs.ts', // 只装活动包(不碰治理台配置)—— 开发活动时一天要装十几次 'install-activities': 'src/app/install-activities.ts', + // 名册同步:整册从 Keycloak 照过来(ops 路由够不着时走这条) + 'sync-roster': 'src/app/sync-roster.ts', }, format: ['esm'], target: 'node22', diff --git a/apps/web/src/app/gov-shell.tsx b/apps/web/src/app/gov-shell.tsx index 1c3faad..893fbc2 100644 --- a/apps/web/src/app/gov-shell.tsx +++ b/apps/web/src/app/gov-shell.tsx @@ -135,6 +135,7 @@ export function GovShell({ surface }: { surface: Surface }) { const domains = sortDomains([...new Set((objects.data?.objects ?? []).map((o) => o.domain))]); // 进台账没带域(左轨点进来)时落到首域 —— 页签与内容同一判定 const effectiveTab = zone === 'ledgers' && tab === '' ? (domains[0] ?? '') : tab; + const tabHint = TABS[zone].find((t) => t.key === tab)?.hint; /* 面包屑:台面 › 区 › 页签 › 下钻各级 */ const crumbs: Array<{ label: string; go?: () => void }> = [ @@ -249,7 +250,8 @@ export function GovShell({ surface }: { surface: Surface }) { * 摆一个只有一颗按钮的导航条,是在给人做无谓的选择。 * 台账例外 —— 它的页签是随发布生长的数据域,数量本来就不定。 */} -
+
+
{zone === 'ledgers' ? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上 domains.map((d) => ( @@ -273,6 +275,9 @@ export function GovShell({ surface }: { surface: Surface }) { {t.label} ))} +
+ {/* 右端一句话:这一格管什么、写下去会发生什么 —— 界面自己说清楚,不靠人记 */} + {tabHint !== undefined && {tabHint}}
diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 61af5bd..c4ddf53 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -84,6 +84,10 @@ .g-head-t p { margin: 5px 0 0; font-size: 12.5px; line-height: 1.7; color: var(--ink-3); max-width: 108ch; } /* 区内页签:一个页签 = 一个维度 */ +/* 页签栏一行两件:左边那颗药丸是页签,右端一句话说清这一格管什么(可缺席) */ +.g-tabsrow { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.g-tabsrow.hidden { display: none; } +.g-tabs-hint { font-size: 12px; color: var(--ink-4); text-align: right; } .g-tabs.hidden { display: none; } .g-tabs { display: flex; flex-wrap: wrap; gap: 3px; padding: 4px; border-radius: 12px; background: #e3e8ef; align-self: flex-start; } .g-tab { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 14px; border: 0; border-radius: 9px; background: transparent; color: var(--ink-3); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); } @@ -742,6 +746,19 @@ /* 供应商:一行小签 —— 挑一个即预填它的默认端点。 从前是 146px 宽的大牌子,一个人吃掉 118px 高,把下面的输入框全顶出屏幕。 */ .g-chiprow { display: flex; flex-wrap: wrap; gap: 6px; } +/* 组织那一屏:左树右详情;窄屏叠成一列(树在上,详情在下) */ +.g-split { display: grid; grid-template-columns: minmax(240px, 340px) 1fr; gap: 14px; align-items: start; } +@media (max-width: 900px) { .g-split { grid-template-columns: 1fr; } } +.g-tree { display: flex; flex-direction: column; gap: 2px; } +.g-tree-r { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ink-2); font: inherit; font-size: 12.5px; text-align: left; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); } +.g-tree-r:hover { background: #f1f4f8; color: var(--ink); } +.g-tree-r.on { background: var(--brand-soft, #e8f0ff); color: var(--ink); font-weight: 700; } +.g-tree-r b { flex: 1; font-weight: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-tree-r em { font-style: normal; font-size: 11px; color: var(--ink-4); } +/* 一枚勾选 + 一句话 —— `.g-swrow` 会把行内**所有** input 压成 14px, + 而这一行里还站着检索框,所以单起一个,只管勾选框自己 */ +.g-tick { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; white-space: nowrap; } +.g-tick input { width: 14px; height: 14px; accent-color: var(--brand); cursor: pointer; } .g-chip { padding: 6px 11px; border: 1px solid var(--cline); border-radius: 15px; background: #fff; font: 500 12px/1.2 var(--sans); color: var(--ink-3); cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast), color var(--t-fast); } .g-chip:hover { border-color: #aebdcd; color: var(--ink); } .g-chip.on { border-color: var(--brand); background: var(--brand-wash); color: var(--brand-deep); font-weight: 600; } diff --git a/apps/web/src/features/gov-staff/internal/member-page.tsx b/apps/web/src/features/gov-staff/internal/member-page.tsx new file mode 100644 index 0000000..7f26b98 --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/member-page.tsx @@ -0,0 +1,255 @@ +/** + * 成员详情 —— 一个人的全部:身份、能进哪几个端、角色授收、所属组、启停与删除。 + * + * 这一屏的每个动作都**穿透到 Keycloak**,没有撤销;所以一律走二次确认带。 + * 两处顺手做的事要说出来,不然人不知道: + * 停用与收权都**当场踢掉他的全部会话** —— 否则他手上那枚 token 到期前照样通行, + * 而屏上已经写了「已停用」。界面不撒谎。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + deleteOrgUserRoute, + patchOrgUserRoute, + resetOrgUserPasswordRoute, + setOrgUserEnabledRoute, + setOrgUserRoleRoute, +} from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, Field, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; +import type { Nav } from '@web-shared/gov/gov-route.ts'; +import { ORG_KEYS, useOrgRoles, useOrgUser } from '@web-shared/gov/gov-api.ts'; +import { EndChips } from './members.tsx'; + +export function MemberPage({ id, nav }: { id: string; nav: Nav }) { + const qc = useQueryClient(); + const q = useOrgUser(id); + const roles = useOrgRoles(); + const u = q.data; + const [err, setErr] = useState(undefined); + const [pwd, setPwd] = useState(''); + const [editing, setEditing] = useState<{ firstName: string; lastName: string; email: string } | undefined>(undefined); + + /** 改一个人:他自己 + 名单 + 角色持有数;组的成员数没变(删人那一路另说) */ + const bust = (): void => { + void qc.invalidateQueries({ queryKey: ORG_KEYS.user }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.users }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.roles }); + }; + const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e)); + + const setEnabled = useMutation({ + mutationFn: (enabled: boolean) => call(setOrgUserEnabledRoute, { params: { sub: id }, body: { enabled } }), + onSuccess: () => { setErr(undefined); bust(); }, + onError: fail, + }); + const setRole = useMutation({ + mutationFn: (v: { role: string; granted: boolean }) => + call(setOrgUserRoleRoute, { params: { sub: id, role: v.role }, body: { granted: v.granted } }), + onSuccess: () => { setErr(undefined); bust(); }, + onError: fail, + }); + const patch = useMutation({ + mutationFn: (v: { firstName: string; lastName: string; email: string }) => + call(patchOrgUserRoute, { params: { sub: id }, body: v }), + onSuccess: () => { setEditing(undefined); setErr(undefined); bust(); }, + onError: fail, + }); + const reset = useMutation({ + mutationFn: () => call(resetOrgUserPasswordRoute, { params: { sub: id }, body: { password: pwd } }), + onSuccess: () => { setPwd(''); setErr(undefined); }, + onError: fail, + }); + const drop = useMutation({ + mutationFn: () => call(deleteOrgUserRoute, { params: { sub: id } }), + // 删人是唯一会动到组的那一路:他从他所在的每个组里没了 + onSuccess: () => { + bust(); + void qc.invalidateQueries({ queryKey: ORG_KEYS.groups }); + nav.jump('people', 'members'); + }, + onError: fail, + }); + + return ( + + {u === undefined ? ( + {`Keycloak 里没有这个人(${id})—— 可能刚被删了。`} + ) : ( + <> + + {u.username} + + {u.enabled ? '在职' : '已停用'} + + + } + desc="身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。" + /> + + {err !== undefined && {err}} + + + + {editing === undefined ? ( + + setEditing({ firstName: u.firstName ?? '', lastName: u.lastName ?? '', email: u.email ?? '' }), + }, + ]} + /> + ) : ( +
+
改资料(用户名建后不可改)
+
+ + setEditing({ ...editing, lastName: e.target.value })} /> + + + setEditing({ ...editing, firstName: e.target.value })} /> + + + setEditing({ ...editing, email: e.target.value })} /> + + setEditing(undefined) }, + { label: '保存', ico: 'check', primary: true, busy: patch.isPending, onClick: () => patch.mutate(editing) }, + ]} + /> +
+
+ )} +
+ + 由下面的角色 × 「权限」页签的配置算出来}> +
+ +
+
+ + 授收都穿透 Keycloak;收权当场踢掉他的会话}> + +
+ {(roles.data?.roles ?? []).map((r) => { + const has = u.roles.includes(r.name); + return ( + setRole.mutate({ role: r.name, granted: !has })} + title={ + has + ? `收掉「${r.name}」—— 他手上的会话会被当场踢掉` + : `授予「${r.name}」(可进:${r.ends.length === 0 ? '无端' : r.ends.join(' / ')})` + } + > + {(has ? '✓ ' : '+ ') + r.name} + + ); + })} +
+
+
+ + + {u.groups.length === 0 ? ( + 不属于任何组 —— 进出组在「组织」页签里做。 + ) : ( +
+ {u.groups.map((g) => ( + + {g.path} + + ))} +
+ )} +
+ + 一律临时口令 —— 本人首登必须改}> +
+ setPwd(e.target.value)} + /> + reset.mutate()}> + 重置口令 + +
+ 服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。 +
+ + + setEnabled.mutate(!u.enabled), + }, + }, + { + label: '删除', + ico: 'trash-2', + danger: true, + busy: drop.isPending, + ask: { + title: `把「${u.displayName}」从 Keycloak 删掉`, + note: '本地名册那一行会停用但不删 —— 留痕、在途任务、台账里都还指着这个 sub,删了那些地方就成了悬空的 id。', + impact: { + who: u.displayName, + undo: '不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub', + when: '按下即生效', + }, + okLabel: '确认删除', + onOk: () => drop.mutate(), + }, + }, + ]} + /> + + + )} +
+ ); +} diff --git a/apps/web/src/features/gov-staff/internal/members.tsx b/apps/web/src/features/gov-staff/internal/members.tsx new file mode 100644 index 0000000..0f8c372 --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/members.tsx @@ -0,0 +1,188 @@ +/** + * 成员 —— Keycloak 目录的投影(ADR-012)。 + * + * **这里的写是穿透的**:建的人立刻能登录、停的人立刻登不上,没有本地草稿,也没有撤销。 + * 所以每个写动作都走 `Acts` 的二次确认带(三必答:谁受影响 / 能不能撤 / 几时生效)。 + * + * 检索与「含停用」都交给服务端 —— 那边握着全量,前端只拿要摆的那些。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createOrgUserRoute, SURFACE_LABEL } from '@contracts'; +import type { Surface } from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, Field, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; +import type { Nav } from '@web-shared/gov/gov-route.ts'; +import { ORG_KEYS, useOrgUsers } from '@web-shared/gov/gov-api.ts'; + +/** 能进的端 —— 摆人读名;一个都没有就直说,别留一片空白让人以为没加载出来 */ +export function EndChips({ ends }: { ends: readonly Surface[] }) { + if (ends.length === 0) return 无端可进; + return ( + <> + {ends.map((e) => ( + + {SURFACE_LABEL[e]} + + ))} + + ); +} + +export function MembersTab({ nav }: { nav: Nav }) { + const [q, setQ] = useState(''); + const [includeDisabled, setIncludeDisabled] = useState(true); + const [creating, setCreating] = useState(false); + const users = useOrgUsers(q, includeDisabled); + const rows = users.data?.users ?? []; + const active = rows.filter((u) => u.enabled).length; + /* + * 数还没取回来就别摆数 —— `?? []` 会让计数在载入那几秒显示「0 人 · 0 在职」, + * 而那是**一句假话**:不是没有人,是还不知道有几个人(铁规矩 8)。 + */ + const counted = users.data !== undefined; + + return ( + <> + + setQ(e.target.value)} + /> + + {counted ? `${rows.length} 人 · ${active} 在职` : '数着…'} + setCreating((v) => !v)}> + {creating ? '收起' : '新建成员'} + + + } + > + + 真源是 Keycloak · 停用的人立刻登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 —— + 它是身份不是人。 + + {creating && setCreating(false)} />} + + {rows.length === 0 ? ( + {q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`} + ) : ( + + {rows.map((u) => ( + }, + ]} + right={ + + {u.enabled ? '在职' : '已停用'} + + } + onClick={() => nav.push({ k: 'emp', id: u.sub })} + /> + ))} + + )} + + + + ); +} + +/** + * 新建成员 —— 口令一律**临时**:本人首登必须改。 + * 我们不存它、不回显 —— 它经过这个表单就再也不出现了,所以建完那一句话得把它说清楚。 + */ +function CreateMemberForm({ onDone }: { onDone: () => void }) { + const qc = useQueryClient(); + const [username, setUsername] = useState(''); + const [lastName, setLastName] = useState(''); + const [firstName, setFirstName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [err, setErr] = useState(undefined); + + const create = useMutation({ + mutationFn: () => + call(createOrgUserRoute, { + body: { + username: username.trim(), + ...(lastName.trim() === '' ? {} : { lastName: lastName.trim() }), + ...(firstName.trim() === '' ? {} : { firstName: firstName.trim() }), + ...(email.trim() === '' ? {} : { email: email.trim() }), + ...(password === '' ? {} : { password }), + }, + }), + onSuccess: () => { + // 多了一个人 → 名单变了,角色的持有数也跟着变;组没动 + void qc.invalidateQueries({ queryKey: ORG_KEYS.users }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.roles }); + onDone(); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + + return ( +
+
新建成员
+
+ + setUsername(e.target.value)} placeholder="如 zhangsan" /> + + + setLastName(e.target.value)} /> + + + setFirstName(e.target.value)} /> + + + setEmail(e.target.value)} /> + + + setPassword(e.target.value)} /> + + {err !== undefined && {err}} + create.mutate(), + }, + }, + ]} + /> +
+
+ ); +} diff --git a/apps/web/src/features/gov-staff/internal/orgs.tsx b/apps/web/src/features/gov-staff/internal/orgs.tsx new file mode 100644 index 0000000..16262ff --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/orgs.tsx @@ -0,0 +1,276 @@ +/** + * 组织 —— Keycloak 的组(ADR-012)。左边一棵树,右边选中那个组的成员与操作。 + * + * 树是**平表**渲染的:一行一个组,靠 `depth` 缩进(契约里就不放递归结构 —— + * 递归 schema 到了声明文件那一层只会变成一坨 any)。按 `path` 升序,父在子前。 + * + * 删组连子组一起没(Keycloak 语义),人不会被删 —— 只是不再属于这个组。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + createOrgGroupRoute, + deleteOrgGroupRoute, + renameOrgGroupRoute, + setOrgUserGroupRoute, +} from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; +import { ORG_KEYS, useOrgGroupMembers, useOrgGroups, useOrgUsers } from '@web-shared/gov/gov-api.ts'; +import type { GovOrgGroups } from '@web-shared/gov/gov-api.ts'; + +export function OrgsTab() { + const groups = useOrgGroups(); + const rows = groups.data?.groups ?? []; + const [picked, setPicked] = useState(undefined); + const current = rows.find((g) => g.id === picked); + const tops = rows.filter((g) => g.depth === 0).length; + /* 取回来之前一个数都别摆、一句「还没有组」也别说 —— 那时候我们并不知道有没有组 */ + const loaded = groups.data !== undefined; + + return ( +
+ {loaded ? `${tops} 顶层组` : '数着…'}}> + Keycloak 组 · 点一行看它的成员与操作 + + {rows.length === 0 ? ( + 还没有组 —— 先在下面「建顶层组」。 + ) : ( +
+ {rows.map((g) => ( + + ))} +
+ )} +
+ +
+ + {current === undefined ? ( + + + {!loaded + ? '组织树还在取…' + : rows.length === 0 + ? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。' + : '在左侧点一个组,这里看它的成员与操作。'} + + + ) : ( + setPicked(undefined)} + /> + )} +
+ ); +} + +/** 建组 —— `parentId` 给了就是子组,不给就是顶层组 */ +function GroupCreate({ label, hint, parentId }: { label: string; hint: string; parentId?: string }) { + const qc = useQueryClient(); + const [name, setName] = useState(''); + const [err, setErr] = useState(undefined); + const create = useMutation({ + mutationFn: () => + call(createOrgGroupRoute, { + body: { name: name.trim(), ...(parentId === undefined ? {} : { parentId }) }, + }), + onSuccess: () => { + setName(''); + setErr(undefined); + // 只有组变了 —— 成员那一趟很贵,别拖它下水 + void qc.invalidateQueries({ queryKey: ORG_KEYS.groups }); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + return ( +
+
{label}
+
+
+ setName(e.target.value)} /> + create.mutate()}> + 建组 + +
+ {err !== undefined && {err}} +
+
+ ); +} + +function GroupDetail({ + group, + onGone, +}: { + group: { id: string; name: string; path: string; depth: number; memberCount: number }; + onGone: () => void; +}) { + const qc = useQueryClient(); + const members = useOrgGroupMembers(group.id); + const all = useOrgUsers('', true); + const [renaming, setRenaming] = useState(undefined); + const [err, setErr] = useState(undefined); + /** 组这一屏的写只动组与组成员;人的角色与端一个字没变 */ + const bust = (): void => { + void qc.invalidateQueries({ queryKey: ORG_KEYS.groups }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.groupMembers }); + }; + const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e)); + + const rename = useMutation({ + mutationFn: (name: string) => call(renameOrgGroupRoute, { params: { id: group.id }, body: { name } }), + onSuccess: () => { setRenaming(undefined); setErr(undefined); bust(); }, + onError: fail, + }); + const drop = useMutation({ + mutationFn: () => call(deleteOrgGroupRoute, { params: { id: group.id } }), + onSuccess: () => { + /* + * 删成功了 —— **「这个组没了」是已知事实**,不必等服务端再说一遍。 + * 那一趟重取要好几秒(组树得逐组数成员),期间树上还钉着已经没了的组; + * 撞上 token 到期强刷的那一次更久,实测有一轮干脆没刷过来。 + * 手上有的答案先落进缓存,重取照发 —— 回来时一比,一样。 + * 子组跟着一起没(Keycloak 语义),按**路径前缀**连它们一起摘,不是只摘直属那一层。 + */ + qc.setQueryData(ORG_KEYS.groups, (old) => + old === undefined + ? old + : { groups: old.groups.filter((g) => g.id !== group.id && !g.path.startsWith(`${group.path}/`)) }, + ); + onGone(); + bust(); + }, + onError: fail, + }); + const setMember = useMutation({ + mutationFn: (v: { sub: string; member: boolean }) => + call(setOrgUserGroupRoute, { params: { sub: v.sub, groupId: group.id }, body: { member: v.member } }), + onSuccess: () => { setErr(undefined); bust(); }, + onError: fail, + }); + + const inGroup = members.data?.members ?? []; + const inIds = new Set(inGroup.map((m) => m.sub)); + const outside = (all.data?.users ?? []).filter((u) => !inIds.has(u.sub)); + + return ( + {`${inGroup.length} 人`}} + > + {`路径 ${group.path}`} + + {renaming !== undefined && ( +
+
改组名
+
+
+ setRenaming(e.target.value)} /> + setRenaming(undefined)}>取消 + rename.mutate(renaming.trim())} + > + 改名 + +
+
+
+ )} + + {err !== undefined && {err}} + + + {inGroup.length === 0 ? ( + 这个组还没有人 —— 在下面「把人加进来」。 + ) : ( + + {inGroup.map((m) => ( + setMember.mutate({ sub: m.sub, member: false })} + > + 移出 + + } + onClick={() => undefined} + /> + ))} + + )} + + +
+
把人加进来
+
+ {outside.length === 0 ? ( + 所有人都已经在这个组里了。 + ) : ( +
+ {outside.map((u) => ( + setMember.mutate({ sub: u.sub, member: true })} + > + {`+ ${u.displayName}`} + + ))} +
+ )} +
+
+ + + + setRenaming(group.name) }, + { + label: '删掉这个组', + ico: 'trash-2', + danger: true, + busy: drop.isPending, + ask: { + title: `删掉「${group.name}」`, + note: '子组会跟着一起没。人不会被删 —— 只是不再属于这个组。', + impact: { + who: `这个组里的 ${inGroup.length} 个人,以及它下面的所有子组`, + undo: '不能撤销 —— 组和它的层级关系直接从 Keycloak 消失', + when: '按下即生效', + }, + okLabel: '确认删组', + onOk: () => drop.mutate(), + }, + }, + ]} + /> +
+ ); +} diff --git a/apps/web/src/features/gov-staff/internal/panel.tsx b/apps/web/src/features/gov-staff/internal/panel.tsx index 4be5325..f4a2ccb 100644 --- a/apps/web/src/features/gov-staff/internal/panel.tsx +++ b/apps/web/src/features/gov-staff/internal/panel.tsx @@ -1,367 +1,31 @@ /** - * feature `gov-staff` —— 员工管理:账号、岗位授权、停用前置检查 + * feature `gov-staff` —— 成员与组织(ADR-012,2026-08-26 用户按目标件定的三页签)。 * - * 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移: - * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 - * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 + * 三个页签一一对着 Keycloak 的三样东西: + * **成员** = users(目录投影;新建 / 启停 / 删除都穿透过去) + * **权限** = realm roles × 本系统的「角色 → 端」表(每个请求都按它判) + * **组织** = groups(建组 / 改名 / 删组 / 成员进出组) + * + * 原「岗位授权」与「工作组」两个页签已拿掉(用户定)。**后端数据与接口原样留着** —— + * 岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流;拿掉的只是入口。 */ -import { useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { GOVERNANCE_FUNCTION_LABEL, GRANT_ACTION_LABEL, saveWorkGroupRoute, staffIdSchema } from '@contracts'; -import type { StaffId } from '@contracts'; -import { call } from '@web-shared/typed-fetch.ts'; -import { - Acts, - Card, - Chip, - Clear, - Entry, - Field, - NList, - NRow, - Note, - PageHead, - QueryGate, - Rows, - St, -} from '@web-shared/gov/primitives.tsx'; +import { MembersTab } from './members.tsx'; +import { MemberPage } from './member-page.tsx'; +import { OrgsTab } from './orgs.tsx'; +import { PermsTab, RolePage } from './perms.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; -import { useGovGrants, useGovStaff, useGovTasks, useGovWorkGroups } from '@web-shared/gov/gov-api.ts'; export type HomeProps = { tab: string; nav: Nav }; export function PeopleHome({ tab, nav }: HomeProps) { - if (tab === 'groups') return ; - return ; + if (tab === 'perms') return ; + if (tab === 'orgs') return ; + return ; } -function StaffHome({ tab, nav }: HomeProps) { - const q = useGovStaff(); - const tasks = useGovTasks(); - const staff = q.data?.staff ?? []; - const positions = q.data?.positions ?? []; - - if (tab === 'roles') { - return ( - <> - - 权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。 - 人换岗即换权,权限随岗位走。 - - - {positions.map((p) => ( - s.position.id === p.id).length }]} - onClick={() => nav.push({ k: 'role', id: p.id })} - /> - ))} - - - ); - } - - return ( - - <> - - {staff.map((s) => { - const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined); - return ( - - {s.state === 'active' ? '在职' : '已停用'} - - } - onClick={() => nav.push({ k: 'emp', id: s.id })} - /> - ); - })} - - - - ); -} - -/* ---------------- ③ 工作流管理 ---------------- */ - +/** 详情页 `emp` —— 路由名沿用(URL 里已有的书签不该失效),内容是成员详情 */ export function EmpPage({ id, nav }: { id: string; nav: Nav }) { - const q = useGovStaff(); - const tasks = useGovTasks(); - const s = (q.data?.staff ?? []).find((x) => x.id === id); - if (s === undefined) return 员工不存在。; - const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name); - const live = mine.filter((t) => t.terminal === undefined); - return ( - <> - - - {s.position.name} · {s.position.code} - - {s.state === 'active' ? '在职' : '已停用'} - - } - desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。" - /> - {live.length}}> - - {live.map((t) => ( - nav.push({ k: 'task', id: t.id })} /> - ))} - {live.length === 0 && 无在途任务——可安全停用。} - - - nav.push({ k: 'role', id: s.position.id })} /> - - ); + return ; } -export function RolePage({ id }: { id: string }) { - const staff = useGovStaff(); - const grants = useGovGrants(id); - const position = (staff.data?.positions ?? []).find((p) => p.id === id); - return ( - <> - {position?.code ?? '—'}} - desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。" - /> -
- {(grants.data?.grants ?? []).map((g, i) => ( -
- - {g.object.kind === 'data' - ? `数据 · ${g.object.domain}.${g.object.object}` - : g.object.kind === 'governance' - ? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}` - : `任务 · ${g.object.activityId ?? '全部活动'}`} - - {GRANT_ACTION_LABEL[g.action]} -
- ))} - {(grants.data?.grants ?? []).length === 0 && 未授予任何权限。} -
- - ); -} - -/* ---------------- 流程包 ---------------- */ - - -/* ---------------- 工作组(ADR-011) ---------------- */ - -/** - * 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。 - * - * 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人; - * 没有组才回落四策略(独任 / 轮询 / 抢单 / 指派)。 - * 装进工作流的是**快照** —— 改组不改已起的单,留痕不被改写。 - */ -function WorkGroupsTab() { - const groups = useGovWorkGroups(); - const staffQ = useGovStaff(); - const [editing, setEditing] = useState(undefined); - - const staff = staffQ.data?.staff ?? []; - const positions = staffQ.data?.positions ?? []; - const nameOf = (id: string) => staff.find((s) => s.id === id)?.name ?? id; - const rows = groups.data?.groups ?? []; - - return ( - - <> - - 工作组把岗位绑到人:谁起的单,整条流的岗位就按谁的组指人 —— 绑定先于策略, - 没绑定的岗位仍走四策略。绑定在起单那一刻快照进工作流,此后改组不影响已起的单。 - - {rows.length === 0 ? ( - 还没有工作组 —— 给谁建一个,他起的单整条流就能一次指到人。 - ) : ( - rows.map((g) => - editing === g.ownerId ? ( - setEditing(undefined)} - onCancel={() => setEditing(undefined)} - /> - ) : ( - setEditing(g.ownerId)}> - 改绑定 - - } - > - ({ - k: `${p.name} · ${p.code}`, - v: g.members[p.code] === undefined ? '未绑定 · 走四策略' : nameOf(g.members[p.code]!), - })), - ]} - /> - - ), - ) - )} - - - {staff - .filter((s) => !rows.some((g) => g.ownerId === s.id)) - .map((s) => - editing === s.id ? null : ( - 建组 →} - onClick={() => setEditing(s.id)} - /> - ), - )} - {staff.every((s) => rows.some((g) => g.ownerId === s.id)) && 每个人都有组了。} - - - {staff - .filter((s) => !rows.some((g) => g.ownerId === s.id) && editing === s.id) - .map((s) => ( - setEditing(undefined)} - onCancel={() => setEditing(undefined)} - /> - ))} - - - ); -} - -/** 建组 / 改组表单:逐岗位选人,留空即该岗位不绑定 */ -function WorkGroupForm({ - ownerId, - ownerName, - name, - members, - positions, - staff, - onDone, - onCancel, -}: { - ownerId: string; - ownerName: string; - name: string; - members: Readonly>; - positions: ReadonlyArray<{ id: string; code: string; name: string }>; - staff: ReadonlyArray<{ id: string; name: string; position: { code: string; name: string } }>; - onDone: () => void; - onCancel: () => void; -}) { - const queryClient = useQueryClient(); - const [draftName, setDraftName] = useState(name); - const [draft, setDraft] = useState>({ ...members }); - - const save = useMutation({ - mutationFn: (input: { name: string; members: Record }) => - call(saveWorkGroupRoute, { - body: { ownerId: staffIdSchema.parse(ownerId), name: input.name, members: input.members }, - }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['gov', 'work-groups'] }); - onDone(); - }, - }); - - const bound = Object.values(draft).filter((v) => v !== '').length; - - return ( - - - setDraftName(e.target.value)} /> - - {positions.map((p) => ( - - - - ))} - 绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。, - impact: { - who: '仅此后新起的工作流;在途不受影响 —— 绑定是起单时的快照', - undo: '可随时改;解绑某岗位即回落四策略', - when: '确认后即时生效', - }, - okLabel: '确认保存', - onOk: () => { - const picked: Record = {}; - for (const [code, id] of Object.entries(draft)) { - if (id !== '') picked[code] = staffIdSchema.parse(id); - } - save.mutate({ name: draftName, members: picked }); - }, - }, - }, - ]} - /> - {save.isError && 保存失败,请重试。} - - ); -} +export { RolePage }; diff --git a/apps/web/src/features/gov-staff/internal/perms.tsx b/apps/web/src/features/gov-staff/internal/perms.tsx new file mode 100644 index 0000000..600ca81 --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/perms.tsx @@ -0,0 +1,204 @@ +/** + * 权限 —— **两份事实拼在一起**(ADR-012): + * 角色本身是 Keycloak 的(realm roles,授收穿透过去); + * 「这个角色能进哪几个端」是**本系统自己的**(`sys.role_access`),Keycloak 不知道这回事。 + * + * 权限只到端:设计中心 / 运营中心 / 工作台。一个请求进不进得来,鉴权中间件每次都按这张表判 —— + * 所以这一屏改的不是一句描述,是**下一个请求的成败**。 + * + * 锁死不变量:改完之后至少还得有人能进运营中心 —— 否则这张表就再也没人改得了。 + * 服务端算完了才落库,不过就 409;这边如实把那句话摆出来,不自己拦(拦了两处口径会分叉)。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { QueryClient } from '@tanstack/react-query'; +import { deleteRoleAccessRoute, setRoleAccessRoute, SURFACE_LABEL, SURFACES } from '@contracts'; +import type { Surface } from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { ORG_KEYS, useOrgRoles, useRoleAccess } from '@web-shared/gov/gov-api.ts'; +import { EndChips } from './members.tsx'; + +/** + * 改「角色 → 端」之后,**每个人的可进端都跟着变** —— 所以人那一份也得失效, + * 不然成员那一屏还摆着旧的端胶囊(同一件事两屏说两句话)。组没动。 + */ +function bustAccess(qc: QueryClient): void { + void qc.invalidateQueries({ queryKey: ORG_KEYS.roleAccess }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.roles }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.users }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.user }); +} + +export function PermsTab() { + const roles = useOrgRoles(); + const access = useRoleAccess(); + const rows = roles.data?.roles ?? []; + + return ( + {roles.data === undefined ? '数着…' : `${rows.length} 个角色`}} + > + + 角色来自 Keycloak(realm roles),能进哪几个端由这里说了算 —— 每个请求都按它判, + 不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。 + + + {rows.length === 0 ? ( + realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。 + ) : ( +
+ {rows.map((r) => ( + x.role === r.name)} /> + ))} +
+ )} +
+
+ ); +} + +function RoleRow({ + role, + configured, +}: { + role: { name: string; description?: string | undefined; composite: boolean; ends: readonly Surface[]; holders: number }; + configured: boolean; +}) { + const qc = useQueryClient(); + const [draft, setDraft] = useState(undefined); + const [err, setErr] = useState(undefined); + const editing = draft !== undefined; + const shown = draft ?? role.ends; + + const save = useMutation({ + mutationFn: (ends: readonly Surface[]) => + // 一个端都不给 = 这个角色哪儿都进不去。删掉那一行与存一行空的是同一件事,存空的更好读 + call(setRoleAccessRoute, { params: { role: role.name }, body: { ends: [...ends] } }), + onSuccess: () => { + setDraft(undefined); + setErr(undefined); + bustAccess(qc); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + + const remove = useMutation({ + mutationFn: () => call(deleteRoleAccessRoute, { params: { role: role.name } }), + onSuccess: () => { + setDraft(undefined); + setErr(undefined); + bustAccess(qc); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + + return ( +
+
+ {role.name} + {role.composite && 组合角色} + {`${role.holders} 人持有`} +
+
+ {role.description !== undefined && {role.description}} + {editing ? ( +
+ {SURFACES.map((s) => ( + + ))} + 勾上的端,持有这个角色的人就能进;一个都不勾 = 有这个角色也哪儿都进不去。 +
+ ) : ( +
+ + {!configured && 还没配过} +
+ )} + {err !== undefined && {err}} + { setDraft(undefined); setErr(undefined); } }, + { + label: '保存', + ico: 'check', + primary: true, + busy: save.isPending, + ask: { + title: `把「${role.name}」的可进端改成:${shown.length === 0 ? '无' : shown.map((s) => SURFACE_LABEL[s]).join(' / ')}`, + note: '这张表每个请求都在读 —— 改完下一个请求就按新的判。', + impact: { + who: `持有这个角色的 ${role.holders} 个人`, + undo: '可以改回来 —— 但改完得还有人进得了运营中心,否则服务端会拒', + when: '立刻;他们手上的 token 不用重新登录也按新表判', + }, + okLabel: '确认改', + onOk: () => save.mutate(shown), + }, + }, + ] + : [ + { label: '改可进端', ico: 'pencil-line', onClick: () => setDraft(role.ends) }, + ...(configured + ? [ + { + label: '删掉这一行', + ico: 'trash-2', + danger: true, + busy: remove.isPending, + ask: { + title: `删掉「${role.name}」的端配置`, + note: '删掉之后这个角色不再命中任何端 —— 与「一个端都不勾」是同一个效果。', + impact: { + who: `持有这个角色的 ${role.holders} 个人`, + undo: '可以再配回来', + when: '立刻', + }, + okLabel: '确认删', + onOk: () => remove.mutate(), + }, + }, + ] + : []), + ] + } + /> +
+
+ ); +} + +/** 角色详情:谁持有它 —— 从成员那一屏点进来,或从这一屏的角色名点进来 */ +export function RolePage({ id }: { id: string }) { + const roles = useOrgRoles(); + const role = (roles.data?.roles ?? []).find((r) => r.name === id); + return ( + + {role === undefined ? ( + {`realm 里没有角色「${id}」—— 可能刚在 Keycloak 里删了。`} + ) : ( + {`${role.holders} 人持有`}}> +
+ +
+ 授收这个角色在「成员」那一屏的成员详情里做 —— 一次改一个人,收权当场踢掉他的会话。 +
+ )} +
+ ); +} diff --git a/apps/web/src/shared/gov/gov-api.ts b/apps/web/src/shared/gov/gov-api.ts index c77d625..34e7128 100644 --- a/apps/web/src/shared/gov/gov-api.ts +++ b/apps/web/src/shared/gov/gov-api.ts @@ -23,6 +23,7 @@ import type { GovTriggersOutput, GovWorkGroupsOutput, GovWorkflowOutput, + RouteOutput, ProcessPackage, GovLedgerByActivityOutput, } from '@contracts'; @@ -44,6 +45,12 @@ import { listLlmInstancesRoute, listLlmProvidersRoute, listProcessPackagesRoute, + getOrgUserRoute, + listOrgGroupMembersRoute, + listOrgGroupsRoute, + listOrgRolesRoute, + listOrgUsersRoute, + listRoleAccessRoute, listStaffRoute, listToolsRoute, listTriggersRoute, @@ -190,3 +197,65 @@ export const useGovWorkflow = (id: string): UseQueryResult => export const useGovTaskLog = (taskId: string): UseQueryResult => useQuery({ queryKey: ['gov', 'tasklog', taskId], queryFn: () => call(govTaskLogRoute, { params: { taskId } }) }); + +/* ── 成员与组织(ADR-012)———————————————————————————————————————— */ + +/** + * 各屏的查询键 —— 写完**按碰到的那几屏**失效,别一把 `['gov','org']` 全清。 + * 成员那一趟最贵(服务端要逐人问角色,见 debt),把它拖进来重取, + * 屏上那棵组织树就多钉着已经没了的组好几秒。 + */ +export const ORG_KEYS = { + users: ['gov', 'org', 'users'] as const, + user: ['gov', 'org', 'user'] as const, + roles: ['gov', 'org', 'roles'] as const, + groups: ['gov', 'org', 'groups'] as const, + groupMembers: ['gov', 'org', 'group-members'] as const, + roleAccess: ['gov', 'org', 'role-access'] as const, +}; + +export type GovOrgUsers = RouteOutput; +export type GovOrgUserDetail = RouteOutput; +export type GovOrgRoles = RouteOutput; +export type GovOrgGroups = RouteOutput; +export type GovOrgGroupMembers = RouteOutput; +export type GovRoleAccess = RouteOutput; + +/** + * 成员列表 —— 检索与「含停用」都在**服务端**过滤(那边握着全量,前端只拿要摆的那些)。 + * 服务端第一次取要几秒(Keycloak 没有批量取角色的口,见 debt),它自己有 60 秒缓存; + * 这边 `staleTime` 跟着设一档,免得同一屏的几个组件各要一遍。 + */ +export const useOrgUsers = (q: string, includeDisabled: boolean): UseQueryResult => + useQuery({ + queryKey: ['gov', 'org', 'users', q, includeDisabled], + queryFn: () => + call(listOrgUsersRoute, { + query: { q: q === '' ? undefined : q, includeDisabled: includeDisabled ? '1' : undefined }, + }), + staleTime: 30_000, + }); + +export const useOrgUser = (sub: string | undefined): UseQueryResult => + useQuery({ + queryKey: ['gov', 'org', 'user', sub], + queryFn: () => call(getOrgUserRoute, { params: { sub: sub! } }), + enabled: sub !== undefined, + }); + +export const useOrgRoles = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'org', 'roles'], queryFn: () => call(listOrgRolesRoute), staleTime: 30_000 }); + +export const useOrgGroups = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'org', 'groups'], queryFn: () => call(listOrgGroupsRoute), staleTime: 30_000 }); + +export const useOrgGroupMembers = (groupId: string | undefined): UseQueryResult => + useQuery({ + queryKey: ['gov', 'org', 'group-members', groupId], + queryFn: () => call(listOrgGroupMembersRoute, { params: { id: groupId! } }), + enabled: groupId !== undefined, + }); + +/** 角色 → 端 —— 这张表是**本地**的(ADR-012),不是 Keycloak 的字段 */ +export const useRoleAccess = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'org', 'role-access'], queryFn: () => call(listRoleAccessRoute) }); diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index a2780e6..7021d64 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -110,10 +110,10 @@ export const ZM: Readonly>> = { +/** `hint` 摆在页签栏右端 —— 一句话说清这一格管什么、写下去会发生什么 */ +export const TABS: Readonly< + Record> +> = { blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }], /* * 测试台三格 —— 从前是一格加一个「两档旋钮」,旋钮长在内容里,别处跳不进来、 @@ -206,10 +209,16 @@ export const TABS: Readonly; + +export const orgUserDetailSchema = orgUserSchema.extend({ + firstName: z.string().optional(), + lastName: z.string().optional(), + emailVerified: z.boolean(), + /** 直属组;`path` 形如 `/技术部/平台组` */ + groups: z.array(z.object({ id: z.string(), path: z.string() })), +}); +export type OrgUserDetail = z.infer; + +export const listOrgUsersRoute = { + method: 'GET', + path: '/api/gov/org/users', + access: 'ops', + output: z.object({ users: z.array(orgUserSchema) }), +} as const satisfies RouteContract; + +export const getOrgUserRoute = { + method: 'GET', + path: '/api/gov/org/users/:sub', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + output: orgUserDetailSchema, +} as const satisfies RouteContract; + +/** + * 新建成员 —— 建完立刻能登录。口令是**临时**的:本人首登必须改 + * (`temporary:true`,手册 §14.2);我们不存它,也不回显。 + */ +export const createOrgUserRoute = { + method: 'POST', + path: '/api/gov/org/users', + access: 'ops', + body: z.object({ + username: z.string().min(1), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + /** 临时口令;不给就建一个登不进来的壳(人自己去改密码那条路) */ + password: z.string().min(1).optional(), + /** 一并授的 realm 角色;不给就只有 Keycloak 的默认角色 */ + roles: z.array(z.string()).optional(), + }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/** 改资料(姓名 / 邮箱)—— 用户名建后不可改,不在这里 */ +export const patchOrgUserRoute = { + method: 'PATCH', + path: '/api/gov/org/users/:sub', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + body: z.object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/** + * 启用 / 停用。停用**立刻踢掉该用户全部会话** —— 不然他攥着的 token + * 还能用到过期为止,而「已停用」这三个字在界面上已经写出去了(界面不撒谎)。 + */ +export const setOrgUserEnabledRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/enabled', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + body: z.object({ enabled: z.boolean() }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/** 重置口令 —— 一律临时口令,本人首登必须改;服务端不回显、不留痕 */ +export const resetOrgUserPasswordRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/reset-password', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + body: z.object({ password: z.string().min(1) }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** 删人 —— 从 Keycloak 消失,不可撤销;本地名册那一行同批停用(留痕与在途还挂着他) */ +export const deleteOrgUserRoute = { + method: 'DELETE', + path: '/api/gov/org/users/:sub', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ─────────────────────────── */ + +export const orgRoleSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + /** Keycloak 的组合角色(展开后会带出别的角色) */ + composite: z.boolean(), + /** 这个角色能进哪几个端(`sys.role_access`;没配过 = 空 = 哪儿都进不去) */ + ends: z.array(surfaceSchema), + /** 持有它的成员数(已剔服务账号) */ + holders: z.number().int().nonnegative(), +}); +export type OrgRole = z.infer; + +export const listOrgRolesRoute = { + method: 'GET', + path: '/api/gov/org/roles', + access: 'ops', + output: z.object({ roles: z.array(orgRoleSchema) }), +} as const satisfies RouteContract; + +/** + * 授 / 收一个 realm 角色。收权**立刻踢掉该用户全部会话**(手册 §17.3)—— + * 否则他手上那枚 token 还带着旧角色,收权要等到 token 过期才生效。 + * 不能收自己的最后一条进运营中心的路(409)—— 收完没人进得来,这扇门就锁死了。 + */ +export const setOrgUserRoleRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/roles/:role', + access: 'ops', + params: z.object({ sub: z.string().min(1), role: z.string().min(1) }), + body: z.object({ granted: z.boolean() }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/* ── 组织:Keycloak 组 ─────────────────────────────────────────────────── */ + +export const orgGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + /** 从根拼下来,如 `/技术部/平台组` */ + path: z.string().min(1), + /** 顶层组为空 */ + parentId: z.string().optional(), + /** 0 = 顶层 */ + depth: z.number().int().nonnegative(), + /** 本组**直属**成员数(已剔服务账号,不含子组) */ + memberCount: z.number().int().nonnegative(), +}); +export type OrgGroup = z.infer; + +/** 组树平表,按 `path` 升序 —— 层级看 `parentId` / `depth` */ +export const listOrgGroupsRoute = { + method: 'GET', + path: '/api/gov/org/groups', + access: 'ops', + output: z.object({ groups: z.array(orgGroupSchema) }), +} as const satisfies RouteContract; + +/** 组的直属成员(含被停用的人 —— 这一屏管的是归属,不是能不能登录) */ +export const listOrgGroupMembersRoute = { + method: 'GET', + path: '/api/gov/org/groups/:id/members', + access: 'ops', + params: z.object({ id: z.string().min(1) }), + output: z.object({ members: z.array(orgUserSchema) }), +} as const satisfies RouteContract; + +/** 建组:`parentId` 给了就是子组,不给就是顶层组。同级不得重名(Keycloak 那一侧拦,409) */ +export const createOrgGroupRoute = { + method: 'POST', + path: '/api/gov/org/groups', + access: 'ops', + body: z.object({ name: z.string().min(1), parentId: z.string().optional() }), + output: orgGroupSchema, +} as const satisfies RouteContract; + +export const renameOrgGroupRoute = { + method: 'PATCH', + path: '/api/gov/org/groups/:id', + access: 'ops', + params: z.object({ id: z.string().min(1) }), + body: z.object({ name: z.string().min(1) }), + output: orgGroupSchema, +} as const satisfies RouteContract; + +/** 删组 —— 连同子组一起没(Keycloak 语义);人不会被删,只是不再属于这个组 */ +export const deleteOrgGroupRoute = { + method: 'DELETE', + path: '/api/gov/org/groups/:id', + access: 'ops', + params: z.object({ id: z.string().min(1) }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** 成员进 / 出组 */ +export const setOrgUserGroupRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/groups/:groupId', + access: 'ops', + params: z.object({ sub: z.string().min(1), groupId: z.string().min(1) }), + body: z.object({ member: z.boolean() }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** + * 这一屏的全部路由 —— 归属一处列全,测试拿它逐条断言。 + * 逐个 import 去断言的写法,新增一条只要忘了加进名单就悄悄漏过去了。 + */ +export const ORG_DIRECTORY_ROUTES = [ + listOrgUsersRoute, + getOrgUserRoute, + createOrgUserRoute, + patchOrgUserRoute, + setOrgUserEnabledRoute, + resetOrgUserPasswordRoute, + deleteOrgUserRoute, + listOrgRolesRoute, + setOrgUserRoleRoute, + listOrgGroupsRoute, + listOrgGroupMembersRoute, + createOrgGroupRoute, + renameOrgGroupRoute, + deleteOrgGroupRoute, + setOrgUserGroupRoute, +] as const satisfies readonly RouteContract[]; diff --git a/packages/contracts/src/org.ts b/packages/contracts/src/org.ts index e53c340..0f40110 100644 --- a/packages/contracts/src/org.ts +++ b/packages/contracts/src/org.ts @@ -21,6 +21,20 @@ export const STAFF_STATES = ['active', 'disabled'] as const; export const staffStateSchema = z.enum(STAFF_STATES); export type StaffState = z.infer; +/** + * 人,还是服务账号。 + * + * Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为 + * `service-account-` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在 + * 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。 + * + * 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**: + * 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。 + * 这条判断归后端 —— 界面不该去猜账号名长什么样。 + */ +export const staffKindSchema = z.enum(['person', 'service']); +export type StaffKind = z.infer; + export const staffSchema = z.object({ /** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */ id: staffIdSchema, @@ -35,6 +49,8 @@ export const staffSchema = z.object({ state: staffStateSchema.default('active'), /** realm 角色快照(token 签发时;噪声角色已滤) */ roles: z.array(z.string()).default([]), + /** 人 / 服务账号 —— 由账号名判(见 `staffKindSchema`),不落库 */ + kind: staffKindSchema.default('person'), }); export type Staff = z.infer;