Archived
Compare commits
253
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed997187e | ||
|
|
0e02bb9411 | ||
|
|
3b9c559a47 | ||
|
|
4174c5f194 | ||
|
|
3c563525f1 | ||
|
|
e474275b28 | ||
|
|
cec19adc85 | ||
|
|
8314148ebc | ||
|
|
4d2c3657f9 | ||
|
|
f5435f4c9c | ||
|
|
445aa5ef22 | ||
|
|
ce72da77eb | ||
|
|
e47ce92702 | ||
|
|
8c5f3a7736 | ||
|
|
04ec718a66 | ||
|
|
228739eaca | ||
|
|
9412dd7aa8 | ||
|
|
68faeb4f50 | ||
|
|
1de4ab9862 | ||
|
|
c4219a7b7d | ||
|
|
7515885afa | ||
|
|
26db6c685b | ||
|
|
05ebecb541 | ||
|
|
51914292dc | ||
|
|
f6c8dccb4f | ||
|
|
66be5bab6e | ||
|
|
794af58902 | ||
|
|
11375eaf93 | ||
|
|
5a7bb9b58f | ||
|
|
e2db7cfa01 | ||
|
|
e18611006b | ||
|
|
1e3e5d40c6 | ||
|
|
e5ca9caaa6 | ||
|
|
2265ab7833 | ||
|
|
c8988c912f | ||
|
|
11e41b79c5 | ||
|
|
1373eb4980 | ||
|
|
f0789a0f13 | ||
|
|
18de7653bd | ||
|
|
64fe4b43c9 | ||
|
|
0f40bae0d1 | ||
|
|
e38631224a | ||
|
|
4076621e04 | ||
|
|
45cec82984 | ||
|
|
9f5b1c99d3 | ||
|
|
764dab342d | ||
|
|
9e2f7387b2 | ||
|
|
8586f7dac8 | ||
|
|
d420284887 | ||
|
|
6636abfc39 | ||
|
|
67fa226380 | ||
|
|
832c662fdb | ||
|
|
57870329e6 | ||
|
|
d9bcce9516 | ||
|
|
a408542bed | ||
|
|
882f22a01e | ||
|
|
716f17ee2a | ||
|
|
77df79d6fb | ||
|
|
4bd1a04511 | ||
|
|
04675e814d | ||
|
|
59afcbfdbd | ||
|
|
10f529d03c | ||
|
|
abe92e0531 | ||
|
|
40daa6c9f7 | ||
|
|
fdcd1f88a7 | ||
|
|
c60a2adf43 | ||
|
|
b2159022bc | ||
|
|
392d085f51 | ||
|
|
725f1ecb0c | ||
|
|
cd88d0e026 | ||
|
|
8c125042ba | ||
|
|
1c6978818c | ||
|
|
89f0be8b81 | ||
|
|
60955171da | ||
|
|
18d7147ebd | ||
|
|
02e0d1206d | ||
|
|
defa048270 | ||
|
|
ab0dfd8b49 | ||
|
|
f22402e155 | ||
|
|
f00ed5c011 | ||
|
|
d244864317 | ||
|
|
7b8d3b559b | ||
|
|
94b664bc85 | ||
|
|
65a4c7a7dd | ||
|
|
c3fed573ee | ||
|
|
c60c882f06 | ||
|
|
642bdcef3b | ||
|
|
588d5c2dd4 | ||
|
|
2d20202b1d | ||
|
|
647b86753a | ||
|
|
a09acc7504 | ||
|
|
198d12af1b | ||
|
|
6212b34e39 | ||
|
|
3190392a1d | ||
|
|
fcf1822233 | ||
|
|
bd26756da9 | ||
|
|
6cbac3220c | ||
|
|
dba6071772 | ||
|
|
2bb974a10d | ||
|
|
fc48920c32 | ||
|
|
154b222860 | ||
|
|
09885c3dbd | ||
|
|
35ef0f1790 | ||
|
|
6f721ffc6d | ||
|
|
81fb8179c2 | ||
|
|
9ab2f4b695 | ||
|
|
3636496774 | ||
|
|
f59e237035 | ||
|
|
343a281af5 | ||
|
|
80e9b01f84 | ||
|
|
fbd5867206 | ||
|
|
e9da8d25f5 | ||
|
|
fe8de5319c | ||
|
|
b0a3cc3a55 | ||
|
|
58ff4f1b90 | ||
|
|
f950a4419d | ||
|
|
fc8b3c504b | ||
|
|
16fe8ba71b | ||
|
|
04234d2fda | ||
|
|
b9951483bc | ||
|
|
2d1a2ff442 | ||
|
|
91840067d4 | ||
|
|
a370616f01 | ||
|
|
2cef6f7489 | ||
|
|
e1d8d86b16 | ||
|
|
c1137fb09a | ||
|
|
fff2e6ec2d | ||
|
|
21d41f5859 | ||
|
|
f41bd76b0e | ||
|
|
88f2149d12 | ||
|
|
e3479e0b9b | ||
|
|
282584d26e | ||
|
|
f77c6325fe | ||
|
|
a40fc1a459 | ||
|
|
7b85a79323 | ||
|
|
909b1fe4b7 | ||
|
|
0923fbfe4d | ||
|
|
43aecde9da | ||
|
|
7f32a7e450 | ||
|
|
27d2fe6bb4 | ||
|
|
75553371b0 | ||
|
|
94e66d5a58 | ||
|
|
91458219ee | ||
|
|
b98ec75a89 | ||
|
|
ffa1ef1076 | ||
|
|
fa27cef60e | ||
|
|
51c87af583 | ||
|
|
3171d8a864 | ||
|
|
8556432dba | ||
|
|
bd86dd6088 | ||
|
|
6b7bf82df3 | ||
|
|
d67f744f59 | ||
|
|
6a69acfc69 | ||
|
|
d133d6c2a8 | ||
|
|
723d379dbd | ||
|
|
6f7513aa58 | ||
|
|
d03fed97d2 | ||
|
|
182d3c39b5 | ||
|
|
df18882322 | ||
|
|
e235207e4a | ||
|
|
eb80876554 | ||
|
|
7ea08fb16a | ||
|
|
c121349dea | ||
|
|
c6b02dfd39 | ||
|
|
02ef572470 | ||
|
|
00133c2ca0 | ||
|
|
c3ab236f70 | ||
|
|
148d16a1b7 | ||
|
|
e1b04f01aa | ||
|
|
7d743635eb | ||
|
|
ec3de1f19e | ||
|
|
0629a7f054 | ||
|
|
f9dbdc8c9c | ||
|
|
f7fe8e3836 | ||
|
|
13183b9287 | ||
|
|
f52f0168df | ||
|
|
63034f5ab2 | ||
|
|
b74b841444 | ||
|
|
92b7796601 | ||
|
|
a8797b8cc8 | ||
|
|
fce60b1570 | ||
|
|
8139948c28 | ||
|
|
237286e908 | ||
|
|
023d3157a0 | ||
|
|
0c029b694d | ||
|
|
99f021f964 | ||
|
|
a681ffe9f7 | ||
|
|
de63c275cf | ||
|
|
f52edd5383 | ||
|
|
0f1e53d114 | ||
|
|
f70de44338 | ||
|
|
58b1111e7e | ||
|
|
816389a185 | ||
|
|
18ca6dacfe | ||
|
|
e862a5279b | ||
|
|
e0fc16db33 | ||
|
|
b6ec37a231 | ||
|
|
ab30ce56c5 | ||
|
|
2d1558b99e | ||
|
|
7b79d542a8 | ||
|
|
d64f0b5ffd | ||
|
|
0c6e8e2c1b | ||
|
|
58358210e2 | ||
|
|
5bd92f2e93 | ||
|
|
7afc40987b | ||
|
|
f04c64926b | ||
|
|
40e592ae94 | ||
|
|
fba82c71dc | ||
|
|
c5cb779390 | ||
|
|
43bacffd90 | ||
|
|
78e36ab73c | ||
|
|
79fd25e1c1 | ||
|
|
1328aeb142 | ||
|
|
b9a4af9a2a | ||
|
|
d65579c0d8 | ||
|
|
5f1e64b4d4 | ||
|
|
8d7e56aa92 | ||
|
|
6748ef01b7 | ||
|
|
58d6d778ea | ||
|
|
83f65b2785 | ||
|
|
7c6e399b57 | ||
|
|
411fecc8c6 | ||
|
|
404a32fa0a | ||
|
|
26f07f3487 | ||
|
|
649cec1197 | ||
|
|
ebc6f778df | ||
|
|
bb22545bdf | ||
|
|
5facf6b745 | ||
|
|
5964a80b28 | ||
|
|
253c6c3afa | ||
|
|
a4716c8632 | ||
|
|
7978c6faca | ||
|
|
aa18701130 | ||
|
|
9848e2f735 | ||
|
|
4dabac2f7d | ||
|
|
fc5afa9600 | ||
|
|
61622f94d5 | ||
|
|
8dc71db9d8 | ||
|
|
dd31b91829 | ||
|
|
81a185cd61 | ||
|
|
d66f7fad3a | ||
|
|
3a34362eec | ||
|
|
2eef8b4544 | ||
|
|
4d55ac465d | ||
|
|
43917b559a | ||
|
|
cb143d6413 | ||
|
|
5108d7a580 | ||
|
|
a5c8d4786d | ||
|
|
61f96d5ab2 | ||
|
|
d788140548 | ||
|
|
094386f02c | ||
|
|
8ba4ab339e | ||
|
|
98ce2966a2 |
@@ -0,0 +1,320 @@
|
||||
# JCAI-WOS CI —— 五道门禁 + 从空库起的集成测试 + 生产构建
|
||||
#
|
||||
# 只在主干跑:push 到 mvp-foundation(PR 合并产生的那一推同样算)。
|
||||
# runner 是 host 模式,label `ubuntu-latest`;宿主机自带 node22 / pnpm10 / docker。
|
||||
# 门禁一律以退出码验(CLAUDE.md 铁规矩 1),不以 echo 验。
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [mvp-foundation]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# 国内镜像源:pnpm 认 npm 的 env 形式
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
|
||||
COREPACK_NPM_REGISTRY: https://registry.npmmirror.com
|
||||
# NODE_ENV 一律不设 production —— secret-box.ts 在 production 下拿不到
|
||||
# JCAI_SECRET_KEY 会直接抛;CI 要的是 dev 兜底那条路。
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
# ── 取代码:纯 git,一个第三方 action 都不依赖 ─────────────────────
|
||||
- name: 取代码
|
||||
env:
|
||||
T1: ${{ secrets.GITEA_TOKEN }}
|
||||
T2: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
git init -q .
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git remote set-url origin "$URL" 2>/dev/null || git remote add origin "$URL"
|
||||
|
||||
TOKEN="${T1:-${T2:-}}"
|
||||
HEADER="http.${GITHUB_SERVER_URL}/.extraheader"
|
||||
if [ -n "$TOKEN" ]; then
|
||||
AUTH="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')"
|
||||
git config --local "$HEADER" "Authorization: basic $AUTH"
|
||||
fi
|
||||
|
||||
fetch() { git fetch --depth=1 --no-tags origin "+${1}:refs/ci/head"; }
|
||||
|
||||
# 两档退让:先按 commit(服务端未必允许按 SHA 取),再按分支 ref
|
||||
if ! fetch "$GITHUB_SHA" && ! fetch "$GITHUB_REF"; then
|
||||
# 换一种带凭据的写法再试一遍(extraheader 与 token 进 URL,Gitea 各版本认的不一样)
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo '取代码失败:没有可用 token,匿名也拉不下来'; exit 1
|
||||
fi
|
||||
git config --local --unset-all "$HEADER" || true
|
||||
SCHEME="${GITHUB_SERVER_URL%%://*}"
|
||||
HOST="${GITHUB_SERVER_URL#*://}"
|
||||
git remote set-url origin "${SCHEME}://${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git"
|
||||
fetch "$GITHUB_SHA" || fetch "$GITHUB_REF"
|
||||
fi
|
||||
|
||||
git checkout -qf refs/ci/head
|
||||
# 留着 node_modules(host 模式工作区跨次复用,留着就等于一份缓存),
|
||||
# 其余未跟踪物一律清掉 —— 上一跑的 dist/.build/.api-tmp 不许影响这一跑
|
||||
git clean -ffdx -e node_modules
|
||||
echo "HEAD=$(git rev-parse HEAD) 期望=${GITHUB_SHA}"
|
||||
|
||||
- name: 工具链断言
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -v
|
||||
pnpm -v
|
||||
node -e 'const [a, b] = process.versions.node.split(".").map(Number);
|
||||
if (a < 22 || (a === 22 && b < 6)) {
|
||||
console.error("需要 Node >= 22.6 —— gen:tiers 用 --experimental-strip-types");
|
||||
process.exit(1);
|
||||
}'
|
||||
case "$(pnpm -v)" in
|
||||
10.*) ;;
|
||||
*) echo "需要 pnpm 10.x(仓库钉的是 10.33.0)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: 装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ── 五道门禁,逐条一步 ────────────────────────────────────────────
|
||||
- name: 门禁前置 · 生成层级表
|
||||
run: pnpm gen:tiers
|
||||
|
||||
- name: 门禁 1/5 · 类型检查
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: 门禁 2/5 · Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: 门禁 3/5 · 依赖门禁
|
||||
run: pnpm depcruise
|
||||
|
||||
- name: 门禁 4/5 · 契约快照
|
||||
run: pnpm api:check
|
||||
|
||||
# api:check 自己那道 git 比对是**会失败开口**的(git 一抛错就印「非 git 仓库,
|
||||
# 跳过比对」然后 exit 0)。这一步自己再 status 一遍,吞不掉;
|
||||
# 顺带管住 gen:tiers 的入库产物 tools/depcruise-tiers.generated.cjs。
|
||||
# 位置在测试之前 —— 测试期的 seed 可能往 .data/ 落文件,那不算漂移。
|
||||
- name: 生成物无漂移
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIRTY="$(git status --porcelain)"
|
||||
if [ -n "$DIRTY" ]; then
|
||||
echo "$DIRTY"
|
||||
echo '---'
|
||||
git --no-pager diff --stat
|
||||
echo '门禁跑完工作树有改动:入库的生成物与现生成的不一致。'
|
||||
echo '本地跑 pnpm gates,把 api-report/ 与 tools/depcruise-tiers.generated.cjs 一并提交。'
|
||||
exit 1
|
||||
fi
|
||||
echo '工作树干净 ✔'
|
||||
|
||||
# ── 库:一次性,跑完就拆 ──────────────────────────────────────────
|
||||
- name: 建本次跑的一次性库
|
||||
id: db
|
||||
env:
|
||||
CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }}
|
||||
run: node tools/ci.mjs db-create "jcai_ci_${GITHUB_RUN_ID}"
|
||||
|
||||
- name: 从空库跑完整条迁移链
|
||||
run: pnpm db:migrate
|
||||
|
||||
- name: 库真的连得上
|
||||
run: node tools/ci.mjs db-probe
|
||||
|
||||
- name: 门禁 5/5 · 单元与集成测试
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm test --reporter=default --reporter=json \
|
||||
--outputFile.json="${RUNNER_TEMP:-/tmp}/vitest.json"
|
||||
node tools/ci.mjs assert-it "${RUNNER_TEMP:-/tmp}/vitest.json"
|
||||
|
||||
# 用 steps.db.outcome 而不是裸 always():装依赖就失败的那种跑法里
|
||||
# 根本没有库可拆,裸 always() 只会多吐一条看着吓人的假失败。
|
||||
- name: 拆掉一次性库
|
||||
if: ${{ always() && steps.db.outcome == 'success' }}
|
||||
env:
|
||||
CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }}
|
||||
run: node tools/ci.mjs db-drop "jcai_ci_${GITHUB_RUN_ID}"
|
||||
|
||||
# ── 生产构建 ────────────────────────────────────────────────────
|
||||
- name: 生产构建 · api
|
||||
run: pnpm --filter @app/api build
|
||||
|
||||
- name: 生产构建 · web
|
||||
run: pnpm --filter @app/web build
|
||||
|
||||
- name: 生产构建 · 活动包
|
||||
run: pnpm activities:build
|
||||
|
||||
# ══ CD ══════════════════════════════════════════════════════════
|
||||
# 门禁全绿之后才走到这儿(同一个 job,前面任一步红后面自然不跑)。
|
||||
# 机器相关的东西一律从 vars.* 读,不写死;机密从 secrets.* 读。
|
||||
- name: 部署 · 前置断言
|
||||
env:
|
||||
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
|
||||
PM2_APP: ${{ vars.PM2_APP }}
|
||||
WEB_ROOT: ${{ vars.WEB_ROOT }}
|
||||
PG_CONTAINER: ${{ vars.PG_CONTAINER }}
|
||||
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
|
||||
JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }}
|
||||
JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }}
|
||||
run: node tools/cd.mjs preflight
|
||||
|
||||
# 迁移没有 down,备份是唯一的回滚手段 —— 所以它在换代码之前,且备不成就红
|
||||
- name: 部署 · 备份生产库
|
||||
env:
|
||||
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
|
||||
PG_CONTAINER: ${{ vars.PG_CONTAINER }}
|
||||
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
run: node tools/cd.mjs backup
|
||||
|
||||
- name: 部署 · 确保生产库在
|
||||
env:
|
||||
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
run: node tools/cd.mjs db-ensure
|
||||
|
||||
- name: 部署 · 发布产物
|
||||
env:
|
||||
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REL="${DEPLOY_DIR}/releases/${GITHUB_SHA}"
|
||||
rm -rf "$REL"
|
||||
mkdir -p "$REL"
|
||||
|
||||
# 产物 + db/(migrate.js 靠向上找 db/migrations,相对位置不能变)+ 根三件
|
||||
rsync -a --relative \
|
||||
./apps/api/dist ./apps/api/package.json \
|
||||
./apps/web/dist \
|
||||
./db \
|
||||
./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml \
|
||||
"$REL"/
|
||||
# 活动包产物(装包时要读;装完就不再需要)
|
||||
rsync -a --relative ./activities/*/dist "$REL"/
|
||||
# 各 workspace 包的 package.json —— pnpm 认工作区要它们全在,少一个 lockfile 就对不上
|
||||
git ls-files '*/package.json' | grep -v '^spike/' > "$RUNNER_TEMP/pkgs.txt"
|
||||
rsync -a --files-from="$RUNNER_TEMP/pkgs.txt" ./ "$REL"/
|
||||
|
||||
# 产物是 ESM 且 skipNodeModulesBundle —— 目标目录必须有自己的 node_modules
|
||||
cd "$REL"
|
||||
pnpm install --prod --frozen-lockfile --ignore-scripts
|
||||
|
||||
printf 'sha=%s\nref=%s\nrun=%s\nat=%s\n' \
|
||||
"$GITHUB_SHA" "$GITHUB_REF_NAME" "$GITHUB_RUN_ID" "$(date -Iseconds)" > "$REL/RELEASE"
|
||||
echo "RELEASE_DIR=$REL" >> "$GITHUB_ENV"
|
||||
echo "发布目录:$REL"
|
||||
|
||||
# 直接跑 dist 里的入口,不走 pnpm db:migrate —— 那条会先 tsup(clean:true),
|
||||
# 在正跑着的服务脚下把 dist 清掉
|
||||
- name: 部署 · 迁移
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
run: cd "$RELEASE_DIR" && node apps/api/dist/migrate.js
|
||||
|
||||
- name: 部署 · 迁移到位断言
|
||||
env:
|
||||
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
run: node tools/cd.mjs migrate-verify
|
||||
|
||||
# 出两个旗:FIRST_DEPLOY / ACTIVITIES_INSTALL。
|
||||
# 版本没升但产物内容变了会在这里当场红(否则新代码静默不上线)。
|
||||
- name: 部署 · 判首次播种与活动包升级
|
||||
env:
|
||||
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
run: node tools/cd.mjs activities-plan
|
||||
|
||||
# 空库唯一该跑 seed 的时刻。升级时绝不跑 —— 它会把治理台配好的
|
||||
# 工具连接端点打回 replay://crm、重置白名单、重置模型实例的 baseUrl/model。
|
||||
- name: 部署 · 首次播种
|
||||
if: ${{ env.FIRST_DEPLOY == '1' }}
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
|
||||
JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }}
|
||||
JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 上面 if: 判错的危险方向是「升级时误跑 seed」—— 那会重写整套治理台配置。
|
||||
# 所以这里自己再确认一次:表达式判错也伤不到生产。
|
||||
[ "${FIRST_DEPLOY:-0}" = "1" ] || { echo "FIRST_DEPLOY 不是 1,不播种(这一步本不该执行到)"; exit 0; }
|
||||
cd "$RELEASE_DIR"
|
||||
JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/seed.js
|
||||
|
||||
- name: 部署 · 装活动包
|
||||
if: ${{ env.FIRST_DEPLOY == '0' && env.ACTIVITIES_INSTALL == '1' }}
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 同上:装包会按主键覆盖 8 行样例主数据并发出 data 事件,不该装的时候别装
|
||||
[ "${ACTIVITIES_INSTALL:-0}" = "1" ] || { echo "ACTIVITIES_INSTALL 不是 1,不装(这一步本不该执行到)"; exit 0; }
|
||||
cd "$RELEASE_DIR"
|
||||
JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/install-activities.js
|
||||
|
||||
# install-activities 的所有失败路径都只打日志、永远 exit 0 —— 回查库才说得出实情
|
||||
- name: 部署 · 活动包回查
|
||||
env:
|
||||
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
run: node tools/cd.mjs activities-verify
|
||||
|
||||
- name: 部署 · 发前端
|
||||
env:
|
||||
WEB_ROOT: ${{ vars.WEB_ROOT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rsync -a --delete ./apps/web/dist/ "$WEB_ROOT"/
|
||||
echo "前端已发到 $WEB_ROOT($(find "$WEB_ROOT" -type f | wc -l | tr -d ' ') 个文件)"
|
||||
|
||||
# 用 releases 的**真实路径**起,不经 current 软链:
|
||||
# main.ts 的入口守卫拿 import.meta.url 与 process.argv[1] 做字符串比较,
|
||||
# 经软链启动两边不等 → boot() 永不执行 → 进程零输出 exit 0(实测过)。
|
||||
# 换版本时脚本路径会变,所以是 delete+start 而不是 restart。
|
||||
- name: 部署 · 重启 pm2
|
||||
env:
|
||||
PM2_APP: ${{ vars.PM2_APP }}
|
||||
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
|
||||
APP_NODE_ENV: ${{ vars.APP_NODE_ENV }}
|
||||
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
|
||||
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
|
||||
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
|
||||
JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 有 HTTPS 才该设 production:production 会给登录 cookie 加 Secure,
|
||||
# 而 Secure 的 cookie 浏览器在 HTTP 下根本不回传 —— 登录会静默失效。
|
||||
export NODE_ENV="${APP_NODE_ENV:-production}"
|
||||
export PORT="${DEPLOY_PORT:-8787}"
|
||||
export DATABASE_URL="$PROD_DATABASE_URL"
|
||||
export JCAI_ATTACHMENT_ROOT="$DEPLOY_DIR/data/attachments"
|
||||
pm2 delete "$PM2_APP" >/dev/null 2>&1 || true
|
||||
pm2 start "$RELEASE_DIR/apps/api/dist/main.js" --name "$PM2_APP" --cwd "$RELEASE_DIR" --update-env
|
||||
pm2 list
|
||||
# 探活地址交给下一步,别让它去猜端口 —— 猜错会打到别人的服务上,还判成绿
|
||||
echo "HEALTH_URL=http://127.0.0.1:$PORT/api/health" >> "$GITHUB_ENV"
|
||||
|
||||
# 库挂了 /api/health 照样返 200,只是 body 里 db:'down' —— 所以验 body
|
||||
- name: 部署 · 探活
|
||||
run: node tools/cd.mjs health
|
||||
|
||||
- name: 部署 · 收尾
|
||||
env:
|
||||
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ln -sfn "$RELEASE_DIR" "$DEPLOY_DIR/current"
|
||||
node tools/cd.mjs prune
|
||||
echo '── 这一版发出去了 ──'
|
||||
cat "$RELEASE_DIR/RELEASE"
|
||||
ls -1t "$DEPLOY_DIR/backups" | head -1 | sed 's/^/最近一份备份:/'
|
||||
@@ -18,3 +18,9 @@ coverage/
|
||||
# 浏览器走查的截图与日志(本地取证用,不入库)
|
||||
.playwright-mcp/
|
||||
/*.png
|
||||
|
||||
# SDD 工作区(ledger / brief / 评审包)—— 记录归 git 历史,过程件不入库
|
||||
.superpowers/
|
||||
|
||||
# 稳定版快照与运行态(./stable.sh 写的)
|
||||
.stable/
|
||||
|
||||
@@ -1294,6 +1294,772 @@
|
||||
的 card-sent 也算隔开),一句话只领一张卡。3 测(26 → 全绿)。**同一症状两条独立的路**
|
||||
(时钟偏 / 并卡误认),治完一条要把另一条也走一遍 —— 这是 08-23 灰底那课的第二次。
|
||||
|
||||
- **工作成果里摆着别单的资料:「这一单产出的」被算成了「这张表里所有的行」**(2026-08-25
|
||||
柴浩:「这个工作成果怎么有 4 个资料,这单的需求只会产出一个」):那一单只写了
|
||||
`CT-2026-0004` 一行(三笔留痕全指着它、放行合并 3 行),而工作成果里摆着四份 ——
|
||||
另外三份是**品牌内容库里历次跑出来的资产**。病根一处、抄了两遍:放行存档
|
||||
(`archiveAssetFiles`)与在办列单(`assetFilesOf`)取行都用 `taskView(where:{})` ——
|
||||
那是「正式版全表 + 本单草稿」的合并视图,而资料常落在**主数据**上(`content.asset`),
|
||||
整表里躺着别单放行过的行。台账契约本就把这两件事分得清清楚楚(`taskDrafts` 的注释:
|
||||
「引擎求值要区分『这一单的事实』与『别人放行后的事实』」),视图模版也一律绑 `mine:`
|
||||
——只有资料这两处忘了。修:抽一段 `assetRowsOf` 存档与列单同用,认**本单碰过的行**
|
||||
(草稿键),值仍取叠加行(改过一格的行按落库后的样子造文件);`touched` 是必填入参 ——
|
||||
「哪几行是这一单的」每个调用方都得回答得出来。e2e 补**份数断言**(存档份数 =
|
||||
本单写下的资产行数,多一份也判失败 —— 从前只断「取得回、渲得出」,多摆几份照样全绿)。
|
||||
用户那一单误存的三份已删,工作成果现摆 1 份、点开即那份 .docx。5 测。
|
||||
顺手清两笔:`wake.test.ts` 的 `as unknown as`(§12.5 禁止清单,lint 红)、
|
||||
`wake` 契约的 api-report 快照(上一轮没入库,api:check 一直红)。
|
||||
- **统一鉴权中心(Keycloak)接入,只做后端**(2026-08-25,ADR-012):资源服务器 —— 后端只验 Bearer JWT
|
||||
(`jose`,RS256 / iss / aud 本地验,不回源);`Staff.id` = `sub`,首登即建、Admin API 同步名册;
|
||||
**权限只到端**:`sys.role_access` 一行一个 realm 角色,运营中心可改(锁死不变量:改完得有人还能进运营中心);
|
||||
路由归属写在契约 `access` 上,app 层一处中间件统一判 401/403,治理路由里 59 处守卫整体删除,
|
||||
覆盖测试 fail-closed;SSE 与下载走 60 秒票据;口令 / 会话表 / 岗位授权退役(八条路由回 410,契约留一轮)。
|
||||
web 待按前端手册接入,接入前登不进。
|
||||
- **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与
|
||||
企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 →
|
||||
`sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。
|
||||
分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。
|
||||
发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、
|
||||
运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。
|
||||
15 条 `/api/gov/mail/*` 路由**全部归运营中心 `ops`**(读写都不给设计端 —— 邮箱那一屏没有设计端消费方,入站正文是客户来往内容,
|
||||
见 ADR-013 决策 10);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。
|
||||
欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由(共十五笔见 debt.md)。
|
||||
- **「工作台疯狂轮询三个接口」= 两台后端互桥回弹的广播风暴**(2026-08-26 用户报):前端没有任何轮询
|
||||
(`refetchInterval` 全仓只在测试台),是 SSE 在以每秒几千条的速度推 `system` touch —— 挂在库的
|
||||
`jcai_touch` 频道上实测 **45 秒 192,270 条**,全是同一条 `POST /api/gov/process/validate`,
|
||||
在 8787 与 8790 两个进程之间严格交替、条数相等。病根 `broadcast-bridge.ts`:收到别人的 NOTIFY 就
|
||||
`broadcaster.publish` 到本地,而桥自己也订阅着这个 broadcaster,那条转发被当成「自己推的」再 NOTIFY
|
||||
一遍(from 换成自己)→ 对面不是它发的、再转发、再 NOTIFY……「自己发的自己不收」只挡一跳,两台接力
|
||||
就是永动机;08-24 验桥时只有一台在跑,没有第二方回弹。修:转发进来的那条打记号(WeakSet,publish
|
||||
同步派发),「本地 → 库」那一侧认出记号就不发;`bridgeBroadcaster` 第三参 `self` 可注入(同进程两座桥
|
||||
才模拟得出两台机器)。2 测:转发不再 NOTIFY 出去 · 两台互桥一条广播各到一次就停(假频道封顶 50 条,
|
||||
让用例失败而不是把测试进程撑爆 —— 第一版真·无限递归 OOM 了)。两侧任一打上补丁回弹即断。
|
||||
连带看见但未动:`task-service.tick` 每拍无条件 `setPhase` 广播 + 每拍推帧 + 推进器 `advanced`,一拍
|
||||
3-4 条 `task` touch,前端 `invalidateFor` 无合并 —— 推送有多密请求就有多密(记 debt)。
|
||||
- **工作台任务界面给 SSE 配了 10 秒兜底轮询**(2026-08-28 用户定):`app/fallback-poll.ts` ——
|
||||
任务界面开着时每 10 秒把 `desk` 下**在用的**查询(清单 / 这一单 / 角标,`type:'active'`)各重取一次,
|
||||
推送漏了画面最多旧 10 秒;**不取消在途**(`cancelRefetch:false`,慢接口不被兜底打成永远取不回)、
|
||||
页签藏起不打、回前台先补一趟;壳层 `useEffect` 起停,切模块 / 卸载即 `stop()`(定时器与可见性监听一起摘)。
|
||||
`doc` 可注入,4 测钉死(周期 / 停止 / 可见性 / 在途去重,第四条做过变异验证)。
|
||||
§9.2「组件禁止自行轮询」照旧:兜底只此一处、由壳层起停。`queryKeys.desk` 新增作整支前缀。
|
||||
- **录入卡在等人时,工作区摆占位页,不摆件**(2026-08-28 用户看柴浩的差异评估单定的):建档那一步
|
||||
(「客户 123 不在客户档案里,请核对建档」)要人做的事全在对话区那张录入卡上,而工作区里躺着的是
|
||||
**上一拍**推的 HTML 件,人以为要在那份工单上办点什么。前端判:`pendingCardsOf(detail.chat)`
|
||||
(纯函数,5 测)认出未确认的录入卡 → `ViewRegion` 在 running 之后、viewHtml 之前插一支占位
|
||||
`EmptyState`(点名那张表的人读名,指向对话区);决策卡**不走**这一支 —— 人要决定的常常就是件上那份稿。
|
||||
|
||||
- **第三个活动 ACT-WORK-05 差异评估已入库并全流程走查**(2026-08-27,`part-substitution-assessment@1.2.6`):
|
||||
原 PN 与候选替代料**逐项差异评估** —— 比参数、判四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)、
|
||||
定推荐等级(推荐 / 条件推荐 / 备选 / 不推荐)、留证据与验证任务,落 P-11(替代关系 + 差异明细)、
|
||||
回写 P-01,发一口 `assessment-released` 交 ACT-GATE-03。
|
||||
**九个计划项**(五处不给 doneWhen):S0_bind → S0d_p08_master / S0d_p10_document(两张数据工单各一份件 ——
|
||||
一个计划项只能绑一份画面,不能共用)→ S1_input_confirm(唯一的执行工单,零输入)→ S2_normalize →
|
||||
S3_assess(两枚纯函数工具:`compare-parameters` / `recommend`)→ S4_review(决策卡循环)→
|
||||
S5_prepare_landing → T_release。
|
||||
**新建 4 域 7 张主数据表**(`parts.profile` / **`parts.parameter` 子表** / `parts.document` / `demand.request` /
|
||||
`substitution.relation` / `substitution.difference_item` / `validation.record`),主数据域 4 → 8。
|
||||
五张模版**逐字节搬自目标件**(`views/target-style.ts` 是抽取的原样式表,不用 E04/I01 的外壳)。
|
||||
**六轮浏览器走查全部 `released`**(都在库常路 / 候选缺库 / 三候选含硬约束不推荐 / 原PN缺库硬阻塞 /
|
||||
新客户建档 / 录入卡复验),覆盖决策卡、审核驳回循环、终局重做循环、数据录入卡四条人环分支;
|
||||
逐单 SQL 复核落账(`customer_approval_status` 每单留空 —— 只读边界守住,`rule_version` 随结论落账)。
|
||||
逐轮结论与 A–K 十一条发现见 `docs/ACT-WORK-05-端到端测试记录.md`,字段口径见 `docs/数据域字段说明-W05.md`。
|
||||
**三条值得记住的**:①**「机检只读基座数」第六次现形,这次是存在性不是计数** ——
|
||||
原 PN 根本不在库而模型判「在库」,剧本已写到头;治法是 `pn_check.found_pn_ref`
|
||||
(`refersTo: parts.profile.internal_pn` + `requiredWhen: {exists_status: found}`)——
|
||||
**判在库就必须拿得出一个基座查得到的料号**,查无此值当场拒、填不出则收不了步。
|
||||
②**模型会压拍,压过之后只有说明书还在**(第四次):S1 的「先写清单再发卡」只在剧本里,
|
||||
压拍那一拍卡发出去而清单一行没有,人对着空表;规矩搬进说明书。
|
||||
③**措辞治不了的结构治,但先分清「已经说过」与「从没说过」**:同一天的发现 I 是前者(该上结构),
|
||||
发现 J(数据工单派给了在库的那颗)是后者 —— 剧本从没写过「found 的不建」,补一句话就够。
|
||||
平台侧四条欠账已记 debt(`disable` 无路可走 · 前一项不收掉后一项的件推不出来 ·
|
||||
拒回数不出「缺的是一次工具调用」· 零行台账的提交闸无牙)。
|
||||
|
||||
- **脚本 e2e 逼出的八条:两条线逮到的缺陷不重合**(2026-08-27,`w05` 五案例连跑六轮):
|
||||
浏览器走查逮**人看见什么、点得动什么**;脚本逮**机制** —— 它每张卡都先空手交一次、
|
||||
每个岔路口按序代答,而人手点的时候永远不会去试「空着交上去会怎样」。**发现 L–S 八条,
|
||||
没有一条是浏览器那六轮逮到的**。末轮:跑到的四例全部 released、三例被拒 0
|
||||
(第五例停在大模型账户 `Insufficient Balance`,不是代码问题)。
|
||||
平台侧六条(全部有测):①**闸看错了表** —— 卡没绑表就回落到必交付物,而数据工单要人补的是
|
||||
`parts.*` 主数据、不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。
|
||||
契约新增 `ExecutionWorkOrder.gateLedgers`(**这张卡该拦哪几张表,发卡的人说了算**),
|
||||
`gateAddressesOf` 变三级取一(绑的表 > 卡自报 > 交付物);②**拒回数不出「缺的是一次工具调用」** ——
|
||||
`finish` 撞 doneWhen 为假时只谈「行与列」,现在摆判据原文 + 那几个名字此刻的读数,
|
||||
`tool_*_calls` 读到 0 的单独点名;③**拒回喂指针没用**:人点了决策卡、模型每拍再发一张,
|
||||
把「他选的是 approve」写进拒回**照发不误** —— 下一拍它又从头翻一次会滚走的窗口;
|
||||
改结构 `answeredCardFact`,把「这张卡已答过 → 他选的是什么」做成**每拍都在的事实行**
|
||||
(worldTouched 2026-08-21、E04 事实行 2026-08-24 之后同一条道理的第三次);
|
||||
④模型传 `requirement: null` 把工具打崩(边界上一律经 `text()` 归一);
|
||||
⑤拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— **假线索模型会去追**;
|
||||
⑥**零行 = 零缺**:模型压拍没写骨架就发数据工单,人打开是空表,闸与哨兵一起放过
|
||||
(`emptyGate`:卡自报的表一行都没有 = 没完成);⑦**「早收」不该算撞墙** ——
|
||||
工具还没轮到调 `finish` 先到了,那是计划次序的小错,头一次只落观察、第二次起才算被拒
|
||||
(与「重发已答过的卡」同一把尺)。
|
||||
活动侧 1.2.9:三张执行工单各自声明 `gateLedgers`;说明书补「发卡前骨架必须先有行」与
|
||||
S1 那张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账。
|
||||
e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(卡会落到 token 主人的工作台、不续期;
|
||||
服务账号 `client_credentials` 仍是正路)。
|
||||
|
||||
- **数据工单确认即入库**(2026-08-27 下午,用户:「数据工单里面填的数据应该是确认这个数据工单后
|
||||
这些数据就应该入库了」):查下来草稿层通往正式表只有整单放行一道口子(`releaseWrite(taskId)`)——
|
||||
W05 的 P-08/P-10 卡上写着「确认后即入库」,而人建好的主数据要等整条评估放行才进表,试跑单
|
||||
(不落主数据)永远不进。**不能简单地「确认时 releaseWrite 那几张表再清草稿」**:引擎的 `mine:` /
|
||||
`_rows` / 哨兵(`parts_profile_rows >= created`)、视图 `data-rows="mine:parts.profile"` 全靠草稿层
|
||||
认「本单写下的行」,清掉就当场断。修法:`releaseWrite(taskId, { only, cause })` **范围放行** ——
|
||||
只并这张卡提交闸盯的那几张表(`gateLedgers` / 绑的 `blocks`,与闸同一份名单),并过的行**留在
|
||||
草稿层打 `landed` 记号**(`__draft.landed_at`):读面一字不变,整单放行不再写它、不抛事件、不做
|
||||
冲突检查(`landedEarlierRows` 分开报);再写一笔记号擦掉、基线换成落地后的值。事件不指回本单
|
||||
(它正等自己的人事件)。app 侧 `data-order-landing.ts` 在回传路由里、保存与回传落定后调它;
|
||||
失败不拦人(痕上说清,放行时再并)。`createOnly` 与 `drop` 对本单已落地的行改口(别 drop ——
|
||||
撤了读面少一行);数据域「在办草稿」不再数已落地的行。活动 1.4.8:两张数据工单的剧本改口
|
||||
「已入正式表,别再写、别 drop」。测:ledger IT 3 条(范围/记号/再写/试跑)+ app 7 条 + 域 1 条 +
|
||||
活动 1 条;e2e w05 数据工单交卷即验痕(`JCAI_E2E_LAND_MASTER=1` 时连正式表一起验)。
|
||||
|
||||
- **验收卡只许有一种样式;验收件题头改验收绿**(2026-08-27 下午,用户:「验收卡怎么有两种样式,
|
||||
统一都使用评审放行与落账确认这一步派出的这个验收卡的样式;中央 html 模版区域如果是验收工单
|
||||
把标题部分的红色改为和验收卡一样的绿色」):①审核卡(决策卡 `variant:'review'`)从前只换了头上
|
||||
的字、身子还是 A/B 选项列表;现在照终局验收卡 `AcceptForm` 那一套摆(`ReviewForm`:请你验收 ·
|
||||
验收工单入口 · 理由框 · [重做] … [确认通过]),机制不变 —— 通过交 approve 项,重做写了理由走
|
||||
`custom:<理由>`(基座按字面点名对行)、没写走驳回项;判断抽成纯函数 `review-face.ts`(7 测)。
|
||||
「验收工单」钮对没有工单行的审核卡也要有反应:`ViewRegion.locate` 找不到那张单就把待处理翻出来
|
||||
(件在那一屏)。②W05 两张验收件(ACC1 差异评估结论审核 / ACC2 评审放行与落账确认)的 `--ledger`
|
||||
由目标件的 #b23a2e 红改成对话区验收卡同一枚 #0aa294(`--st-collab`),题头渐变与徽章投影同改;
|
||||
CONDITIONAL 状态徽标是语义色,保留原红。W05@1.4.9 已装;浏览器走查未做(需登录统一鉴权)。
|
||||
|
||||
- **S1 清单把人刚填的参数摆成缺口:示例引了原话,模型照抄示例**(2026-08-27 下午,用户:「第一步已经
|
||||
补充了 XL2596S-5.0 使能阈值,当前这一步还要填一次?」):人在 P-08 件上填了使能阈值 2.5(已入库),
|
||||
两分钟后 S1 清单上却有一行「XL2596S-5.0 使能阈值 · 原厂未披露」。查下来 S1 剧本写着
|
||||
`input_item 如「XL2596S-5.0 使能阈值」`、说明书⑤卡骨架写着「原厂未披露的参数(如使能阈值)」——
|
||||
模型按示例造行,不读现值(值就在 mine:/focus 里)。与 08-03「反例引了原话,模型照抄反例」同一课:
|
||||
**示例也不引具体料号 + 参数名**。W05@1.4.10:示例改成占位形「<料号> <参数名>」,写明「先读现值,
|
||||
人填了值的参数不是缺口、零行合法」;包内测钉死。顺带看见:压拍时 `focus:` 只带拍首那一步的 reads
|
||||
(S0d_p10 那一拍连做 S1 的写与发卡,S1 声明的 parts.parameter 不在 focus 段,只靠 3000 字的 mine: 段)。
|
||||
|
||||
- **运营中心 · 活动模块接口落地(L1/L2/L3 十一条,全 `ops`)**(2026-08-27,`docs/API文档-活动模块.md` 已按契约回写):
|
||||
契约 `packages/contracts/src/{activity-ops,api/activity-ops}.ts`;服务端住 app 层 `apps/api/src/app/activity-ops/*`
|
||||
(纯函数:启动参数派生 / 五态映射 / 时间线 / 摘要 / 视图注入;raw SQL 存取;服务编排)+ `routes/activities.ts`,
|
||||
写全走既有 port(`task.start` origin=ops / `task.control('terminate')` / `registry.setEnabled`)。
|
||||
**设计端两条改名让位**:`GET/POST /api/gov/activities` → `/api/gov/activity-packages`(前端零改动,import 的是路由对象)。
|
||||
迁移 0034(`sys.task.seq` 人读实例号 `INS-<seq>` + 按活动索引)/ 0035(`sys.activity_launch` 幂等键一句 SQL 判占、
|
||||
`sys.activity_profile` 负责人/说明/图标/色相,seed 按岗位填三个真包,取不到留空)。
|
||||
五态是对 task 的如实映射(`pending` = 建单未被推进器领到;`rejected` = 打回未重提交那一段)。
|
||||
W05@1.5.0:四个启动字段加 `x-control`/`x-options`/`description`(combo 候选从正式台账现读),
|
||||
全流程分支图作 `activity-overview` 沙箱视图(模版无脚本,报高脚本由出口注入,协议 `jcai:view-height`)。
|
||||
前端 §10 对接另起一轮。测试:契约 7 + app 层 33 + W05 包内 +1。
|
||||
|
||||
- **差异评估结论审核件改版:一个候选一张表**(2026-08-27 傍晚,用户改了目标件
|
||||
`docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-01-差异评估结论审核.html`:「存在多个替代 PN 要对比
|
||||
差异时看不清楚,现在将每个待选 PN 分开」):渲染器从前只认一层 `data-rows`,做不出「外层按候选复制、
|
||||
里层只列它名下的明细」。补**嵌套行模版 + `data-by="子列[=父列]"` 父键过滤**(`view-render.ts`,里层先按
|
||||
本行展开再填本行标量;4 测),它也能当查表用 —— 品牌不在 relation 上,`<span data-rows="parts.profile"
|
||||
data-by="internal_pn=candidate_internal_pn">` 从整表挑那一颗料的行。件照目标件重搬:候选 PN 结论汇总
|
||||
(一个候选一条;差异统计绑 `difference_summary`,S3 剧本本就让它写成「7 项比对:4 MATCH / …」)+
|
||||
`<article data-rows>` 包 `<section class="cand">` 的分组明细(`<tbody data-by="substitution_relation_id">`);
|
||||
判据四条照目标件改;ACC1_CSS 整段重搬(.cand*/.rec*/.stat*,CONDITIONAL 改蓝),主题色仍取卡的
|
||||
#0aa294(目标件改版自己写的是 #16a34a)。W05@1.4.11。规范 §4 绑定表补 `data-by` 一行。
|
||||
|
||||
- **「库里明明有值,清单还要填一遍」第三次复现:根因不是措辞,是让模型宣布台账事实**(2026-08-27
|
||||
傍晚,用户:「都已经出现几次这个问题了,自己找到根因并解决」):1.4.10 改了措辞照样复现 ——
|
||||
这一单 XL2596S-5.0 已在库、三颗料 21 个参数全有值、说明书已是新版,模型仍写出
|
||||
「使能阈值 · 原厂未披露」并在交代里说「原厂没披露」。三条线索:①S1 的「待确认」行是**模型对参数表
|
||||
的一句断言**,基座没有任何读数核它;②说明书里「使能阈值」与「未披露 / UNKNOWN」同句出现三处,
|
||||
其中一处是我 1.4.10 写的反例原话「使能阈值 · 原厂未披露」(08-03「反例一律不引原话」那课又犯一遍);
|
||||
③S-FEEL 没有列表过滤,想拿判据核「gap 行数 = 未披露参数数」也写不出。修(W05@1.5.1):**S1 一行参数
|
||||
都不写**(item_key 不许以 unknown_ 开头);件上加「候选参数现值」只读块 —— `<article data-rows=
|
||||
"mine:sub_eval.pn_check">` 逐颗复制,里层 `parts.parameter` 按 `data-by="internal_pn"` 现读,人看得见
|
||||
每个参数此刻的值;未披露的由 S3 判 UNKNOWN + 验证任务(本来就是它的活)。说明书全文不再出现
|
||||
「使能阈值」(包内测钉死);S3 示例改「实测<参数名>」。设计律:**台账里有的事实,不许由模型在别处再宣布一遍。**
|
||||
|
||||
- **「你真的改好了吗」—— 改是改好了,库里没有**(2026-08-27 傍晚):新起的单 `loop_state` 里还是旧说明书。
|
||||
查下来两条:①`sys.activity_package` 里 1.5.0 / 1.5.1 两行都是**另一个会话**在同一个 checkout 上
|
||||
改出来的(activity-overview 视图 + 启动表单 x-control),各比我的安装早几分钟占了版本号 ——
|
||||
我两次 `activities:install`(候选分组件 / S1 参数行退役)全是**同版重装 no-op**,脚本还打印
|
||||
「活动包已装」;②`manifestOf` 每次读库,不是服务缓存。修:升 1.5.2 重装并**用 SQL 核库里的清单**
|
||||
(查 JSON 文本别用 `\"` 手写转义 —— 第一次核出个假阴性);`InstallVerdict.stored`(契约,默认 true)
|
||||
+ 安装脚本同版时改口「已是这一版,未重装」。规矩:**装完看库,不看脚本的话;两个会话同改一个包,
|
||||
版本号先问库里最高的是几。**
|
||||
|
||||
- **「验收卡又变成需要你定」「差异明细两张表没渲染出来」—— 同一个根:稳定版后端跑的是早上的代码**
|
||||
(2026-08-27 傍晚):`.stable/RELEASE` 是 `3171d8a`(11:53 构的),早于「审核卡摆成验收工单」那一提交,
|
||||
更早于当天所有改动;它与开发后端(:8787)抢同一个库的租约,抢到哪一拍就用旧代码发那一拍的卡、
|
||||
推那一拍的帧 —— 决策卡没 variant、帧里 `data-rows`/`data-by` 原样漏出(旧渲染器不认嵌套)。
|
||||
修:`./stable.sh update`(稳定版现为 `7555337+未提交改动`,与开发一致);并把审核卡的面孔改成
|
||||
**引擎按剧本定**(`faceOf`:点名的项优先,挂不到就按当下在办的那一步 → `cards.decide.variant`),
|
||||
不再只靠模型给对 itemId(2 测)。排查法:先看 `.stable/RELEASE` 的 commit 与 `lsof -iTCP:8790` 的
|
||||
进程启动时间 —— **两台后端一个库,先问另一台跑的是哪一版。**
|
||||
|
||||
- **「叫了人我都没填你就直接推进了」—— 两道结构闸**(2026-08-27 傍晚,用户:「不能再有叫了人又继续
|
||||
自动推进的问题」):实测账 —— S1 的执行卡 17:48:00.2 发出,0.3 秒后投到的是**上一张(P-10)卡回传的
|
||||
人事件**(回传落库那一刻已 wake、推进器读 card-reply 先跑了那一拍,事件晚 12 秒),`deliver` 照样
|
||||
tick,再一拍模型读到事实行「这一步已经发过卡,人已经被问过了,照『人交回之后』办」,把没人答的 S1
|
||||
收掉。①`task.deliver`:人事件带 `occurredAt`,任务在这条回传之后推进过就只记到达痕(stale)不推;
|
||||
②`runTurn`:`cards.unanswered(taskId)` 非空这一拍不问模型(`human-tool.pendingCards` = 没办结工单 +
|
||||
没作答且没被顶替的对话卡),谁叫醒都推不动 —— 这一条管住所有多余唤醒(另一台后端 / 手动 wake)。
|
||||
5 测(task 3 + turn-runner 2)。两台后端都要装上:dev 自动重载,稳定版 `./stable.sh update`。
|
||||
|
||||
- **审核卡 = 终局验收卡的结构:判据表,不是参考信息**(2026-08-27 傍晚,用户:「给你说了要最后一个
|
||||
评审放行与落账确认这个验收卡的样式和结构,没有参考信息这个东西,变成 L1、L2 这种东西」):
|
||||
`decisionCardSchema.criteria`(可选)—— 任务详情读时对**没作答的 review 卡**现算同一摞判据
|
||||
(`checks.of`,L5 不上卡,过契约 schema 归型),与终局验收卡同一段算法同一份读数;`ReviewForm` 摆
|
||||
`Crit` 判据表 + 头上「n/m 达标」,卡上不再有参考信息 / AI 建议。答过的卡不带判据。
|
||||
|
||||
- **验收卡的条目 = 人工验收清单,人点「达标 / 不达标」,点全才能通过**(2026-08-27 傍晚,用户定,先只做
|
||||
ACT-WORK-05):从前卡上摆的是系统判的判据(L1/L2…)让人签;用户定「不是系统判断的条目,是人要对这个
|
||||
验收工单的内容进行审核需要的步骤,每份验收工单固定」。契约:`PlanStep.acceptanceChecklist`(剧本声明)→
|
||||
`AcceptanceWorkOrder.checklist` / `DecisionCard.checklist`;回传 `checked`(落 `done.checked` /
|
||||
`chosen.checked`);human-tool 回传校验:通过必须 `checked ⊇ checklist`(`checklist-incomplete`),审核卡只拦
|
||||
「通过」选项。引擎:审核卡随面孔带清单(`faceOf`),终局验收卡在 submit 时从剧本取(声明了清单且不是审核步)。
|
||||
前端 `Checklist`:序号格 + 右侧开关(**待确认灰 ↔ 已确认绿**,用户定;不叫「不达标」—— 没点过不是判了不合格),
|
||||
头上「已确认 n/m」,确认通过灰到点全;答过的只读摆当时点的。W05@1.5.3:
|
||||
S4_review 4 条、T_release 5 条(按两份目标件的【验收判据】写成人眼能核的动作)。E04 / I01 未动(用户定先不管)。
|
||||
`decisionCardSchema.criteria`(上一轮加的)撤掉。测:card-rules 2 + turn-runner + review-face + 包内 1。
|
||||
|
||||
- **「标红的填了还是不让推进」:红带点的是「缺输入项」,不是补充内容**(2026-08-27 晚,用户实测):S1 清单
|
||||
11 行全没有 `input_item`(必填的人读名列),模型这一拍只写了 status/detail/origin;件上红带的固定前缀写死
|
||||
「补充内容没填」,人于是把 11 行的补充内容全填成「1」,闸照拦(它拦的是输入项)。病根:剧本只在括号里提过
|
||||
一次「input_item 用人读名」,逐行做法里从没再说 —— 措辞的「从没说过」那一档。修(W05@1.5.5):剧本逐行写死
|
||||
六列与每行的人读名(客户 / 客户需求 / 原 PN / 候选 N / 应用场景 / BOM 位 / 替代类型 / 规则版本 / 负责人期限 /
|
||||
原 PN 资料版本)+ 发卡前自核 `_input_gap_missing = 0`;红带前缀改「必填格」不写死列名;包内测钉死。
|
||||
顺带答用户「这些输入项库里拿不到吗」:输入项只是每行的名字(AI 该写),不是库里的数据;真要人填的只有
|
||||
应用场景 / BOM 位两格 —— 它们不在任何表里(P-01 是本单新建的、P-09 BOM 没建模)。
|
||||
|
||||
- **进度条钉在 1/6 · 重做意见变成「驳回(不写具体意见)」**(2026-08-27 晚,用户实测两条):①`phaseProgress`
|
||||
从前「一段里的项全收完才算过」,都在库时数据工单两项永远 Available,条钉死在第一段;改成「案子走到了哪一段」——
|
||||
最后一个有项动过(Active / Completed / Terminated)的阶段,全收完就进下一段,没收完停那一段;一项没动过才退回
|
||||
老规矩(3 测)。②模型把审核卡骨架里的 `allowCustom` 抄丢,引擎默认 false,前端写了意见的重做只能退回驳回项,
|
||||
卡面摆的是选项原标签;现在审核卡一律收自定义意见(`allowCustom ?? reviewFace`),答过的卡摆**验收的话**
|
||||
(确认通过 / 重做:<意见> / 重做(未写意见)),不摆选项标签。
|
||||
|
||||
- **「评审放行与落账确认」派了两次:先一张执行单,人答完才 submit 又一张验收单**(2026-08-27 晚,用户送来
|
||||
5616e13d 那单):S5 在同一拍里被自动收步后模型接着被问,读到人工激活步的事实行「唯一合法的动作是先发那一张卡
|
||||
(human/entry/decide)或立监听」,就给 T_release 发了一张执行工单;下一拍才 judge + submit。修三处:
|
||||
①`isReleaseStep`(剧本声明了人工验收清单且不是审核步)—— `human` 挂到放行步当场拒并指向 submit;
|
||||
②放行步的事实行只指向 submit(不再套执行步那句);③验收卡题目取案例模型里那一项的名字
|
||||
(「评审放行与落账确认(ACT-GATE-03)」),「收口验收」只是兜底。W05@1.5.6 剧本补一句「不发执行工单」。
|
||||
2 测 + 包内 1 测。
|
||||
|
||||
- **工作成果详情右上角只翻页,不翻份**(2026-08-27 晚,用户:「这个东西不是让你在整个工作成果里面切换,
|
||||
是让你在单个工单有多个页面的情况下切换的」):08-23 加的「翻份」撤掉;右上角那对箭头只在这一份件里翻页
|
||||
(一页也摆 1/1),换一份成果回列表点。
|
||||
|
||||
- **验收卡「又变成老的」:投影按标题里有没有「验收」二字挑面孔**(2026-08-27 晚,用户实测):
|
||||
`chat-projection` 从前拿 `title.includes('验收')` 决定 card-sent 是判据卡(`criterion` → `AcceptForm`)
|
||||
还是执行卡(`guidance` → 通用「派出工单」气泡),而同晚验收卡题目改成了案例模型项名
|
||||
「评审放行与落账确认(ACT-GATE-03)」—— 四个字不在了,人工验收清单整张不见,摆出的是 11 条判据 +
|
||||
「打开报告」。同文件上面几行早写过「标题措辞是给人看的、随时会改」。改认痕里的结构化键 `mode`
|
||||
(发卡时随 payload 入痕),老痕没有它才退回标题。2 测;读时投影,在途单刷新即好;稳定版已更新。
|
||||
|
||||
- **「字已签下,正在落地」停住不动:迟到守卫把放行裁决也丢了**(2026-08-27 晚,用户送来 317c9124):
|
||||
同晚加的「迟到的人事件不再推」(`lastProgressAt > occurredAt` → 只记痕不投)前提是**那一拍能从
|
||||
留痕 / 卡库读到答复**(执行单填报、决策卡选项都如此);而放行裁决只走事件这一条路 —— loop 的
|
||||
`onEvent` 把 verdict 落进在途验收信封。实测账:签字 04.129 落库 → `wake` → 推进器抢先一拍
|
||||
(在途验收没裁决原地返回,但 lastProgressAt 挪到 04.483)→ 事件 05.774 才到 → 判 stale 丢掉,
|
||||
再没有任何事件叫醒它。修:`payload.mode === 'acceptance'` 的人事件不进迟到守卫(认结构化键);
|
||||
1 测。那一单以「删 event_consumption 行 + 投递行翻回 pending」让派发器按新路重投 → released、
|
||||
合并 24 行、发口 assessment-released。**两台后端都装上再重投** —— 稳定版的派发器同样会抢这条。
|
||||
|
||||
- **P-10 的卡对着 P-08 的件:画面跟着这一拍发出去的卡走**(2026-08-27 晚,用户送来 3ef3fc3e:
|
||||
「当前这一步不是应该给我推 P-10 的数据工单吗?怎么还是推送的芯片主数据创建」):模型没收掉
|
||||
S0d_p08_master(剧本两处白纸黑字「先 finish 再发下一张」,措辞治不了)就发了 S0d_p10 的数据工单,
|
||||
两步同时 Active,推帧按「第一步没干完的」取了 P-08 的件 —— 人拿着 P-10 的卡,屏上没有资料表可填,
|
||||
提交闸拦「缺资料」。08-23 那刀(按 currentRuntime 重算)治不了两人环项相邻的形状(debt 早记了)。
|
||||
结构治:`applyAction` 的 human / entry / decide 三条路把 `issuedFor`(卡是哪一步的)交回拍内,
|
||||
推帧时那一步声明了画面就推它,压过别的规则;没发卡的拍照旧。1 测;debt 销账。
|
||||
在途那单以「task_view.template 改成 p10-document-create + `views.repush`」救活(tsx 起容器跑脚本,
|
||||
`-e` 不支持顶层 await,要写成 .mts 文件)。
|
||||
**根治**(用户:「根因修了吗?不要只修表象」):病根是**这一步办完没办完是引擎手里现成的事实
|
||||
(卡答了、数据入库了),却让模型去记得 finish**。①引擎:人工激活的项在 `worldTouched` 之后照常求
|
||||
doneWhen(此前一律跳过,怕替人签字 —— 门槛保留:卡没发过不收),拍首扫一遍、**拍尾再扫一遍**
|
||||
(人答卡那一拍模型写完东西 doneWhen 才真;刚发卡的项排除;submit 拍不扫免擦信封);
|
||||
②W05@1.5.8:P-08 / P-10 各给一条只读入库事实的 doneWhen(要建的每颗料 = absent + created,
|
||||
主数据/参数/资料都落了、必填不缺、逐颗对账),S1 哨兵不再等模型写 `created` 旗(那是「机检只读
|
||||
基座数」的哨兵形);剧本里「顺序不能反」改口「引擎也会自己收」。现有 E04 / I01 / W05 没有任何
|
||||
人工激活项声明过 doneWhen → 零回归。3 引擎测 + 包内 220 测;规范 §4.4 / 建模纪律 3、4 改口。
|
||||
e2e w05 这轮没跑(用户的测试台占着,起单会顶掉他的单)。
|
||||
|
||||
- **工作区正文里的占位上下居中**(2026-08-27 晚,用户:「AI 推进中 / 这单已收尾 / 还没有往来记录 …居中显示」):
|
||||
`.jc-empty` 早写了 `margin:auto`,可它住在自绘滚动区的视口 `.wf-body`(块级)里,纵向 auto 边距算 0,
|
||||
全贴顶边。与 08-x 治五模块空态同一招:`.wf-body:has(> .jc-empty / > .jc-working)` 才切成纵向 flex,
|
||||
件 / 日志 / 往来记录这些正文照旧从顶往下排。浏览器实证「这单已收尾」「还没有往来记录」两屏居中。
|
||||
|
||||
- **两颗一模一样的输入电压范围判成 MISMATCH:客户约束一票否决**(2026-08-27 晚,用户送来 0bddd862):
|
||||
客户写「输入电压在 45V 以内」→ 模型译成 requirement `>= 45`,原 PN 与候选上限都是 40V;`judgePair` 只看候选
|
||||
过不过客户线,不看原 PN 自己过不过 —— 需求边界与在用料的差距记在候选头上,硬约束 MISMATCH → 整颗不推荐。
|
||||
差异评估比的是「换上去会不会比现在差」:工具 `compare-parameters` 升 1.1.0 —— 候选与原 PN 相等 / 盖住、
|
||||
且客户线原 PN 也过不去 → 仍按比对结果判(MATCH,风险中),备注点名「需求边界,请回头核对需求」;
|
||||
候选更窄且过不了线仍 MISMATCH(3 形有测)。工具注册一名一行、upsert 换版本,handler 在进程内按名绑,
|
||||
装完稳定版重建即生效;在途单要人在审核卡上「重做」并写明意见才会重比。
|
||||
|
||||
- **差异比对的入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的
|
||||
harness 肯定有问题,要让它认真比对每一条,然后再输出结论」):同一单痕里模型念出「输入上限 32V 低于客户 40V」——
|
||||
那是 S3 剧本示例的原话,不是这单的数;工具入参只存哈希,喂了哪几对值事后查不到;写回时还把工具的 `equal`
|
||||
改成 `less`。两头都靠模型抄、两头都没有基座核。改三层:①工具运行时把 `taskId / activityId` 交给处理器
|
||||
(`ToolHandler` 入参加两个可选键);②`compare-parameters` 1.2.0:给 `originalInternalPn + candidates[].
|
||||
candidateInternalPn(+ requirements)`,参数行从 `parts.parameter`、封装 / 生命周期从 `parts.profile`、证据引用从
|
||||
`parts.document` 由工具现读任务视图(本单草稿也算),出参每项带原值与证据,写盘照抄;老形状(自带 pairs)照接,
|
||||
试运行走它;container 用 `compareParametersWith(ledger)` 顶掉静态名单那份;③≤800 字的工具入参整份进 `result`
|
||||
痕(审得了才谈得上认真);W05 S3 第一拍只报料号与约束、示例只留占位形,第三拍 compare_result 也照抄;
|
||||
S4 重比只改 requirements。13 工具测 + 包内 89。**基座能读到的事实,不让模型转述给工具。**
|
||||
|
||||
- **「下限~上限」一律是范围:解析器补齐写法、出参摆出读法**(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型
|
||||
搞不清楚,harness 里强调一下是范围值」):实测解析器真有洞 —— 单位写进值里(`4.5V~40V` / `-40℃~+85℃`)被读成
|
||||
单个数,Unicode 减号、`…`、`≥45 / 40 以内` 读成文本。工具 1.3.0:分隔符认 ~ ~ - – — … 至 到 to、单位可夹在值里、
|
||||
单边界读成半开范围;出参每项 `readAs:{original,candidate}` 写明「范围 4.5~40 / 定值 5 / 下界 ≥ 45 / 文本」——
|
||||
比对依据可见,模型照它写影响、不自己重新解释值;契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,
|
||||
不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。W05 升版装上。3 测(11+7 种写法)。
|
||||
|
||||
- **AI 把原 PN 的规格书填到了候选料号头上:人填的表要声明,判据不摆给还欠卡的那一步**(2026-08-28
|
||||
柴浩实测单 6ddcecf8,连撞五拍熔断):P-10 资料记录的标题 / 版本 / 链接与台账里 `LM2596S-5.0` 那一行**逐字相同**
|
||||
—— 不是幻觉,是「手边有一行看着对的就拿来用」(那一步 reads 摊开了 `parts.document`,视野里唯一一份真资料
|
||||
就是原 PN 的)。三层合谋:①把 P-08 的回卡认成 P-10 的(P-10 的卡从没发过);②我 08-27 给这一步加的 doneWhen
|
||||
把「资料必填不缺」摆成目标、后面还跟「成立了就 finish」,**与同一份提示词里「唯一合法的动作是先发那一张卡」
|
||||
直接打架**;③它顺手把 `exists_status` 改成 created,按剧本发卡名单自己空了 —— 一个合法动作都不剩。
|
||||
止血两刀:**①`PlanStep.humanWrites`**(契约)—— 声明的台账 `write` 当场拒并指路「去发那张卡,值由人填」,
|
||||
`drop` 不拦(写错了得撤得掉);判法认点名的项、点不出退回当下在办的那一步(压拍时骨架写在上一步名下正好放行)。
|
||||
**②还欠一张卡的那一步不摆 doneWhen** —— 判据同时是摆在模型眼前的目标,而它此刻只许发卡。
|
||||
W05@1.6.5 声明 `humanWrites: ['parts.document']` 并从 writes 去掉。4 测(墙 / 放行 / drop / 声明)。
|
||||
**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑那个判据。**
|
||||
用户同时点出更大的一条(记 debt,未做):**存在性核对 + 派数据工单是确定性流程,根本不该交给模型** ——
|
||||
平台缺「确定性步骤」(纯工具只读、判定表不写台账、doneWhen 只收步),所以只能写进 guide;
|
||||
引擎三样能力都有(taskView / draftWrite / issueExecution),缺的只是声明面。
|
||||
|
||||
- **确定性步骤已出设计,未动代码**(2026-08-28,ADR-014):盘完 W05 九步 ——
|
||||
**三步整个确定**(S0_bind / S0d_p10 / S5)、四步八成确定、只有 S3 是 agent 主场;一单十来拍里七八拍在做确定的活。
|
||||
agent 真正不可替代的只有四类:自然语言→结构化 · 读人的意见落到具体行 · 写给人看的话 · 非结构化抽取(实跑中一次没发生)。
|
||||
设计要点:**声明式配方进 manifest,不执行包代码**(ADR-004「不执行代码即知包为何物」;artifact 至今零执行,
|
||||
不为这个功能开沙箱面);三个动词 `project` / `card` / `say`,条件复用 S-FEEL、聚合复用基座读数,不发明第二套求值;
|
||||
`auto` 是「问模型之前先把确定的事做了」,作用域到交出控制权为止;**一步要么确定要么不是**(判断点先切出去,
|
||||
不做「一半 auto」);出错交人不重试不退回模型;静态审把地址/列名/S-FEEL 在装的时候审死。
|
||||
**回溯验算:已记录的八条事故落在会被 auto 掉的三步半上**(编号抄别单 / 判在库判错 / 派给在库的料 /
|
||||
没写骨架就发卡 / 清单漏列 / 库里有值当缺口 / 抄别的料的资料 / 撞墙五拍熔断)。
|
||||
立场要改口:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。**
|
||||
五条待定见 ADR §11(决策卡给不给配方 / 工具串接 / humanWrites 细到列 / S2 与比对工具的 join 重复 / T_release 第三条裁量该删)。
|
||||
|
||||
- **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行
|
||||
实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 +
|
||||
参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。
|
||||
**取数归活动声明、执行归基座**:契约新增 `viewTemplates[].data`(`case{ledger,key,prefix}` + `rows[{as,ledger,by,not,orderBy}]`,
|
||||
`from` 只能指 `case` 或前面的 `<as>.<列>`),`view-scope.ts` 按声明逐表等值读正式表、摊成扁平作用域
|
||||
(`<as>` / `<as>.<列>` / `_rows` / tally 计数 / `case:*` / `board:state` 三态)交 `renderView`,`/view` 出口照旧注入报高脚本。
|
||||
案号 = 前缀 + 开案时刻 14 位(`case-stamp.ts`,与引擎注给模型铸主键的 `case:openedAt` 同一函数 —— 实测 INS-20
|
||||
`13:51:03Z` ↔ `W05-20260827215103` 一字不差);放行后草稿层已清空,所以只能这么圈。`store.latestReleased`。
|
||||
W05@1.6.0(库里核过带 `data` 11 条);`views/overview.ts` 删除(docs 里的源文件不动)。测:契约 3 + view-scope 9 +
|
||||
service 3 + store 1 + 包内 4;真库渲染 `v-ready`、INS-21 / `W05-20260827215148`。欠账见 debt(挑单 / 行级来源 /
|
||||
`vtok` 三份 / `data` 装包静态审)。
|
||||
|
||||
- **确定性步骤已落地**(2026-08-29,ADR-014):`PlanStep.auto` 声明式配方,五个动词
|
||||
(`project` / `analyze` / `card` / `submit` / `say`)、四种地址、嵌套两层封顶;引擎在拍首跑,
|
||||
**发了卡就收拍(零 token)**,有 `guide` 的照常问模型。`analyze` 是**一次结构化 AI 调用** ——
|
||||
系统组装入参、声明出参 schema、系统落表,**不给工具不给记忆不多轮**。
|
||||
三条纪律进了规范 §4.6:**行由确定的那一侧决定不由 AI 决定** / 判据不许编数不出来的数 /
|
||||
每一笔之前刷读数。静态审在装包时把地址、列名、模型名、视图、submit 的验收清单逐条核死。
|
||||
配套四件:`data-options`(下拉选项从台账现读)· 件上保存后 `wake`(「保存即铺格」)·
|
||||
审核卡放宽到单选项 · 卡答复即收步。**E04 / I01 / W05 一个字没改** —— 它们的剧本里没有 `auto`。
|
||||
- **ACT-WORK-05 从『什么都丢给 AI』重写成『系统确定性取数 → AI 只在真需要判断处出场一次』**
|
||||
(2026-08-29/30,`part-substitution-assessment@2.1.6`,design spec
|
||||
`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`,用户原话立场:「不是把
|
||||
所有东西都丢给 ai 来处理…一定是系统先确定性的从数据库中取出数据后交给 ai 分析」)。
|
||||
九步砍成**四步**(S1_inputs 核对入参与补数据 → S2_assess **整个活动唯一一次** `analyze` →
|
||||
S3_review 结论审核 → T_release 落账放行),**全程零 `guide`**,行为整体搬进 `PlanStep.auto`
|
||||
声明式配方(ADR-014 第一个真实用户);零工具、零技能,两枚旧工具与两张 DMN 一并退役 ——
|
||||
规则只许有一处真源,活在配方的 `when` 链里。新建两张类型字典
|
||||
(`parts.chip_type`/`parts.type_param`):按芯片类型(不是按料号)标一次核心参数字段与
|
||||
硬软约束,「两颗料必须用同一套参数名」从此是**结构保证**,原先靠测三次都没拦住的
|
||||
自指引用墙退休。三张件(数据工单 · 结论审核 · 落账确认),数据工单按缺口条件发卡,
|
||||
零卡直通是常态路径之一。
|
||||
**e2e 五案例的真实战果**(2026-08-30,连续多轮真跑,详见
|
||||
`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-11-report.md`):候选缺库·新建类型 /
|
||||
都在库·零卡直分析 / 原PN缺库三例 `released`、**被拒 0**、**模型调用恰 1 次**、发口
|
||||
`assessment-released` 全对——**「候选缺库·新建类型」这条记录后来被全分支终审推翻**
|
||||
(见下方「全分支终审」条):那次 `released` 靠脚本走 API **显式传 `type_id:'90'`**,
|
||||
没有走真人在浏览器里点「新建类型」那条路;`e2e-a2.mjs` 自己的注释原话是
|
||||
「这条路径连真人在浏览器里都走不通」。**该走的那条路当时藏着 Critical 1**(见下方条目),
|
||||
这里的「released」只证明了 API 直传能通,不证明「新建类型端到端通了」。
|
||||
类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁
|
||||
(`prune` 与「数据工单确认即入库」的架构性冲突:人先交一次、字段已落地,再改类型选择,
|
||||
`prune` 想撤已落地的陈旧行被显式挡住,两条机制各自都对、合起来死锁);两颗都缺库那例
|
||||
卡在「决策卡答完之后不再推进」,`advancing_until` 反复核过是空、租约与陈旧进程两种假说
|
||||
均已排除,**真因未定位**,如实记录未解决,不强装成功。**`judgement_class` 逐行核对通过**
|
||||
(7/7:使能阈值 / 工作温度 / 开关频率 / 输入电压范围 / 输出电压 / 输出电流 / 静态电流,
|
||||
AI 判的 `difference_status` 与规则链算出的 `judgement_class` 逐行对上,`risk_level`
|
||||
联动也对)—— 这条验证之所以要点名贴出来,是因为它曾经**逐行全错**。
|
||||
**地基缺陷**:活动清单存进 `sys.activity_package.manifest` 这个 jsonb 列后,PostgreSQL
|
||||
按「先短后长、同长字典序」重排了所有嵌套对象的键,而 `project`/`analyze` 的 `fields`
|
||||
原来靠 `Object.entries` **声明顺序**求值(后一字段靠 `ctx.computed` 看前一字段已算出的
|
||||
值)——「声明序 = 依赖序」这条从没写下来的假设一进数据库就被打破,且**不报错**、
|
||||
隔离测试也复现不出来(它直接读 `dist/manifest.json`,不经数据库往返),只有真跑 e2e
|
||||
才现形。W05 实证:合成列 `judgement_class` 被排到它依赖的 `difference_status` 前面,
|
||||
读到 `undefined`,七行**全部**误判(`difference_status` 本身走 `lookup` 不依赖字段序,
|
||||
所以是对的 ——「一个对一个全错」的怪现象由此而来)。**修法是把字段求值从『声明序』
|
||||
改成『依赖拓扑排序』**(Kahn 算法,新增 `packages/utils` 的 `topoOrder`,契约纯新增);
|
||||
真正的环在装包静态审当场拒装,不兜底、不静默放行。四条新纪律入规范 §4.6(六条→十条):
|
||||
`fields` 顺序不可依赖拓扑排序之外的任何假设 / 跨笔顺序仍要人自己排(一笔读的是另一笔
|
||||
写出来的数就得排在后面)/ 方向相反的依赖拆两笔写、中间夹子表 / `gateLedgers` 同时管
|
||||
「闸要求非空」与「确认时并进正式表」两件事,漏想一半就会悬空引用或永久卡死。
|
||||
`pnpm gates` 退出码 0(2447 passed | 34 skipped)。
|
||||
- **W05 数据工单换版面已退回**(2026-08-31 用户:「改了 ui 之后这个版本我感觉有点问题」):
|
||||
`report.ts` / `target-style.ts` / `activity.test.ts` 三个文件**逐字节恢复**到换版面之前
|
||||
(45cec82 的样子),包版本 **2.3.0**(内容是旧版面 —— `store.manifest` 不传版本时取
|
||||
**最近安装**的那一条,不是版本号最大的,所以退源码必须配一个新号装进去才真生效)。
|
||||
**换版面那一版原样活在分支 `feat/w05-view-swap`**(HEAD `1373eb4`),随时切得回去;
|
||||
下面这条记的是它的账,留着当索引 —— 里面三条渲染机制的实锚(宿主闭合标签 / 行内
|
||||
class-of 只认行作用域 / data-by 回落)与平台侧探针测试**不随退回作废**,它们钉的是
|
||||
平台行为,测试与目标件 html 都还在主支上。
|
||||
- **(已退回,见上条)W05 数据工单换版面 + 两条渲染机制的实锚**(2026-08-30,`part-substitution-assessment@2.2.2`,
|
||||
用户给了新目标件 `docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html`):
|
||||
`input-supplement` 整张照它重排 —— **两条并列轨道**(每颗料一条:档案卡 + 主数据 + 类型选择)
|
||||
+ `<details>` 新增类型面板(chip_type / type_param 两张表原样搬入,独立「核心参数字段」区退役)
|
||||
+ 核心参数值**瓷贴单块平铺**;绑定面一列不差(四张台账 / 四道闸 / 两处加行 / 终审 C1 的
|
||||
disabled type_id / 主键钥匙)。目标件脚本驱动的状态全部无脚本翻译:保存即铺格顶掉「选类型
|
||||
即时加载」、details 顶掉 `.newtype.show`、步骤圆点按基座数点亮、gateprog 进度条如实退掉
|
||||
(分母不是基座现有的数)。**三条机制账**,全有测钉死:
|
||||
①**`data-rows` 宿主的闭合标签就是行模版的截刀** —— ROWS 正则按宿主自己的闭合标签非贪婪
|
||||
截取,`<div class="lanes">` 被行模版里第一个 `</div>` 截半,第二行嵌进第一行没闭合的标签里
|
||||
档案卡当场串行(A2 立的「行容器用行内不会再出现的标签」说的正是**宿主**,它自己就是
|
||||
`<article>` 包 `<section>`);防再犯钉用**配平判法**(「不含闭合标签」是空话 —— 非贪婪截取
|
||||
本来就停在闭合标签之前,那条断言永远不会红);
|
||||
②**行内 `data-class-of` 只认行作用域**(renderRows 末段 renderTemplate 拿的是行对象),
|
||||
作用域级读数在行内投不出 —— 圆点的投影挂在行外的 `.pj` 包裹层,后代选择器点亮;
|
||||
③**`data-by` 行缺键落回外层同名字段**(终审 I3)真咬人:XL 没选类型,`sub_eval.case`
|
||||
摊平的 `chip_type_id` 顶进来,查名 pill 摆出它没有的类型 —— 守卫认 `selectCurrent` 按
|
||||
**这一行自己的值**写下的 `option[selected]`,行里没真选上名字不摆(判断全在样式表)。
|
||||
同批逮到并修死:profile 的编辑闸在第一版 body 里**整个漏掉**(宿主在而闸不在,XL 必填
|
||||
永远补不上 —— 「四道闸」旧断言只查 data-edit 宿主没查 [data-edit-for] 闸,补强)。
|
||||
另一半探针钉进平台测试:嵌套 data-rows 在唯一 data-edit 宿主里**必然把一颗料的格子并成
|
||||
一条记录**(rowOf 认宿主直接子元素;真实 renderView+collect 实测,
|
||||
`view-container-collect-div-rows.test.ts`)—— 步骤③不按轨道分两栏是机制不是偏好。
|
||||
**验收**:活动自测 62 → 74(新钉逐条变异验证);`pnpm gates` 退出码 0(2497 测);
|
||||
2.2.0/2.2.1/2.2.2 三连装库 SQL 逐次核过;浏览器全流程真人路 —— 起单 → 两轨道核对 →
|
||||
保存即铺格(XL 七格当场铺出)→ 填参数 → 确认写入办结 → S2 分析推进;新建类型走种子行
|
||||
续号 `03`、DC-DC 原行未被覆盖、下拉即时长出;类型不一致护栏真亮 v-2 再熄灭。
|
||||
终审评审 0 Critical / 0 Important / 1 Minor(注释漂移,已修)。跑完 reset-runs,
|
||||
主数据与活动缓存零残留。
|
||||
|
||||
- **W05 三张工单件视图层按 C 端重设计**(2026-08-31 用户定:「不要后台管理端的样子,
|
||||
要给客户用的 C 端界面;可加主题 SVG 与动画;只保留功能,视图层可以推翻重新设计」,
|
||||
`part-substitution-assessment@2.6.0`):「精密检测报告」语言 —— 深色电路板夜景 hero
|
||||
三张各一幕(建档台:两颗料的芯片铭牌由 `mine:sub_eval.part` 铺行、不在库虚线描边 /
|
||||
对决量尺:原 PN→候选 PN + 推荐等级徽章 / 落账席:慢转印章环 + 一句话结论),正文瓷白
|
||||
报告版面(丝印小标 / 比对光谱分布带 / 落账三卡大数);动画全 transform/opacity、
|
||||
reduced-motion 全静;验收两件仍是验收绿 #0aa294。**功能绑定逐项保留**:可编辑表 DOM
|
||||
结构逐字节不动(收值链路按结构认元素),机制规则(计数器/种子行显隐/gate-bar :has/
|
||||
overflow-x)逐字保留仍被测试钉着。设计系统收进 `views/style.ts`,`target-style.ts`
|
||||
退役(目标件时代的锚),report.ts 的 vtok/badgeCss 收归 badge.ts(销一笔 debt)。
|
||||
离线预览自审(真 renderView + 造景数据,不碰测试台)逮到并修掉三洞:kv 占位破折号
|
||||
连嵌套徽标一起吃(改只认直接子级)、光谱条里渗计数字、加行钮没 base display:none。
|
||||
104 包内测 + 2516 全仓绿,两条新锚变异验证过,2.6.0 装库 SQL 核过。
|
||||
(2.5.0 是同日另一会话的启动表单芯片类型联动 —— 两会话同 checkout,版本号先问库。)
|
||||
|
||||
- **W05 数据工单三病一改:档案卡上桌,prune 跳过已落地**(2026-08-31 用户实测三连 +
|
||||
「设计只是头上加了动画」,`part-substitution-assessment@2.7.0`):
|
||||
①「类型自己给上了」= data-by 行缺键落回外层(debt I3 原样咬人)—— 查名 pill 摆出案卷锚
|
||||
(原 PN)的类型;治:守卫认 `selectCurrent` 按**行作用域**写的 `option[selected]`,
|
||||
真选上才摆名、没选摆「待选择」胶囊。**守卫必须单层 `:has`** —— `:has()` 里嵌 `:has()`
|
||||
是非法选择器,整条被浏览器静默丢弃(第一版就栽在这,预览截图逮回),挂共同祖先上写。
|
||||
②「品牌…点保存根本不会保存」= 块① 行源是 `sub_eval.part` 而那张案卷表没有主数据列,
|
||||
`renderTemplate` 只认行作用域 —— 保存真落草稿、显示与孪生件永远空白(四步重写后的潜伏缺陷,
|
||||
真人一填才现形;e2e 走 API、旧浏览器走查在 swap 版上,两条线都照不到)。治:行源改
|
||||
`mine:parts.profile` 本身(S1 ④ prune:true 恒有两行),行里天生有全部列。
|
||||
③「改回同一类型,下面还是两个类型」= ②③ 自投影为绕「已落地行撤不掉」的死锁特意不带
|
||||
prune,没落地的陈旧行也跟着积。治在平台:`draftDrop` 加配方专用旗 `skipLanded`
|
||||
(跳过并出声「N 行已随数据工单入库、这一笔不清它」,不炸),②③ 恢复 prune:true ——
|
||||
H35 案例 2 不再死锁,debt「prune 与确认即入库的架构性冲突」销账。quiet 拍仍不撤行(F3),
|
||||
清理落在每次确认工单那一拍。
|
||||
④设计:块① 从 11 列横滑表推翻成**电路板工作台** —— 深色板底上两张可就地编辑的芯片
|
||||
档案卡(SOIC 封装 SVG + 角色/在库徽 + 类型选择行 + 双栏主数据格),编辑态整卡变表单;
|
||||
三级占位(必填红/建议橘)上卡与参数值表(徽标包装的必填格用单层 `:has` 认裸 class 补胶囊);
|
||||
参数值表所属料号列加角色徽。测:107 包内(守卫/行源/嵌套零 data-col 三锚变异验证)+
|
||||
auto-runner skipLanded(变异验证)+ 2520 全仓;2.7.0 装库 SQL 核过。
|
||||
另:1838474「在库」是真的 —— 用户此前某单 land_master 落过它(正式表 chip_type_id 为空)。
|
||||
|
||||
- **W05 第二轮:比对基准合一卡 + `prune:'live'` + 蓝图板底**(2026-08-31 用户:「类型和字段
|
||||
合并成一个卡…两个芯片同类型就只显示一种,现在只变多不变少」「卡片容器黑色和页面不搭」,
|
||||
`part-substitution-assessment@2.8.1`):①类型+字段并成**比对基准卡**(只读、跟手)——
|
||||
不列 mine: 镜像(积旧选择,撤行只在确认拍跑),按案卷锚嵌套查字典,保存即换脸、同类型
|
||||
恰显一种;新建类型收进 details 抽屉(零脚本),编辑表原样在抽屉里。②契约新增
|
||||
`prune:'live'`(quiet 拍也撤,**只给人加不了行的表**用 —— F3 防「人刚加的行被撤」,
|
||||
这种表没有该风险;有加行钮的表永远不许用):W05 参数骨架改 'live',改类型参数行跟手换;
|
||||
static-audit 盖住 'live',规范 §4.6 纪律 5 补反面特例。③档案卡板底深色→**浅色蓝图**
|
||||
(强调色晕染+淡格线),整页只剩 hero 一处深色。锚测各变异验证;2.8.1 装库核过;
|
||||
:8787 watcher 又断,应急手起 node dist/main.js(14:41)。
|
||||
|
||||
- **W05 第三轮:数据工单去表格化 —— 翻面芯片卡 + 平台多宿主收值**(2026-08-31 用户:
|
||||
「界面东西太多做成几个区域;类型与字段做成和芯片卡差不多的卡片;芯片卡与核心参数值
|
||||
合并,点击翻转、背面写参数、悬浮看得出能翻;这个界面都不再需要表格展示数据」,
|
||||
`part-substitution-assessment@2.9.0`):主画面零表格 —— 基准卡瓷贴化(类型丸+字段
|
||||
瓷贴),芯片卡纯 CSS 翻面(checkbox+:has 零脚本,悬浮提示丸+侧倾;主数据编辑锁正面、
|
||||
参数编辑自动翻背面),背面参数瓷贴就地可改;说明四条收折叠;表格只剩新建类型抽屉
|
||||
(加行机制认 tr)。**平台扩面:ui-kit ViewContainer 多宿主收值** —— 同一地址
|
||||
querySelectorAll 全数进来,**格子只归最近的 data-edit 祖先**(嵌套异地址排除;不带
|
||||
data-edit 的嵌套 data-rows 行为原样,两条 collect 旧钉测不动);addRow 仍单宿主。
|
||||
三脚各留一条:CSS 注释写「data-col=」原文会踩翻别件的字面断言(选择器与注释都不许);
|
||||
`.flip-cue i` 特异度压过 `.cue-back{display:none}`;host 计数断言别拿渲染后的数去数
|
||||
模版源码。ui-kit multihost 3 测(变异验证)+ 包内 110 + 2528 全仓;2.9.0 装库核过;
|
||||
浏览器实测点卡真翻、参数编辑双卡自动翻背成表单。
|
||||
|
||||
- **W05 第四轮九连改:笔↔软盘卡角编辑 + 只选不建**(2026-08-31 用户一次点齐九件,
|
||||
`part-substitution-assessment@2.10.0`):①编辑闸两行退役 → 卡右上角笔(开当前面)↔
|
||||
软盘(存)+叉(弃),按钮即 data-edit-open/save/cancel 触发器,按面显隐全在样式表;
|
||||
②ui-kit `mirrorTwins` 修「保存一瞬间下拉变数字」—— 镜选项文案不镜 value,顺手把
|
||||
selected 拨到人选项(守卫 CSS 认它);③渲染器 twinInputs 认 `data-ph` 落 placeholder;
|
||||
④参数说明空着什么都不摆;⑤芯片档案区提到比对基准前;⑥**新建芯片类型整条路移除**
|
||||
(只选已有):抽屉/种子行/Critical-1 修法/thead 回归网退役,S1 配方八笔→六笔(②③
|
||||
自投影删),gateLedgers 收窄 profile+parameter,e2e 案例 0 改选既有 type_1,七条退役
|
||||
钉测 it.skip 留墓志铭;⑦题头回原目标件浅蓝渐变(hd-light,夜景只留验收两件);
|
||||
⑧对话区执行卡不摆「参考信息」(basis 随卡数据照旧);⑨比对基准降噪:字段丸 +
|
||||
说明悬浮气泡。2525 全仓四门绿,2.10.0 装库六探针核过,:8787 重启载入新渲染器。
|
||||
|
||||
- **W05 第五轮:按卡独立编辑 + 顶条合并点名**(2026-08-31 用户四连,
|
||||
`part-substitution-assessment@2.11.0`):①「不要做联动」—— ui-kit 新增 `scopedTargets`:
|
||||
开合作用域跟着触发钮走(钮在本地址宿主的行里→只这一行;钮在别的宿主行里→行内的
|
||||
本地址宿主;外部闸→全宿主老行为),收值仍按地址全量、「只写真改了的行」服务端把关;
|
||||
编辑态 CSS 全改按卡(.dossier.on),同地址同时只开一张卡(快照键按地址,双开互踩)。
|
||||
②逐块「还差 N 行必填」红带撤下,_missing/_missing_keys 并进顶条「还差什么」两行子行
|
||||
(v-0 自收)。③比对基准/芯片档案区序换回(基准在前)。④比对基准换层叠双封装 SVG
|
||||
(t-ghost 淡剪影 —— 一类而非一颗)。ui-kit 35 测 + 包内 105 + 2528 全仓;2.11.0 装库
|
||||
五探针核过;浏览器实证单卡独立翻背编辑。
|
||||
|
||||
- **W05 第六轮:确认闸条件档 + 量高可缩 + 互换动画**(2026-08-31 用户三连,
|
||||
`part-substitution-assessment@2.12.0`):①「类型不一致还能确认」—— 平台加**确认闸
|
||||
条件档**:配方 card 声明 `gateWhen`(S-FEEL 只读基座读数)+`gateWhenSay`,受理回传前
|
||||
`submitGate` 按声明表现读 rows/missing/tally/distinct 现算,为假 400 用发卡人那句话;
|
||||
静态审同 when 一套;评估不出的条件不成墙。W05 钉 `parts_profile_distinct_chip_type_id
|
||||
<= 1`(钉 profile 本表零滞后 —— 钉 sub_eval.part 要等配方 tick 会误拦刚存完就确认的人),
|
||||
profile.chip_type_id 加 distinct。②「底部没东西还能滑一截」—— ViewContainer 量高
|
||||
documentElement.scrollHeight 以 iframe 高为下限,**只涨不缩**;改 body 优先(margin 0
|
||||
的 body 才是内容自己的高)。③卡间动画换「可替换」语义:对向双信号点互穿(卡后走、
|
||||
缝里现身)+ 中央 ⇄ 徽呼吸。2531 全仓;2.12.0 装库核过;:8787 重建重启。
|
||||
|
||||
- **W05 互换动画第二稿:与卡同级的实体动画道**(2026-08-31 用户:「不要做到背景板上会被
|
||||
卡片覆盖,和卡片做到一级,压缩一点卡片」,`part-substitution-assessment@2.12.1`):
|
||||
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
|
||||
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
|
||||
|
||||
- **复核轮审核人说了算 —— AI 不许拿硬规矩顶回去**(2026-09-01 用户第二次实测:「重做还是没有
|
||||
修改掉内容」):他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 于是搬出
|
||||
第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去 ——
|
||||
`evidence_note` 白纸黑字:「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。
|
||||
**行的范围这次是对的**(只把那一条交给 AI、只写回那一条,其余六条一个字没动),错在**谁说了算**。
|
||||
两处想岔了:①那条规矩管的是**它自己判**的时候别被措辞带偏,不是用来否决审核人的;
|
||||
②方向也反了 —— 那条守的是「不许把不满足说成满足」(危险方向),而他要的是**收紧**结论;
|
||||
③更根本的是:审核人在件上**本来就能亲手改任何一条四态**,AI 在重判这条路上顶回去,
|
||||
只是让同一件事在两条路上给出相反的答案。
|
||||
改:复核指令明写这条覆盖 —— **他点名要什么结论就落什么**(「不匹配」= mismatch、「有条件」=
|
||||
conditional…,不要跟他讲道理、不要顶回去);落的同时 `evidence_note` 记两句
|
||||
「按谁的意见」+「数据面是什么」(例:「按审核人意见调整为 mismatch:<原话>。数据面:两侧均为 5mA。」)
|
||||
—— 下一个人回看时要能一眼分清**这是数据判出来的**还是**这是人定的**;
|
||||
他只是提疑问 / 要补依据的,照旧由 AI 复核后自判。
|
||||
e2e 案例 0 的打回意见同批换成**点名要结论**那一种,新断言逐字核那一行真变成了他要的值
|
||||
(「打回点名要的结论真落下去了(使能阈值.difference_status = conditional)」)。
|
||||
**同批把这一轮的交代摆进对话区**(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口
|
||||
展示出来,不然都不知道发生了什么」):复核完说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,
|
||||
摆的是**件上那一行自己的话**,不另编一句(对话区与件上说的是同一件事,就该是同一句话)。
|
||||
**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 `say` 要等下一拍才跑:
|
||||
原来那句「差异结论已送审,请核对」实测正是在**人答完之后**才出现的,而且它与卡自己的 `ask`
|
||||
本来就是同一件事,一并退役。
|
||||
|
||||
- **落账席那枚印章把旁边的结论压住了**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」,
|
||||
`@2.24.0`):**在浏览器里量出来的账** —— 徽章那一坨 `.seal-core` **139px**,而印章圈定死 **112px**,
|
||||
溢出 26px;右边那句一句话结论从 603px 起,正好被压住。病根是徽章按自己的字宽撑开、
|
||||
又带着 `white-space:nowrap` 缩不了,而圈是定死的圆(`place-items:center` 只会让它对称地溢出去)。
|
||||
治法两条一起:核心不许超过圈的内径(`max-width:calc(100% - 16px)`)、圈里那一枚字号收小并
|
||||
**允许折行**(以后有更长的等级名就两行,圆里两行照样是圆)。改完再量:核心 94px、圈内余 9px、
|
||||
「有条件推荐」一行放得下、结论那段完全露出来。**视觉的账在真 DOM 上量,不在脑子里推**
|
||||
—— 离线渲一份真件挂本地服务、浏览器里 `getBoundingClientRect` 取数,前后各一次。
|
||||
|
||||
- **「最后落账的步骤直接没了」= 单子先被烧死了**(2026-09-01 用户送来的 `INS-80`,终态 `exhausted`):
|
||||
他在结论审核上连打回四次,每打回一次配方就重发一张审核卡、每张 +1 `humanCalls`,第八次触顶 ——
|
||||
放行那一步再没机会出现。屏上看着像「那一步没了」,实际是**预算见底**。两处一起治:
|
||||
①**预算提高**(用户当场定):8 → 24(授权面与资源面同一个数,测里改成「两处一致 + 下限 12」,
|
||||
不再把某个具体数字钉死)。**这是配置不是机制** —— 机制上的正解是「人自己打回引出的那张卡
|
||||
不算打扰」(他不是被打扰,是他叫的),记在 debt。
|
||||
②**「一条都没点到」不再整表重判**(当天上线、当天撤):当时想的是「说的是整批」,
|
||||
而他那句实际是 **「收口结论里面的推荐理由改为非常不推荐」** —— 说的根本不是某条参数,
|
||||
是收口那四格(本来就能在件上直接改)。整表重判的下场:AI 拿这句话把 **5 条参数**的结论
|
||||
改成「不匹配」,每条依据都写着「按审核人意见调整为不匹配:推荐理由改为非常不推荐」——
|
||||
**一句与它们无关的话,改了五行**。现在点不到名就**一条都不改**,并照实说:
|
||||
「你这句意见我没能对上具体哪一条参数,这一轮一条都没改;要重判某一条请写上参数名,
|
||||
收口那四格在件上直接改就行。」—— **「猜他指的是全部」比「承认没对上」贵得多。**
|
||||
|
||||
- **「怎么还有重做??你没改上吗?」—— 手写字段清单第三次把值扔了**(2026-09-01):
|
||||
上一条算对了、release 转对了、契约也声明了,而**容器那个显式对象**只转它点到名的那几样,
|
||||
`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。**同一处已经吃过三次**:
|
||||
①`fields` 写死成 `[]`(验收报告只能看不能改)②`blocks` 漏一行(卡面正文块恒空)③这次。
|
||||
那几行旁边就写着两条「少写一行就静默落地」的警告 —— **措辞治不了的结构治**:
|
||||
改成把 context 三样拆出去、**其余整包转交**,以后端口上新增可选字段不改这里也到得了;
|
||||
剩下两处 `as never` 是类型收窄,不是丢值。断言钉在**最末端**:试跑观测面的卡带上 `redoable`,
|
||||
e2e 断言「放行验收卡不摆重做(redoable=false 一路到了卡上)」—— 这类丢值只有末端断言拦得住。
|
||||
- **人答得比这一拍收尾还快,这一拍就别报「等人」**(2026-09-01,同一夜第二种时序,又停掉一整例):
|
||||
**决策卡的回传不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
|
||||
`running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,再没有第三样东西会来叫
|
||||
(工单回传那一路还有人事件兜底 + 昨夜补的 stale 那一下,决策卡这一路**一样都没有**)。
|
||||
实测:卡 02:06:13.250 发出、**219ms 后**被答,这一拍 .636 才收尾,单子停到 20 分钟超时。
|
||||
修:配方发卡收尾前再看一眼**刚发的这张卡还在不在等** —— 已经答了就照实说「还在跑」,
|
||||
推进器下一轮接着处理那份答复;**只认这一张卡**,别的「等外部回音」照旧是等,不会空转成热循环。
|
||||
同批把假端口改老实:**发出去的卡默认就在等**(从前发完卡还回「一张待答的都没有」,
|
||||
那是生产里不会有的局面 —— 新那道门正是靠这个分辨的)。
|
||||
|
||||
- **打不回的验收卡就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该
|
||||
有重做的选项,因为这里都没有接入 ai」):W05 的 T_release 整个由配方跑完(`guide` 是空串,
|
||||
不问模型),件又是只读的 —— 打回之后**没有任何东西能接住那句意见**:配方只会原样再提交一次,
|
||||
撞在「打回之后台账没动过就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的
|
||||
兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆**(墙要配路;没有路就别立那扇门)。
|
||||
契约加 `AcceptanceWorkOrder.redoable`(不写 = 打得回,老卡老行为);装配层按放行那一步的声明现算
|
||||
(`releaseRedoable` 纯函数:有 `auto` 且 `guide` 是空串 → 打不回;半 auto 与模型驱动照旧打得回);
|
||||
卡面收起理由框与那颗钮,换一句**真路**:「这一步只落账,退不回上一步 —— 有异议就别确认,
|
||||
去终止这一单重开」;**服务端才是门**:`redoable === false` 的卡收到非 pass 的回传当场拒
|
||||
(`redo-unavailable`),直调 API 也绕不过。两处变异验证;S3 结论审核那张审核卡不受影响 ——
|
||||
它的重做现在真会带着意见重判(见上一条)。
|
||||
|
||||
- **答得太快就没人再叫醒它 —— stale 那条守卫补最后一下**(2026-09-01,一夜之内撞停两次,
|
||||
第二次直接把我自己的验证卡住 15 分钟):卡发出 **270ms** 后人就答了,而**把 `lastProgressAt`
|
||||
往前挪的那一拍,正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传,
|
||||
2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)在这种时序下不成立;
|
||||
回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。
|
||||
三样凑齐:卡答完了、没有新卡在等、单子停着 —— **再没有第三样东西会叫醒它**
|
||||
(手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。
|
||||
修:`deliver` 判了 stale **照旧不跑这一拍**(它的本意不变),但把停着的单子拨回 `running`
|
||||
交给推进器;真没事可干,它自己再停回去(代价一拍空转)。两条测钉死 + 变异验证。
|
||||
真人点卡要几秒,撞不上这条窗口;**e2e 是 3 秒轮询 + 秒答,专把这种窗口撞出来** ——
|
||||
它替人踩的正是这一类。
|
||||
|
||||
- **「我打回让改工作温度,它给我改了静态电流」—— 重判的行由确定的一侧圈,不由 AI 决定**
|
||||
(2026-09-01 用户实测,`part-substitution-assessment@2.18.0`)。上一条刚把意见交回给 AI 重判,
|
||||
当天就被这一单打脸,而库里的证据一字不差:**工作温度它没改**(还给了理由:候选值 1℃ 远窄于
|
||||
-40~+85℃,硬约束不满足 —— 这半截是对的),而**没人问过的静态电流**被它从 soft_mismatch
|
||||
翻成 match,`evidence_note` 里却写着「**维持原判**」。两张审核卡的「本轮结论」都还停在
|
||||
S2 那一版(0 项一致 / 5 硬 / 2 软),而草稿此刻是 1 match / 5 硬 / 1 软 —— 差的那一项就是它。
|
||||
**根因不是提示词不够严,是整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
|
||||
而「我只改了那一条」这句话本身也出自它嘴里(它说了「维持原判」,可它改了)。规范 §4.6 纪律一
|
||||
——**行由确定的那一侧决定,不由 AI 决定** —— 这次栽在自己手上。
|
||||
治法(S3 复核轮从两笔变四笔,全部只在 `case_redo_note != ""` 那一轮跑):
|
||||
①**圈范围**:按字面点名(`contains(case_redo_note, parameter_name)`,与基座给模型摆的
|
||||
「意见点名对上了哪几行」同一种认法)把行标成 `named` / `kept`(明细表新增 `redo_scope` 列,tally);
|
||||
②**兜底**:一条都没点到(「结论整体判得太松」这类意见说的是整批)→ 整表算被点名,
|
||||
读的是上一笔刚写下的 tally 读数,不重新算一遍;③**重判只喂 `named` 的行** ——
|
||||
没点名的行 AI 连见都见不到,自然改不着(也省 token);④**写回也只写这几行**,
|
||||
模型漏答某一条时 `else` 兜回它上一轮的值(漏答不该变成「把它清空」)。
|
||||
两处变异各自验证(写回不过滤 / 圈范围时一律标 named,都当场变红)。
|
||||
e2e 案例 0 加一条**跨轮比对**断言:打回前存一份明细,放行前留最后一份,
|
||||
逐行比「名字没出现在意见里的行,四态与三段叙述一个字都不许变」——
|
||||
途中还逼出脚本自己的一个坑:**放行会清空草稿层**,收工时再取明细什么都没有,快照得留在放行之前。
|
||||
|
||||
- **重做的意见现在真交回给 AI 重跑那一步**(2026-09-01 用户:「唯一调用 ai 的这一步,使用重做后
|
||||
是否没有将重做原因带回去重新让 ai 重跑?」——**确实没有**:S3 那一轮只把派生列重算一遍,
|
||||
人写的那句话除了留痕之外无人读,AI 判过的四态一个字不会变,除非他自己在件上逐格改)。
|
||||
平台加一样东西:引擎把「人这次打回写了什么」注给配方,名字 **`case_redo_note`** ——
|
||||
下划线不是 `case:` 前缀,因为 S-FEEL 的名字不许带冒号(`lexer.ts` 的 NAME_PART),
|
||||
而这句话最要紧的用法恰恰是当 `when` 条件用;**没打回也永远绑着空串** ——
|
||||
未绑定的名字 `!= ""` 求值为**真**,不绑就等于「只在被打回时才跑」的那几笔每一轮都跑
|
||||
(拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住)。静态审同批认这个名字
|
||||
(它当场拦下过一次:「配方的 when 引用了 case_redo_note,而台账、案卷、起单入参里都没有这个名字」——
|
||||
这道门做对了事)。
|
||||
W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 —— **带意见重判**(入参比第一轮多两样:
|
||||
上一轮的四态/影响/依据/UNKNOWN 去向,以及他写的那句话;判法与第一轮**同一份** `ASSESS_RULES`,
|
||||
后面只接一段复核纪律:**没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉)
|
||||
+ **判完落回明细**(与第一轮同一笔投影,只多一个 `when`)。为此把判法、出参形状、落明细那一笔
|
||||
抽成三个共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。
|
||||
**验收**:e2e w05 **5/5 released、被拒 0**,案例 0(先点名驳回、再通过)**模型调用恰 2 次**,
|
||||
其余四例仍是 1 次 —— 没被打回就不多花一次模型。
|
||||
|
||||
- **「我重做后怎么直接推进了?」—— 打回与通过在收步那道门眼里一模一样**(2026-08-31 夜,
|
||||
柴浩实测的那一单逐条对得上):审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」
|
||||
→「**自动收步:S3_review —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。
|
||||
意见落进了轨迹(还被 note-anchors 逐行对上了),而再没有任何东西会去读它。
|
||||
**这条缺陷 2026-08-30 全分支终审时已被记录成 Important 2、还留着一条坐实病灶的测试**,
|
||||
连「半个修法会把单子卡死」都预判对了 —— 这次照它的方向修完:
|
||||
①收步那道门认答复(`answerSettlesStep`:`custom:`(人没选任何选项、写了自己的话)与约定的
|
||||
`reject` 都不算办完;空答复照旧算「照原样确认」);②**同一句判断也让 `carded` 让位** ——
|
||||
幂等挡的是「同一问被重复发出去」,而人点重做正是说「这一轮不算,改了再问我一遍」,
|
||||
那不是重复,是下一轮:配方重跑(该重算的重算)再发一张,并在日志里说一句「人把这一步打回重做:…」。
|
||||
只做①就是那个被预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
|
||||
三条引擎测(重做→重开 / 重做但发不出卡→也不收 / 通过→照旧收)+ 3 条纯函数测,
|
||||
两个半边各自变异验证;**e2e w05 5/5 released**,案例 0 现在真的发出**两张**审核卡
|
||||
(先带意见驳回、再确认通过)。
|
||||
**顺带撞见一条独立的老毛病并记账**(未修,见 debt.md):人答得比发卡那一拍收尾还快时,
|
||||
回传被判 `stale`、`wake` 又被那一拍的 park 覆盖,单子停在 `awaiting-event` 再没人叫醒 ——
|
||||
手工拨回 `running` 立刻走完。2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)
|
||||
在这种时序下不成立:把时刻往前挪的那一拍,正是发出这张卡的那一拍。
|
||||
|
||||
- **「你怎么给我发成了执行工单」—— 面孔在真发卡那条路上被丢掉**(2026-08-31 用户实测,
|
||||
`part-substitution-assessment@2.16.0`):装配层发执行工单那一处是一句**写死只认 `'data'`**
|
||||
的三目(`engines.ts`),配方递下来的 `variant: 'accept'` 静默丢掉 —— 对话区照旧摆「执行工单」。
|
||||
**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,前端那一头断言了
|
||||
「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样全绿 —— 三处都对,而它们中间那一段把值扔了。
|
||||
修:抽成纯函数 `executionCardVariant`(与旁边的 `decisionCardFace` 同一种做法、同一个测试位),
|
||||
认 data / accept、拒 review、认不出的当没写;新测 4 条变异验证过。
|
||||
e2e 同批**改认面孔不认标题**(试跑观测面的 `cards` 补 `variant`,契约变更):标题是活动自己写的
|
||||
字符串,卡穿哪副面孔由装配层决定,两者可以对不上 —— 那一次正是「标题叫验收工单、发出去是执行工单」。
|
||||
**面孔要在真发卡那条路上断言。**
|
||||
同批(用户第二句「标题那一块需要是验收卡的那种绿色」):件① 验收工单那一副整套强调色换成
|
||||
**验收绿 #0aa294**(令牌从 `ACCENT_LEDGER` 现改选择器得来,数值与件②③、对话区验收卡一处真源),
|
||||
题头那条写死的浅蓝渐变单独给一条绿的;数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。
|
||||
|
||||
- **起单不许选到同一颗料;S1 数据齐了也叫人一次**(2026-08-31 用户两条,
|
||||
`part-substitution-assessment@2.15.0`):
|
||||
①**两格互斥**(「创建活动的启动参数里面不应该能选到同样的两个芯片」)—— 查下来不只是没意义,
|
||||
是**把单子做死**:`sub_eval.part` 主键 `eval_ref + internal_pn`(role 不在主键里),配方铸的两行
|
||||
撞成同一个主键、后一笔覆盖前一笔,两行收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立、
|
||||
S1 收不了步,而件上那两行人加不出来(行源 `mine:`,没有「+加一行」)—— 唯一出路是终止重开
|
||||
(与「两格留空」同一条链,清单头注早写着)。平台加互斥声明 `x-distinct`(契约 `distinctGroup`)——
|
||||
与 `x-link` 正相反:那个是引导,**这个是墙**。前端把别的格占着的候选从这一格下拉里抽掉
|
||||
(唯独自己填着的那个永远留着,否则 `<select>` 当场变空白),走「新增」自己写一个同样的 →
|
||||
小红字 + 启动按不下去;服务端 `validateLaunchParams` 独立拦(脚本 / API 绕不过),400 点名后填那格。
|
||||
「算不算同一个」= 去空白 + 不分大小写(`launchDistinctKey`,前后端同一把尺;只挡精确相等的话
|
||||
`lm2596s-5.0` 照样进去,混一颗垃圾主数据出来)。
|
||||
②**数据齐了也叫人一次**(「就算数据是足的也进入这个数据工单,就派发一个验收工单…把芯片卡片上的
|
||||
编辑按钮去掉,只展示不能编辑,标题旁边的数据工单改成验收工单」)——病根在**引擎拍首那道
|
||||
`doneWhen` 扫描**:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,配方一笔都还没跑,步骤当场被收掉,
|
||||
卡永远发不出去。治法是 CMMN 给 S1 标 **`manualActivation`** —— 那道扫描多一个门槛(`worldTouched`),
|
||||
次序变成「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 照旧是真门,只是不抢在人前面。
|
||||
平台加**执行工单第三副面孔** `variant: 'accept'`(验收工单:件只读、人只确认,「确认无误」)——
|
||||
与 `mode:'acceptance'`(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断抽成纯函数
|
||||
`desk-task/internal/order-face.ts`(有测)。活动侧:S1 配方两笔互斥的 `card`(引擎 `carded` 幂等
|
||||
保证一步只发一张 —— 数据工单发过之后验收工单永远轮不到,人不会被叫第二次),案卷加**粘着的旗**
|
||||
`order_kind`(`data`/`accept`,第一档读上一拍写下的自己;不粘着的话人把最后一格填上、还没点确认,
|
||||
件的题头就当场改名、笔也没了);**一张件两副面孔** —— 题头 / 标题 / 页脚三处名字与卡角编辑钮
|
||||
按 `data-class-of="order_kind"` 投出的 `v-data`/`v-accept` 由样式表挑(藏的是容器 `.fabs`,
|
||||
不是逐枚钮:那几枚各自的 display 由 `html.editable` 一串更特异的规则给着)。
|
||||
**验收**:5 处变异验证;引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、
|
||||
卡一张发不出);e2e w05 **5/5 released、被拒 0、模型调用恰 1 次** —— 都在库那例摆的正是
|
||||
「验收工单 · 差异评估的输入核对」,缺东西那三例照旧是数据工单;真渲染核过 `.card` 两副 class。
|
||||
2558 测 / 五门禁绿;2.15.0 装库六探针核过。
|
||||
**副产**:e2e 样例 3「原PN缺库」的前提已被污染(`LM2596S-3.3` 现在真在库里,多半是白天 ops 起单
|
||||
落的主数据),那一例实际走的也是验收工单那条路 —— 名字与实测不符,清库存那件事记在这儿。
|
||||
|
||||
- **「类型不一致还能点下一步」的病根:确认闸条件在发卡装配被丢 —— 手写字段清单第四次丢值**
|
||||
(2026-09-01 用户:「两个芯片类型不一样,页面上方有红色提示,右边派发的卡片还可以点击下一步」):
|
||||
活动白纸黑字声明了 `gateWhen`(2026-08-31 加的确认闸条件档),而**发卡装配那段手写清单**
|
||||
(engines.ts 的 human 口 —— `variant` 上次被丢的同一段)逐项点名转交 `gateLedgers`/`variant`,
|
||||
没点 `gateWhen`/`gateWhenSay` 的名:落库工单载荷里它是 undefined(稳定库任务 43 法证),
|
||||
按钮禁用与服务端拒收读的都是它,**一起无声失效**;红带是视图画的所以照亮 ——
|
||||
屏上两处说着相反的话。此前 fields / blocks / redoable 三次同款,这次的教训:
|
||||
**中段是不可信卡骨架的缝,不能整包 spread,那就抽纯函数校验着转交并在 variant 的
|
||||
测试位旁边钉死**(`cardConditionGate`,5 测:转交原样 / 不发明键 / 脏值不端 /
|
||||
say 单独无意义 / when 可无 say)。**末端断言照 redoable 那课补上**:e2e w05 数据卡
|
||||
阶段 `expectCardGateWhen` —— 卡一到手查 desk 载荷,丢了当场判失败。
|
||||
活体验证(测试台真单):载荷带 `gateWhen` → detail 闸 `ready:false` 带活动那句话 →
|
||||
API 强行确认 **400**;e2e w05 案例 0 全绿(released、被拒 0、模型 2 次)。
|
||||
活动侧零改动(`GATE_OPEN_WHEN` 含类型不一致,mismatch 时发的必是带闸的数据卡;
|
||||
⑧ 验收卡的件只读、碰不上这形)。门禁 exit 0(2595 测)。
|
||||
|
||||
- **「终止活动」接口被蒸馏吊 13–45 秒到报错:收尾不等模型**(2026-09-01 用户:「点击终止后
|
||||
接口迟迟不返回,还会报错,但实例确实终止了」):法证在库里 —— 四单被终止的任务,
|
||||
`finished_at` 与蒸馏条目 `created_at` 差 13.2 / 36.3 / 43.4 / 44.6 秒:`finish` 把终态
|
||||
落库并广播**之后**,同步 `await` 了 `memory.distill`(整单记忆 + JSON schema 结构化输出的
|
||||
一次真模型调用),HTTP 响应吊在半空 —— 屏上早显示已终止(SSE 那一刻就发了),响应拖到
|
||||
任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。吊着的不止终止接口:
|
||||
**放行收尾那一拍同样走 `finish`,推进器与任务租约陪模型等 40 秒**。修:蒸馏后台跑
|
||||
(`void …distill().catch()`),封卷仍在前、失败照旧吞掉不掀终态;三测钉死(悬着的蒸馏不挡
|
||||
control 返回 / 拒绝不外泄 / seal 在 distill 前),改前第一条即 30 秒超时 —— 变异天然验证。
|
||||
CONTRACT.md 同步。门禁 exit 0(2590 测);:8787 已换上(watcher 只在重建时拉起、不守护
|
||||
死进程 —— 杀了要 touch 源码触发重建才回来)。
|
||||
|
||||
- **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」,
|
||||
`part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔
|
||||
(jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写),
|
||||
与发卡条件共用 GATE_OPEN_WHEN 一处真源;红条 data-class-of 投影 v-clear 收起。
|
||||
**教训:要用「清成空串」表达状态翻转的列,空值不落笔会把它焊死 —— 开关就该是开关列。**
|
||||
|
||||
## 文档权威地图
|
||||
|
||||
| 层 | 文件 | 说明 |
|
||||
@@ -1302,6 +2068,13 @@
|
||||
| | `docs/产品设计.md` | 三面一体的产品形态与决策记录 |
|
||||
| | `docs/活动开发规范.md` | **开发活动照这份**(2026-08-02 第三版,按四轴重组:解耦机制/Harness 契约/Loop/活动怎么写):基座读数与机检纪律、门与闸、五类卡、剧本 steps、建模十纪律、三常备案例与验收口径(released+被拒0)、**排障速查表**;§8 = 待实现 |
|
||||
| | `docs/HTML报告生成规范.md` | **报告模版照这份**(2026-08-02 从开发规范拆出):一份 HTML 三个身份(件/表单/归档件)、**报告与卡的关系与同源纪律**、绑定语言全表、编辑态(闸/孪生件/增删行/红带)、PRD 平移法、陷阱表 |
|
||||
| | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 |
|
||||
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
|
||||
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
|
||||
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
|
||||
| | `docs/ACT-WORK-05-端到端测试记录.md` | **历史记录,已停止更新**(2026-08-30 标注):旧九步设计从零到 released 的走查账,六轮逐轮结论 + A–Z 二十六条发现总表(现象 / 病根 / 治法 / 层);四步重写(2026-08-29)之后的发现不再往里追加,散落在源码注释与 `docs/debt.md` |
|
||||
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 重写后九张台账 / 66 列 / 人填列 20 —— 建活动时照它对字段 |
|
||||
| | `docs/adr/ADR-014-deterministic-steps.md` | **确定性步骤(已实现,2026-08-29)**:W05 九步的确定/判断盘点、`PlanStep.auto` 声明式配方(五动词:`project`/`analyze`/`card`/`submit`/`say`)、引擎拍内次序、回溯验算、迁移次序、§11 待定前两条已定(决策卡给了 / 工具串接换成 `analyze`) |
|
||||
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
||||
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
||||
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
||||
@@ -1317,10 +2090,11 @@
|
||||
3. **§12.5 禁止清单**:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO;
|
||||
不删测试;不动门禁配置;不绕写三分/两缝。
|
||||
4. **mock 只许出现在外界**(邮件通道、外部系统的录制回放替身);系统内部机制不得 mock。
|
||||
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SEED_PASSWORD`(种子口令)、
|
||||
`JCAI_SECRET_KEY`(凭证密封);不入源码、不入包、不入留痕;E2E 永不回退开发库。
|
||||
6. **试跑一律用 AI 账号**(密码 `jcai-dev-2026`),绝不派到用户工作台;测完
|
||||
`pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
|
||||
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SECRET_KEY`(凭证密封 + 票据派生)、
|
||||
`KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_*`(名册同步,可选);
|
||||
不入源码、不入包、不入留痕;E2E 永不回退开发库。
|
||||
6. **试跑一律用 AI 服务账号**(Keycloak 里 `bpos-e2e` 的服务账号,client_credentials 拿 token;`JCAI_TEST_ACCOUNTS` 列上它的用户名),
|
||||
绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
|
||||
7. **安装恒新增**:活动/工具改了内容必须升版本号,同版重装是 no-op(静默丢失,踩过)。
|
||||
8. **界面不撒谎**:状态如实(未绑就写未绑)、错误带厂商原话、不可用不摆假数字。
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { planStepSchema } from '@contracts';
|
||||
import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
|
||||
import { manifest } from '../src/manifest.ts';
|
||||
|
||||
const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
|
||||
/*
|
||||
* **泛型 `Extract`,不是 `PlanStep` 标注**(2026-08-29,评审 fix round 1 改判)——
|
||||
* `ASSESSMENT_STEPS` 不是元组,`.find()` 的返回类型是数组**元素类型**(四步 `auto.do` 里所有动作形状的并集),
|
||||
* 与查的是哪个 `item` 无关。第一版把返回类型标成契约 `PlanStep`,而 `PlanStep['auto']` 落在
|
||||
* `fields: Readonly<Record<string, AutoExpr>>` 这个**开放索引签名**上 —— 拼写保护是回来了,但整个
|
||||
* 文件里按方括号取字段值的 14 处(含 S1 原有 `:155`/`:156`/`:166` 三条)全部失去编译期拼写检查,
|
||||
* `evidence_note` 手滑打成 `evidence_noet` 之类的错字,`tsc` 会**静默放行**,只能等 `vitest` 断言
|
||||
* 落空才发现,反馈反而往后挪了一步。
|
||||
*
|
||||
* 现在按查的 `item` 精确 narrow:四步各自的 `item` 字段已经在 `case-model.ts` 加了 `as const`,
|
||||
* `(typeof ASSESSMENT_STEPS)[number]` 因此是四种带字面量 `item` 标签的形状的并集;`stepOf('S2_assess')`
|
||||
* 传的是字符串字面量,泛型 `T` 原样推成 `'S2_assess'`,`Extract<并集, {item: T}>` 精确挑出**那一步自己的**
|
||||
* 形状 —— 别的步的 `project`/`analyze` 形状不再混进来,`.fields['xxx']` 落回的是那一步真实写的字面量对象
|
||||
* 类型(有名字的那几个属性),不是开放索引签名。拼错字段名会报 `TS2551` 并带拼写建议。
|
||||
*/
|
||||
function stepOf<T extends string>(item: T) {
|
||||
return ASSESSMENT_STEPS.find(
|
||||
(s): s is Extract<(typeof ASSESSMENT_STEPS)[number], { item: T }> => s.item === item,
|
||||
);
|
||||
}
|
||||
|
||||
describe('四步案例模型', () => {
|
||||
it('恰好四步,次序即业务次序', () => {
|
||||
expect(ASSESSMENT_STEPS.map((s) => s.item)).toEqual(ITEMS);
|
||||
});
|
||||
|
||||
it('每一步都过得了 PlanStep 契约', () => {
|
||||
for (const step of ASSESSMENT_STEPS) expect(planStepSchema.safeParse(step).success).toBe(true);
|
||||
});
|
||||
|
||||
it('**全程零 guide、零工具** —— 这是这次重写的立场', () => {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
expect(step.guide).toBe('');
|
||||
expect(step.tools).toEqual([]);
|
||||
expect(step.auto).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('CMMN 里四个计划项都在,且触达世界的两步标了人工激活', () => {
|
||||
for (const item of ITEMS) expect(CMMN_XML).toContain(`id="${item}"`);
|
||||
/* 审核与放行触达的是人 —— 完成与否不该由数据形状推出来 */
|
||||
for (const item of ['S3_review', 'T_release']) {
|
||||
expect(new RegExp(`id="${item}"[^>]*manualActivation="true"`).test(CMMN_XML)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('**触达世界的两步不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
|
||||
expect(stepOf('S1_inputs')?.doneWhen).toBeTruthy();
|
||||
expect(stepOf('S2_assess')?.doneWhen).toBeTruthy();
|
||||
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
|
||||
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
|
||||
it('**判据不许编一个数不出来的数**:doneWhen 里不出现「本类型有几个字段」这类', () => {
|
||||
/* `parts_type_param_rows` 数的是本单草稿写下的字段行(人新建的那几个),不是整本字典 —— 拿它当分母是错的 */
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
expect(step.doneWhen ?? '').not.toContain('parts_type_param_rows');
|
||||
}
|
||||
});
|
||||
|
||||
it('S1 收步:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺', () => {
|
||||
const done = stepOf('S1_inputs')?.doneWhen ?? '';
|
||||
expect(done).toContain('sub_eval_part_exists_status_found = 2');
|
||||
expect(done).toContain('sub_eval_part_missing = 0');
|
||||
expect(done).toContain('sub_eval_part_distinct_chip_type_id = 1');
|
||||
expect(done).toContain('parts_profile_missing = 0');
|
||||
expect(done).toContain('parts_parameter_missing = 0');
|
||||
});
|
||||
|
||||
it('S2 收步:明细写了且不缺、关系一行、验证记录一行', () => {
|
||||
const done = stepOf('S2_assess')?.doneWhen ?? '';
|
||||
expect(done).toContain('substitution_difference_item_rows > 0');
|
||||
expect(done).toContain('substitution_difference_item_missing = 0');
|
||||
expect(done).toContain('substitution_relation_rows = 1');
|
||||
expect(done).toContain('validation_record_rows = 1');
|
||||
});
|
||||
|
||||
it('两张卡各带人工验收清单 —— 一条一句人眼核得动的动作', () => {
|
||||
for (const item of ['S3_review', 'T_release']) {
|
||||
const list = stepOf(item)?.acceptanceChecklist ?? [];
|
||||
expect(list.length).toBeGreaterThanOrEqual(4);
|
||||
for (const line of list) expect(line.length).toBeGreaterThan(6);
|
||||
}
|
||||
expect(stepOf('S3_review')?.cardVariant).toBe('review');
|
||||
});
|
||||
|
||||
it('阶段:除放行外每一项恰好属于一个阶段', () => {
|
||||
const covered = ASSESSMENT_PHASES.flatMap((p) => p.items);
|
||||
expect(covered.sort()).toEqual(['S1_inputs', 'S2_assess', 'S3_review'].sort());
|
||||
expect(new Set(covered).size).toBe(covered.length);
|
||||
});
|
||||
|
||||
it('说明书只讲整单的事 —— 逐步做法归配方,而配方不是给模型看的', () => {
|
||||
expect(NARRATIVE.length).toBeLessThan(2000);
|
||||
/* **示例不引具体料号与参数名** —— 模型会把示例当成这一单的值(2026-08-27 复现三次) */
|
||||
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值']) {
|
||||
expect(NARRATIVE).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 恢复自 Task 2 遗留的两条 `it.skip`(原住在这份文件里,注释指名「Task 2 → Task 3 补回」)——
|
||||
* 旧版断言的是旧九步剧本引旧台账地址 / 旧两枚工具,那两个前提随四步重写一起撤了,
|
||||
* 这里按新的四步剧本与九张新台账重新写实(逻辑不变:读写面必须落在清单声明里、
|
||||
* 工具必须落在声明过的工具里),不是新造的断言。
|
||||
*/
|
||||
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);
|
||||
/* reads 里混着入参名(originalInternalPn 等)与 case: 前缀伪读数,只有「域.对象」形状的才对声明表 */
|
||||
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 全部是声明过的工具 —— 且四步全是空数组(零工具是这次重写的立场,不是疏漏)', () => {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
|
||||
expect(step.tools, step.item).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('S1 配方', () => {
|
||||
const recipe = stepOf('S1_inputs')?.auto;
|
||||
const acts = recipe?.do ?? [];
|
||||
const kinds = acts.map((a) => a.kind);
|
||||
|
||||
/*
|
||||
* 次序是承重的,不是随手写的(2026-08-29 评审 Important 修正):案卷投影(`sub_eval.case`)
|
||||
* 的 `gate_reason` 要读 `parts_profile_missing`/`parts_parameter_missing` —— 这两个数由
|
||||
* 主数据/参数骨架两笔撑出来,案卷这一笔必须排在它们**之后**,否则读到的是这一拍刷新之前
|
||||
* 的旧数。病根写在 case-model.ts ④ 那一笔头上;引擎级的复现(旧次序真的会让 gate_reason
|
||||
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
|
||||
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
|
||||
*/
|
||||
it('七笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 数据工单 → 验收工单 → 交代', () => {
|
||||
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'card', 'say']);
|
||||
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
|
||||
'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **数据齐了也叫人一次**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个
|
||||
* 验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)。两张卡是互斥的两条路,不是先后:
|
||||
* 引擎的 `carded` 幂等保证一步只发一张 —— 数据工单发过之后,验收工单这一笔永远轮不到
|
||||
* (补完数据不会再被叫第二次)。
|
||||
*/
|
||||
it('两张卡:缺东西派数据工单(读闸)· 都齐了派只读验收工单(读上一笔刚写下的粘旗)', () => {
|
||||
const cards = acts.filter((a) => a.kind === 'card');
|
||||
expect(cards.map((c) => (c.kind === 'card' ? c.variant : undefined))).toEqual(['data', 'accept']);
|
||||
/* 验收工单不重算一遍闸 —— 读的是案卷刚写下的旗,同一件事只许有一处判定 */
|
||||
expect(cards[1]?.kind === 'card' && cards[1].when).toBe('sub_eval_case_order_kind_accept > 0');
|
||||
/* 两张都是执行工单(验收工单是它的变体,不是第三种 mode);零输入,填报/核对都在件上 */
|
||||
for (const c of cards) expect(c.kind === 'card' && c.as).toBe('execution');
|
||||
/* 闸同一份:件只读也要在确认那一刻再数一遍「数据是齐的」 */
|
||||
for (const c of cards) expect(c.kind === 'card' && c.gateLedgers).toEqual(['parts.profile', 'parts.parameter']);
|
||||
});
|
||||
|
||||
/*
|
||||
* 旗**粘着**:第一档读的是上一拍写下的自己(tally 读数)。不粘着的后果实打实 ——
|
||||
* 人在数据工单上把最后一格填上、还没点确认,闸当场变 clear,件的题头会当场改名叫
|
||||
* 「验收工单」、卡角的笔也没了,而他手里那张卡还开着。
|
||||
*/
|
||||
it('order_kind 粘着:已经是 data 就一直是 data,否则按此刻的闸决定(与发卡条件同一句)', () => {
|
||||
const caseAct = acts.find((a) => a.kind === 'project' && a.into === 'sub_eval.case');
|
||||
const kind = caseAct?.kind === 'project' ? caseAct.fields['order_kind'] : undefined;
|
||||
const gate = acts[4]?.kind === 'card' ? acts[4].when : undefined;
|
||||
expect(kind).toEqual({
|
||||
when: 'sub_eval_case_order_kind_data > 0',
|
||||
then: 'data',
|
||||
/* 第二档与数据工单那张卡读的是同一句闸(一处真源) */
|
||||
else: { when: gate, then: 'data', else: 'accept' },
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」
|
||||
* 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行,
|
||||
* 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。
|
||||
*
|
||||
* **带 `prune`,已落地的行由基座 `skipLanded` 跳过**(2026-08-31 改回)。曾特意不带
|
||||
* `prune`(2026-08-30 e2e 案例 2:已落地的旧类型行撤不掉,配方整单炸)—— 代价是
|
||||
* **没落地的**陈旧行也不清:人把候选从 LDO 改回 DC-DC,LDO 的字典/字段行积在屏上
|
||||
* (2026-08-31 用户实测点名「下面还是显示两个类型」)。现在 `draftDrop` 对配方这一路
|
||||
* 跳过已落地的行(loop-engine/port.ts),prune 回来:没落地的陈旧选型照撤,
|
||||
* 已落地的原地留下 —— 案例 2 不再炸,用户那条路清得干净。
|
||||
*/
|
||||
/*
|
||||
* SKIP(2026-08-31 用户定「只选不建」):②③ 两笔自投影连同 gateLedgers 上那两张表
|
||||
* 一并退役 —— 类型只许从字典里选,选中的行本来就在正式表里,镜像与落地都失去了对象。
|
||||
* 这条钉的机制(prune + skipLanded)仍活在 auto-runner 测试里,不随本活动退役。
|
||||
*/
|
||||
it.skip('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('案号由 let 铸,只从本单开案时刻取材', () => {
|
||||
expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
|
||||
});
|
||||
|
||||
it('两颗料由字面两行立起来 —— 这张表是后面每一笔遍历的唯一来源', () => {
|
||||
const part = acts[0];
|
||||
expect(part?.kind === 'project' && 'rows' in part.from && part.from.rows).toHaveLength(2);
|
||||
/* 在库与否是一次 lookup 的结果,不是一句断言:先取依据,再按依据判 */
|
||||
const fields = part?.kind === 'project' ? part.fields : undefined;
|
||||
expect(fields?.['found_pn_ref']).toMatchObject({ lookup: 'parts.profile', take: 'internal_pn' });
|
||||
expect(fields?.['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' });
|
||||
});
|
||||
|
||||
/*
|
||||
* **2026-08-30 修 Critical 2**:两个启动参数改成必填之后(`manifest.ts` 的
|
||||
* `contract.input.required`),`sub_eval_part_rows < 2` 只剩一条能触发它的路径 ——
|
||||
* 两颗料号填成了同一个(`internal_pn` 撞成同一把主键,两行覆盖成一行)。旧文案
|
||||
* 「原料号与候选料号还没填全 —— 在下面把两格补上」指向一条不存在的路(块① 没有
|
||||
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
|
||||
*/
|
||||
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
|
||||
const kase = acts[3];
|
||||
const fields = kase?.kind === 'project' ? kase.fields : undefined;
|
||||
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
|
||||
expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
|
||||
expect(gateReason?.then).not.toContain('还没填全');
|
||||
expect(gateReason?.then).not.toContain('把两格补上');
|
||||
expect(gateReason?.then).toContain('终止');
|
||||
});
|
||||
|
||||
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
|
||||
const params = acts[2];
|
||||
/* 'live':人加不了行的表,quiet 拍也撤 —— 改类型后旧参数行跟手消失(2026-08-31 用户点名「只会变多不会变少」) */
|
||||
expect(params?.kind === 'project' && params.prune).toBe('live');
|
||||
const from = params?.kind === 'project' ? params.from : undefined;
|
||||
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
|
||||
expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } });
|
||||
const fields = params?.kind === 'project' ? params.fields : undefined;
|
||||
expect(fields?.['param_value']).toMatchObject({ lookup: 'parts.parameter', take: 'param_value' });
|
||||
});
|
||||
|
||||
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
|
||||
const card = acts[4];
|
||||
const when = card?.kind === 'card' ? (card.when ?? '') : '';
|
||||
/*
|
||||
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
|
||||
* 于是未绑定 —— 拿它当条件,恰恰在一切正常时把卡发出去。条件只用恒有值的基座读数。
|
||||
*/
|
||||
expect(when).not.toContain('gate_reason');
|
||||
expect(when).toContain('sub_eval_part_exists_status_found < 2');
|
||||
expect(when).toContain('parts_parameter_missing > 0');
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-30,Ruling H35(推翻 2026-08-29 评审 Critical 那条旧结论,不是又改主意):
|
||||
* 从前 `parts.chip_type` 永久不列进这张卡的闸,理由是「它没有编辑手段,`mine:` 天然
|
||||
* 为空,列了就永久卡死」——那条理由**在 ②③ 两笔自投影加上之后不再成立**。
|
||||
* 而不列进闸的代价是真实存在的:「数据工单确认即入库」按 `gateLedgers` 整批并入正式表,
|
||||
* `parts.chip_type` 不在名单里,人新建的类型字典行就只留在草稿层、从不落地,
|
||||
* 引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 —— 一条悬空引用
|
||||
* (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。
|
||||
*/
|
||||
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => {
|
||||
const card = acts[4];
|
||||
expect(card?.kind === 'card' && card.as).toBe('execution');
|
||||
expect(card?.kind === 'card' && card.variant).toBe('data');
|
||||
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.profile', 'parts.parameter']);
|
||||
/*
|
||||
* 类型不一致不许确认(2026-08-31 用户定)。钉 parts.profile 本表的 distinct ——
|
||||
* 提交闸从草稿现读零滞后;钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦。
|
||||
*/
|
||||
expect(card?.kind === 'card' && card.gateWhen).toBe('parts_profile_distinct_chip_type_id <= 1');
|
||||
expect(card?.kind === 'card' && typeof card.gateWhenSay === 'string' && card.gateWhenSay.includes('不是同一个芯片类型')).toBe(true);
|
||||
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
|
||||
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('红条开关 gate_state 与发卡条件同一句判定(一处真源)—— gate_reason 清空不落笔,开关列两个方向都真写', () => {
|
||||
const kase = acts[3];
|
||||
const fields = kase?.kind === 'project' ? kase.fields : undefined;
|
||||
const gs = fields?.['gate_state'] as { when?: string; then?: string; else?: string } | undefined;
|
||||
const card = acts[4];
|
||||
expect(gs?.then).toBe('open');
|
||||
expect(gs?.else).toBe('clear');
|
||||
expect(card?.kind === 'card' && card.when).toBe(gs?.when);
|
||||
});
|
||||
|
||||
it('发卡条件与 doneWhen 是同一件事的正反面', () => {
|
||||
const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
|
||||
for (const reading of [
|
||||
'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
|
||||
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
|
||||
]) {
|
||||
expect(when).toContain(reading);
|
||||
expect(stepOf('S1_inputs')?.doneWhen).toContain(reading);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('S2 配方 —— AI 只出场一次', () => {
|
||||
const recipe = stepOf('S2_assess')?.auto;
|
||||
const acts = recipe?.do ?? [];
|
||||
|
||||
it('六笔:分析 → 关系(先摆一遍满足 refersTo)→ 明细 → 关系(重算)→ 验证记录 → 交代;analyze 恰好一笔', () => {
|
||||
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'say']);
|
||||
expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
|
||||
/* 两次 project 到 substitution.relation 是同一个引用(RELATION_PROJECTION),不是抄了两份 */
|
||||
expect(acts[1]).toBe(acts[3]);
|
||||
expect(acts[1]?.kind === 'project' && acts[1].into).toBe('substitution.relation');
|
||||
expect(acts[2]?.kind === 'project' && acts[2].into).toBe('substitution.difference_item');
|
||||
});
|
||||
|
||||
it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
|
||||
const items = acts[2];
|
||||
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
|
||||
/*
|
||||
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
|
||||
* `SomeShape | {}` 在按方括号取值时会被 TS 整体收窄成裸 `{}`(`{}` 是任何对象的父类型,
|
||||
* 联合化简会把更具体的那一支吃掉),于是**全部**字段访问失去编译期保护、报
|
||||
* 「不能索引类型 {}」,与 `stepOf` 标没标 `Extract` 无关(受控实验见任务报告)。
|
||||
* `: undefined` 不会被这样吃掉,后续按 `?.[key]` 取值,与 S1 既有的
|
||||
* `:164`/`:172`/`:175` 三处同一写法 —— 断言真跑到的值不变(条件为真时 `?.` 无副作用)。
|
||||
*/
|
||||
const fields = items?.kind === 'project' ? items.fields : undefined;
|
||||
/* AI 的出参只是一张查表:漏一条,那一格就是空 —— required 立刻把它变成 _missing > 0 */
|
||||
expect(fields?.['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' });
|
||||
expect(fields?.['impact_statement']).toMatchObject({ lookup: 'result:items' });
|
||||
expect(fields?.['evidence_note']).toMatchObject({ lookup: 'result:items' });
|
||||
});
|
||||
|
||||
it('**值不取 AI 出参,一律 lookup 台账**', () => {
|
||||
const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
|
||||
for (const col of ['original_value', 'candidate_value'] as const) {
|
||||
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
|
||||
}
|
||||
/* 约束类型来自字典这一行本身,不是 AI 说的 */
|
||||
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
|
||||
});
|
||||
|
||||
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
|
||||
const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
|
||||
const jc = JSON.stringify(fields?.['judgement_class']);
|
||||
expect(jc).toContain('hard_mismatch');
|
||||
expect(jc).toContain('soft_mismatch');
|
||||
/* 顺序要紧:先认四态,最后才按约束类型分硬软 */
|
||||
expect(jc.indexOf('"match"')).toBeLessThan(jc.indexOf('hard_mismatch'));
|
||||
});
|
||||
|
||||
it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => {
|
||||
// acts[1] 与 acts[3] 是同一个 RELATION_PROJECTION 引用,查哪个都一样
|
||||
const rel = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
|
||||
const level = JSON.stringify(rel?.['recommendation_level']);
|
||||
expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
|
||||
expect(level).toContain('not_recommended');
|
||||
/* 硬约束不符必须直接落到「不推荐」,排在最前 */
|
||||
expect(level.indexOf('hard_mismatch')).toBeLessThan(level.indexOf('unknown'));
|
||||
});
|
||||
|
||||
it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => {
|
||||
const val = acts[4];
|
||||
expect(val?.kind === 'project' && val.into).toBe('validation.record');
|
||||
expect(val?.kind === 'project' && val.from).toEqual({ once: true });
|
||||
const fields = val?.kind === 'project' ? val.fields : undefined;
|
||||
expect(fields?.['validation_status']).toBe('not_started');
|
||||
expect(fields?.['validation_type']).toBe('parametric');
|
||||
});
|
||||
|
||||
it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => {
|
||||
const analyze = acts[0];
|
||||
const prompt = analyze?.kind === 'analyze' ? analyze.instruction : '';
|
||||
expect(prompt).toContain('不要复述');
|
||||
expect(prompt).toContain('unknown');
|
||||
expect(prompt).toContain('硬约束');
|
||||
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值', '输入电压范围']) {
|
||||
expect(prompt).not.toContain(forbidden);
|
||||
}
|
||||
/* 出参 schema 顶层是对象,数组字段名与 into 对得上 */
|
||||
const out = analyze?.kind === 'analyze' ? analyze.output : undefined;
|
||||
expect(out?.['type']).toBe('object');
|
||||
expect(analyze?.kind === 'analyze' && analyze.into).toBe('items');
|
||||
});
|
||||
|
||||
it('AI 的入参**每一条都带说明** —— 参数名本身说不清「它为什么要紧」', () => {
|
||||
const analyze = acts[0];
|
||||
const fields = analyze?.kind === 'analyze' ? analyze.input.fields : undefined;
|
||||
expect(fields?.['param_desc']).toEqual({ col: 'param_desc' });
|
||||
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
|
||||
for (const col of ['original_value', 'candidate_value'] as const) {
|
||||
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 / S4 配方', () => {
|
||||
const s3 = stepOf('S3_review')?.auto?.do ?? [];
|
||||
const s4 = stepOf('T_release')?.auto?.do ?? [];
|
||||
const s2 = stepOf('S2_assess')?.auto?.do ?? [];
|
||||
|
||||
it('S3 九笔:圈范围 → 复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 两句交代 → 卡', () => {
|
||||
expect(s3.map((a) => a.kind)).toEqual(['project', 'analyze', 'project', 'project', 'project', 'project', 'say', 'say', 'card']);
|
||||
for (const at of [0, 2, 3]) expect(s3[at]?.kind === 'project' && s3[at].into).toBe('substitution.difference_item');
|
||||
expect(s3[4]?.kind === 'project' && s3[4].into).toBe('substitution.relation');
|
||||
expect(s3[5]?.kind === 'project' && s3[5].into).toBe('validation.record');
|
||||
});
|
||||
|
||||
/*
|
||||
* **人打回时,意见要交回给 AI 重判**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
|
||||
* 重新让 ai 重跑这一步?」)—— 在此之前 S3 那一轮只把派生列重算一遍,人写的那句话除了留痕
|
||||
* 之外**无人读**:AI 判过的四态一个字不会变,除非人自己在件上逐格改。
|
||||
*
|
||||
* 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的那句话(没打回就是空串)——
|
||||
* 常路(第一次送审)这两笔跳过,整个活动仍然只调一次模型。
|
||||
*/
|
||||
it('复核轮四笔都钉在「人打回了才跑」上 —— 常路一笔都不跑,整单仍只调一次模型', () => {
|
||||
/* `when` 不是每一种笔都有的键 —— 按「有没有这个键」取,不做属性访问(与本文件既有的 `'prune' in …` 同一写法) */
|
||||
const whenOf = (at: number): unknown => {
|
||||
const act = s3[at];
|
||||
return act !== undefined && 'when' in act ? act.when : undefined;
|
||||
};
|
||||
for (const at of [0, 1, 2]) expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('case_redo_note != ""');
|
||||
/* 后面那几笔(重算 / 关系 / 验证)照旧每一轮都跑 —— 人就地改了四态也要跟着重算 */
|
||||
for (const at of [3, 4, 5]) expect(whenOf(at), `第 ${at} 笔`).toBeUndefined();
|
||||
/*
|
||||
* **点不到名就一条都不改**(2026-09-01 当天上线当天撤的那一笔:整表重判)——
|
||||
* 重判与写回都多一个前提「真点到了名」,免得一句与参数无关的话(实测:「收口结论里面的
|
||||
* 推荐理由改为非常不推荐」)把 5 条参数的结论一起改掉,还白烧一次模型。
|
||||
*/
|
||||
for (const at of [1, 2]) {
|
||||
expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('substitution_difference_item_redo_scope_named > 0');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **谁可以变,由确定的一侧圈**(2026-09-01 用户实测:「我打回让修改工作温度的结论,给我改了
|
||||
* 静态电流的结论」)—— 第一版把整表交给 AI 重判、整表写回,没有任何东西限制它只动被点名的
|
||||
* 那一行;它改了没人问过的那条,`evidence_note` 里还写着「维持原判」。
|
||||
* 现在:①按字面点名圈出 `named`;③入参**只有** `named` 的行;④写回也只写这几行。
|
||||
*/
|
||||
it('圈范围:点名的标 named,其余 kept —— 一条都没点到时什么都不改,只把话说清楚', () => {
|
||||
const mark = s3[0];
|
||||
const markFields: Readonly<Record<string, unknown>> = mark?.kind === 'project' ? mark.fields : {};
|
||||
expect(markFields['redo_scope']).toEqual({ when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' });
|
||||
/* 没点到名的那一轮:一句交代,告诉他怎么才办得成 —— 而不是猜「他指的是全部」 */
|
||||
const noHit = s3[7];
|
||||
expect(noHit?.kind === 'say' && noHit.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named = 0');
|
||||
const text = noHit?.kind === 'say' ? String(noHit.text) : '';
|
||||
expect(text).toContain('一条都没改');
|
||||
expect(text).toContain('参数名');
|
||||
/* 收口四格不归 AI 判 —— 那几格人在件上直接改(2026-08-31 立的口径) */
|
||||
expect(text).toContain('件上直接改');
|
||||
});
|
||||
|
||||
it('重判与写回都只认 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着', () => {
|
||||
const redo = s3[1];
|
||||
const land = s3[2];
|
||||
const named = { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } };
|
||||
expect(redo?.kind === 'analyze' ? redo.input.from : undefined).toEqual(named);
|
||||
expect(land?.kind === 'project' ? land.from : undefined).toEqual(named);
|
||||
/* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */
|
||||
const rules = s2[0]?.kind === 'analyze' ? s2[0].instruction : '';
|
||||
expect(redo?.kind === 'analyze' && redo.instruction.startsWith(rules)).toBe(true);
|
||||
/*
|
||||
* **复核轮审核人说了算**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」)——
|
||||
* 他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 搬出第一轮那条
|
||||
* 「硬约束不因措辞变成 match」把他顶了回去。那条规矩管的是**它自己判**的时候别被措辞带偏;
|
||||
* 审核人是这一步的责任人(要签他的字,他在件上本来就能亲手改任何一条四态),不归它管。
|
||||
* 指令里必须**明写这条覆盖**,否则前面那份规矩排在前面、模型照旧照它办。
|
||||
*/
|
||||
const tail = redo?.kind === 'analyze' ? redo.instruction.slice(rules.length) : '';
|
||||
expect(tail).toContain('这一轮不归它管');
|
||||
expect(tail).toContain('他点名要什么结论,就落什么');
|
||||
expect(tail).toContain('不要顶回去');
|
||||
/* 落的同时要把「按谁的意见、数据面是什么」记进依据 —— 下一个人得能分清人定的与数据判的 */
|
||||
expect(tail).toContain('数据面');
|
||||
/* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */
|
||||
const fields: Readonly<Record<string, unknown>> = redo?.kind === 'analyze' ? redo.input.fields : {};
|
||||
for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) {
|
||||
expect(Object.keys(fields)).toContain(key);
|
||||
}
|
||||
expect(fields['reviewer_note']).toEqual({ in: 'case_redo_note' });
|
||||
/* 模型漏答某一条:兜回这一行上一轮的值 —— 漏答不该变成「把它清空」 */
|
||||
const landFields: Readonly<Record<string, unknown>> = land?.kind === 'project' ? land.fields : {};
|
||||
for (const col of ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition']) {
|
||||
expect(landFields[col]).toMatchObject({ lookup: 'result:items', else: { col } });
|
||||
}
|
||||
/* 派生列与两处创建笔共用同一份 when 链 */
|
||||
const s2Fields: Readonly<Record<string, unknown>> = s2[2]?.kind === 'project' ? s2[2].fields : {};
|
||||
expect(landFields['judgement_class']).toBe(s2Fields['judgement_class']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这是 2026-08-30 task-12「结论纹丝不动」缺陷的回归钉**:S3 从前直接从
|
||||
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 起步,人改的 `difference_status`
|
||||
* 没有任何一笔把它转译成 `judgement_class`,两笔投影读到的 tally 纹丝不动。
|
||||
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
|
||||
*/
|
||||
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
|
||||
const recompute = s3[3];
|
||||
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
|
||||
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
|
||||
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
|
||||
expect('prune' in (recompute ?? {})).toBe(false);
|
||||
const fields = recompute?.kind === 'project' ? recompute.fields : undefined;
|
||||
/* 只有两把主键(定位这一行)+ 两个派生列(judgement_class/risk_level)—— 一个都不多写:
|
||||
difference_status / impact_statement / evidence_note / unknown_disposition(人可以就地改的那几列)
|
||||
一个都不在这份 fields 里,靠 draftWrite 的稀疏 patch 语义保证不被这一笔碰到。 */
|
||||
expect(Object.keys(fields ?? {}).sort()).toEqual(['difference_item_id', 'judgement_class', 'risk_level', 'substitution_relation_id']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **判定分类 / 风险等级的 when 链只许有一处真源**:S3 的重算笔与 S2 差异明细创建笔的
|
||||
* `judgement_class`/`risk_level` 必须是**同一个对象引用**(都来自 `...JUDGEMENT_DERIVED_FIELDS`
|
||||
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
|
||||
*/
|
||||
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
|
||||
const s3Fields = s3[3]?.kind === 'project' ? s3[3].fields : undefined;
|
||||
const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined;
|
||||
expect(s2Fields?.['judgement_class']).toBeDefined();
|
||||
expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']);
|
||||
expect(s3Fields?.['risk_level']).toBe(s2Fields?.['risk_level']);
|
||||
});
|
||||
|
||||
/**
|
||||
* **S3 的关系笔只重算判定,收口四格一个字不碰**(2026-08-31 用户:「差异评估结论审核这个
|
||||
* 界面的收口结论下面这 4 个字段也需要可以编辑」)。
|
||||
*
|
||||
* 此前 S2 与 S3 共用同一份 `RELATION_PROJECTION`(「各写一份早晚分叉」)。件上把四格打开
|
||||
* 之后那条口径不能再照搬:那一笔每跑一次就按规则重写这四列,而人在件上按保存会触发
|
||||
* quiet 拍、quiet 恰恰跑 `project` —— 人刚写下的话当场被冲掉,等于格子白开。
|
||||
*
|
||||
* 现在 S3 换成 `RELATION_RECOMPUTE`:只列主键 + `recommendation_level`。`patch` 是稀疏的、
|
||||
* DB 侧是 jsonb 浅合并,没列的列原样留着 —— **这条断言的要害就是「没列」**,列了就等于没改。
|
||||
* 而 `recommendation_level` 必须仍在(判据核心:件② 判据表 / 件③ 落账结论 / 出口谓词都读它,
|
||||
* 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。
|
||||
*/
|
||||
it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => {
|
||||
const relation = s3[4];
|
||||
expect(relation?.kind).toBe('project');
|
||||
const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly<Record<string, unknown>> =>
|
||||
act?.kind === 'project' ? act.fields : {};
|
||||
const fields = fieldsOf(relation);
|
||||
for (const col of ['recommendation_reason', 'difference_summary', 'risk_level', 'verification_requirement']) {
|
||||
expect(Object.keys(fields), col).not.toContain(col);
|
||||
}
|
||||
/* 判定仍要重算,且与 S2 那份共用同一条 when 链 —— 两处各写一份早晚分叉 */
|
||||
const s2Relation = fieldsOf(s2[3]);
|
||||
expect(fields['recommendation_level']).toBeDefined();
|
||||
expect(fields['recommendation_level']).toBe(s2Relation['recommendation_level']);
|
||||
/* 主键带回:同 PK 覆盖,不是新开一行 */
|
||||
expect(fields['substitution_relation_id']).toBeDefined();
|
||||
/* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */
|
||||
expect(s3[5]).toBe(s2[4]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这一条读的是真 `case-model.ts`,不是任何镜像**(2026-08-29 复审 Important fix round 2)——
|
||||
* `w05-s3-review-recompute.test.ts` 证明的是机制(S3 少了 `let`,复用的投影主键就算不出来,
|
||||
* 写会被静默丢掉、或拼出一行垃圾);但那份测试手写镜像、不 import 这份真文件(架构上也不能),
|
||||
* 红绿与 `case-model.ts` 此刻写的是什么**完全脱钩**——明天有人把 `S3_review.auto.let` 删掉,
|
||||
* 原样重犯这次的 bug,`AutoRecipe.let` 是可选字段(typecheck 不报),镜像测试也不会变红。
|
||||
* 这一条补上「读真文件」那一半:S3 与 S2 的 `auto.let` 必须是**同一个引用**,少了就在这里当场炸。
|
||||
*/
|
||||
it('S3 与 S2 共用同一个 `let` 引用 —— 少了它,复用的投影主键在 S3 那一拍就是未绑定的空串', () => {
|
||||
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let);
|
||||
});
|
||||
|
||||
/*
|
||||
* **交代必须排在卡之前**(2026-09-01 用户:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,
|
||||
* 不然都不知道发生了什么」)—— 发了卡这一拍就收了,排在卡后面的 `say` 要等**下一拍**才跑,
|
||||
* 那时人早看完卡了。实测:原来那句「差异结论已送审,请核对」出现在人答完之后。
|
||||
* 两句互斥:被打回那一轮说「按你的意见改了哪一条、改成什么」(摆的是件上那一行自己的
|
||||
* `evidence_note`,不另编一句);第一轮说「结论已备好,请核对」。
|
||||
*/
|
||||
it('打回那一轮的交代排在卡之前,摆的是件上那一行的判断依据', () => {
|
||||
const kinds = s3.map((a) => a.kind);
|
||||
/* **排在卡之前**:发了卡这一拍就收了,排在后面的 say 要等下一拍 —— 那时人早看完卡了 */
|
||||
expect(kinds.lastIndexOf('say')).toBeLessThan(kinds.indexOf('card'));
|
||||
/* 只在被打回那一轮说 —— 第一轮那句与卡自己的 ask 是同一件事,退役了 */
|
||||
const redoSay = s3[6];
|
||||
expect(redoSay?.kind === 'say' && redoSay.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named > 0');
|
||||
const text = redoSay?.kind === 'say' ? redoSay.text : undefined;
|
||||
const parts = JSON.stringify(text);
|
||||
expect(parts).toContain('按你的意见复核了');
|
||||
/* 摆的是件上那一行自己的话与名字 —— 对话区与件上说的是同一件事,就该是同一句话 */
|
||||
expect(parts).toContain('evidence_note');
|
||||
expect(parts).toContain('parameter_name');
|
||||
expect(parts).toContain('substitution_difference_item_redo_scope_named');
|
||||
});
|
||||
|
||||
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
|
||||
const card = s3[8];
|
||||
expect(card?.kind === 'card' && card.as).toBe('decide');
|
||||
expect(card?.kind === 'card' && card.variant).toBe('review');
|
||||
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
|
||||
expect((card?.kind === 'card' ? card.options ?? [] : [])[0]?.value).toBe('approve');
|
||||
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('S4 两笔:写结论一句话 → 提交放行', () => {
|
||||
expect(s4.map((a) => a.kind)).toEqual(['project', 'submit']);
|
||||
expect(s4[0]?.kind === 'project' && s4[0].into).toBe('sub_eval.case');
|
||||
expect(s4[1]?.kind === 'submit' && typeof s4[1].rationale !== 'undefined').toBe(true);
|
||||
});
|
||||
|
||||
it('`submit` 所在的步声明了人工验收清单 —— 系统提交不比模型提交宽松', () => {
|
||||
expect((stepOf('T_release')?.acceptanceChecklist ?? []).length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('两步都不给 doneWhen —— 收步由人答卡决定', () => {
|
||||
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
|
||||
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "@activities/part-substitution-assessment", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
/** 清单与案例模型共用的常量 —— 单独成文件,免得 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';
|
||||
/*
|
||||
* `TOOL_COMPARE` / `TOOL_RECOMMEND` 两个常量已随 2026-08-29 的整体重写退役
|
||||
* (spec `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §1/§8):
|
||||
* 逐项四态改由 S2 唯一一次 `analyze` 出场判定,推荐等级改由配方按 §3.5 的规则算 ——
|
||||
* 两枚工具已在 Task 10 于工具注册表里停用(墓志铭见 `apps/api/src/app/tools/first-party.ts`)。
|
||||
* 案例模型(`case-model.ts`)全程零工具调用,没有第二处字面量引用留着。
|
||||
*/
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
|
||||
* 案例模型 + 说明书 + 步骤剧本(全 `auto`,零 `guide`)+ 台账声明 + 三张 HTML 报告模版
|
||||
* (**0 条技能** —— 2026-08-29 整体重写后全程无 agent 临场编排,技能戳随两枚工具、
|
||||
* 两张 DMN 一并退役)。这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
|
||||
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
|
||||
export default defineActivity({ name: 'part-substitution-assessment' });
|
||||
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* 活动包清单 —— ACT-WORK-05 差异评估(案例活动)。
|
||||
*
|
||||
* **2026-08-29 整体重写(@2.0.0)**:从「九步、两枚工具、全程 agent 驱动」改成「四步、零工具、
|
||||
* 零 `guide`、AI 出场恰好一次」。设计权威:
|
||||
* `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`(下称「spec」)。
|
||||
* 用户原话立的场:「不是把所有东西都丢给 ai 来处理…一定是系统先确定性的从数据库中取出数据后交给
|
||||
* ai 分析」——`docs/ACT-WORK-05-端到端测试记录.md` 的 A–AE 发现表里,八条事故都落在
|
||||
* 「本来就该是确定的」那几步(编号抄别单、判在库判错、数据工单派给在库的料…),
|
||||
* 真正需要判断的只有一件事:**这一条参数,候选跟原厂比算不算事**。
|
||||
*
|
||||
* **2026-08-30 更新**:这份注释原写于重写 Task 2(只动 `declarations.ledger` 与
|
||||
* `contract` 两段那一刻),彼时 `case-model.ts` 还是旧九步——**Task 3 已把它整体重写成
|
||||
* spec §4 的四步**(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`)+ 四道哨兵 +
|
||||
* 零 `guide`(行为改由 `PlanStep.auto` 声明式配方接管),清单与案例模型此刻同版一致,
|
||||
* 不再有「一边新一边旧」的过渡态。`assets` 里当时已不自洽的那几处(spec §8 的退役清单,
|
||||
* 见「计划裁定 G3」)同批处理完毕。
|
||||
*
|
||||
* ## 九张台账声明(spec §3)
|
||||
* 两张新字典(`parts.chip_type` / `parts.type_param`,Task 1 已物化 + 播种)承接「约束类型按类型
|
||||
* 标一次」;P-08 主记录 `parts.profile` 撤 `createOnly`(在库老料要能补上 `chip_type_id` 这一格)、
|
||||
* 封装与工艺退休(它们本来就是「核心参数」,该以类型字段的身份出现在字典里);P-08 子表
|
||||
* `parts.parameter` 撤 `constraint_type` 与参数名自指引用(名字对不对由 S1 配方铺格子这件事本身
|
||||
* 保证,不必再靠一堵测三次都没拦住的墙)。原来的三张单据(`pn_check` / `input_gap` / `verdict`)
|
||||
* 由两张顶掉:`sub_eval.part`(**一单恰两行** —— 配方 `each` 遍历「哪几颗料要建」的唯一数据源,
|
||||
* 拆成两行的理由见 spec §3.3「为什么非拆成两张不可」)与 `sub_eval.case`(一单一行的案卷,
|
||||
* 挂入参未就绪的原因与结论摘要)。P-11 明细加合成列 `judgement_class`(约束类型 × 四态的合成,
|
||||
* 因为「硬约束不符有几条」不是任何单列 `tally` 数得出来的数)。P-01 需求主记录 / P-10 原厂资料 /
|
||||
* 客户档案**本活动不碰**(它们的存在性核对与录入不再是这个活动的事)。
|
||||
*
|
||||
* ## 契约(spec §4.1/§4.5/§7)
|
||||
* 启动只剩 `originalInternalPn` / `candidateInternalPn` 两格(单数,一次评估恰一对一候选),
|
||||
* 都选填 —— 缺了由第一步的配方派数据工单补。零工具:`contract.resources.tools` 与
|
||||
* `declarations.tools` 都是 `[]`,这是这次重写的立场,不是疏漏(四态改 S2 唯一一次 `analyze`
|
||||
* 判定,推荐等级改配方按 spec §3.5 的规则算)。判据全部改读基座数(`域_对象_rows` / `_missing` /
|
||||
* `_distinct_列` / tally 计数),没有一条读活动自己写的汇总列。
|
||||
*/
|
||||
import type { ActivityManifest } from '@contracts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { CMMN_XML, NARRATIVE, ASSESSMENT_PHASES, ASSESSMENT_STEPS } from './case-model.ts';
|
||||
import { LEDGER_BOARD, LEDGER_BOARD_DATA } from './views/ledger-board.ts';
|
||||
import { INPUT_SUPPLEMENT, DIFFERENCE_REVIEW, LANDING_CONFIRM } from './views/report.ts';
|
||||
import { ACTIVITY_ID_TEXT, D } from './constants.ts';
|
||||
|
||||
export { D, RULE_VERSION } from './constants.ts';
|
||||
const ACTIVITY_ID = activityIdSchema.parse(ACTIVITY_ID_TEXT);
|
||||
|
||||
/** 字段声明的短写(与 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 · 差异评估',
|
||||
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
|
||||
packageVersion: '2.24.0',
|
||||
category: '需求分析与方案匹配',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-part-substitution-assessment',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* **两格,都必填**(2026-08-30 修 Critical 2,推翻 spec §4.1 原来的「都选填」)——
|
||||
* 一次评估恰是「一对一候选」,这两颗料号是这件事的**身份**,不是可以事后补的细节:
|
||||
* 库里有就选,没有就自己写一个新的(合法的新料号字符串,不是留空)。
|
||||
* 「启动后由第一步的配方核对存在性、缺了就派数据工单补齐」这句话说的是料号的**主数据**
|
||||
* (品牌/类型/参数),从来不是料号**本身**——旧措辞把两件事含糊成了一件事。
|
||||
*
|
||||
* 都选填时留空的下场(评审逐行核过的链条):S1 配方 `project into sub_eval.part` 拿
|
||||
* `originalInternalPn`/`candidateInternalPn` 铸两行的主键 `internal_pn`,两个空字符串
|
||||
* 是**同一个主键**——第二笔 `draftWrite` 覆盖第一笔,`sub_eval.part` 收成**一行**不是
|
||||
* 两行;`gate_reason` 的 `sub_eval_part_rows < 2` 当场为真,提示「在下面把两格补上」——
|
||||
* 而块① 的行源是 `mine:sub_eval.part`(人不加行,没有「+加一行」按钮),那两格根本
|
||||
* 不存在。`doneWhen` 永不成立,提交也按不下去,**唯一出路是终止重开**,且五条样例与
|
||||
* e2e 五案例都没填过空值,两条验证线都照不到这条死路。
|
||||
*
|
||||
* 改必填**不是关上「留空」这扇门就完事**——平台早有配套机制接住它(2026-07-30 立的
|
||||
* 「受理归一」:缺必填不拒单,任务落成 `awaiting-human`,一张补全卡问负责人要那几个
|
||||
* 字段,答完合并进 `data` 才转 `running`;`apps/api/src/modules/task/internal/usecase/
|
||||
* task-service.ts` 的 `missingRequired`/`askCompletion`,`__tests__/intake.test.ts`
|
||||
* 已经把这条路测得很实)。必填不是墙,是把「两颗料号是什么」这件唯一要人先说清楚的
|
||||
* 事挪到起单那一刻问清楚,不留给配方去猜一个猜不出来的空字符串。
|
||||
*
|
||||
* 运营端启动表单渲染这两格为「选择 + 新增」下拉(`x-control: combo`),候选从
|
||||
* `parts.profile.internal_pn` 现读(`x-options`);`x-order` 定表单次序(清单存在
|
||||
* jsonb 里,书写顺序到不了运营端)。
|
||||
*
|
||||
* **两格按芯片类型联动**(2026-08-31 用户定):`x-options.group` 让候选带上各自的
|
||||
* `chip_type_id` 与它在 `parts.chip_type` 里的人读名,`x-link: 'chip-type'` 把两格结成一组 ——
|
||||
* 组内**先填的那格是锚**(摆全量),另一格只列同类型的料 + 库里未标类型的(殿后一组)+「新增」。
|
||||
* 锚没有类型、或锚填的是库里没有的新料号 → 不收窄(两颗都还没建档时不该拦着人)。
|
||||
* **这只是表单的引导,不是墙**:服务端照收两格不同类型的组合(`launch-params.ts` 头注),
|
||||
* 类型一致由 S1 的收步判据 `sub_eval_part_distinct_chip_type_id = 1` 管 —— 那才是数得出来的门。
|
||||
*
|
||||
* **两格不能是同一颗料**(`x-distinct: 'part'`,2026-08-31 用户定:「不应该能选到同样的两个芯片」)——
|
||||
* 这一条**是墙**,不是引导,理由与上面「留空」那段是同一条链:两格同一个料号 → 上面那笔 `project`
|
||||
* 铸的两行撞上同一个主键(`eval_ref + internal_pn`,`role` 不在主键里)→ 后一笔覆盖前一笔 →
|
||||
* `sub_eval.part` 收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立,S1 收不了步;
|
||||
* 而件上那两行人加不出来(块① 行源 `mine:parts.profile`,没有「+加一行」),**唯一出路是终止重开**。
|
||||
* 墙立在起单这一刻,是因为**路只在这里** —— 那时换一格就好了。运营端表单据此不把另一格已经选走的
|
||||
* 料号列出来;自己写一个同样的、或脚本 / API 直接起单,由服务端 `validateLaunchParams` 拒。
|
||||
*
|
||||
* requestDescription / candidateInternalPns(数组)/
|
||||
* datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填
|
||||
* datasheet」这几件事。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||
properties: {
|
||||
originalInternalPn: {
|
||||
type: 'string',
|
||||
title: '原内部料号',
|
||||
description: '差异评估基准料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
|
||||
examples: ['LM2596S-5.0'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||
'x-order': 1,
|
||||
'x-link': 'chip-type',
|
||||
'x-distinct': 'part',
|
||||
},
|
||||
candidateInternalPn: {
|
||||
type: 'string',
|
||||
title: '候选内部料号',
|
||||
description: '被评估的候选料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
|
||||
examples: ['XL2596S-5.0'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||
'x-order': 2,
|
||||
'x-link': 'chip-type',
|
||||
'x-distinct': 'part',
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: 'FAE 可在此说明客户的应用边界或验证条件',
|
||||
},
|
||||
output: {
|
||||
/* 单一必发口:签下去 P-11 落账、P-06 落账;载荷是已落地的事实,不是待办 */
|
||||
mandatory: [
|
||||
{
|
||||
name: 'assessment-released',
|
||||
description: '差异评估结论已落账(P-11 替代关系主记录 + 差异明细、P-06 验证记录)—— 载荷即已落地的事实',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['evalRef', 'relationId', 'originalPn', 'candidatePn', 'recommendationLevel', 'matchTotal', 'mismatchTotal', 'unknownTotal', 'conditionalTotal', 'validationId'],
|
||||
properties: {
|
||||
evalRef: { type: 'string', title: '评估案号' },
|
||||
relationId: { type: 'string', title: '替代关系ID' },
|
||||
originalPn: { type: 'string', title: '原内部料号' },
|
||||
candidatePn: { type: 'string', title: '候选内部料号' },
|
||||
recommendationLevel: { type: 'string', title: '推荐等级' },
|
||||
matchTotal: { type: 'integer', title: 'MATCH 条数' },
|
||||
mismatchTotal: { type: 'integer', title: 'MISMATCH 条数' },
|
||||
unknownTotal: { type: 'integer', title: 'UNKNOWN 条数' },
|
||||
conditionalTotal: { type: 'integer', title: 'CONDITIONAL 条数' },
|
||||
validationId: { type: 'string', title: '关联验证ID' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
/* 一份成果常不止一张表:差异评估结论由明细 + 关系两张共同承载,落账内容由关系 + 验证记录承载 */
|
||||
requiredDeliverables: [
|
||||
{ key: 'difference-items', label: '差异评估结论', ledger: 'substitution.difference_item', ledgers: ['substitution.difference_item', 'substitution.relation'], editableAtRelease: [] },
|
||||
{ key: 'landing', label: '落账内容', ledger: 'substitution.relation', ledgers: ['substitution.relation', 'validation.record'], editableAtRelease: [] },
|
||||
],
|
||||
/*
|
||||
* 验收判据(spec §7)—— 五条机检 L1/L2 全读基座数,两条 L4 裁量,一条 L5 责任关(不上卡)。
|
||||
* `input-ready` 的谓词与 S1(Task 3 待写)的 `doneWhen` 同构:两颗都在库、类型一致、参数不缺 ——
|
||||
* **不写「参数行数 = 字段数×2」**,那不是基座数得出来的数(`parts_type_param_rows` 数的是
|
||||
* 本单写下的字段行,不是整本字典;给判据编一个数不出来的数,是这个仓踩过六次的同一个坑)。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{
|
||||
key: 'input-ready',
|
||||
label: '评估输入已就绪:原 PN 与候选都在库、归了同一个芯片类型、主数据与参数值都不缺必填',
|
||||
level: 'L1',
|
||||
predicate:
|
||||
'sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and sub_eval_part_distinct_chip_type_id = 1 and ' +
|
||||
'parts_profile_missing = 0 and parts_parameter_missing = 0',
|
||||
},
|
||||
{
|
||||
key: 'every-param-judged',
|
||||
label: '每条核心参数都有明确状态:每条差异明细都是 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 之一,没有一条缺必填',
|
||||
level: 'L2',
|
||||
predicate:
|
||||
'substitution_difference_item_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',
|
||||
},
|
||||
{
|
||||
key: 'level-by-rule',
|
||||
/* §3.5 的规则原样译成析取式:推荐等级只有一种取值能配上当前的 judgement_class 分布,规则说了算不是话术说了算 */
|
||||
label: '推荐等级与判定分类一致:有硬约束不符的候选不能是「推荐」,等级由规则算,不由措辞改判',
|
||||
level: 'L2',
|
||||
predicate:
|
||||
'(substitution_relation_recommendation_level_not_recommended = 1 and substitution_difference_item_judgement_class_hard_mismatch > 0) or ' +
|
||||
'(substitution_relation_recommendation_level_conditional = 1 and substitution_difference_item_judgement_class_hard_mismatch = 0 and ' +
|
||||
'(substitution_difference_item_judgement_class_unknown > 0 or substitution_difference_item_judgement_class_conditional > 0 or substitution_difference_item_judgement_class_soft_mismatch > 0)) or ' +
|
||||
'(substitution_relation_recommendation_level_recommended = 1 and substitution_difference_item_judgement_class_hard_mismatch = 0 and ' +
|
||||
'substitution_difference_item_judgement_class_unknown = 0 and substitution_difference_item_judgement_class_conditional = 0 and substitution_difference_item_judgement_class_soft_mismatch = 0)',
|
||||
},
|
||||
{
|
||||
key: 'unknown-has-disposition',
|
||||
label: '每个 UNKNOWN 都有去向:去向、影响说明、判断依据没有一条缺',
|
||||
level: 'L2',
|
||||
predicate: 'substitution_difference_item_missing = 0',
|
||||
},
|
||||
{
|
||||
key: 'validation-landed',
|
||||
label: '验证记录已落账:P-06 恰有一行且不缺必填(全推荐也要写「本轮无待验项」)',
|
||||
level: 'L2',
|
||||
predicate: 'validation_record_rows = 1 and validation_record_missing = 0',
|
||||
},
|
||||
/*
|
||||
* **两条 L4 补了谓词**(2026-08-30,真跑 e2e 撞见的结构性发现 —— **不是**记进
|
||||
* `docs/ACT-WORK-05-端到端测试记录.md`:那份文档整篇是 2026-08-27 及更早的旧
|
||||
* 九步设计,四步重写[2026-08-29]之后没有再往里追加过任何一条,已在文档开头标为
|
||||
* 历史;这条发现连同其余 2026-08-30 的都只活在这份源码注释与 `docs/debt.md` 里)。
|
||||
* 「裁量项带谓词时谓词说了算」这条纪律
|
||||
* (2026-08-02 立,`engines.ts` 的 `criterionCheck`:`settledByData`)本仓已有先例,
|
||||
* 从前 W05 这两条 L4 是裸的(无 `predicate`),而 T_release 全程 `auto` 驱动、
|
||||
* `submit` 是五个声明式动词之一(`packages/contracts/src/auto-recipe.ts`),
|
||||
* **没有第六个 `judge` 动词** —— 裁量项没谓词就只能等 `judgement`(Agent 的 `judge`
|
||||
* 行动)来结算,而这个活动压根不会有 Agent 走到这一步。结果是 `submittable()`
|
||||
* 对这两条永远读到 `pass:false`,`T_release` 的 `submit` 每一拍都被
|
||||
* `raiseAcceptance` 拒,而拒绝不算错误(不触发 `failedAt`)、下一拍原样重来 ——
|
||||
* 稳定地把步数预算耗到 `exhausted`,而不是报错崩溃,所以静态审计与包内自测都没照见它。
|
||||
*
|
||||
* 两条谓词只覆盖**客观、基座算得出来的那一半**(有没有值、值哪来的),
|
||||
* **主观那一半(写得好不好、可信不可信)已经由人工验收清单接住**——S3 审核卡
|
||||
* 第 1/4 条「逐条对过原料与候选料的参数值…」「影响说明站得住…」正是这两条 L4
|
||||
* 标签里「站得住」「对得上」那两个字的着落点,人签字通过那一刻已经确认过。
|
||||
* 谓词为真只说「客观前提具备」,不越权替人下「写得好」这个主观判断。
|
||||
*/
|
||||
{
|
||||
key: 'evidence-credible',
|
||||
label: '证据链可信:每条差异的影响说明与判断依据站得住',
|
||||
level: 'L4',
|
||||
/* 客观前提:每一行都有值(必填不缺)——写得「站不站得住」是 S3 人工验收清单的事 */
|
||||
predicate: 'substitution_difference_item_missing = 0',
|
||||
},
|
||||
{
|
||||
key: 'values-traceable',
|
||||
label: '参数值与原厂资料对得上:候选与原 PN 的取值来源可追溯',
|
||||
level: 'L4',
|
||||
/* 客观前提:原始参数集不缺、差异明细里逐条的原值候选值也不缺 —— 两值均来自
|
||||
parts.parameter 的 lookup,不是模型现编;「对不对得上」是 S3 人工验收清单的事 */
|
||||
predicate: 'parts_parameter_missing = 0 and substitution_difference_item_missing = 0',
|
||||
},
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-06 落账,结论供下游消费', 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: '本活动只答技术可替代性:不做数量、价格、交期、采购判断' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [],
|
||||
preauthorizedActions: [],
|
||||
/* 与下面 `resources.budget.humanCalls` 同一个数 —— 常路调人三次(补数据 / 审核 / 放行),
|
||||
余量给审核打回:每打回一次就多叫一次人(2026-09-01 用户定,见那一行的注释) */
|
||||
humanCallBudget: 24,
|
||||
},
|
||||
resources: {
|
||||
tools: [],
|
||||
skills: [],
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['FAE/RI'],
|
||||
/*
|
||||
* **调人次数给足**(2026-09-01 用户定「那你就把预算提高」)—— 8 次在真人手里三四轮就见底:
|
||||
* 常路已经要 3 次(数据/验收工单 1 + 结论审核 1 + 落账放行 1),而**审核每打回一次就多叫一次**,
|
||||
* 实测他连打回四次,单子落 `exhausted` —— 屏上看着像「最后落账那一步直接没了」。
|
||||
* 24 次 = 常路 3 + 二十来轮打回,人自己点得累了也到不了顶。
|
||||
*
|
||||
* **这是配置,不是机制**:机制上的正解是「人自己打回引出的那张卡不算打扰」(他不是被打扰,
|
||||
* 是他叫的)—— 那要在发卡那条路上分「主动叫人」与「应他要求再问一遍」,记在 debt。
|
||||
*/
|
||||
budget: { steps: 40, tokens: 300_000, activeMinutes: 60, humanCalls: 24 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
/* 零工具 —— 这是这次重写的立场,不是疏漏(见头注) */
|
||||
tools: [],
|
||||
ledger: [
|
||||
/* ── 两张类型字典(parts 域,与 P-08 同域;Task 1 已物化 + 播种)── */
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'chip_type',
|
||||
kind: 'master',
|
||||
label: '芯片类型字典',
|
||||
fields: [
|
||||
/*
|
||||
* **`type_id` 声明成 `text` 而不是 `integer`**(Task 7b,2026-08-29,件① 补「新建类型」
|
||||
* 逼出来的两处平台级发现,记在这里免得下一个人重踩):
|
||||
* ①`resolveRowKey`(`apps/api/src/app/routes/desk.ts`)的「③ 续号」只认 `isSeq`
|
||||
* (declared type 是 integer/number),不看 `auto` —— 这个类型碰巧是**唯一**一个
|
||||
* `auto:true` 又是 `integer` 的主键,于是它抢在 `writeEditedRows` 的 `autoKeys()`
|
||||
* (真正的续号器,数official+草稿叠加、不会撞号)前面,自己按「本单草稿有几行」
|
||||
* 铸了个从 1 起的号——种子已经占了 `type_id=1/2`,人第一次新建类型就会撞车,
|
||||
* 把种子的「DC-DC 降压稳压器」悄悄改名。②即便①不发生,`autoKeys()` 的续号是
|
||||
* 两位补零字符串(`'03'`),而**引用它的那几列**(`parts.profile.chip_type_id` 等)
|
||||
* 若声明成 `integer`,写入时会被 `coerceToColumn` 按 `Number()` 解析掉前导零——
|
||||
* `'03'` 存成 `parts.chip_type.type_id`、`3` 存成 `parts.profile.chip_type_id`,
|
||||
* `refersTo`/`data-by` 那几处按 `String(...)` 比对的查找会全部失配(`'03' !== '3'`),
|
||||
* 写入当场被 `refersTo` 拒、或下拉/查表显示不出人刚建的类型。
|
||||
* **两处病根都是「integer 类型的自增列」这个此前从未出现过的组合触发的**——本仓
|
||||
* 唯一先例 `contact_code` 一直是 `text`。**`resolveRowKey`/`coerceToColumn` 这两处
|
||||
* 平台代码本身仍带着这两个坑没修**(下一个建同样组合主键的人会重新踩上,已记
|
||||
* `docs/debt.md`)——这里只是在活动自己的声明层绕开,不是修复。
|
||||
*
|
||||
* 改成 `text` 为什么是安全的(此列与下面 5 处同引用列一起改,保持同一条身份链路
|
||||
* 全程一种类型):**`ledger.__draft`(任务级草稿层)才是 JSONB**
|
||||
* (`ledger-store.ts` 的 `DRAFT_TABLE`/`DRAFT_DDL`:`key`/`patch`/`baseline` 三列都是
|
||||
* `jsonb`)——但 `master`/`record` 这类**正式表是真实的物理列**(同文件 `PG_TYPE`
|
||||
* 映射,`integer → bigint`,`create table`/`add column`/`alter column ... type ...`
|
||||
* 都拼的是真 PG 类型)。改声明安全,靠的是 `schema-merge.ts` 的**单调加宽 lattice**
|
||||
* (`SCALAR_CHAIN.integer = ['integer', 'number', 'text']`,`widen()`
|
||||
* 判定 `integer → text` 是**合法加宽**、不是类型冲突)配合 `widen-column` 那支生成的
|
||||
* 显式转换(`alter table … alter column … type text using col::text`——整数转文本
|
||||
* 沿 PG 的 `bigint::text` 转型,没有失败路径)。这次重装(`activities:install`)会
|
||||
* 把 `ledger.parts__chip_type.type_id` 等六列从 `bigint` 真的 `ALTER … TYPE text`
|
||||
* 过去一遍。
|
||||
*
|
||||
* S-FEEL 那几处只做等值/去重比较,不做数值运算,不受影响(已逐处核对:distinct
|
||||
* 计数 / lookup·take / refersTo 皆按字符串比,`>` 只用在 `distinct_chip_type_id`
|
||||
* 这个计数列上,不用在值本身)。查证过程详见 Task 7b 报告
|
||||
* (`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-7b-report.md`)。
|
||||
*/
|
||||
col({ name: 'type_id', type: 'text', label: '类型编号', primaryKey: true, auto: true }),
|
||||
col({ name: 'type_name', type: 'text', label: '类型名称', required: true }),
|
||||
col({ name: 'description', type: 'text', label: '类型说明', expect: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'type_param',
|
||||
kind: 'master',
|
||||
label: '芯片类型核心参数字段',
|
||||
fields: [
|
||||
/* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text——见上面那条大注释 */
|
||||
col({ name: 'type_id', type: 'text', label: '所属类型', primaryKey: true, refersTo: 'parts.chip_type.type_id' }),
|
||||
col({ name: 'param_name', type: 'text', label: '参数名', primaryKey: true }),
|
||||
/* AI 分析时读它 —— 这一格说清「这个参数是什么意思」,是 S2 唯一一次 analyze 出场的入参之一 */
|
||||
col({ name: 'param_desc', type: 'text', label: '参数说明', required: true }),
|
||||
/* 约束类型按类型标一次,长在这里(不在每颗料的 `parts.parameter` 上)—— 见该表的头注 */
|
||||
col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }),
|
||||
col({ name: 'param_unit', type: 'text', label: '参考单位', expect: true }),
|
||||
],
|
||||
},
|
||||
/*
|
||||
* P-08 芯片主数据 —— 声明后为八列,物理表其余列本活动不碰。`createOnly` 撤掉
|
||||
* (spec §3.2):它当初是为了拦模型凭空建料,而配方现在按本单两颗料铺行,
|
||||
* 凭空建在结构上已不可能;撤掉它换来一件必需的事 —— 给在库的老料补上
|
||||
* `chip_type_id` 这一格(迁移前建的行没有类型,不许改就永远补不了)。
|
||||
* package / process_technology / eol_pcn_status / application_scenarios 不声明:
|
||||
* 封装和工艺本来就是「核心参数」,该以类型字段的身份出现在字典里,不在主记录上另开一列。
|
||||
*/
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'profile',
|
||||
kind: 'master',
|
||||
label: '芯片主数据(P-08)',
|
||||
fields: [
|
||||
col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
|
||||
/* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */
|
||||
col({ name: 'chip_type_id', type: 'text', label: '芯片类型', required: true, distinct: true, refersTo: 'parts.chip_type.type_id' }),
|
||||
col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }),
|
||||
col({ name: 'lifecycle_status', type: 'text', label: '生命周期状态', required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }),
|
||||
col({ name: 'specification_description', type: 'text', label: '规格描述', expect: true }),
|
||||
col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }),
|
||||
col({ name: 'datasheet_ref', type: 'text', label: 'Datasheet 引用', expect: true }),
|
||||
col({ name: 'datasheet_version', type: 'text', label: 'Datasheet 版本', expect: true }),
|
||||
],
|
||||
},
|
||||
/*
|
||||
* P-08 子表:标准化参数集,一行一参数。`createOnly` 撤掉(理由同上一张:在库那颗料的
|
||||
* 现值要能回填进草稿层,只许建新就写不进去)。两处退休(spec §3.2):
|
||||
* `constraint_type` 搬去 `parts.type_param`(同一个参数在两颗料上标法不一致这件事从结构上
|
||||
* 消失);`param_name` 的自指引用墙与 `distinct` 一并退休 —— 它当初是为了硬逼两颗料用同一套
|
||||
* 参数名,而字段清单现在由类型字典给、行由配方按字典铺,名字对不上根本不可能发生。
|
||||
*/
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'parameter',
|
||||
kind: 'master',
|
||||
label: '标准化参数集(P-08 子表)',
|
||||
fields: [
|
||||
col({ name: 'internal_pn', type: 'text', label: '所属料号', primaryKey: true, refersTo: 'parts.profile.internal_pn' }),
|
||||
col({ name: 'param_name', type: 'text', label: '参数名', primaryKey: true }),
|
||||
col({ name: 'param_value', type: 'text', label: '值', required: true }),
|
||||
col({ name: 'param_unit', type: 'text', label: '单位', expect: true }),
|
||||
/* 值说明,AI 也读它(spec §3.2) */
|
||||
col({ name: 'param_note', type: 'text', label: '说明', expect: true }),
|
||||
/*
|
||||
* **约束类型不在这里** —— 它长在 `parts.type_param`(按类型标一次)。
|
||||
* 从前每颗料各标一次,同一个参数在两颗料上标法不一致就成了可能;
|
||||
* 而比对是逐参数对着比的,两边约束档不同,判出来的四态就没有意义。
|
||||
*/
|
||||
],
|
||||
},
|
||||
/*
|
||||
* ── 活动自持两张(spec §3.3,顶掉退休的 pn_check / input_gap / verdict 三张)──
|
||||
* `sub_eval.part` 是配方唯一能拿来遍历「哪几颗料要建」的数据源(`each: 'mine:sub_eval.part'
|
||||
* where exists_status = absent`)—— 挤进一行案卷里就没法 `where` 过滤,而不能过滤就会给
|
||||
* 已经在库的那颗料也铺一遍空参数行,这是非拆成两张不可的理由。
|
||||
*/
|
||||
{
|
||||
domain: D,
|
||||
object: 'part',
|
||||
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'] }),
|
||||
/*
|
||||
* **「在库」是一次 lookup 的结果,不是一句断言**(spec §3.3):判 found 就必须拿得出
|
||||
* 一个基座查得到的料号,`refersTo` 让查无此值当场拒。`exists_status` 与这一列**每一拍
|
||||
* 由配方重算**(不设 created 档、不靠谁记账)—— 人把料建好之后自己变 found。
|
||||
*/
|
||||
col({ name: 'found_pn_ref', type: 'text', label: '在库核对依据', refersTo: 'parts.profile.internal_pn', requiredWhen: { column: 'exists_status', value: 'found' } }),
|
||||
col({ name: 'exists_status', type: 'text', label: '在库状态', required: true, tally: ['found', 'absent'] }),
|
||||
col({
|
||||
name: 'chip_type_id',
|
||||
/* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */
|
||||
type: 'text',
|
||||
label: '芯片类型',
|
||||
required: true,
|
||||
/*
|
||||
* **`distinct`**:「两颗料是不是同一个类型」从此是基座数得出来的数
|
||||
* (`sub_eval_part_distinct_chip_type_id`)—— 不同取值恰好 1 种才算一致。
|
||||
* 这一条是 S1 收步与 S2 入场的硬门:两边字段清单不是同一套,逐项比对无从谈起。
|
||||
*/
|
||||
distinct: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: D,
|
||||
object: 'case',
|
||||
kind: 'record',
|
||||
caseKey: 'eval_ref',
|
||||
label: '本单案卷',
|
||||
fields: [
|
||||
col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }),
|
||||
/* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */
|
||||
col({ name: 'chip_type_id', type: 'text', label: '本单锁定的芯片类型' }),
|
||||
col({ name: 'gate_reason', type: 'text', label: '入参未就绪原因(件上摆的就是它)' }),
|
||||
/*
|
||||
* **红条的开关是真数据,不是那句话空不空**(2026-08-31 用户:「没有必填要警告了,
|
||||
* 红色区域就不要显示」):gate_reason 清成空串时按列归型**不落笔**(空值=不写这一列,
|
||||
* jsonb 合并保旧值)—— 那句话一旦写下就清不掉,红条永远亮着。开关列两个值都非空,
|
||||
* 两个方向都写得动;件上 data-class-of 投影 v-clear 收红条,判断留在样式表。
|
||||
*/
|
||||
col({ name: 'gate_state', type: 'text', label: '入参闸门状态', tally: ['open', 'clear'] }),
|
||||
/*
|
||||
* **这一步派的是哪种工单**(2026-08-31 用户定「数据齐了也叫人一次」):
|
||||
* `data` = 数据工单(有格子要填)· `accept` = 验收工单(只读,只核对)。
|
||||
* 件按它换脸(题头四字 + 卡角编辑钮显隐),配方按它决定发哪张卡 —— 见 case-model
|
||||
* 那一笔 `order_kind` 的注释(**粘着**:派出去是哪种,这一步到底就是哪种)。
|
||||
*/
|
||||
col({ name: 'order_kind', type: 'text', label: '这一步派的工单种类', tally: ['data', 'accept'] }),
|
||||
col({ name: 'summary', type: 'text', label: '结论摘要' }),
|
||||
],
|
||||
},
|
||||
/*
|
||||
* P-11 替代关系主记录 —— 一单一行。退休三列(spec §3.4):`substitution_type`(新流程里没有
|
||||
* 它的定夺人)、`confidence_score`、`analysis_report_ref`;`recommendation_level` 的
|
||||
* `alternate` 档一并退休(原 D02 的 configurable 来路已不存在,只剩三档)。
|
||||
*/
|
||||
{
|
||||
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: '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' }),
|
||||
/* 与 `parts.chip_type.type_id` 同一条身份链路,同一个理由改成 text —— 见该字段的大注释 */
|
||||
col({ name: 'chip_type_id', type: 'text', label: '芯片类型', required: true }),
|
||||
col({ name: 'recommendation_level', type: 'text', label: '推荐等级', required: true, tally: ['recommended', 'conditional', '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'] }),
|
||||
col({ name: 'verification_requirement', type: 'text', label: '验证要求', requiredWhen: { column: 'recommendation_level', value: 'conditional' } }),
|
||||
col({ name: 'validation_id', type: 'text', label: '关联验证ID' }),
|
||||
/* 固定 fae_confirmed —— 分析状态与客户批准状态分离,本活动只写这一层 */
|
||||
col({ name: 'analysis_status', type: 'text', label: '分析状态', required: true }),
|
||||
col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
|
||||
],
|
||||
},
|
||||
/*
|
||||
* P-11 差异明细子表 —— 一参数一行。`evidence_ref` 改名 `evidence_note`「判断依据」:
|
||||
* 不写 P-10 了,AI 能给的就是依据不是文件引用,名字得说实话(spec §3.4)。
|
||||
*/
|
||||
{
|
||||
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 }),
|
||||
/* 字段说明快照,归档件不必 join 回字典 */
|
||||
col({ name: 'parameter_desc', type: 'text', label: '参数说明', expect: true }),
|
||||
col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }),
|
||||
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 }),
|
||||
/* AI 判:逐参数比对,S2 唯一一次 analyze 出场的出参之一 */
|
||||
col({ name: 'difference_status', type: 'text', label: '差异状态', required: true, tally: ['match', 'mismatch', 'unknown', 'conditional'] }),
|
||||
/*
|
||||
* **`judgement_class` 是合成列**:推荐等级要数的是「**硬约束**不符有几条」,
|
||||
* 而基座读数只会数单列 —— 把「约束类型 × 四态」先合成一列,规则才算得出来。
|
||||
* 由配方按 §3.5 的 when 链算,不是 AI 出的。
|
||||
*/
|
||||
col({ name: 'judgement_class', type: 'text', label: '判定分类', required: true, tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'] }),
|
||||
col({ name: 'impact_statement', type: 'text', label: '影响说明(MATCH 写「无」)', required: true }),
|
||||
col({ name: 'evidence_note', type: 'text', label: '判断依据', 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: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
|
||||
/*
|
||||
* **这一轮复核点到了哪几条**(2026-09-01 用户实测:「我打回让修改工作温度的结论,
|
||||
* 给我改了静态电流的结论」)—— 人打回时写的那句话里点了名的行标 `named`,其余 `kept`。
|
||||
* 谁可以变由这一列说了算(**行由确定的那一侧决定,不由 AI 决定** —— 规范 §4.6 纪律一):
|
||||
* 只有 `named` 的行才进重判的入参、也只有它们会被写回;`kept` 的行 AI 连见都见不到。
|
||||
* 意见一条都没点到时另有一笔把整表标 `named`(那种意见说的是整批,不是某一条)。
|
||||
*/
|
||||
col({ name: 'redo_scope', type: 'text', label: '本轮复核范围', tally: ['named', 'kept'] }),
|
||||
],
|
||||
},
|
||||
/* P-06 验证记录:一单一行,关联的是本单案号(P-01 不在本活动里,顶掉 request_id) */
|
||||
{
|
||||
domain: 'validation',
|
||||
object: 'record',
|
||||
kind: 'master',
|
||||
label: '芯片验证记录(P-06)',
|
||||
fields: [
|
||||
col({ name: 'validation_id', type: 'text', label: '验证ID', primaryKey: true }),
|
||||
col({ name: 'eval_ref', 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', 'parametric'] }),
|
||||
/* 固定 not_started */
|
||||
col({ name: 'validation_status', type: 'text', label: '验证状态', required: true }),
|
||||
col({ name: 'test_condition', type: 'text', label: '测试条件', expect: true }),
|
||||
col({ name: 'next_validation_action', type: 'text', label: '后续验证动作', required: true }),
|
||||
],
|
||||
},
|
||||
],
|
||||
listeners: [],
|
||||
harness: {
|
||||
memoryWindow: 24,
|
||||
stallAfter: 'PT4H',
|
||||
nudge: [{ after: 'PT2H', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT30S' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
/*
|
||||
* DMN 判定表已退役:D01(逐参数四态)改由 S2 唯一一次 analyze 判定;D02(推荐等级)的规则
|
||||
* 搬进配方的 when 链(spec §3.5)—— 规则只许有一处真源,再留一份 DMN 就是同一件事两处
|
||||
* 各存一份。`steps` 已是四步、`guide` 全部清空(auto 配方接管行为,Task 3 完成)。
|
||||
*/
|
||||
businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: { drd: [], tables: [] }, steps: ASSESSMENT_STEPS, phases: ASSESSMENT_PHASES },
|
||||
/* 三条技能已随重写退役(全程无 agent,没有临场编排可言);skills.ts 已在 Task 10 删除 */
|
||||
skills: [],
|
||||
/*
|
||||
* 五张旧局面全部作废(它们绑的九步与旧台账地址已经不存在)。**Task 7 已补回第一张**:
|
||||
* `input-supplement`(评估输入补全,S1 的数据工单,三块竖排 —— 存在性与主数据 /
|
||||
* 芯片类型字段 / 参数值)。**Task 8 已补回第二张**:`difference-review`(差异评估结论审核,
|
||||
* S3 的验收工单 —— 对决头 + 四态分布带 + 推荐等级大徽章只读,逐条明细可就地改四态 /
|
||||
* 影响说明 / 判断依据 / UNKNOWN 去向,收口四格只读)。**Task 9 已补回第三张**:
|
||||
* `landing-confirm`(落账确认,T_release 的终局验收工单 —— 全只读,答「这一签下去发生
|
||||
* 什么」:进哪几张表各几行 / 结论卡 / 四态分布并把硬约束不符逐条点名 / 待验项 / 发哪一口)。
|
||||
* 三张件此刻齐了。看板自己的 `data` 声明(`LEDGER_BOARD_DATA`)同批换成了新九张表 ——
|
||||
* `sub_eval.pn_check`/`sub_eval.verdict`/`demand.request`/`customer.profile`/
|
||||
* `parts.document` 这五张已退役地址不再出现。
|
||||
*/
|
||||
viewTemplates: [
|
||||
{ name: 'activity-ledger', html: LEDGER_BOARD, data: LEDGER_BOARD_DATA },
|
||||
{ name: 'input-supplement', html: INPUT_SUPPLEMENT },
|
||||
{ name: 'difference-review', html: DIFFERENCE_REVIEW },
|
||||
{ name: 'landing-confirm', html: LANDING_CONFIRM },
|
||||
],
|
||||
cardSchemas: {},
|
||||
/* 蒸馏钩子撤空:它是给 agent 在终态回看轨迹用的,而这个活动全程无 agent(AI 只出场一次、答一份结构化判断,没有可供归纳的多轮轨迹) */
|
||||
distillHooks: [],
|
||||
},
|
||||
|
||||
/*
|
||||
* ── 样例集:五条形状真不同(常备案例即验收案例)──
|
||||
* **2026-08-29 Task 11 换成新入参形状**(单数 `candidateInternalPn`,与 §4.1 的启动两参同步 ——
|
||||
* 旧九步遗留的 `customerCode`/`requestDescription`/`candidateInternalPns`(数组)三键随之撤下,
|
||||
* 起单表单本就只剩两格)。五条覆盖 S1 的五种入场局面:①候选缺库(新建类型那条路,S1 数据工单
|
||||
* 三块都要动)②两颗都在库且同型(零卡直接进 S2)③两颗都在库但分属不同芯片类型(硬拦,
|
||||
* 光凭其它格子填满绕不过去)④原 PN 缺库(候选在库)⑤两颗都缺库。
|
||||
* 库里播了 LM2596S-5.0 / LM2596S-ADJ(DC-DC 降压稳压器,type_id=1)与 AMS1117-3.3
|
||||
* (LDO 线性稳压器,type_id=2)三颗(见 seed-mvp-data.ts);XL2596S-5.0 / CN-DCDC-32V /
|
||||
* LM2596S-3.3 刻意不在库。
|
||||
*/
|
||||
samples: [
|
||||
{ name: '候选缺库·建档定型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
|
||||
{ name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
|
||||
{ name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } },
|
||||
{ name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
|
||||
{ name: '两颗都缺库', input: { originalInternalPn: 'CN-DCDC-32V', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
|
||||
],
|
||||
|
||||
entry: { file: 'index.js', checksum: 'dev-entry-part-substitution-assessment' },
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 徽标别名的生成器(与 `report.ts` 里那一段同一套逻辑;report.ts 另有一份 —— 并成一处记在 debt)。
|
||||
*
|
||||
* **2026-08-27 实测事故**:手写的 `.jc-tag.v-pin_to_pin::after{…}` 永远匹配不上,件上显示「待定」,
|
||||
* 而台账里 `substitution_type` 明明是 `pin_to_pin`。`data-class-of` 投影出的 class 走 `view-render.ts` 的
|
||||
* `classToken`(只留 `[a-z0-9-]`,**吃掉下划线**,封顶 24 字),所以选择器一律从 tally 值**算**出来。
|
||||
*/
|
||||
/** 必须与 `classToken` 一字不差:小写、只留 `[a-z0-9-]`、封顶 24 字。 */
|
||||
export const vtok = (v: string): string => v.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
|
||||
/** `[值, 人读名, 可选颜色声明]`:值与文案成对写在一处 */
|
||||
export type BadgeSpec = readonly [value: string, label: string, color?: string];
|
||||
export const badgeCss = (selector: string, specs: readonly BadgeSpec[]): string =>
|
||||
specs
|
||||
.map(([v, label, color]) => {
|
||||
const cls = `${selector}.v-${vtok(v)}`;
|
||||
return color === undefined ? `${cls}::after { content: "${label}"; }` : `${cls}::after { content: "${label}"; }\n${cls} { ${color} }`;
|
||||
})
|
||||
.join('\n');
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* 运营中心 L2 沙箱视图 —— ACT-WORK-05「关键台账数据」看板(2026-08-27 初版,2026-08-29 Task 9 重绑)。
|
||||
*
|
||||
* **2026-08-29 Task 9「运营中心看板重绑」**:2026-08-29 整体重写(spec
|
||||
* `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`)把九步案例模型砍成四步、
|
||||
* 台账从旧的 `sub_eval.pn_check`/`sub_eval.verdict` 三张单据 + `demand.request`/`customer.profile`/
|
||||
* `parts.document` 五张外域表,换成本活动自持的 `sub_eval.case`/`sub_eval.part` 两张 + 新增的
|
||||
* `parts.chip_type`/`parts.type_param` 两张类型字典 —— 这份看板原样引着已退役的五张地址,
|
||||
* 装包会被静态审拦下。本轮把 `LEDGER_BOARD_DATA` 与 `LEDGER_BOARD` 一并**重绑**到新九张表:
|
||||
* 案卷(`sub_eval.case`)→ 两颗料(`sub_eval.part`)→ 类型与字段字典(`parts.chip_type`/
|
||||
* `parts.type_param`)→ 参数对照(`parts.profile`/`parts.parameter`)→ 关系与明细
|
||||
* (`substitution.relation`/`substitution.difference_item`)→ P-06(`validation.record`,
|
||||
* 含同料号历史 `validation.history`)。P-01 需求主台账 / P-10 原厂资料两个板块随之整段撤下 ——
|
||||
* 本活动这次重写已经不碰这两张表(见 manifest.ts「这个活动不碰 P-01 / P-10 / 客户档案」)。
|
||||
* 新设计是**一次评估恰一对一候选**(不再是「多候选,部分放弃」),`substitution.relation`
|
||||
* 一单一行、`sub_eval.part` 一单两行(原/候选各一),「候选」不再是需要 `data-rows` 逐个复制的
|
||||
* 集合,页头 / P-08 两处原来的 `.cands`(多候选并排)、`.aband`(放弃候选的候选行)两个概念
|
||||
* 一并撤下 —— 新模型里没有「本轮放弃的候选」这件事。
|
||||
*
|
||||
* 版式与样式**沿用**(重绑不是重做):页头 / 数据来源图例 / 分层图例 / 表格样式 / 三态横幅
|
||||
* 这几段版面语言原样保留;`.tbl-wrap` 放开横滑、徽标文案由 `data-class-of` 投影 + 生成式
|
||||
* `::after` 出的既有约定不变。溯源口径参照 `…-数据溯源.md`(该文档写在旧九步年代,
|
||||
* 域名对照以本文件为准)。
|
||||
*
|
||||
* **它摆的是最近一次已放行实例的落账快照**(用户定):行由基座按 `LEDGER_BOARD_DATA` 的声明从**正式表**圈出 ——
|
||||
* 每个 `data-rows` 键都是声明里的一个 `as`,不是整表;`<as>.<列>` 是那一集合首行的标量,`<as 去符号>_rows` /
|
||||
* `_<列>_<值>` 是基座数出来的读数(`apps/api/src/app/activity-ops/view-scope.ts`)。模版仍**不算不判**:
|
||||
* MATCH 行淡显、三态横幅,全是 `data-class-of` 投出的 class 交样式表。
|
||||
* 无脚本、无外链(过安装静态审);报高脚本由服务端出口注入。
|
||||
*/
|
||||
import type { ViewData } from '@contracts';
|
||||
import { badgeCss } from './badge.ts';
|
||||
import { D } from '../constants.ts';
|
||||
|
||||
/**
|
||||
* 案号 `W05-<开案时刻 14 位>`(与说明书让模型铸 `eval_ref` 的写法同源);行集按案号 → 案卷 →
|
||||
* 两颗料 → 类型字典(按案卷锁定的 `chip_type_id` 查)→ 主数据 / 参数(按两颗料的 `internal_pn` 查)→
|
||||
* 关系(按案号查,一单一行)→ 明细(按关系的主键查,`by.from` 必须指向**前面**已求出的集合 ——
|
||||
* 明细依赖关系的主键,所以 `rows` 数组里关系排在明细**之前**,即便「关系」是这一签最终的结论、
|
||||
* 「明细」是支撑结论的逐条证据,版面上想先摆哪个都行,数据依赖顺序是另一件事)→ P-06(按案号查,
|
||||
* 一单固定一条;同料号历史另开一集,按两颗料的 `internal_pn` 查、排除本单自己那一条)。
|
||||
*/
|
||||
export const LEDGER_BOARD_DATA: ViewData = {
|
||||
case: { ledger: `${D}.case`, key: 'eval_ref', prefix: 'W05-' },
|
||||
rows: [
|
||||
{ as: `${D}.case`, ledger: `${D}.case`, by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: `${D}.part`, ledger: `${D}.part`, by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }, { field: 'internal_pn', dir: 'asc' }] },
|
||||
{ as: 'parts.chip_type', ledger: 'parts.chip_type', by: { column: 'type_id', from: `${D}.case.chip_type_id` } },
|
||||
{ as: 'parts.type_param', ledger: 'parts.type_param', by: { column: 'type_id', from: `${D}.case.chip_type_id` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: `${D}.part.internal_pn` } },
|
||||
{ as: 'parts.parameter', ledger: 'parts.parameter', by: { column: 'internal_pn', from: `${D}.part.internal_pn` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
|
||||
{
|
||||
as: 'substitution.difference_item',
|
||||
ledger: 'substitution.difference_item',
|
||||
by: { column: 'substitution_relation_id', from: 'substitution.relation.substitution_relation_id' },
|
||||
orderBy: [{ field: 'difference_item_id', dir: 'asc' }],
|
||||
},
|
||||
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'eval_ref', from: 'case' } },
|
||||
/* 同料号在别单 / 别处留下的验证记录:本次评估的历史证据引用,排除本单自己那一条 */
|
||||
{
|
||||
as: 'validation.history',
|
||||
ledger: 'validation.record',
|
||||
by: { column: 'validation_internal_pn', from: `${D}.part.internal_pn` },
|
||||
not: { column: 'eval_ref', from: 'case' },
|
||||
orderBy: [{ field: 'validation_id', dir: 'desc' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* ==================== 目标件样式(基座壳与版面语言沿用旧版;域名相关内容按新九张表改写) ==================== */
|
||||
const TARGET_CSS = `
|
||||
:root{
|
||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
||||
--data:#3370ff; --data-soft:#e1eaff; --data-line:#c3d6ff;
|
||||
--human:#d97706; --human-soft:#fdf1dc; --human-line:#f0d8a8;
|
||||
--ledger:#16a34a; --ledger-soft:#e6f6ec; --ledger-line:#b7e5c6;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
|
||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#fff;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
.page{max-width:none;margin:0;padding:0}
|
||||
|
||||
/* 头部 */
|
||||
.mast{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:22px 26px;position:relative;overflow:hidden}
|
||||
.mast::before{content:"";position:absolute;inset:0 0 auto 0;height:4px;background:linear-gradient(90deg,var(--brand) 0%,var(--brand-deep) 100%)}
|
||||
.mast .kick{font-size:11px;letter-spacing:.12em;color:var(--ink-4);font-weight:800}
|
||||
.mast h1{margin:4px 0 6px;font-size:23px;font-weight:800;line-height:1.3}
|
||||
.mast h1 .code{font-family:var(--mono);font-size:14px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash);border:1px solid var(--brand-soft);border-radius:8px;padding:3px 10px;margin-right:10px;vertical-align:3px}
|
||||
.mast .sub{font-size:13px;color:var(--ink-3)}
|
||||
.mast .sub b{color:var(--ink-2)}
|
||||
|
||||
/* 数据来源图例 */
|
||||
.legend{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0;font-size:12px;color:var(--ink-3)}
|
||||
.legend .li{display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid var(--line-soft);border-radius:999px;padding:3px 12px}
|
||||
.dot{width:8px;height:8px;border-radius:50%;flex:none}
|
||||
.dot.full{background:var(--brand)}
|
||||
.dot.half{background:var(--amber)}
|
||||
|
||||
/* 分层图例 —— 新九张表收成四层(P-01/P-10 两层随重写撤下) */
|
||||
.layers{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:14px 0 18px}
|
||||
.layer{background:#fff;border:1px solid var(--line-soft);border-radius:11px;padding:11px 14px;font-size:12px;color:var(--ink-2);border-top:3px solid var(--brand)}
|
||||
.layer b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px}
|
||||
.layer .lg{font-family:var(--mono);font-weight:800;color:var(--brand-deep)}
|
||||
.layer.core-out{border-top-color:var(--ledger)} .layer.core-out .lg{color:var(--ledger)}
|
||||
.layer.evid{border-top-color:var(--human)} .layer.evid .lg{color:var(--human)}
|
||||
.layer.loop{border-top-color:var(--amber)} .layer.loop .lg{color:var(--amber)}
|
||||
|
||||
/* 区块 */
|
||||
section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:18px 22px;margin-bottom:16px}
|
||||
.sec-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.sec-head h2{margin:0;font-size:15.5px;font-weight:800}
|
||||
.sec-head h2 .lid{font-family:var(--mono);font-size:12px;color:var(--brand-deep);background:var(--brand-wash);border:1px solid var(--brand-soft);border-radius:6px;padding:1px 8px;margin-right:8px}
|
||||
.ltag{font-size:10.5px;font-weight:800;padding:2px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.ltag.in{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||
.ltag.out{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||
.ltag.evid{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
.ltag.loop{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||
.sec-head .why{font-size:12px;color:var(--ink-4)}
|
||||
.sec-head .cnt{margin-left:auto;font-size:11.5px;color:var(--ink-4);font-family:var(--mono)}
|
||||
|
||||
/* 表格 —— 表宽超出就得能横滑(本仓 2026-08-23 定) */
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow-x:auto;overflow-y:hidden}
|
||||
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||
td{padding:9px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr{transition:background .15s}
|
||||
tbody tr:hover{background:var(--brand-wash)}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.pn{font-weight:700}
|
||||
.cust{font-weight:700}
|
||||
|
||||
/* 状态 pill */
|
||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
|
||||
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
||||
.st.unknown{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||
.st.cond{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||
.st.gray{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
||||
.st.warn{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
|
||||
/* 参数集摘要 chips */
|
||||
.pchips{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.pchips span{font-size:11px;font-family:var(--mono);background:var(--soft);border:1px solid var(--line-soft);border-radius:6px;padding:1px 7px;color:var(--ink-2);white-space:nowrap}
|
||||
|
||||
/* 次要台账条 */
|
||||
.minor{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.minor .m{flex:1;min-width:220px;border:1px solid var(--line-soft);border-radius:10px;padding:11px 14px;font-size:12px;color:var(--ink-2)}
|
||||
.minor .m b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:3px}
|
||||
.minor .m .lid{font-family:var(--mono);color:var(--ink-4);font-weight:700;margin-right:6px}
|
||||
|
||||
footer{margin-top:18px;font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:900px){.layers{grid-template-columns:repeat(2,1fr)}}
|
||||
`;
|
||||
|
||||
/* ==================== 绑定层:徽标别名 —— 只取值,不算不判 ==================== */
|
||||
const OK = 'color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4;';
|
||||
const MISS = 'color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5;';
|
||||
const UNKNOWN = 'color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line);';
|
||||
const COND = 'color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft);';
|
||||
const GRAY = 'color:var(--ink-3);background:var(--soft);border-color:var(--line);';
|
||||
|
||||
const BIND_CSS = `
|
||||
/* 徽标:值域各归各族(同一个词在不同列意思不同),文案与颜色由 data-class-of 投出的 v-<token> 决定;
|
||||
没投出 class 的格子摆「—」 */
|
||||
.st::after{content:"—"}
|
||||
${badgeCss('.st.lc', [
|
||||
['active', 'Active', OK],
|
||||
['nrnd', 'NRND', MISS],
|
||||
['eol', 'EOL', MISS],
|
||||
['discontinued', '停产', MISS],
|
||||
['risk', '风险', UNKNOWN],
|
||||
])}
|
||||
${badgeCss('.st.rec', [
|
||||
['recommended', '推荐', OK],
|
||||
['conditional', '条件推荐', UNKNOWN],
|
||||
['not_recommended', '不推荐', MISS],
|
||||
])}
|
||||
/* analysis_status 这次重写没有再声明 tally(本活动只写这一个固定值,分析层与客户批准层分离) */
|
||||
${badgeCss('.st.an', [
|
||||
['fae_confirmed', 'FAE已确认', COND],
|
||||
])}
|
||||
${badgeCss('.st.rk', [
|
||||
['low', '低', OK],
|
||||
['medium', '中', UNKNOWN],
|
||||
['high', '高', MISS],
|
||||
])}
|
||||
${badgeCss('.st.dst', [
|
||||
['match', 'MATCH', OK],
|
||||
['mismatch', 'MISMATCH', MISS],
|
||||
['unknown', 'UNKNOWN', UNKNOWN],
|
||||
['conditional', 'CONDITIONAL', COND],
|
||||
])}
|
||||
/* validation_status 同样没有声明 tally(本活动只写 not_started —— 验证是否通过不是这个活动的事) */
|
||||
${badgeCss('.st.vs', [
|
||||
['not_started', '未开始', GRAY],
|
||||
])}
|
||||
/* 在库状态(sub_eval.part.exists_status)—— 这次重写只剩两档(found/absent),
|
||||
「本单建档」「已放弃」两档随旧的多候选设计一起退役 */
|
||||
${badgeCss('.st.ex', [
|
||||
['found', '在库直读', COND],
|
||||
['absent', '缺库', MISS],
|
||||
])}
|
||||
${badgeCss('.st.rl', [
|
||||
['original', '原 PN', COND],
|
||||
['candidate', '候选', GRAY],
|
||||
])}
|
||||
/* 纯文字的枚举列:只补文案 */
|
||||
.enumtxt::after{content:"—"}
|
||||
${badgeCss('.enumtxt.con', [
|
||||
['hard', '硬约束'],
|
||||
['soft', '软约束'],
|
||||
])}
|
||||
/* validation_type 这次重写多了 parametric 一档(S2 的验证记录投影固定写它) */
|
||||
${badgeCss('.enumtxt.vt', [
|
||||
['pin_to_pin', 'Pin-to-Pin'],
|
||||
['functional', '功能'],
|
||||
['reliability', '可靠性'],
|
||||
['certification', '认证'],
|
||||
['retest', '复测'],
|
||||
['parametric', '参数验证'],
|
||||
])}
|
||||
|
||||
/* 参数瓷贴:参数名淡、值粗、单位紧跟 */
|
||||
.pchips i{font-style:normal;color:var(--ink-4);margin-right:3px}
|
||||
.pchips b{font-weight:700}
|
||||
.pchips em{font-style:normal;margin-left:1px}
|
||||
.pchips:empty::after{content:"参数集未登记";font-size:11px;color:var(--ink-4)}
|
||||
/* 料号格里的两枚小徽(角色 / 在库状态) */
|
||||
.pnrow{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}
|
||||
.pnrow .st{font-size:10px;padding:0 7px}
|
||||
/* 差异明细:MATCH 行淡显(不藏:那也是比对过的事实),非 MATCH 才是要看的 */
|
||||
tr.v-match td{opacity:.55}
|
||||
/* P-06 历史行:首格挂一枚「历史」灰签 */
|
||||
tbody.hist td:first-child::after{content:"历史";display:inline-block;margin-left:6px;font-size:10px;font-weight:800;padding:0 6px;border-radius:999px;color:var(--ink-3);background:var(--soft);border:1px solid var(--line);font-family:Inter,ui-sans-serif,system-ui,sans-serif}
|
||||
td .sub{display:block;font-size:11px;color:var(--ink-3);font-weight:400}
|
||||
td .sub:empty{display:none}
|
||||
/* 三态横幅:none = 本活动还没有已放行实例;missing = 有实例但案号在正式表里查无 */
|
||||
.notice{display:none;align-items:flex-start;gap:8px;margin:0 0 16px;padding:12px 16px;border-radius:12px;font-size:13px;line-height:1.6}
|
||||
.notice.none{background:var(--soft);border:1px solid var(--line);color:var(--ink-2)}
|
||||
.notice.missing{background:var(--amber-soft);border:1px solid var(--amber-line);color:var(--amber)}
|
||||
.page.v-none .notice.none{display:flex}
|
||||
.page.v-missing .notice.missing{display:flex}
|
||||
.page.v-none .layers,.page.v-none section,.page.v-none .mast .sub.case,.page.v-none .mast .sub.verdict{display:none}
|
||||
.page.v-missing section,.page.v-missing .mast .sub.verdict{display:none}
|
||||
`;
|
||||
|
||||
const th = (...cols: string[]): string => `<thead><tr>${cols.map((c) => `<th>${c}</th>`).join('')}</tr></thead>`;
|
||||
|
||||
export const LEDGER_BOARD = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ACT-WORK-05 差异评估 · 关键台账数据 · SS02 需求分析与方案匹配</title>
|
||||
<style>${TARGET_CSS}${BIND_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page" data-class-of="board:state">
|
||||
|
||||
<header class="mast">
|
||||
<div class="kick">SS02 需求分析与方案匹配 · 活动台账视图</div>
|
||||
<h1><span class="code">ACT-WORK-05</span>关键台账数据</h1>
|
||||
<p class="sub case">视角:本活动最近一次已放行实例 <b data-slot="case:instanceNo"></b>(<b data-slot="case:openedAt"></b> 开案 · <b data-slot="case:releasedAt"></b> 放行 · 负责人 <b data-slot="case:owner" data-blank></b>)的落账快照 · 案号 <b class="mono" data-slot="case:ref"></b> · 评估基准 <b data-slot="substitution.relation.original_internal_pn"></b> → 候选 <b data-slot="substitution.relation.candidate_internal_pn"></b> · 芯片类型 <span data-slot="parts.chip_type.type_name" data-blank></span> · 规则版本 <b data-slot="substitution.relation.rule_version"></b></p>
|
||||
<p class="sub verdict">评估结论:<span data-slot="${D}.case.summary" data-blank></span></p>
|
||||
<div class="legend">
|
||||
<span class="li"><span class="dot full"></span>正式台账直读 —— 放行落地后的事实,按案号圈出这一单的行</span>
|
||||
<span class="li"><span class="dot half"></span>同料号的历史验证记录(别单 / 线下留下的,作本次评估的证据引用)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="notice none">本活动还没有已放行的实例 —— 有一单放行后,这里摆它的关键台账落账快照。</div>
|
||||
<div class="notice missing">实例 <b data-slot="case:instanceNo"></b> 已放行,但案号 <b class="mono" data-slot="case:ref"></b> 在正式表里查无 —— 多半是起单时关了「落真库」(主数据只留在活动缓存),这一单没有可摆的落账数据。</div>
|
||||
|
||||
<!-- 重要性分层 -->
|
||||
<div class="layers">
|
||||
<div class="layer"><b><span class="lg">P-08</span> 芯片主数据</b>核心输入 · 评估原材料,查无即硬阻塞</div>
|
||||
<div class="layer evid"><b><span class="lg">字段字典</span> 类型与核心参数</b>比对基准 · 按芯片类型定义要比的字段与硬软约束</div>
|
||||
<div class="layer core-out"><b><span class="lg">P-11</span> 替代批准关系库</b>核心产出 · 结论与明细的落账地</div>
|
||||
<div class="layer loop"><b><span class="lg">P-06</span> 样品验证记录</b>闭环去向 · UNKNOWN 的实测收口</div>
|
||||
</div>
|
||||
|
||||
<!-- P-08 -->
|
||||
<section id="p08">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-08</span>芯片主数据台账</h2>
|
||||
<span class="ltag in">核心输入 · R(查无 → 数据工单创建)</span>
|
||||
<span class="why">原 PN 与候选 PN 的物料基准 + 标准化参数集(子表 P-08 参数),逐项比对的原材料</span>
|
||||
<span class="cnt">正式表 · <i data-slot="parts_profile_rows"></i> 条(本次评估相关)</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('内部料号', '品牌', '规格描述', '生命周期', 'Datasheet 版本', '标准化参数集')}
|
||||
<tbody data-rows="parts.profile">
|
||||
<tr>
|
||||
<td class="pn mono"><span data-slot="internal_pn"></span><span class="pnrow" data-rows="${D}.part" data-by="internal_pn"><i class="st rl" data-class-of="role"></i><i class="st ex" data-class-of="exists_status"></i></span></td>
|
||||
<td data-slot="brand_name" data-blank></td>
|
||||
<td data-slot="specification_description" data-blank></td>
|
||||
<td><span class="st lc" data-class-of="lifecycle_status"></span></td>
|
||||
<td class="mono" data-slot="datasheet_version" data-blank></td>
|
||||
<td><div class="pchips" data-rows="parts.parameter" data-by="internal_pn"><span><i data-slot="param_name"></i><b data-slot="param_value" data-blank></b><em data-slot="param_unit" data-blank></em></span></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 字段字典 -->
|
||||
<section id="dict">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">字段字典</span>芯片类型与核心参数</h2>
|
||||
<span class="ltag evid">比对基准 · R</span>
|
||||
<span class="why">两颗料共同归属的芯片类型,及该类型定义的核心参数字段(名字 / 说明 / 硬软约束 / 单位)</span>
|
||||
<span class="cnt">类型 · <b data-slot="parts.chip_type.type_name" data-blank></b> · 字段 <i data-slot="parts_type_param_rows"></i> 条</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('参数名', '参数说明', '约束', '参考单位')}
|
||||
<tbody data-rows="parts.type_param">
|
||||
<tr>
|
||||
<td class="cust" data-slot="param_name"></td>
|
||||
<td class="sub" data-slot="param_desc" data-blank></td>
|
||||
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
||||
<td class="mono" data-slot="param_unit" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- P-11 -->
|
||||
<section id="p11">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-11</span>替代选型与批准关系库 · 替代关系主记录</h2>
|
||||
<span class="ltag out">核心产出 · R+W</span>
|
||||
<span class="why">原 PN → 候选 PN 一条关系记录;推荐等级 / 风险 / 摘要由规则算出,客户批准状态为只读边界,本活动不写</span>
|
||||
<span class="cnt">正式表 · <i data-slot="substitution_relation_rows"></i> 条</span>
|
||||
</div>
|
||||
<div class="tbl-wrap" style="margin-bottom:14px">
|
||||
<table>
|
||||
${th('替代关系 ID', '原 → 候选', '推荐等级', '分析状态', '风险', '差异摘要 / 理由', '验证要求 / 回链', '规则版本')}
|
||||
<tbody data-rows="substitution.relation">
|
||||
<tr>
|
||||
<td class="mono" data-slot="substitution_relation_id"></td>
|
||||
<td class="mono"><span data-slot="original_internal_pn"></span> → <b data-slot="candidate_internal_pn"></b></td>
|
||||
<td><span class="st rec" data-class-of="recommendation_level"></span></td>
|
||||
<td><span class="st an" data-class-of="analysis_status"></span></td>
|
||||
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||
<td><span data-slot="difference_summary" data-blank></span><span class="sub" data-slot="recommendation_reason" data-blank></span></td>
|
||||
<td class="mono"><span data-slot="validation_id" data-blank></span><span class="sub" data-slot="verification_requirement" data-blank></span></td>
|
||||
<td class="mono" data-slot="rule_version" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="sec-head" style="margin-bottom:10px">
|
||||
<h2 style="font-size:13.5px"><span class="lid">P-11</span>差异明细子表(按字段字典逐项比对得出;MATCH 项淡显)</h2>
|
||||
<span class="cnt"><i data-slot="substitution_difference_item_rows"></i> 条 · MATCH <i data-slot="substitution_difference_item_difference_status_match"></i> · MISMATCH <i data-slot="substitution_difference_item_difference_status_mismatch"></i> · UNKNOWN <i data-slot="substitution_difference_item_difference_status_unknown"></i> · CONDITIONAL <i data-slot="substitution_difference_item_difference_status_conditional"></i></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('参数项', '约束', '原值 → 候选值', '单位', '结论', '风险', '判断依据', '影响说明')}
|
||||
<tbody data-rows="substitution.difference_item">
|
||||
<tr data-class-of="difference_status">
|
||||
<td data-slot="parameter_name"></td>
|
||||
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
||||
<td class="mono"><span data-slot="original_value" data-blank></span> → <span data-slot="candidate_value" data-blank></span></td>
|
||||
<td data-slot="parameter_unit" data-blank></td>
|
||||
<td><span class="st dst" data-class-of="difference_status"></span></td>
|
||||
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||
<td class="mono" data-slot="evidence_note" data-blank></td>
|
||||
<td data-slot="impact_statement" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- P-06 -->
|
||||
<section id="p06">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-06</span>样品交付与芯片验证记录台账</h2>
|
||||
<span class="ltag loop">闭环去向 · R + 条件写(不可创建,只能实测产生)</span>
|
||||
<span class="why">UNKNOWN / CONDITIONAL 的收口处;一单固定一条</span>
|
||||
<span class="cnt">本单挂的 · <i data-slot="validation_record_rows"></i> 条 · 同料号历史 · <i data-slot="validation_history_rows"></i> 条</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('验证 ID', '验证料号', '验证类型', '测试条件', '结果 / 状态', '后续动作')}
|
||||
<tbody data-rows="validation.record">
|
||||
<tr>
|
||||
<td class="mono" data-slot="validation_id"></td>
|
||||
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||
<td data-slot="test_condition" data-blank></td>
|
||||
<td><span class="st vs" data-class-of="validation_status"></span></td>
|
||||
<td data-slot="next_validation_action" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody class="hist" data-rows="validation.history">
|
||||
<tr>
|
||||
<td class="mono" data-slot="validation_id"></td>
|
||||
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||
<td data-slot="test_condition" data-blank></td>
|
||||
<td><span class="st vs" data-class-of="validation_status"></span></td>
|
||||
<td data-slot="next_validation_action" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 次要台账 -->
|
||||
<section id="minor">
|
||||
<div class="sec-head">
|
||||
<h2>次要台账(只读引用,不进主看板)</h2>
|
||||
</div>
|
||||
<div class="minor">
|
||||
<div class="m"><b><span class="lid">GOV</span>评估规则库</b>四态判定与推荐等级口径;本次锁定版本 <span class="mono" data-slot="substitution.relation.rule_version"></span>,随结论一并落账。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>SS02 · ACT-WORK-05 差异评估 · 关键台账数据视图</span>
|
||||
<span style="font-family:var(--mono)">数据来源:正式台账(parts / substitution / validation / sub_eval)· 实例 <span data-slot="case:instanceNo"></span> · 案号 <span data-slot="case:ref"></span></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -0,0 +1,689 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
|
||||
*
|
||||
* **2026-08-31 视图层整体重设计**(用户定:「中间这一块不要搞的像后台管理端,要像给客户
|
||||
* 使用的 C 端界面;可以按主题加 SVG 图形与动画;只保留功能,视图层可以推翻重新设计」):
|
||||
* 三张件换成「精密检测报告」语言 —— 深色电路板夜景 hero(芯片封装 SVG + 流动信号点,
|
||||
* 三张件各一幕:建档台 / 对决量尺 / 落账席),正文回到瓷白纸面的报告排版。设计系统在
|
||||
* `./style.ts`(SHELL_CSS + 两套强调色);目标件时代的 `target-style.ts` 随之退役。
|
||||
*
|
||||
* **功能一个不动**:三张件的绑定面(data-rows / data-edit / data-col / 编辑闸 / 加删行 /
|
||||
* 种子行 / 主键钥匙 / 红带与还差什么)与 2.4.0 逐项相同 —— 可编辑表的 DOM 结构逐字节
|
||||
* 保留(收值链路 collect / resolveRowKey / addRow 都按结构认元素,动结构就是动功能);
|
||||
* 重设计只落在外壳、hero、只读块与表格皮肤上。
|
||||
*
|
||||
* 三张局面:
|
||||
* · `input-supplement`(S1 数据工单):四块竖排 —— 要评估什么(`sub_eval.part` 铺行 /
|
||||
* `parts.profile` 承载编辑)/ 芯片类型(`parts.chip_type`,含静态种子行)/ 核心参数
|
||||
* 字段清单(`parts.type_param`,含种子行与加删行)/ 参数值(`parts.parameter`);
|
||||
* 顶部红带摆配方算好的 `gate_reason`。
|
||||
* · `difference-review`(S3 验收工单):对决 hero(原 PN → 候选 PN + 推荐等级大徽章)+
|
||||
* 四态光谱 + 逐条差异明细(可就地改四态)+ 收口四格(可就地改,2026-08-31 用户定)。
|
||||
* · `landing-confirm`(T_release 终局验收工单):全只读,答「这一签下去发生什么」——
|
||||
* 印章 hero + 落账去向 + 四态计数与硬约束点名 + 待验项。
|
||||
*/
|
||||
import { vtok, badgeCss } from './badge.ts';
|
||||
import { SHELL_CSS, ACCENT_DATA, ACCENT_LEDGER, ACCENT_ACCEPT } from './style.ts';
|
||||
import { D } from '../constants.ts';
|
||||
|
||||
/* ==================== 徽标配色(值与文案成对交给 badgeCss,选择器从值算,不手写) ==================== */
|
||||
const OK = 'color: var(--ok); background: var(--ok-soft); border-color: #c3ecd3;';
|
||||
const DANGER = 'color: var(--danger); background: var(--danger-soft); border-color: #f6c3bc;';
|
||||
const AMBER = 'color: var(--amber); background: var(--amber-soft); border-color: #eeda9e;';
|
||||
const MUTED = 'color: var(--ink-3); background: #edf1f5; border-color: var(--line);';
|
||||
const COND = 'color: var(--cond); background: var(--cond-soft); border-color: #cdd2fb;';
|
||||
|
||||
/* ==================== 内联 SVG 图形 ==================== */
|
||||
/** 分区小图标(丝印风,描边取 currentColor) */
|
||||
const ZIC_TASK =
|
||||
'<svg class="zic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 12h6M9 16h4"/></svg>';
|
||||
const ZIC_TABLE =
|
||||
'<svg class="zic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M9 10v10"/></svg>';
|
||||
/**
|
||||
* 芯片封装(SOIC 俯视,铜引脚 + 晶粒)—— hero 舞台的主角。全部样式走 CSS class
|
||||
* (c-body / c-pin / c-die / c-dot),不用 defs 渐变:同一份文档里出现两次时渐变 id 会撞。
|
||||
*/
|
||||
const CHIP =
|
||||
'<svg class="csvg" viewBox="0 0 96 64" aria-hidden="true">' +
|
||||
'<rect class="c-pin" x="3" y="9" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="3" y="22" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="3" y="35" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="3" y="48" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="81" y="9" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="81" y="22" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="81" y="35" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="81" y="48" width="12" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-body" x="14" y="4" width="68" height="56" rx="9"/>' +
|
||||
'<rect class="c-die" x="36" y="21" width="24" height="22" rx="4"/>' +
|
||||
'<circle class="c-dot" cx="25" cy="14" r="2.6"/>' +
|
||||
'</svg>';
|
||||
|
||||
/* ==================== 通用绑定小工具(输出形状被包内测试钉死,改字就是改契约) ==================== */
|
||||
const zone = (label: string, aside: string, icon = ZIC_TASK): string =>
|
||||
`<header class="sec"><span class="sec-pin">${icon}</span><span class="sec-t">【${label}】</span><span class="sec-d">${aside}</span></header>`;
|
||||
const note = (items: readonly string[]): string => `<aside class="brief">${items.map((p) => `<p>${p}</p>`).join('')}</aside>`;
|
||||
const flow = (text: string): string => `<div class="flow">${text}</div>`;
|
||||
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
|
||||
<span class="jc-editgate-t">${label}</span>
|
||||
<button class="jc-egbtn eg-open" type="button" data-edit-open="${address}">编辑</button>
|
||||
<button class="jc-egbtn eg-cancel" type="button" data-edit-cancel="${address}">取消</button>
|
||||
<button class="jc-egbtn eg-save" type="button" data-edit-save="${address}">保存</button>
|
||||
</div>`;
|
||||
const gapbar = (object: string, what: string): string => `<div class="jc-gapbar" data-class-of="${object}_missing">
|
||||
<span><b>还差 <span data-slot="${object}_missing"></span> 行${what}没填</b> —— <span data-slot="${object}_missing_keys"></span>。打开编辑,带 * 的必填格填上,补齐了才确认得了。</span>
|
||||
</div>`;
|
||||
/** 可改格:只读那一份留空,渲染器自动配孪生 `<input>`(手写 input 会不填值,人一点编辑就是白框)。
|
||||
* `ph` 落成孪生件的 placeholder(渲染器认 `data-ph`,2026-08-31)—— 短短一句说清这格填什么。 */
|
||||
const cell = (col: string, cls = '', ph = ''): string =>
|
||||
`<span class="jc-vcell${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-col="${col}"${ph === '' ? '' : ` data-ph="${ph}"`} data-blank></span>`;
|
||||
const select = (col: string, options: ReadonlyArray<readonly [string, string]>, required = false): string =>
|
||||
`<select class="jc-cell" data-col="${col}"${required ? ' required' : ''}><option value=""></option>${options.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>`;
|
||||
/**
|
||||
* 枚举徽标。可改表里挨着 `<select class="jc-cell">` 孪生件时包一层 `.jc-vcell`(不带 `.ro`):
|
||||
* 看的时候摆徽标,编辑态随只读格一起藏起来,只留下拉框。
|
||||
*/
|
||||
const badge = (klass: string, col: string, wrapTwin = false): string => {
|
||||
const inner = `<span class="${klass}" data-class-of="${col}"></span>`;
|
||||
return wrapTwin ? `<span class="jc-vcell">${inner}</span>` : inner;
|
||||
};
|
||||
/**
|
||||
* **下拉的选项从台账现读**(spec §5.3):`data-options` 与 `data-slot` 不能共存 ——
|
||||
* 渲染时 `fillOptions` 会把 `data-slot` 一并摘掉。「当下值」由 `data-col` 决定
|
||||
* (`selectCurrent` 认它),这里不写 `data-slot`。
|
||||
*/
|
||||
const optionSelect = (col: string, address: string, valueCol: string, labelCol: string, required = false): string =>
|
||||
`<select class="jc-cell" data-col="${col}"${required ? ' required' : ''} data-options="${address}" data-option-value="${valueCol}" data-option-label="${labelCol}"></select>`;
|
||||
/**
|
||||
* 顶部「还差什么」红带。**不挂 `data-class-of="gate_reason"`**:这个字段是中文自由文本,
|
||||
* `classToken` 在它上面永远算不出 v-token;显隐靠 `.jc-gate-text` 的 `data-blank` +
|
||||
* CSS `:has(:empty)`(规则在 SHELL_CSS,包内测试钉着)。
|
||||
*/
|
||||
const gateBar = (): string => `<div class="jc-gate-bar" data-class-of="gate_state">
|
||||
<span><b>还差什么</b> —— <span class="jc-gate-text" data-slot="gate_reason" data-blank></span></span>
|
||||
<span class="jc-gate-sub" data-class-of="parts_profile_missing">主数据还差 <b data-slot="parts_profile_missing"></b> 行必填 —— <span data-slot="parts_profile_missing_keys"></span></span>
|
||||
<span class="jc-gate-sub" data-class-of="parts_parameter_missing">参数值还差 <b data-slot="parts_parameter_missing"></b> 行必填 —— <span data-slot="parts_parameter_missing_keys"></span></span>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 三层壳:`page > card`,题头是深色电路板 hero(`.card-head`,类名被「外壳」测试钉着)。
|
||||
* 收件人 / 关联任务两格由引擎注入;工单编号即本次评估案号 `eval_ref`(页脚)。
|
||||
* `stage` 是每张件自己的 hero 场景(SVG 舞台),`accent` 决定这张件的强调色。
|
||||
*/
|
||||
const doc = (input: {
|
||||
accent: string;
|
||||
alias: string;
|
||||
tag: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
step: string;
|
||||
stage: string;
|
||||
body: string;
|
||||
footTag: string;
|
||||
headTone?: 'light';
|
||||
/**
|
||||
* **这份件按哪一列换脸**(2026-08-31):列名给进来,渲染时投成 `v-<值>` 挂在最外层 `.card` 上,
|
||||
* 件里凡是要随之变的(题头四字 / 标题 / 卡角编辑钮)都由样式表按这个类挑 —— 判断仍留在样式表,
|
||||
* 模版不算不判。S1 那张件用它区分「数据工单(有格子要填)」与「验收工单(只读)」。
|
||||
*/
|
||||
kindOf?: string;
|
||||
}): string =>
|
||||
`<style>${SHELL_CSS}${input.accent}${input.alias}</style>
|
||||
<div class="page"><div class="card${input.headTone === 'light' ? ' hd-light' : ''}"${input.kindOf === undefined ? '' : ` data-class-of="${input.kindOf}"`}>
|
||||
<header class="card-head">
|
||||
<div class="hero-top">
|
||||
<span class="hero-sys">${input.kicker}</span>
|
||||
<span class="hero-chips"><span class="hero-tag">${input.tag}</span><span class="hero-step">${input.step}</span></span>
|
||||
</div>
|
||||
<h1 class="hero-title">${input.title}</h1>
|
||||
${input.stage}
|
||||
<div class="hero-meta">收件人 <b data-slot="case:owner"></b> · 关联任务 <b class="mono" data-slot="case:taskId" data-blank></b></div>
|
||||
</header>
|
||||
<div class="card-bd">${input.body}</div>
|
||||
<footer class="card-foot">
|
||||
<span>${input.kicker} · ${input.title}</span>
|
||||
<span class="foot-r mono">${input.footTag} · <span data-slot="eval_ref" data-blank></span></span>
|
||||
</footer>
|
||||
</div></div>`;
|
||||
|
||||
/**
|
||||
* 芯片**类型**的图形 —— 层叠双封装(一类而非一颗),别与档案卡的单颗封装共用一枚
|
||||
* (2026-08-31 用户:「芯片类型能不能换一个更贴近芯片类型的 svg,不要和芯片使用同一个图片」)。
|
||||
*/
|
||||
const TYPE_SVG =
|
||||
'<svg class="tsvg" viewBox="0 0 96 72" aria-hidden="true">' +
|
||||
'<g class="t-ghost"><rect class="c-pin" x="13" y="7" width="10" height="4" rx="1.4"/><rect class="c-pin" x="13" y="18" width="10" height="4" rx="1.4"/><rect class="c-pin" x="13" y="29" width="10" height="4" rx="1.4"/><rect class="c-pin" x="79" y="7" width="10" height="4" rx="1.4"/><rect class="c-pin" x="79" y="18" width="10" height="4" rx="1.4"/><rect class="c-pin" x="79" y="29" width="10" height="4" rx="1.4"/><rect class="c-body" x="22" y="2" width="58" height="42" rx="7"/></g>' +
|
||||
'<rect class="c-pin" x="3" y="27" width="11" height="5" rx="1.6"/><rect class="c-pin" x="3" y="39" width="11" height="5" rx="1.6"/><rect class="c-pin" x="3" y="51" width="11" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-pin" x="70" y="27" width="11" height="5" rx="1.6"/><rect class="c-pin" x="70" y="39" width="11" height="5" rx="1.6"/><rect class="c-pin" x="70" y="51" width="11" height="5" rx="1.6"/>' +
|
||||
'<rect class="c-body" x="13" y="22" width="58" height="44" rx="8"/>' +
|
||||
'<rect class="c-die" x="31" y="36" width="22" height="18" rx="3.5"/>' +
|
||||
'<circle class="c-dot" cx="23" cy="30" r="2.3"/>' +
|
||||
'</svg>';
|
||||
|
||||
/** 卡角编辑钮的三枚图标:笔(开编辑)/ 软盘(保存)/ 叉(放弃)—— 2026-08-31 用户定的笔↔软盘交互 */
|
||||
const PEN_SVG =
|
||||
'<svg class="fic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
|
||||
const DISK_SVG =
|
||||
'<svg class="fic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>';
|
||||
const X_SVG =
|
||||
'<svg class="fic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
|
||||
/* ==================== 局面:评估输入补全(input-supplement,S1 的数据工单) ==================== */
|
||||
|
||||
/*
|
||||
* 徽标别名 —— 生命周期状态(parts.profile)与在库状态(sub_eval.part.exists_status)共用
|
||||
* `.stx` 胶囊:取值域互不重叠(active/nrnd/eol/discontinued/risk 与 found/absent 没有一个词撞车)。
|
||||
* 角色(原/候选)是分类标签不是状态,`.rl` 不配色只补文案。
|
||||
*/
|
||||
const ALIAS_INPUT = `
|
||||
${badgeCss('.stx', [
|
||||
['active', 'Active', OK],
|
||||
['nrnd', 'NRND', AMBER],
|
||||
['eol', 'EOL', DANGER],
|
||||
['discontinued', '停产', MUTED],
|
||||
['risk', '风险', DANGER],
|
||||
['found', '在库', OK],
|
||||
['absent', '不在库', AMBER],
|
||||
])}
|
||||
${badgeCss('.ct', [
|
||||
['hard', '硬约束', DANGER],
|
||||
['soft', '软约束', MUTED],
|
||||
])}
|
||||
${badgeCss('.rl', [
|
||||
['original', '原内部料号'],
|
||||
['candidate', '候选内部料号'],
|
||||
])}
|
||||
/*
|
||||
* **一张件两副面孔**(2026-08-31 用户定)—— 最外层 .card 上由 data-class-of=order_kind
|
||||
* 投出 v-data / v-accept,这里只管挑:
|
||||
* · 题头 / 标题 / 页脚三处的四字换名(.k-data ↔ .k-acc);
|
||||
* · **验收工单把卡角那组编辑钮整组收起** —— 藏的是容器 .fabs,不是逐枚钮:
|
||||
* 那几枚各自的 display 由 html.editable 开头那一串更特异的规则给着,逐枚去盖是打不完的;
|
||||
* 父容器一藏,里面的按钮连渲染都不渲染,件就此只读(人点不开编辑,孪生件也就不现身)。
|
||||
* 旗还没写过(老单 / 第一帧)时两个类都不在 → 落回默认的「数据工单」那一副,与从前一模一样。
|
||||
*/
|
||||
.k-acc { display: none; }
|
||||
.v-accept .k-data { display: none; }
|
||||
.v-accept .k-acc { display: inline; }
|
||||
.v-accept .fabs { display: none; }
|
||||
${ACCENT_ACCEPT}
|
||||
`;
|
||||
|
||||
|
||||
/**
|
||||
* 块① 要评估什么 —— 电路板工作台:两颗料各一张**可就地编辑的档案卡**。
|
||||
*
|
||||
* **行源改成 `mine:parts.profile` 本身**(2026-08-31,用户实测「品牌…点保存根本不会保存」
|
||||
* 的病根):从前行源是 `mine:sub_eval.part`,而那张案卷表上只有角色/在库/类型三列 ——
|
||||
* `renderTemplate` 只认行作用域,品牌/生命周期这些 `data-slot` 与孪生件的值**永远是空白**;
|
||||
* 人填了、保存也真落了草稿,件上却读不出来,像没保存。S1 配方第④笔(prune:true)保证
|
||||
* `parts.profile` 草稿恒有两行(原/候选各一,值回填现值),行里天生有全部主数据列 ——
|
||||
* 显示与孪生件从此有值可读,保存 → wake → 重推,当场看得见。
|
||||
*
|
||||
* **角色 / 在库状态是嵌套只读查表**(`mine:sub_eval.part` 按 `internal_pn` 对行):
|
||||
* 嵌套行里**一个 `data-col` 都不许有** —— 嵌套 data-rows 里的输入格会被 `collect()`
|
||||
* 并成一条记录(平台测试 view-container-collect-div-rows 钉过的机制),只读徽标无此虞。
|
||||
*
|
||||
* **类型名带守卫,不再撒谎**(2026-08-31 用户:「明明还没选类型,界面自己就给上了」):
|
||||
* `data-by` 在行里缺键时会落回外层同名字段(debt I3)—— 候选还没选类型时,查名 pill
|
||||
* 摆出的是**案卷锚(原 PN)的类型**。守卫认 `selectCurrent` 按**这一行自己的值**写下的
|
||||
* `option[selected]`(行作用域求值,不受外层回落污染):这一行真选上了才摆名,
|
||||
* 没选摆「待选择」胶囊 —— 判断全在样式表(与被退回的换版面那版同一招,机制经过实测)。
|
||||
*
|
||||
* 人不加行(要评估哪两颗是启动参数定死的),没有「+加一行」。
|
||||
*
|
||||
* **翻面(2026-08-31 用户定)**:每张卡正面主数据、背面这颗芯片的核心参数值 ——
|
||||
* 点击卡面翻转(纯 CSS:`.flip-tg` checkbox + :has,零脚本;checkbox 不带 `data-col`,
|
||||
* collect 不认它),悬浮出「点击翻面 · 核心参数」提示丸 + 轻微侧倾预告可翻。
|
||||
* **背面的参数就地可改**:两张卡背各住一个 `data-edit="parts.parameter"` 宿主 ——
|
||||
* 多宿主收值是 2026-08-31 平台扩面(ViewContainer `hostsOf` + 「格子只归最近的
|
||||
* data-edit 祖先」,packages/ui-kit/__tests__/view-container-multihost.test.ts 钉着);
|
||||
* 开哪面的编辑,卡就停在哪面(主数据编辑锁正面、参数编辑自动翻背面,CSS 兜底)。
|
||||
*/
|
||||
const DOSSIERS = `<section class="board"><span class="swapfx" aria-hidden="true"><span class="sw-orbit"><i class="sw-dot sw-d1"></i><i class="sw-dot sw-d2"></i></span><svg class="sw-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg></span><article class="dossiers" data-rows="mine:parts.profile" data-edit="parts.profile"><section class="dossier">
|
||||
<label class="flip-hit"><input class="flip-tg" type="checkbox" aria-label="翻转这张卡"></label>
|
||||
<span class="flip-cue"><i class="cue-front">点击翻面 · 核心参数</i><i class="cue-back">点击翻回 · 主数据</i></span>
|
||||
<span class="fabs">
|
||||
<button class="fab xbtn x-f" type="button" data-edit-cancel="parts.profile" title="放弃这一面的修改">${X_SVG}</button>
|
||||
<button class="fab xbtn x-b" type="button" data-edit-cancel="parts.parameter" title="放弃这一面的修改">${X_SVG}</button>
|
||||
<button class="fab pen pen-f" type="button" data-edit-open="parts.profile" title="编辑这一面(主数据)">${PEN_SVG}</button>
|
||||
<button class="fab pen pen-b" type="button" data-edit-open="parts.parameter" title="编辑这一面(核心参数值)">${PEN_SVG}</button>
|
||||
<button class="fab disk disk-f" type="button" data-edit-save="parts.profile" title="保存这一面">${DISK_SVG}</button>
|
||||
<button class="fab disk disk-b" type="button" data-edit-save="parts.parameter" title="保存这一面">${DISK_SVG}</button>
|
||||
</span>
|
||||
<div class="flip-in">
|
||||
<div class="face front">
|
||||
<header class="dz-top">
|
||||
${CHIP}
|
||||
<span class="dz-id">
|
||||
<span class="dz-role"><span data-rows="mine:${D}.part" data-by="internal_pn=internal_pn"><i class="rl" data-class-of="role"></i><i class="stx" data-class-of="exists_status"></i></span></span>
|
||||
<span class="dz-pn">${cell('internal_pn', 'mono')}</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="dz-type">
|
||||
<i class="dz-lb">芯片类型 *</i>
|
||||
<span class="jc-vcell tsel"><span data-rows="parts.chip_type" data-by="type_id=chip_type_id"><i data-slot="type_name" data-blank></i></span></span>
|
||||
${optionSelect('chip_type_id', 'parts.chip_type', 'type_id', 'type_name', true)}
|
||||
</div>
|
||||
<div class="dz-grid">
|
||||
<label class="fcell req"><i>品牌</i>${cell('brand_name')}</label>
|
||||
<label class="fcell req"><i>生命周期</i>${badge('stx', 'lifecycle_status', true)}${select('lifecycle_status', [['active', 'Active'], ['nrnd', 'NRND'], ['eol', 'EOL'], ['discontinued', '停产'], ['risk', '风险']], true)}</label>
|
||||
<label class="fcell sug"><i>制造商</i>${cell('manufacturer_name')}</label>
|
||||
<label class="fcell sug"><i>Datasheet 版本</i>${cell('datasheet_version')}</label>
|
||||
<label class="fcell sug wide"><i>规格描述</i>${cell('specification_description')}</label>
|
||||
<label class="fcell sug wide"><i>Datasheet 引用</i>${cell('datasheet_ref', 'mono')}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="face back">
|
||||
<header class="pb-hd"><b class="mono" data-slot="internal_pn" data-blank></b><span class="pb-lb">核心参数值</span></header>
|
||||
<section class="pback" data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn" data-edit="parts.parameter"><div class="ptile">
|
||||
<span class="pt-hd"><span class="pt-name">${cell('param_name')}</span><i class="pt-unit" data-slot="param_unit" data-blank></i></span>
|
||||
<span class="pt-val pv">${cell('param_value', 'mono', '规格书上的值 · 查不到留空')}</span>
|
||||
<span class="pt-note sg">${cell('param_note', '', '补充说明 · 选填')}</span>
|
||||
<span class="rowkey">${cell('internal_pn', 'mono')}</span>
|
||||
</div></section>
|
||||
</div>
|
||||
</div>
|
||||
</section></article></section>`;
|
||||
|
||||
/**
|
||||
* 比对基准卡 —— 芯片类型与它的核心参数字段**合成一张只读卡**(2026-08-31 用户:
|
||||
* 「芯片类型列表和核心参数字段,不能合并成一个卡片吗…两个芯片都选一样的,就应该只显示一种」)。
|
||||
*
|
||||
* **显示永远跟手、永远只有当前那一种**:不列 `mine:parts.chip_type` 草稿(镜像里会积着
|
||||
* 改选前的旧类型 —— 撤行只在确认工单那一拍跑,F3 不许在人编辑时静默删行),而是按**案卷锚**
|
||||
* (`sub_eval.case.chip_type_id`,配方 ⑥ 每拍从原 PN 的选择现算)嵌套查字典:人卡上改选、
|
||||
* 按保存,quiet 拍照样跑 project,锚跟着动,这张卡当场换脸 —— 两颗料选同一类型时,这里
|
||||
* 恰好就是那一种;还没人选类型时零行,空态一句话指路。
|
||||
*
|
||||
* 全只读(一个 data-col 都没有)—— 选类型在档案卡上,建类型在下面的工作抽屉里。
|
||||
* 嵌套三层:case(恰一行)→ chip_type 按锚查名 → type_param 按锚列字段;
|
||||
* 宿主标签各不与行模版里的标签撞(article / span / tbody)。
|
||||
*/
|
||||
const BASIS_CARD = `<article class="basis" data-rows="mine:${D}.case"><section class="basis-in">
|
||||
<header class="basis-hd">
|
||||
${TYPE_SVG}
|
||||
<span class="basis-id">
|
||||
<span class="basis-lb">比对基准 · 芯片类型</span>
|
||||
<span class="basis-chip" data-rows="parts.chip_type" data-by="type_id=chip_type_id"><b data-slot="type_name" data-blank></b><i class="basis-desc" data-slot="description" data-blank></i></span>
|
||||
</span>
|
||||
</header>
|
||||
<section class="ftiles" data-rows="parts.type_param" data-by="type_id=chip_type_id"><div class="ftile">
|
||||
<span class="ft-hd"><b data-slot="param_name" data-blank></b><span class="ct" data-class-of="constraint_type"></span><i class="ft-unit" data-slot="param_unit" data-blank></i></span>
|
||||
<p class="ft-desc" data-slot="param_desc" data-blank></p>
|
||||
</div></section>
|
||||
</section></article>`;
|
||||
|
||||
/*
|
||||
* ~~块②a 新建芯片类型 / 块②b 核心参数字段清单~~(2026-08-31 用户定:「把添加芯片类型的
|
||||
* 功能从这个活动中移除,不能在这个活动中新增芯片类型,只能选择已有的芯片类型」)。
|
||||
* 两张编辑表(静态种子行、加删行、thead 后置、type_id 禁用 data-col 那整套 Critical-1
|
||||
* 修法)与「新建类型」抽屉一并退役 —— 类型字典只在治理侧维护,本活动只读它。
|
||||
* 机制史与钉测见 git(2.9.0 及更早)与 view-render-addrow-thead.test.ts(平台侧仍在)。
|
||||
*/
|
||||
|
||||
export const INPUT_SUPPLEMENT = doc({
|
||||
accent: ACCENT_DATA,
|
||||
alias: ALIAS_INPUT,
|
||||
/*
|
||||
* **一张件两副面孔**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单,
|
||||
* 界面和数据要和现在的第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉…标题旁边的数据工单
|
||||
* 改成验收工单」)—— 数据与画面一个字不改,只有这三处名字与卡角那组编辑钮随 `order_kind` 换。
|
||||
* 谁是哪一副由配方在派卡那一刻定死(粘着的旗),件只负责摆出来。
|
||||
*/
|
||||
kindOf: 'order_kind',
|
||||
tag: '<span class="k-data">数据工单</span><span class="k-acc">验收工单</span>',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估',
|
||||
title: '<span class="k-data">差异评估的输入还差几格</span><span class="k-acc">差异评估的输入核对</span>',
|
||||
step: '步骤 1/4 · 校验入参与补数据',
|
||||
stage: '',
|
||||
headTone: 'light',
|
||||
footTag: '工单类型:<span class="k-data">数据</span><span class="k-acc">验收</span>',
|
||||
body:
|
||||
gateBar() +
|
||||
`<details class="lab lab-brief"><summary class="lab-sm"><b>【做什么】</b>核对两颗料在不在库、归没归同一个芯片类型、参数值齐不齐 —— 展开看说明与纪律<span class="lab-caret"></span></summary>` +
|
||||
note([
|
||||
'<span class="need">模板定位</span>:目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。',
|
||||
'<span class="need">两副面孔</span>:资料还差东西 → 这是**数据工单**,顶部红带点名缺什么,卡角的笔可就地补;三样都齐了 → 这是**验收工单**,只读,交你核对无误后确认。',
|
||||
'<span class="done">已绑定</span>:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。',
|
||||
'<span class="need">纪律</span>:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型只从字典里选,本活动不新增类型 —— 字典里没有合适的,先请数据管理员补进字典;查不到的参数值留空(会被红带点名),不要编一个值顶替。',
|
||||
]) +
|
||||
`</details>` +
|
||||
zone('比对基准', '芯片类型 + 它的核心参数字段 —— 两颗料归到哪个类型,就按这些字段逐项比;字段说明悬浮即看', ZIC_TABLE) +
|
||||
BASIS_CARD +
|
||||
zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面;可编辑时右上角的笔 = 只编辑当前这一面,软盘 = 保存', ZIC_TABLE) +
|
||||
DOSSIERS +
|
||||
flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区那张卡上进行。确认后活动接着往下推进。'),
|
||||
});
|
||||
|
||||
/* ==================== 局面:差异评估结论审核(difference-review,S3 的验收工单) ==================== */
|
||||
|
||||
/**
|
||||
* 徽标别名 —— 四态(可改列的只读孪生)/ 约束类型 / 推荐等级(规则算的,只读)/ 替代风险。
|
||||
* 全部走 `badgeCss()`:`data-class-of` 消毒出来的是 `v-<值>`,手写选择器对不上是本仓
|
||||
* 踩过三次的坑。四态徽标文案与下面下拉框的选项同一份字面量表(上下两态得说同一个字)。
|
||||
*/
|
||||
const DIFF_STATUS_OPTIONS: ReadonlyArray<readonly [string, string]> = [
|
||||
['match', 'MATCH'],
|
||||
['mismatch', 'MISMATCH'],
|
||||
['unknown', 'UNKNOWN'],
|
||||
['conditional', 'CONDITIONAL'],
|
||||
];
|
||||
const ALIAS_REVIEW_BADGE = `
|
||||
${badgeCss('.st', [
|
||||
['match', 'MATCH', OK],
|
||||
['mismatch', 'MISMATCH', DANGER],
|
||||
['unknown', 'UNKNOWN', AMBER],
|
||||
['conditional', 'CONDITIONAL', COND],
|
||||
])}
|
||||
${badgeCss('.ct', [
|
||||
['hard', '硬约束', DANGER],
|
||||
['soft', '软约束', MUTED],
|
||||
])}
|
||||
${badgeCss('.rec', [
|
||||
['recommended', '推荐', OK],
|
||||
['conditional', '有条件推荐', AMBER],
|
||||
['not_recommended', '不推荐', DANGER],
|
||||
])}
|
||||
${badgeCss('.risk-badge', [
|
||||
['low', '低风险', OK],
|
||||
['medium', '中风险', AMBER],
|
||||
['high', '高风险', DANGER],
|
||||
])}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 四态光谱 + 收口四格的版式。分布条的宽度梯子照 quote-pricing 的 `.jc-distbar` 同一招
|
||||
* (`data-class-of` 把条数投影成 `v-<N>`,梯子在样式表里取宽;模版仍只取值不算);
|
||||
* 光谱上那层白刻度线是静态背景,不算不判。
|
||||
*
|
||||
* **收口四格可就地改**:`.summary-box` 不是表格 —— `setOpen` 对非表格容器把 `.on` 落在
|
||||
* `data-edit` 元素**自身**,所以这几条按 `.summary-box.on` 写,不是 `table.on`。
|
||||
* 注释里不写反引号:这一整段活在 TS 模板字符串里,一个反引号就把串截断。
|
||||
*/
|
||||
const ALIAS_REVIEW_LAYOUT = `
|
||||
.dist{margin:0 0 8px}
|
||||
.dist-bar{position:relative;display:flex;height:14px;border-radius:999px;overflow:hidden;background:var(--soft);border:1px solid var(--line-soft)}
|
||||
.dist-bar::after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 calc(10% - 1px),rgba(255,255,255,.6) calc(10% - 1px) 10%)}
|
||||
.dist-bar>i{flex:0;display:block;font-size:0;color:transparent}
|
||||
.dist-bar>i.v-0,.dist-bar>i:not([class*=" v-"]){display:none}
|
||||
.dist-bar>i.v-1{flex:1}.dist-bar>i.v-2{flex:2}.dist-bar>i.v-3{flex:3}.dist-bar>i.v-4{flex:4}
|
||||
.dist-bar>i.v-5{flex:5}.dist-bar>i.v-6{flex:6}.dist-bar>i.v-7{flex:7}.dist-bar>i.v-8{flex:8}
|
||||
.dist-bar>i.v-9{flex:9}.dist-bar>i.v-10{flex:10}.dist-bar>i.v-11{flex:11}.dist-bar>i.v-12{flex:12}
|
||||
.dist-bar>i.v-13{flex:13}.dist-bar>i.v-14{flex:14}.dist-bar>i.v-15{flex:15}.dist-bar>i.v-16{flex:16}
|
||||
.dist-bar>i.v-17{flex:17}.dist-bar>i.v-18{flex:18}.dist-bar>i.v-19{flex:19}.dist-bar>i.v-20{flex:20}
|
||||
.dist-bar>i.m{background:var(--ok)}.dist-bar>i.x{background:var(--danger)}.dist-bar>i.u{background:var(--amber)}.dist-bar>i.c{background:var(--cond)}
|
||||
.dist-lg{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
||||
.dist-lg span{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--ink-3);border:1px solid var(--line-soft);border-radius:999px;padding:3px 12px;background:#fff}
|
||||
.dist-lg em{display:inline-block;width:9px;height:9px;border-radius:3px}
|
||||
.dist-lg em.m{background:var(--ok)}.dist-lg em.x{background:var(--danger)}.dist-lg em.u{background:var(--amber)}.dist-lg em.c{background:var(--cond)}
|
||||
.dist-lg b{color:var(--ink);font-weight:800;font-family:var(--mono);font-size:12.5px}
|
||||
.jc-tbl-wrap table{min-width:1080px}
|
||||
.summary-box{border:1px solid var(--acc-line);border-radius:12px;background:var(--acc-wash);padding:4px 16px;margin-bottom:4px}
|
||||
.summary-box .jc-cell{display:none;width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 9px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.summary-box .jc-cell:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft);outline:none}
|
||||
.summary-box.on .kv > .jc-vcell{display:none}
|
||||
.summary-box.on .kv > .jc-cell{display:block}
|
||||
.summary-box.on .kv .jc-vcell:empty::after{content:none}
|
||||
.summary-box .rowkey{display:none}
|
||||
.sumrow{display:block}
|
||||
`;
|
||||
|
||||
/**
|
||||
* hero 场景②「对决量尺」:原 PN → 候选 PN,中缝是共享的芯片类型与流动的信号线,
|
||||
* 右边压一枚推荐等级大徽章。`original_internal_pn` / `candidate_internal_pn` 是
|
||||
* `substitution.relation` 这一单唯一一行草稿写下的字段,任务视图把它们摊到了顶层作用域
|
||||
* (裸 `data-slot` 取得到);类型名沿用件①已验证过的查表写法(`data-by`,取值不是算)。
|
||||
*/
|
||||
const STAGE_DUEL = `<section class="stage stage-duel">
|
||||
<span class="duel-type">芯片类型 · <i><span data-rows="parts.chip_type" data-by="type_id=chip_type_id"><b data-slot="type_name" data-blank></b></span></i></span>
|
||||
<article class="hchip">
|
||||
${CHIP}
|
||||
<span class="hchip-role">原内部料号</span>
|
||||
<span class="hchip-pn mono" data-slot="original_internal_pn" data-blank></span>
|
||||
</article>
|
||||
<article class="hchip">
|
||||
${CHIP}
|
||||
<span class="hchip-role">候选内部料号</span>
|
||||
<span class="hchip-pn mono" data-slot="candidate_internal_pn" data-blank></span>
|
||||
</article>
|
||||
</section>
|
||||
<div class="hero-verdict"><span class="rec rec-lg" data-class-of="recommendation_level"></span></div>`;
|
||||
|
||||
/**
|
||||
* 四态光谱 —— 四个基座 tally 读数各自身兼两职:`data-slot` 摆计数字面值,`data-class-of`
|
||||
* 把同一个数投影成宽度梯子的 class(`v-<N>`)。与 `quote-pricing` 的 `.jc-distbar` 完全同款。
|
||||
*/
|
||||
const DIST_PREFIX = 'substitution_difference_item_difference_status_';
|
||||
const DIST = `<div class="dist">
|
||||
<div class="dist-bar">
|
||||
<i class="m" data-class-of="${DIST_PREFIX}match" data-slot="${DIST_PREFIX}match"></i>
|
||||
<i class="x" data-class-of="${DIST_PREFIX}mismatch" data-slot="${DIST_PREFIX}mismatch"></i>
|
||||
<i class="u" data-class-of="${DIST_PREFIX}unknown" data-slot="${DIST_PREFIX}unknown"></i>
|
||||
<i class="c" data-class-of="${DIST_PREFIX}conditional" data-slot="${DIST_PREFIX}conditional"></i>
|
||||
</div>
|
||||
<div class="dist-lg">
|
||||
<span><em class="m"></em>MATCH · <b data-slot="${DIST_PREFIX}match"></b></span>
|
||||
<span><em class="x"></em>MISMATCH · <b data-slot="${DIST_PREFIX}mismatch"></b></span>
|
||||
<span><em class="u"></em>UNKNOWN · <b data-slot="${DIST_PREFIX}unknown"></b></span>
|
||||
<span><em class="c"></em>CONDITIONAL · <b data-slot="${DIST_PREFIX}conditional"></b></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 逐条差异明细 —— **唯一可改的一张表**。四态 / 影响说明 / 判断依据 / UNKNOWN 去向四列可改,
|
||||
* 其余(参数项 / 说明 / 约束 / 原值 / 候选值 / 单位)只读。
|
||||
*
|
||||
* **两枚主键都挂 `data-col`**(`substitution_relation_id` + `difference_item_id`,复合主键):
|
||||
* 它们不摆给人看(一串内部编号,对审核没有信息量),裹进 `.rowkey`(display:none)藏起来 ——
|
||||
* 但必须真的在 DOM 里,人保存这一行时服务端 `resolveRowKey` 才凑得出主键。表头补一个
|
||||
* 不显示文字的 `<th>` 与它对位,首尾列数对得上。
|
||||
*/
|
||||
const DIFF_ITEM_TABLE = `<div class="jc-tbl-wrap"><table>
|
||||
<thead><tr>
|
||||
<th style="width:36px">#</th>
|
||||
<th data-h="参数项 parameter_name">参数项</th>
|
||||
<th data-h="参数说明 parameter_desc">参数说明</th>
|
||||
<th data-h="约束类型 constraint_type">约束</th>
|
||||
<th data-h="原PN值 original_value">原 PN 值</th>
|
||||
<th data-h="候选PN值 candidate_value">候选 PN 值</th>
|
||||
<th data-h="单位 parameter_unit">单位</th>
|
||||
<th class="req" data-h="结论 difference_status">结论(四态)</th>
|
||||
<th class="req" data-h="影响说明 impact_statement">影响说明</th>
|
||||
<th class="req" data-h="判断依据 evidence_note">判断依据</th>
|
||||
<th data-h="UNKNOWN去向 unknown_disposition">UNKNOWN 去向</th>
|
||||
<th style="display:none"></th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:substitution.difference_item" data-edit="substitution.difference_item"><tr>
|
||||
<td class="num"></td>
|
||||
<td class="cust" data-slot="parameter_name" data-blank></td>
|
||||
<td class="desc" data-slot="parameter_desc" data-blank></td>
|
||||
<td><span class="jc-vcell ro">${badge('ct', 'constraint_type')}</span></td>
|
||||
<td class="mono" data-slot="original_value" data-blank></td>
|
||||
<td class="mono" data-slot="candidate_value" data-blank></td>
|
||||
<td data-slot="parameter_unit" data-blank></td>
|
||||
<td>${badge('st', 'difference_status', true)}${select('difference_status', DIFF_STATUS_OPTIONS, true)}</td>
|
||||
<td>${cell('impact_statement')}</td>
|
||||
<td>${cell('evidence_note')}</td>
|
||||
<td>${cell('unknown_disposition')}</td>
|
||||
<td class="rowkey">${cell('difference_item_id')}${cell('substitution_relation_id')}</td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/**
|
||||
* 收口四格 —— **可就地改**(2026-08-31 用户定:审核人对结论有最终发言权)。
|
||||
*
|
||||
* **光把格子打开不够,配方那一侧必须一起改**:`RELATION_PROJECTION` 每跑一次就把这四列
|
||||
* 按规则重写一遍,而人在件上按保存会触发 quiet 拍、quiet 恰恰跑 `project` —— 不动配方,
|
||||
* 人改完一保存就被规则值冲掉。治法见 `case-model.ts` 的 `RELATION_RECOMPUTE`:S3 只重算
|
||||
* **判定**(推荐等级跟着四态走,不容手改),这四格一个字不碰。代价说在明处:改四态之后
|
||||
* 这三句话不会自动跟着改口 —— 件上那段说明已如实写清。
|
||||
*
|
||||
* 三格自由文本走 `cell()`(只读份 + 自动孪生输入);风险是枚举,只读态走徽标
|
||||
* (不裸摆 low/medium/high),编辑态换 `select`。主键 `substitution_relation_id` 挂在
|
||||
* `.rowkey` 里:不给人看,但必须**真挂 `data-col`** —— 漏了钥匙,保存的行凑不出主键。
|
||||
*/
|
||||
const CLOSING_SUMMARY =
|
||||
editGate('substitution.relation', '收口四格是 AI 按规则拟的初稿 —— 点「编辑」就地改,改完保存') +
|
||||
`<article class="summary-box" data-rows="mine:substitution.relation" data-edit="substitution.relation"><section class="sumrow">
|
||||
<div class="kv"><i>推荐理由</i>${cell('recommendation_reason')}</div>
|
||||
<div class="kv"><i>差异摘要</i>${cell('difference_summary')}</div>
|
||||
<div class="kv"><i>替代风险</i><span class="jc-vcell"><span class="risk-badge" data-class-of="risk_level"></span></span>${select('risk_level', [['low', '低'], ['medium', '中'], ['high', '高']], true)}</div>
|
||||
<div class="kv"><i>验证要求</i>${cell('verification_requirement')}</div>
|
||||
<span class="rowkey">${cell('substitution_relation_id', 'mono')}</span>
|
||||
</section></article>`;
|
||||
|
||||
export const DIFFERENCE_REVIEW = doc({
|
||||
accent: ACCENT_LEDGER,
|
||||
alias: ALIAS_REVIEW_BADGE + ALIAS_REVIEW_LAYOUT,
|
||||
tag: '验收工单',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估',
|
||||
title: '差异评估结论审核',
|
||||
step: '步骤 3/4 · 结论审核',
|
||||
stage: STAGE_DUEL,
|
||||
footTag: '工单类型:验收',
|
||||
body:
|
||||
zone('AI 结论说明', '结论是怎么算出来的', ZIC_TASK) +
|
||||
note([
|
||||
'<span class="done">AI 已完成的判定</span>:按差异规则(锁定版本 <b data-slot="rule_version" data-blank></b>),对两颗料在同一芯片类型下的每一条核心参数逐一比对,给出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 四态之一 —— 硬约束不满足即 MISMATCH,证据不足即 UNKNOWN,不会被措辞改判成 MATCH。',
|
||||
'<span class="need">哪些跟着四态走</span>:<b>推荐等级</b>是规则按四态分布算出来的 —— 你改动下表里任何一条四态,它当场重算,不容手改(那是判据的核心)。<b>收口四格</b>(推荐理由 / 差异摘要 / 替代风险 / 验证要求)是 AI 拟的初稿,<b>可以就地改,写下的以你为准</b> —— 也正因如此,改四态<b>不会</b>把它们自动改写掉(否则你刚写的话会被冲掉);四态改完,记得回头看看这四格还对不对。',
|
||||
'<span class="need">就地核对</span>:逐条对过原 PN 与候选 PN 的取值、硬约束是否含糊放过、UNKNOWN 是否有验证去向 —— 要改就在下表里改四态、影响说明、判断依据、UNKNOWN 去向,改完保存即生效,不必重新调用 AI。',
|
||||
]) +
|
||||
zone('比对光谱', '逐条结论的四态分布 —— 条宽即条数', ZIC_TABLE) +
|
||||
DIST +
|
||||
zone('逐条差异明细', '一行一参数,与原 PN 逐项对比;结论 / 影响说明 / 判断依据 / UNKNOWN 去向可就地改', ZIC_TABLE) +
|
||||
editGate('substitution.difference_item', '逐条核对结论、影响说明、判断依据,点「编辑」开表') +
|
||||
gapbar('substitution_difference_item', '必填') +
|
||||
DIFF_ITEM_TABLE +
|
||||
zone('收口结论', 'AI 按规则拟的初稿 —— 四格都可就地改,你写下的以你为准(改四态不会把它们冲掉,也就不会自动跟着改口)', ZIC_TASK) +
|
||||
CLOSING_SUMMARY +
|
||||
flow('<b>审批:</b>本工单只承载内容;逐条核对无误后,请在对话区的【验收卡】中确认通过。通过后进入落账放行(T_release),签字后正式落账并发口。'),
|
||||
});
|
||||
|
||||
/* ==================== 局面:落账确认(landing-confirm,T_release 的终局验收工单) ==================== */
|
||||
|
||||
/*
|
||||
* **全只读,答「这一签下去发生什么」(spec §6.3)**:进哪几张表各几行(摆人读名)· 结论
|
||||
* (印章 hero:等级 + 一句话)· 四态计数并把硬约束不符的逐条点名 · 待验项(P-06 的测试
|
||||
* 条件与后续动作)· 发哪一口。这不是签字前要核的判据表(T_release 自己的
|
||||
* `acceptanceChecklist` 由验收卡渲染,不在这份 HTML 里重复)—— 这里摆的是**事实**。
|
||||
*/
|
||||
const ALIAS_LANDING = `
|
||||
${badgeCss('.rec', [
|
||||
['recommended', '推荐', OK],
|
||||
['conditional', '有条件推荐', AMBER],
|
||||
['not_recommended', '不推荐', DANGER],
|
||||
])}
|
||||
${badgeCss('.ct', [
|
||||
['hard', '硬约束', DANGER],
|
||||
['soft', '软约束', MUTED],
|
||||
])}
|
||||
/* 落账去向:三张台账卡 */
|
||||
.land{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:0 0 6px}
|
||||
.land-card{position:relative;display:flex;flex-direction:column;gap:2px;border:1px solid var(--line-soft);border-radius:12px;background:var(--soft);padding:13px 15px 11px;overflow:hidden}
|
||||
.land-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--acc)}
|
||||
.land-t{font-size:12px;font-weight:800;color:var(--ink)}
|
||||
.land-ref{font-size:10px;font-weight:800;letter-spacing:.09em;color:var(--ink-4);font-family:var(--mono)}
|
||||
.land-n{font-size:22px;font-weight:800;color:var(--acc-deep);line-height:1.2}
|
||||
.land-n:empty::after{content:"—";color:var(--ink-4)}
|
||||
.land-u{font-size:11px;color:var(--ink-4)}
|
||||
@media(max-width:620px){.land{grid-template-columns:1fr}}
|
||||
/* 四态计数:签字页只摆数,不画宽度条 */
|
||||
.four{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 14px}
|
||||
.four-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--ink-3);border:1px solid var(--line-soft);border-radius:999px;padding:3px 12px;background:#fff}
|
||||
.four-item b{font-weight:800;color:var(--ink);font-size:12.5px;font-family:var(--mono)}
|
||||
.four-item.m b{color:var(--ok)}
|
||||
.four-item.x b{color:var(--danger)}
|
||||
.four-item.u b{color:var(--amber)}
|
||||
.four-item.c b{color:var(--cond)}
|
||||
/* 硬约束不符逐条点名 —— 整表渲出来,CSS 只留 judgement_class = hard_mismatch 的那几行
|
||||
(判断留在样式表,模版不判);一条都没有时摆一句正面的话,不是留一张空表让人猜。 */
|
||||
.hardbox{margin:0 0 6px}
|
||||
.hardbox-empty{display:none;margin:0;padding:11px 14px;border-radius:10px;background:var(--ok-soft);border:1px solid #bfe6c8;color:var(--ok);font-size:12.5px;font-weight:700}
|
||||
.hardbox:not(:has(tr.v-${vtok('hard_mismatch')})) .hardbox-empty{display:block}
|
||||
.hardbox:not(:has(tr.v-${vtok('hard_mismatch')})) .jc-tbl-wrap{display:none}
|
||||
.hardbox table tbody tr:not(.v-${vtok('hard_mismatch')}){display:none}
|
||||
`;
|
||||
|
||||
/**
|
||||
* hero 场景③「落账席」:慢转的虚线印章环里压推荐等级大徽章,右侧一句话结论
|
||||
* (`sub_eval.case.summary`,T_release 自己那笔投影拼的整句)。
|
||||
*/
|
||||
const STAGE_SEAL = `<section class="stage-seal">
|
||||
<div class="seal"><span class="seal-core"><span class="rec rec-lg" data-class-of="recommendation_level"></span></span></div>
|
||||
<p class="seal-sum" data-slot="summary" data-blank></p>
|
||||
</section>`;
|
||||
|
||||
/**
|
||||
* 落账去向 —— 「进哪几张表各几行」,人读名 + 基座读数。三张卡是**固定的**(必交付物
|
||||
* `landing` / `difference-items` 承载的正是这三张表),不是 `data-rows` 驱动的动态表 ——
|
||||
* 行数本身才是要摆的数据,行的「有没有」不需要模版去猜。
|
||||
*/
|
||||
const LANDING_CARDS = `<div class="land">
|
||||
<article class="land-card"><span class="land-ref">P-11</span><span class="land-t">替代关系主记录</span><b class="land-n" data-slot="substitution_relation_rows"></b><span class="land-u">行 · 即将写入</span></article>
|
||||
<article class="land-card"><span class="land-ref">P-11 子表</span><span class="land-t">差异明细</span><b class="land-n" data-slot="substitution_difference_item_rows"></b><span class="land-u">行 · 即将写入</span></article>
|
||||
<article class="land-card"><span class="land-ref">P-06</span><span class="land-t">验证记录</span><b class="land-n" data-slot="validation_record_rows"></b><span class="land-u">行 · 即将写入</span></article>
|
||||
</div>`;
|
||||
|
||||
/** 四态计数 —— 与差异评估结论审核件同一批基座读数,这里不画宽度条,只摆数。 */
|
||||
const FOUR_STATE = `<div class="four">
|
||||
<span class="four-item m">MATCH · <b data-slot="${DIST_PREFIX}match"></b></span>
|
||||
<span class="four-item x">MISMATCH · <b data-slot="${DIST_PREFIX}mismatch"></b></span>
|
||||
<span class="four-item u">UNKNOWN · <b data-slot="${DIST_PREFIX}unknown"></b></span>
|
||||
<span class="four-item c">CONDITIONAL · <b data-slot="${DIST_PREFIX}conditional"></b></span>
|
||||
</div>`;
|
||||
|
||||
/**
|
||||
* 硬约束不符逐条点名 —— **签字的人最该看见的就是它**。整表渲出来
|
||||
* (`mine:substitution.difference_item`),行上挂 `data-class-of="judgement_class"`,
|
||||
* CSS 只留 hard_mismatch 那几行。**选择器一律从值算出来(vtok),不手写**:
|
||||
* `classToken` 只留小写字母数字连字符、吃掉下划线,手写直觉的选择器必然对不上
|
||||
* (本包 2026-08-27 栽过一次同款事故)。
|
||||
*/
|
||||
const HARD_MISMATCH_TABLE = `<div class="hardbox">
|
||||
<p class="hardbox-empty">没有硬约束不符项。</p>
|
||||
<div class="jc-tbl-wrap"><table>
|
||||
<thead><tr><th data-h="参数项">参数项</th><th data-h="约束">约束</th><th data-h="原PN值">原 PN 值</th><th data-h="候选PN值">候选 PN 值</th><th data-h="判断依据">判断依据</th><th data-h="影响说明">影响说明</th></tr></thead>
|
||||
<tbody data-rows="mine:substitution.difference_item">
|
||||
<tr data-class-of="judgement_class">
|
||||
<td class="cust" data-slot="parameter_name"></td>
|
||||
<td><span class="ct" data-class-of="constraint_type"></span></td>
|
||||
<td class="mono" data-slot="original_value" data-blank></td>
|
||||
<td class="mono" data-slot="candidate_value" data-blank></td>
|
||||
<td data-slot="evidence_note" data-blank></td>
|
||||
<td data-slot="impact_statement" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>`;
|
||||
|
||||
/** 待验项 —— P-06 的测试条件与后续动作(一单固定一条,直接 kv 行摆,不必 data-rows)。 */
|
||||
const VALIDATION_BLOCK = `<div class="kv-box">
|
||||
<div class="kv"><i>测试条件</i><span data-slot="test_condition" data-blank></span></div>
|
||||
<div class="kv"><i>后续动作</i><span data-slot="next_validation_action" data-blank></span></div>
|
||||
</div>`;
|
||||
|
||||
export const LANDING_CONFIRM = doc({
|
||||
accent: ACCENT_LEDGER,
|
||||
alias: ALIAS_LANDING,
|
||||
tag: '验收工单',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估',
|
||||
title: '评审放行与落账确认',
|
||||
step: '步骤 4/4 · 提交 ACT-GATE-03',
|
||||
stage: STAGE_SEAL,
|
||||
footTag: '工单类型:验收',
|
||||
body:
|
||||
zone('这一签下去发生什么', '全只读 —— 签之前只核对,要改回上一步的验收工单重做', ZIC_TASK) +
|
||||
note([
|
||||
'<span class="need">这一步做什么</span>:核对下面即将落账的内容,确认无误后在对话区的【验收卡】里签字放行 —— 签字后正式落账,不能撤回。',
|
||||
'<span class="done">系统已完成</span>:推荐等级 / 差异摘要 / 风险按规则从下面的差异明细算出,不是这一步现拟 —— 签字确认的是「摆出来的这些是不是要落的」,不是重新判断。',
|
||||
'<span class="need">不写客户批准</span>:客户批准状态由独立审批流维护,本活动只答技术可替代性,不产生那一列。',
|
||||
]) +
|
||||
zone('落账去向', '进哪几张表、各几行 —— 表由必交付物声明承载,行数即将落定', ZIC_TABLE) +
|
||||
LANDING_CARDS +
|
||||
zone('四态分布', '逐条差异明细的结论分布;硬约束不符的逐条点名 —— 签字前最该看的就是它', ZIC_TABLE) +
|
||||
FOUR_STATE +
|
||||
HARD_MISMATCH_TABLE +
|
||||
zone('待验项', 'P-06 的测试条件与后续动作 —— 有 UNKNOWN / CONDITIONAL 才有内容,全推荐则写「本轮无待验项」', ZIC_TASK) +
|
||||
VALIDATION_BLOCK +
|
||||
flow('<b>签字与发射:</b>本工单只承载内容;确认请在对话区的【验收卡】中签字放行。签字后即落账,发射 <b>assessment-released</b>(评估结论已落账)—— 载荷:评估案号 / 替代关系ID / 原内部料号 / 候选内部料号 / 推荐等级 / MATCH·MISMATCH·UNKNOWN·CONDITIONAL 条数 / 关联验证ID。'),
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* ACT-WORK-05 三张工单件的设计系统(2026-08-31 视图层整体重设计。用户定:
|
||||
* 「中间这一块不要搞的像后台管理端,要像给客户使用的 C 端界面;可以按主题加 SVG 图形,
|
||||
* 可以有动画,加强交互 —— 只保留功能,视图层可以推翻重新设计」)。
|
||||
*
|
||||
* 设计语言:「精密检测报告」——
|
||||
* · 题头是一块深色电路板夜景(hero):芯片封装、铜引脚、流动的走线信号,
|
||||
* 三张件各有一幕对应自己职能的场景(建档台 / 对决量尺 / 落账席);
|
||||
* · 正文回到瓷白纸面,报告化排版:丝印体小标(silkscreen)、mono 料号、
|
||||
* 引脚号式的序号列 —— 用电子行业自己的版面语汇,不借后台管理端的灰表格;
|
||||
* · 动画只做 transform / opacity(信号点流动、晶粒呼吸、印章环慢转、入场浮起),
|
||||
* prefers-reduced-motion 下全部静止 —— 与工作台动画同一条纪律。
|
||||
*
|
||||
* 旧的 target-style.ts(逐字节抽自目标件的三段样式)随本次重设计退役:目标件是
|
||||
* 「照着做」时代的锚,这一版是活动自己的设计,样式只此一处真源。
|
||||
*
|
||||
* **这一整段活在 TS 模板字符串里 —— 不许出现反引号**(一个反引号就把串截断,
|
||||
* esbuild 报在几行之外;本仓踩过)。注释里也别写星号斜杠连排。
|
||||
*
|
||||
* 几条被包内测试钉死的**机制规则逐字保留**,别「顺手整理」:
|
||||
* · 序号计数器五连(.jc-tbl-wrap table, .tbl-wrap table 成对出现);
|
||||
* · table:not(.on) tr.jc-row-static { display: none; }(种子行只在编辑态露面);
|
||||
* · .jc-gate-bar:has(.jc-gate-text:empty) 显隐(gate_reason 是中文自由文本,
|
||||
* data-class-of 在它上面永远算不出 v-token,显隐只认 data-blank 出的空文本节点);
|
||||
* · .jc-tbl-wrap 不许 overflow:hidden(表宽超出要能横着滑)。
|
||||
*/
|
||||
|
||||
/** 件① 数据工单的强调色 —— 数据蓝(与治理台「数据」语义同族) */
|
||||
export const ACCENT_DATA = `
|
||||
:root{
|
||||
--acc:#2e6bff; --acc-deep:#1f52d6; --acc-soft:#e3ecff; --acc-line:#c2d5ff; --acc-wash:#f3f7ff;
|
||||
--glow:#6fa1ff; --glow-line:rgba(111,161,255,.36); --hero-tint:rgba(46,107,255,.34);
|
||||
}
|
||||
`;
|
||||
|
||||
/** 件②③ 验收工单的强调色 —— 验收绿 #0aa294(2026-08-27 用户定:与对话区验收卡同色) */
|
||||
export const ACCENT_LEDGER = `
|
||||
:root{
|
||||
--acc:#0aa294; --acc-deep:#07857a; --acc-soft:#dcf5f1; --acc-line:#aee5dd; --acc-wash:#f0fbf9;
|
||||
--glow:#2fd4c2; --glow-line:rgba(47,212,194,.34); --hero-tint:rgba(10,162,148,.32);
|
||||
--ledger:#0aa294;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* **验收工单那一副的强调色**(2026-08-31 用户:「标题那一块需要是验收卡的那种绿色」)——
|
||||
* 件① 一张两副面孔:数据工单蓝、验收工单绿。整套令牌从 `ACCENT_LEDGER` **现改选择器**得来,
|
||||
* 数值只有一处真源(件②③ 与对话区验收卡同一枚 #0aa294);题头那条浅色渐变是写死的蓝白,
|
||||
* 单独给一条绿的override —— `.card.v-accept`(0,2,0)比 `:root`(0,1,0)特异,先后顺序不吃亏。
|
||||
*/
|
||||
export const ACCENT_ACCEPT = `${ACCENT_LEDGER.replace(':root{', '.card.v-accept{')}
|
||||
.card.v-accept.hd-light .card-head{background:linear-gradient(120deg,#e9f9f5 0%,#fff 55%,#e3f7f1 100%);border-bottom-color:var(--acc-deep)}
|
||||
`;
|
||||
|
||||
export const SHELL_CSS = `
|
||||
/* ================= 令牌与底 ================= */
|
||||
:root{
|
||||
--ink:#182430; --ink-2:#3d4c5c; --ink-3:#67788a; --ink-4:#97a5b3;
|
||||
--line:#dbe3ea; --line-soft:#e8edf3; --soft:#f5f8fa; --paper:#edf1f5;
|
||||
--board:#0e1b27; --board-2:#15263a;
|
||||
--cu:#d99a5b; --cu-deep:#b57632;
|
||||
--ok:#1e9e56; --ok-soft:#e4f7ec;
|
||||
--danger:#dd4a3a; --danger-soft:#feecea;
|
||||
--amber:#b07a10; --amber-soft:#fdf3d8;
|
||||
--cond:#5661f0; --cond-soft:#e9ebfe;
|
||||
--hero-ink:#f4f8fc; --hero-dim:rgba(226,238,248,.62);
|
||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--paper);color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
.page{margin:0;padding:0}
|
||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0}
|
||||
.mono{font-family:var(--mono)}
|
||||
i{font-style:normal}
|
||||
|
||||
/* ================= 题头:电路板夜景 hero ================= */
|
||||
.card-head{
|
||||
position:relative;display:flex;flex-direction:column;gap:12px;padding:20px 22px 22px;color:var(--hero-ink);
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,.03) 0 1px, transparent 1px 28px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 28px),
|
||||
radial-gradient(900px 340px at 18% -40%, var(--hero-tint), transparent 62%),
|
||||
linear-gradient(158deg, #13212f 0%, var(--board) 56%, #0f1d2b 100%);
|
||||
}
|
||||
.hero-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.hero-sys{flex:1 1 auto;min-width:0;font-size:10.5px;font-weight:800;letter-spacing:.17em;color:var(--hero-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.hero-chips{flex:none;display:flex;gap:6px}
|
||||
.hero-tag{font-size:11px;font-weight:800;padding:2px 11px;border-radius:999px;color:#fff;background:var(--acc);box-shadow:0 4px 14px var(--glow-line);white-space:nowrap}
|
||||
.hero-step{font-size:11px;font-weight:700;padding:2px 11px;border-radius:999px;color:var(--hero-dim);border:1px solid rgba(255,255,255,.22);white-space:nowrap}
|
||||
.hero-title{margin:0;font-size:23px;font-weight:800;line-height:1.25;letter-spacing:.01em;color:#fff}
|
||||
.hero-meta{font-size:11.5px;color:var(--hero-dim)}
|
||||
.hero-meta b{font-weight:700;color:#fff}
|
||||
|
||||
/* ── hero 舞台:芯片铭牌 + 信号走线 ── */
|
||||
.stage{position:relative;display:flex;align-items:stretch;justify-content:center;gap:clamp(20px,7vw,72px);padding:14px 6px 6px}
|
||||
.stage::before{content:"";position:absolute;left:5%;right:5%;top:52%;border-top:2px dashed var(--glow-line)}
|
||||
.stage::after{content:"";position:absolute;top:52%;left:5%;width:9px;height:9px;margin-top:-4px;border-radius:999px;background:var(--glow);box-shadow:0 0 14px 3px var(--glow-line),0 0 4px 1px var(--glow);animation:sig 3.4s linear infinite}
|
||||
.stage-lab{padding-bottom:8px}
|
||||
.stage-duel{padding-top:28px}
|
||||
.hchip{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:6px;flex:0 1 176px;min-width:128px;padding:13px 12px 11px;border-radius:14px;background:rgba(255,255,255,.97);color:var(--ink);box-shadow:0 14px 30px rgba(3,10,18,.5),inset 0 1px 0 rgba(255,255,255,.7)}
|
||||
.hchip.v-absent{background:rgba(255,252,246,.97);outline:2px dashed var(--cu);outline-offset:-6px}
|
||||
.hchip-role{font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--ink-4)}
|
||||
.hchip-pn{font-size:14.5px;font-weight:800;color:var(--ink);overflow-wrap:anywhere;text-align:center;line-height:1.3}
|
||||
.hchip-badges{display:flex;gap:6px;flex-wrap:wrap;justify-content:center}
|
||||
.csvg{width:86px;height:auto;display:block}
|
||||
.c-body{fill:var(--board-2);stroke:rgba(217,154,91,.55);stroke-width:1.4}
|
||||
.c-pin{fill:var(--cu)}
|
||||
.c-die{fill:var(--acc);opacity:.9;animation:breathe 2.8s ease-in-out infinite}
|
||||
.c-dot{fill:rgba(255,255,255,.85)}
|
||||
.duel-type{position:absolute;z-index:1;left:50%;top:-4px;transform:translateX(-50%);font-size:10.5px;font-weight:800;letter-spacing:.06em;color:var(--hero-dim);border:1px solid rgba(255,255,255,.2);border-radius:999px;padding:2px 12px;background:rgba(10,20,30,.55);white-space:nowrap}
|
||||
.duel-type i{font-style:normal;color:#fff}
|
||||
.hero-verdict{display:flex;justify-content:center;padding:2px 0 0}
|
||||
|
||||
/* ── hero 落账席(件③):印章 + 一句话结论 ── */
|
||||
.stage-seal{position:relative;display:flex;align-items:center;justify-content:center;gap:22px;padding:12px 4px 4px;flex-wrap:wrap}
|
||||
.seal{position:relative;flex:none;width:112px;height:112px;border-radius:999px;display:grid;place-items:center}
|
||||
.seal::before{content:"";position:absolute;inset:0;border:1.6px dashed rgba(255,255,255,.34);border-radius:999px;animation:sealspin 16s linear infinite}
|
||||
.seal-core{display:grid;place-items:center;padding:7px 11px;border-radius:999px;background:rgba(255,255,255,.97);box-shadow:0 12px 26px rgba(3,10,18,.5)}
|
||||
/*
|
||||
* **印章里的徽章按圈的内径收**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」——
|
||||
* 浏览器里量的账:seal-core 139px 宽,而印章圈只有 112px,**溢出 26px**,
|
||||
* 右边那句结论从 603px 起,正好被压住)。
|
||||
* 病根是徽章按自己的字宽撑开、又带着 white-space:nowrap 缩不了,而印章圈是定死的圆。
|
||||
* 治法两条一起:核心不许超过圈的内径;圈里的徽章字号收小、允许折行(圆里两行照样是圆)。
|
||||
*/
|
||||
.seal-core{max-width:calc(100% - 16px);padding:6px 8px}
|
||||
.seal .rec.rec-lg{font-size:12px;letter-spacing:0;padding:5px 8px;white-space:normal;text-align:center;line-height:1.3}
|
||||
.seal-sum{flex:1 1 240px;min-width:200px;max-width:420px;margin:0;font-size:13px;line-height:1.85;color:var(--hero-dim)}
|
||||
.seal-sum:empty::after{content:"—"}
|
||||
|
||||
/* ── 浅色题头变体(件① 2026-08-31 用户:「标题背景还是改成之前的那种蓝色渐变」——
|
||||
渐变取自原目标件 card-head 那一条,深色电路夜景只留给验收两件)── */
|
||||
.hd-light .card-head{background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%);color:var(--ink);border-bottom:2px solid var(--ink)}
|
||||
.hd-light .hero-sys{color:var(--ink-4)}
|
||||
.hd-light .hero-title{color:var(--ink)}
|
||||
.hd-light .hero-step{color:var(--ink-3);border-color:var(--line)}
|
||||
.hd-light .hero-meta{color:var(--ink-3)}
|
||||
.hd-light .hero-meta b{color:var(--ink)}
|
||||
|
||||
/* ================= 正文分区 ================= */
|
||||
.card-bd{padding:20px 22px 8px}
|
||||
.sec{display:flex;align-items:baseline;gap:9px;margin:26px 0 10px;flex-wrap:wrap}
|
||||
.sec:first-child{margin-top:4px}
|
||||
.sec-pin{flex:none;align-self:center;width:24px;height:24px;border-radius:8px;display:grid;place-items:center;background:var(--acc-soft);color:var(--acc-deep)}
|
||||
.zic{width:13px;height:13px;display:block}
|
||||
.sec-t{font-size:13.5px;font-weight:800;color:var(--ink);letter-spacing:.02em}
|
||||
.sec-d{flex:1 1 200px;min-width:0;font-size:11.5px;color:var(--ink-3)}
|
||||
|
||||
/* 简报卡(做什么 / 纪律…) */
|
||||
.brief{border:1px solid var(--line-soft);border-left:3px solid var(--acc);border-radius:10px;background:var(--acc-wash);padding:13px 16px;margin:0 0 4px}
|
||||
.brief p{margin:0 0 7px;font-size:12.5px;line-height:1.75;color:var(--ink-2)}
|
||||
.brief p:last-child{margin-bottom:0}
|
||||
.brief b{color:var(--ink)}
|
||||
.done,.need{display:inline-block;font-size:10.5px;font-weight:800;padding:0 8px;border-radius:999px;vertical-align:1px;margin-right:2px}
|
||||
.done{color:var(--ok);background:var(--ok-soft)}
|
||||
.need{color:var(--acc-deep);background:var(--acc-soft)}
|
||||
|
||||
/* 收尾指引 */
|
||||
.flow{margin:24px 0 14px;padding:12px 16px;border-radius:10px;border:1px dashed var(--acc-line);background:var(--acc-wash);font-size:12.5px;color:var(--ink-2);line-height:1.8}
|
||||
.flow b{color:var(--acc-deep)}
|
||||
|
||||
/* 页脚:丝印条 */
|
||||
.card-foot{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 22px;border-top:1px solid var(--line-soft);background:var(--soft);font-size:11px;color:var(--ink-4);flex-wrap:wrap}
|
||||
.foot-r{letter-spacing:.04em}
|
||||
|
||||
/* ================= 顶部「还差什么」红带(机制:显隐认 data-blank 的空文本节点) ================= */
|
||||
.jc-gate-bar{display:flex;flex-direction:column;gap:5px;margin:0 0 16px;padding:11px 14px;border-radius:10px;background:var(--danger-soft);border:1px solid #f5c0ba;color:var(--danger);font-size:12.5px;line-height:1.7}
|
||||
.jc-gate-bar b{font-weight:800}
|
||||
.jc-gate-bar:has(.jc-gate-text:empty){display:none}
|
||||
/* 都齐了就整条收起(2026-08-31 用户定)—— 开关是案卷真数据 gate_state(v-clear),
|
||||
不看那句话空不空:gate_reason 清空时按列归型不落笔,旧话会一直留在草稿里 */
|
||||
.jc-gate-bar.v-clear{display:none}
|
||||
/* 逐块的缺项点名并进这一条(2026-08-31 用户:「页面上所有的问题都合并到最上面的还差什么」)——
|
||||
与提交闸同源的基座读数,数值为 0 的那一行自己消失,只在待处理态出现 */
|
||||
.jc-gate-sub{display:none;font-size:12px;color:#a03d34;padding-left:2px}
|
||||
html.editable .jc-gate-sub{display:block}
|
||||
html.editable .jc-gate-sub.v-0{display:none}
|
||||
|
||||
/* 缺项红带 —— 与提交闸同源的必填提示,只在待处理态出现 */
|
||||
.jc-gapbar{display:none}
|
||||
html.editable .jc-gapbar{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 13px;border-radius:9px;background:var(--danger-soft);border:1px solid #f5c0ba;color:var(--danger);font-size:12px;line-height:1.7}
|
||||
.jc-gapbar b{font-weight:800}
|
||||
.jc-gapbar span{color:#a03d34}
|
||||
html.editable .jc-gapbar.v-0{display:none}
|
||||
|
||||
/* ================= 编辑闸与加行(只在待处理态露面) ================= */
|
||||
html:not(.editable) .jc-editgate, html:not(.editable) .jc-addrow { display: none !important; }
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;border-radius:10px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
|
||||
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
||||
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
||||
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:999px;padding:6px 15px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s,box-shadow .16s}
|
||||
.jc-egbtn:hover{border-color:var(--acc);color:var(--acc-deep);box-shadow:0 0 0 3px var(--acc-soft)}
|
||||
.jc-egbtn:active{transform:scale(.96)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--acc);border-color:var(--acc)}
|
||||
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
||||
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
||||
.jc-editgate.on{background:var(--acc-wash);border-color:var(--acc-line);color:var(--acc-deep)}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
.jc-addrow{display:none;margin:8px 0 0}
|
||||
.jc-tbl-wrap:has(table.on) + .jc-addrow { display: flex; align-items: center; gap: 8px; }
|
||||
.jc-addrow button{cursor:pointer;border:1px dashed var(--acc-line);border-radius:999px;background:var(--acc-wash);color:var(--acc-deep);font:inherit;font-size:12px;font-weight:700;padding:6px 15px;transition:border-color .16s,box-shadow .16s}
|
||||
.jc-addrow button:hover{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft)}
|
||||
.jc-addrow button:active{transform:scale(.96)}
|
||||
.jc-addrow em{font-style:normal;font-size:11px;color:var(--ink-4)}
|
||||
|
||||
/* ================= 报告表格 ================= */
|
||||
.jc-tbl-wrap{border:1px solid var(--line-soft);border-radius:12px;background:#fff;overflow-x:auto;overflow-y:hidden}
|
||||
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
thead th{position:static;text-align:left;font-size:10.5px;font-weight:800;letter-spacing:.07em;color:var(--ink-4);background:var(--soft);padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||
th.req::after{content:" *";color:var(--danger);font-size:10px}
|
||||
tbody td{padding:10px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top;color:var(--ink-2)}
|
||||
tbody tr:last-child td{border-bottom:0}
|
||||
tbody tr:hover td{background:var(--acc-wash)}
|
||||
td.rn,td.num{font-family:var(--mono);font-size:11px;color:var(--ink-4);white-space:nowrap}
|
||||
td.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
td.cust{font-weight:700;color:var(--ink)}
|
||||
td.desc,.desc{color:var(--ink-3);font-size:12px;line-height:1.6}
|
||||
|
||||
/* 序号列交给 CSS 计数器(沙箱零脚本,增删行后自己数对;两种列名、两种包装类都接) */
|
||||
.jc-tbl-wrap table, .tbl-wrap table { counter-reset: rn; }
|
||||
.jc-tbl-wrap table td.rn, .tbl-wrap table td.rn { counter-increment: rn; }
|
||||
.jc-tbl-wrap table td.rn .jc-rn-num::before, .tbl-wrap table td.rn .jc-rn-num::before { content: counter(rn); }
|
||||
.jc-tbl-wrap table td.num, .tbl-wrap table td.num { counter-increment: rn; }
|
||||
.jc-tbl-wrap table td.num::before, .tbl-wrap table td.num::before { content: counter(rn); }
|
||||
|
||||
/* ── 看与改是两件事:只读份 / 输入孪生件由 .on 二选一 ── */
|
||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.55;overflow-wrap:anywhere}
|
||||
.jc-vcell.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.jc-vcell:empty::after{content:"—";color:var(--ink-4)}
|
||||
.jc-cell{display:none;width:100%;min-width:72px;border:1px solid var(--line);border-radius:8px;padding:6px 9px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.jc-cell:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft);outline:none}
|
||||
.jc-cell.mono{font-family:var(--mono);font-size:12px}
|
||||
input.jc-cell, select.jc-cell { height: 32px; box-sizing: border-box; padding-top: 0; padding-bottom: 0; vertical-align: middle; }
|
||||
table.on td>.jc-vcell{display:none}
|
||||
table.on td>.jc-vcell.ro{display:block}
|
||||
table.on td>.jc-cell{display:block}
|
||||
table.on td>.jc-vcell:empty::after{content:none}
|
||||
|
||||
/* 增删行的痕(取消还原三件套的可视面) */
|
||||
table.on tr.jc-row-new td { background: #f0fbf5; }
|
||||
table.on tr.jc-row-gone td { background: #fff6f5; opacity: .55; }
|
||||
table.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||
.jc-rowdel{display:none;cursor:pointer;border:1px solid var(--line);border-radius:999px;background:#fff;color:var(--ink-3);width:20px;height:20px;line-height:1;font-size:12px;padding:0;margin-left:5px;vertical-align:middle}
|
||||
table.on .jc-rowdel{display:inline-grid;place-items:center}
|
||||
.jc-rowdel:hover{border-color:var(--danger);color:var(--danger)}
|
||||
table.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||
|
||||
/* 静态种子行:零行也加得出第一行 —— 只在编辑态露面(读态看见空壳子是界面撒谎) */
|
||||
table:not(.on) tr.jc-row-static { display: none; }
|
||||
table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed var(--acc-line)}
|
||||
|
||||
/* 主键钥匙格:不给人看,但真挂 data-col(漏了钥匙,这一行凑不出主键) */
|
||||
.rowkey{display:none}
|
||||
|
||||
/* ================= 徽标基座(取值上色一律 data-class-of 投影,选择器从值算) ================= */
|
||||
.stx,.ct,.rl,.st,.risk-badge{display:inline-block;font-size:11px;font-weight:700;padding:1px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.rl{background:var(--soft);border-color:var(--line-soft);color:var(--ink-3);font-size:10.5px;letter-spacing:.03em}
|
||||
.rec{display:inline-block;font-size:11.5px;font-weight:800;padding:2px 13px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.rec.rec-lg{font-size:14.5px;padding:7px 20px;letter-spacing:.03em}
|
||||
|
||||
/* kv 行(收口结论 / 待验项共用的键值版式) */
|
||||
.kv{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--line-soft);font-size:12.5px}
|
||||
.kv:last-child{border-bottom:0}
|
||||
.kv i{flex:0 0 84px;font-style:normal;color:var(--ink-4);font-size:11px;font-weight:700;letter-spacing:.05em;padding-top:2px}
|
||||
.kv > span{flex:1 1 auto;color:var(--ink-2);line-height:1.7;overflow-wrap:anywhere}
|
||||
.kv > span:empty::after{content:"—";color:var(--ink-4)}
|
||||
.kv-box{border:1px solid var(--line-soft);border-radius:12px;background:var(--soft);padding:4px 16px;margin-bottom:4px}
|
||||
|
||||
/* ================= 动画(全部 transform / opacity;reduced-motion 全静) ================= */
|
||||
@keyframes sig{0%{transform:translateX(0);opacity:0}7%{opacity:1}93%{opacity:1}100%{transform:translateX(min(86vw,760px));opacity:0}}
|
||||
@keyframes breathe{0%,100%{opacity:.55}50%{opacity:1}}
|
||||
@keyframes sealspin{to{transform:rotate(360deg)}}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||
.hero-top{animation:rise .5s cubic-bezier(.2,.7,.3,1) both}
|
||||
.hero-title{animation:rise .5s .06s cubic-bezier(.2,.7,.3,1) both}
|
||||
.stage,.stage-seal{animation:rise .55s .14s cubic-bezier(.2,.7,.3,1) both}
|
||||
.hero-verdict,.hero-meta{animation:rise .55s .22s cubic-bezier(.2,.7,.3,1) both}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*{animation:none !important;transition:none !important}
|
||||
}
|
||||
|
||||
/* ================= 电路蓝图工作台(件① 的两张档案卡)=================
|
||||
2026-08-31 用户:「卡片下面那个容器黑色和页面背景不搭」—— 深色板底退场,换**浅色蓝图**:
|
||||
底是强调色的极浅晕染 + 淡格线,走线与信号点用强调色 —— 电路语汇留着,纸面色阶不断裂。 */
|
||||
.board{position:relative;margin:0 0 6px;padding:18px 14px 16px;border-radius:16px;border:1px solid var(--acc-line);
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
|
||||
repeating-linear-gradient(90deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
|
||||
linear-gradient(158deg, var(--acc-wash) 0%, #fff 62%, var(--acc-wash) 100%)}
|
||||
.board::before{content:"";position:absolute;left:4%;right:4%;top:50%;border-top:2px dashed var(--acc-line)}
|
||||
/* 「可替换」动画(2026-08-31 用户第二稿:「不要做到背景板上,背景板会被卡片覆盖;
|
||||
和卡片做到一级,可以压缩一点卡片的大小」)—— .dossiers 改三列网格,中间留一条
|
||||
**实体动画道**(卡片各占左右列、相应收窄,谁也盖不住它):中央 ⇄ 徽轻呼吸,
|
||||
两枚信号点绕徽**轨道对转**(此上彼下地绕圈 = 两颗料换位),底下横穿的虚线总线
|
||||
在道里露出来把两张卡接起来。 */
|
||||
.swapfx{position:absolute;left:50%;top:50%;width:76px;height:76px;transform:translate(-50%,-50%);z-index:1;pointer-events:none}
|
||||
.sw-ic{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:36px;height:36px;padding:8px;box-sizing:border-box;color:var(--acc);background:#fff;border:1px solid var(--acc-line);border-radius:999px;box-shadow:0 4px 12px rgba(18,38,63,.16);animation:swbreathe 2.8s ease-in-out infinite}
|
||||
.sw-orbit{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;animation:sworbit 4.5s linear infinite}
|
||||
.sw-dot{position:absolute;left:50%;width:8px;height:8px;margin-left:-4px;border-radius:999px;background:var(--acc);box-shadow:0 0 8px 2px var(--acc-soft)}
|
||||
.sw-d1{top:-4px}
|
||||
.sw-d2{bottom:-4px}
|
||||
@keyframes sworbit{to{transform:rotate(360deg)}}
|
||||
@keyframes swbreathe{0%,100%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.1)}}
|
||||
/* 三列网格:左右两列给卡,中间 76px 是动画道 —— 与卡同级的真实空间,不被覆盖 */
|
||||
.dossiers{position:relative;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) 76px minmax(0,1fr);row-gap:16px}
|
||||
.dossiers > .dossier:nth-of-type(odd){grid-column:1}
|
||||
.dossiers > .dossier:nth-of-type(even){grid-column:3}
|
||||
.dossier{display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);transition:box-shadow .18s,transform .18s}
|
||||
.dossier:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(18,38,63,.13)}
|
||||
.dz-top{display:flex;align-items:center;gap:12px}
|
||||
.dz-top .csvg{width:64px;flex:none}
|
||||
.dz-id{display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||
.dz-role{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.dz-pn{font-size:15px;font-weight:800;overflow-wrap:anywhere;line-height:1.3}
|
||||
.dz-pn .jc-vcell{font-size:15px;font-weight:800;color:var(--ink)}
|
||||
.dz-type{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:8px 10px;border-radius:10px;background:var(--acc-wash);border:1px solid var(--acc-line)}
|
||||
.dz-lb{flex:none;font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.05em;color:var(--ink-4)}
|
||||
.dz-type .jc-cell{flex:1 1 140px;width:auto}
|
||||
/* 类型名守卫:这一行的下拉真有选中项(selectCurrent 按行作用域写的 option[selected])才摆名 ——
|
||||
行里没选,data-by 落回外层的名一个字都不摆,换「待选择」胶囊。
|
||||
守卫挂在共同祖先 .dz-type 上、单层 :has —— :has() 里再嵌 :has() 是非法选择器,整条会被浏览器
|
||||
静默丢弃(第一版就栽在这:守卫写了等于没写,XL 那张卡照样摆出它没有的类型)。 */
|
||||
.tsel{flex:1 1 auto;min-width:0}
|
||||
.tsel > span > i{display:inline-block;font-size:11.5px;font-weight:800;color:var(--acc-deep);background:var(--acc-soft);border-radius:999px;padding:1px 11px}
|
||||
.dz-type:not(:has(option[selected])) .tsel > span{display:none}
|
||||
.dz-type:not(:has(option[selected])) .tsel::after{content:"待选择 · 点「编辑」挑一个";display:inline-block;font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px}
|
||||
.dz-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px 12px}
|
||||
.fcell{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.fcell.wide{grid-column:1 / -1}
|
||||
.fcell > i{font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.05em;color:var(--ink-4)}
|
||||
.fcell.req > i::after{content:" *";color:var(--danger)}
|
||||
.fcell .jc-vcell{font-size:12.5px}
|
||||
/* 三级占位:必填红 / 建议橘(空格子直说要不要填,不摆一枚含糊的破折号) */
|
||||
.fcell.req .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
|
||||
/* 徽标包装的必填格(如生命周期):没值时徽标 class 恰为裸 "stx"(class-of 没投出 v-*),
|
||||
wrapper 不为 :empty,上一条够不着 —— 认裸 class 补同一枚胶囊(单层 :has) */
|
||||
.fcell.req .jc-vcell:has(> [class="stx"])::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
|
||||
.fcell.sug .jc-vcell:empty::after{content:"建议";font-size:10.5px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 9px}
|
||||
/* 档案卡的编辑态 —— **按卡开,只点亮这张卡的正面**(2026-08-31 用户:「各自编辑各自的部分,
|
||||
不要做联动」:.on 由 scopedTargets 落在被点那张卡的行 section 上,不再落整个宿主);
|
||||
只圈 .front 是因为卡背住着另一地址的宿主,不圈的话参数输入会被主数据的编辑顺手点亮 */
|
||||
.dossier.on .front .jc-vcell{display:none}
|
||||
.dossier.on .front .jc-vcell.ro{display:block}
|
||||
.dossier.on .front .jc-cell{display:block}
|
||||
.dossier.on .front .jc-vcell:empty::after{content:none}
|
||||
.dossier.on .dz-role{display:flex}
|
||||
|
||||
/* ── 翻面(纯 CSS:checkbox + :has,零脚本;reduced-motion 下瞬时切换)── */
|
||||
.dossier{position:relative;perspective:1200px;padding:0;background:none;border:0;box-shadow:none}
|
||||
.dossier:hover{transform:none;box-shadow:none}
|
||||
.flip-in{display:grid;transform-style:preserve-3d;transition:transform .55s cubic-bezier(.3,.7,.3,1)}
|
||||
.face{grid-area:1 / 1;display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);backface-visibility:hidden;min-width:0}
|
||||
.face.back{transform:rotateY(180deg)}
|
||||
.flip-tg{position:absolute;opacity:0;pointer-events:none}
|
||||
.flip-hit{position:absolute;inset:0;z-index:3;cursor:pointer}
|
||||
.flip-cue{position:absolute;z-index:4;bottom:10px;right:10px;pointer-events:none;opacity:.6;transition:opacity .16s,transform .16s}
|
||||
.flip-cue i{font-style:normal;display:inline-block;font-size:10.5px;font-weight:800;color:var(--acc-deep);background:var(--acc-soft);border:1px solid var(--acc-line);border-radius:999px;padding:2px 10px}
|
||||
.dossier:hover .flip-cue{opacity:1;transform:translateY(-2px)}
|
||||
/* 两句提示按面切换 —— 选择器都写到 .flip-cue i 之下的同层级,别被基座那条特异度压回来(踩过) */
|
||||
.flip-cue .cue-back{display:none}
|
||||
/* 翻转三态,后写的赢:人点的 < 主数据编辑锁正面 < 参数编辑翻背面 */
|
||||
.dossier:has(.flip-tg:checked) .flip-in{transform:rotateY(180deg)}
|
||||
.dossier:has(.flip-tg:checked) .flip-cue .cue-front{display:none}
|
||||
.dossier:has(.flip-tg:checked) .flip-cue .cue-back{display:inline-block}
|
||||
.dossier.on .flip-in{transform:rotateY(0deg)}
|
||||
.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)}
|
||||
/* 编辑中的**这张卡**不许翻(哪面在编辑锁哪面),点击层与提示丸一起收;另一张卡不受影响 */
|
||||
.dossier.on .flip-hit,.dossier:has(.pback.on) .flip-hit{display:none}
|
||||
.dossier.on .flip-cue,.dossier:has(.pback.on) .flip-cue{display:none}
|
||||
/* 悬浮侧倾:预告「这张卡点了会翻」—— 只在读态、只在真有悬浮的设备、且不嫌动画的人那里 */
|
||||
@media (hover:hover) and (prefers-reduced-motion: no-preference){
|
||||
.dossier:hover:not(.on):not(:has(.flip-tg:checked)):not(:has(.pback.on)) .flip-in{transform:rotateY(-12deg)}
|
||||
.dossier:hover:not(.on):has(.flip-tg:checked):not(:has(.pback.on)) .flip-in{transform:rotateY(168deg)}
|
||||
}
|
||||
|
||||
/* ── 卡角编辑钮:笔=编辑当前这一面,软盘=保存,叉=放弃(2026-08-31 用户定,顶掉两行编辑闸)──
|
||||
笔只在待处理态露面(html.editable,与从前的闸同一条纪律);软盘/叉只在对应面的编辑态露面。 */
|
||||
.fabs{position:absolute;top:10px;right:10px;z-index:5;display:flex;gap:6px}
|
||||
.fab{display:none;width:32px;height:32px;border-radius:999px;border:1px solid var(--acc-line);background:#fff;color:var(--acc-deep);cursor:pointer;place-items:center;padding:0;box-shadow:0 4px 12px rgba(18,38,63,.14);transition:border-color .16s,color .16s,box-shadow .16s}
|
||||
.fab:hover{border-color:var(--acc);box-shadow:0 6px 16px rgba(18,38,63,.22)}
|
||||
.fab:active{transform:scale(.94)}
|
||||
.fic{width:15px;height:15px;display:block;margin:auto}
|
||||
.fab.pen{color:var(--acc-deep)}
|
||||
.fab.disk{background:var(--acc);border-color:var(--acc);color:#fff}
|
||||
.fab.xbtn{color:var(--ink-3)}
|
||||
/* 笔:这张卡当下这一面、且这张卡没在编辑 —— 各卡各开,互不联动 */
|
||||
html.editable .dossier:not(.on):not(:has(.pback.on)):not(:has(.flip-tg:checked)) .pen-f{display:grid}
|
||||
html.editable .dossier:not(.on):not(:has(.pback.on)):has(.flip-tg:checked) .pen-b{display:grid}
|
||||
.dossier.on .disk-f{display:grid}
|
||||
.dossier.on .x-f{display:grid}
|
||||
.dossier:has(.pback.on) .disk-b{display:grid}
|
||||
.dossier:has(.pback.on) .x-b{display:grid}
|
||||
|
||||
/* ── 卡背:参数瓷贴 ── */
|
||||
.pb-hd{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap}
|
||||
.pb-hd b{font-size:14px;font-weight:800;color:var(--ink)}
|
||||
.pb-lb{font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--ink-4)}
|
||||
.pback{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.ptile{display:flex;flex-direction:column;gap:2px;border:1px solid var(--line-soft);border-radius:10px;background:var(--soft);padding:8px 10px;min-width:0}
|
||||
.pt-hd{display:flex;align-items:baseline;gap:6px;min-width:0}
|
||||
.pt-hd .jc-vcell{font-size:11px;font-weight:800;color:var(--ink-3)}
|
||||
.pt-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)}
|
||||
.pt-val .jc-vcell{font-size:14px;font-weight:800;color:var(--ink)}
|
||||
.pt-note .jc-vcell{font-size:10.5px;color:var(--ink-3)}
|
||||
/* 三级占位:值必填红 / 说明建议橘 */
|
||||
.ptile .pv .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
|
||||
/* 说明空着**什么都不摆**(2026-08-31 用户:「没写就什么都不要展示」)—— 连兜底破折号一起收 */
|
||||
.ptile .sg .jc-vcell:empty{display:none}
|
||||
/* 参数编辑态:值与说明换输入,参数名照旧当标签摆着;两枚主键孪生件藏死(钥匙不是编辑面)。
|
||||
藏钥匙用包装类(.pt-name / .rowkey),不用「data-col 属性选择器」—— 那段选择器文本会原样
|
||||
出现在共享样式表里,把「落账确认全只读」那类字面断言踩翻(2026-08-31 踩过,连注释也不许写)。 */
|
||||
.pback.on .pv > .jc-vcell,.pback.on .sg > .jc-vcell{display:none}
|
||||
.pback.on .pv > .jc-cell,.pback.on .sg > .jc-cell{display:block}
|
||||
.pback .pt-name .jc-cell{display:none}
|
||||
.pt-name{display:inline-flex;min-width:0}
|
||||
|
||||
/* ================= 比对基准卡(类型 + 字段合一,只读、跟手)================= */
|
||||
.basis{display:block;margin:0 0 10px}
|
||||
.basis:empty{border:1.5px dashed var(--line);border-radius:12px;padding:16px;display:block}
|
||||
.basis:empty::after{content:"还没定比对基准 —— 先在上面任一张档案卡上挑一个芯片类型,或展开下面的抽屉新建一个。";font-size:12.5px;color:var(--ink-3)}
|
||||
.basis-in{display:flex;flex-direction:column;gap:10px;border:1px solid var(--acc-line);border-radius:12px;background:var(--acc-wash);padding:12px 14px 14px}
|
||||
.basis-hd{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.basis-lb{flex:none;font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--acc-deep)}
|
||||
.basis-chip b{display:inline-block;font-size:13px;font-weight:800;color:#fff;background:var(--acc);border-radius:999px;padding:2px 14px;margin-right:8px}
|
||||
.basis-desc{font-style:normal;font-size:11.5px;color:var(--ink-3)}
|
||||
.basis-hd:has(.basis-chip:empty)::after{content:"待选择";font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px}
|
||||
.basis-hd .csvg{width:56px;flex:none}
|
||||
/* 类型图:层叠双封装 —— 后面那枚是「同类的别的芯片」,淡出成剪影 */
|
||||
.basis-hd .tsvg{width:60px;flex:none}
|
||||
.tsvg{display:block}
|
||||
.t-ghost{opacity:.32}
|
||||
.basis-id{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1 1 auto}
|
||||
.basis-in:has(.ftiles:empty) .ftiles{display:none}
|
||||
/* 降噪(2026-08-31 用户:「乱糟糟…没必要显示全就不要显示完全,鼠标指上去后再显示」):
|
||||
字段收成一排丸(名 + 硬/软徽 + 单位),说明藏进悬浮气泡 —— 指上去才现身 */
|
||||
.ftiles{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.ftile{position:relative;display:flex;flex-direction:row;align-items:center;gap:7px;border:1px solid var(--line-soft);border-radius:999px;background:#fff;padding:6px 13px;min-width:0;transition:border-color .16s,box-shadow .16s}
|
||||
.ftile:hover{border-color:var(--acc-line);box-shadow:0 4px 12px rgba(18,38,63,.1)}
|
||||
.ft-hd{display:flex;align-items:baseline;gap:7px;white-space:nowrap}
|
||||
.ft-hd b{font-size:12px;font-weight:800;color:var(--ink)}
|
||||
.ft-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)}
|
||||
.ft-desc{display:none;position:absolute;z-index:6;top:calc(100% + 7px);left:0;width:max-content;max-width:280px;margin:0;font-size:11px;line-height:1.65;color:var(--ink-2);background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 11px;box-shadow:0 10px 24px rgba(18,38,63,.16)}
|
||||
.ftile:hover .ft-desc:not(:empty){display:block}
|
||||
/* 类型说明:默认一行截断,悬浮题头才放开 */
|
||||
.basis-desc{display:block;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.basis-hd:hover .basis-desc{white-space:normal}
|
||||
|
||||
/* ================= 新建类型工作抽屉(details,零脚本)================= */
|
||||
.lab{margin:0 0 6px;border:1.5px dashed var(--line);border-radius:12px;background:var(--soft)}
|
||||
.lab[open]{border-style:solid;background:#fff}
|
||||
.lab-sm{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;padding:11px 14px;font-size:12.5px;color:var(--ink-3);user-select:none}
|
||||
.lab-sm::-webkit-details-marker{display:none}
|
||||
.lab-sm b{color:var(--ink-2)}
|
||||
.lab-sm:hover{color:var(--acc-deep)}
|
||||
.lab-caret{margin-left:auto;flex:none;width:8px;height:8px;border-right:2px solid var(--ink-4);border-bottom:2px solid var(--ink-4);transform:rotate(45deg);transition:transform .16s}
|
||||
.lab[open] .lab-caret{transform:rotate(-135deg)}
|
||||
.lab-bd{padding:2px 14px 14px}
|
||||
.lab-brief{margin:0 0 16px}
|
||||
.lab-brief .brief{margin:2px 14px 14px;border-left-width:3px}
|
||||
|
||||
/* ================= 窄屏 ================= */
|
||||
@media(max-width:620px){
|
||||
.card-head{padding:16px 14px 18px}
|
||||
.dz-grid{grid-template-columns:1fr}
|
||||
.swapfx{display:none}
|
||||
.dossiers{grid-template-columns:1fr}
|
||||
.dossiers > .dossier:nth-of-type(odd),.dossiers > .dossier:nth-of-type(even){grid-column:1}
|
||||
.pback{grid-template-columns:1fr}
|
||||
.ftiles{grid-template-columns:1fr}
|
||||
.card-bd{padding:16px 14px 6px}
|
||||
.hero-title{font-size:19.5px}
|
||||
.stage{gap:14px}
|
||||
.hchip{min-width:108px;padding:10px 8px 9px}
|
||||
.csvg{width:68px}
|
||||
.kv i{flex-basis:72px}
|
||||
.card-foot{padding:10px 14px}
|
||||
}
|
||||
`;
|
||||
@@ -117,6 +117,15 @@ export type HumanToolService = {
|
||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||
/**
|
||||
* **这一单正等人交回的卡**(2026-08-27):没办结的工单 + 没作答、没被顶替的对话卡。
|
||||
* 引擎每拍开头问它 —— 有卡还没答,这一拍就不该问模型(叫了人就等人,不许自己往下推)。
|
||||
*/
|
||||
pendingCards(taskId: TaskId): Promise<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
kind: 'work-order' | 'chat-card';
|
||||
}>>;
|
||||
/** org 停用前置检查要问的「待答卡」 */
|
||||
openCardsOf(staffId: StaffId): Promise<Array<{
|
||||
cardId: string;
|
||||
|
||||
@@ -26,6 +26,8 @@ export type CardStore = {
|
||||
recipientId: StaffId;
|
||||
} | undefined>;
|
||||
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||
/** 还开着的对话卡(没被新卡顶替、没作废)—— 「这一单有没有卡在等人」问的是它,不是全部历史 */
|
||||
listOpenChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||
updateChatCard(card: ChatCard): Promise<void>;
|
||||
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
||||
/** 调人次数 —— 打扰率与预算的计数面 */
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor
|
||||
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
||||
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
|
||||
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
|
||||
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||
|
||||
+31
-3
@@ -48,9 +48,29 @@ export type DraftWrite = {
|
||||
readonly patch: LedgerRow;
|
||||
readonly tombstone?: boolean;
|
||||
};
|
||||
/**
|
||||
* **放行写的范围**(2026-08-27,数据工单确认即入库)。
|
||||
*
|
||||
* 不给 = 整单放行:草稿层全部并进正式表、清空。
|
||||
* 给了 = 只并这几张表 —— 它们的门不是放行卡,是**数据工单的确认**(那张卡的提交闸
|
||||
* 盯的就是这几张表:必填齐了才按得下确认,确认那一刻就入库)。并过的行**留在草稿层、
|
||||
* 打上已落地记号**:它仍是本单写下的行(任务视图 / `mine:` / `_rows` 照旧认它),
|
||||
* 只是整单放行时不再写它、不再为它抛事件。
|
||||
* `cause` 落进数据事件的归因(`ledger-write:<cause>`),回看时认得出是哪张卡签下去的。
|
||||
*/
|
||||
export type ReleaseScope = {
|
||||
readonly only: readonly ObjectAddress[];
|
||||
readonly cause: string;
|
||||
};
|
||||
export type ReleaseWriteResult = {
|
||||
readonly mergedRows: number;
|
||||
readonly emittedEvents: number;
|
||||
/**
|
||||
* **早已落地、这次不再写的行数**(2026-08-27)—— 数据工单确认时并进去的那几行,
|
||||
* 整单放行时只把它们当事实读回来(出口载荷要它们),不再写、不再抛事件。
|
||||
* 放行痕上要分开说:「合并 N 行」里若混着这几行,那句话就多报了。
|
||||
*/
|
||||
readonly landedEarlierRows: number;
|
||||
/**
|
||||
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
||||
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
||||
@@ -100,7 +120,13 @@ export type LedgerService = {
|
||||
address: ObjectAddress;
|
||||
key: Record<string, unknown>;
|
||||
}): Promise<boolean>;
|
||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
||||
/**
|
||||
* 放行写。`scope` 见 `ReleaseScope`:不给 = 整单;给了 = 只并那几张表(数据工单确认那一道门),
|
||||
* 并过的行留在草稿层打记号。**不是第四种写**,也不是绕门:同一条冲突检查 → 原子合并 → 抛事件的路,
|
||||
* 只是门换成了那张卡的确认闸 —— 落哪几张表由发卡的人说了算(`gateLedgers`),调用方指不了别的表进来
|
||||
* (清单没声明的表这一单本就写不进草稿层)。
|
||||
*/
|
||||
releaseWrite(taskId: TaskId, scope?: ReleaseScope): Promise<ReleaseWriteResult>;
|
||||
/** 正式版查询 —— 跨任务只见过门的事实 */
|
||||
query(query: LedgerQuery): Promise<LedgerPage>;
|
||||
/** 任务视图 = 正式版 + 本任务草稿层 */
|
||||
@@ -115,8 +141,10 @@ export type LedgerService = {
|
||||
object: string;
|
||||
key: Record<string, unknown>;
|
||||
patch: Record<string, unknown>;
|
||||
/** 已在数据工单确认那一道门上并进正式表(见 `ReleaseScope`);仍是本单写下的行 */
|
||||
landed: boolean;
|
||||
}>>;
|
||||
/** 落地预检第五项:数据冲突检查 */
|
||||
checkConflicts(taskId: TaskId): Promise<ConflictFinding[]>;
|
||||
/** 落地预检第五项:数据冲突检查;`only` 给了就只查那几张表(与 `releaseWrite` 的 scope 同一口径) */
|
||||
checkConflicts(taskId: TaskId, only?: readonly ObjectAddress[]): Promise<ConflictFinding[]>;
|
||||
discardDrafts(taskId: TaskId): Promise<void>;
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* module: ledger —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { ConflictFinding, DraftWrite, LedgerAddress, LedgerKey, LedgerPage, LedgerQuery, LedgerRow, LedgerService, MaterializedObject, MergeConflict, MergeNotice, MergedObject, ObjectAddress, ObjectDeclaration, ReleaseWriteResult, SchemaApplyResult, SystemWrite, WriteKind, } from './contract.ts';
|
||||
export type { ConflictFinding, DraftWrite, LedgerAddress, LedgerKey, LedgerPage, LedgerQuery, LedgerRow, LedgerService, MaterializedObject, MergeConflict, MergeNotice, MergedObject, ObjectAddress, ObjectDeclaration, ReleaseScope, ReleaseWriteResult, SchemaApplyResult, SystemWrite, WriteKind, } from './contract.ts';
|
||||
export { DraftConflictError, ObjectNotMaterializedError, SchemaConflictError } from './contract.ts';
|
||||
export type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from './port.ts';
|
||||
export type { LedgerServiceDeps } from './internal/usecase/ledger-service.ts';
|
||||
|
||||
@@ -77,5 +77,14 @@ export type DraftStore = {
|
||||
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
*/
|
||||
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
||||
/**
|
||||
* 给本单草稿层的一行打上「已落地」记号(2026-08-27,数据工单确认即入库)。
|
||||
*
|
||||
* 那一行刚经一道更早的门并进了正式表:条目**留着**(它仍是本单写下的行,
|
||||
* 任务视图 / `mine:` / `_rows` 照旧认它),只是放行时不再写它。
|
||||
* `baseline` 换成落地后正式表里的值 —— 之后再往这一行写一笔(记号随之擦掉),
|
||||
* 冲突检查比的才是「我改之前表里是什么」,而不是落地前那份早已过时的基线。
|
||||
*/
|
||||
markLanded(taskId: string, ref: ObjectRef, key: LedgerKey, baseline: LedgerRow): Promise<void>;
|
||||
clear(taskId: string): Promise<void>;
|
||||
};
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
|
||||
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
|
||||
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||
export type { AgentPort, AnalyzeGateway, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||
|
||||
@@ -38,6 +38,12 @@ export type TaskDataGateway = {
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
/**
|
||||
* 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段**
|
||||
* (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看,
|
||||
* 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。
|
||||
*/
|
||||
caseKey?: string | undefined;
|
||||
fields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -56,8 +62,23 @@ export type TaskDataGateway = {
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
/**
|
||||
* **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍,
|
||||
* 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。
|
||||
* 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**,
|
||||
* 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。
|
||||
* 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。
|
||||
*/
|
||||
skipUnchanged?: boolean | undefined;
|
||||
/**
|
||||
* **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。
|
||||
* 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义,
|
||||
* 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。
|
||||
*/
|
||||
skipExisting?: boolean | undefined;
|
||||
}): Promise<{
|
||||
changed: boolean;
|
||||
skipped?: boolean;
|
||||
}>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||
@@ -71,8 +92,21 @@ export type TaskDataGateway = {
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
/**
|
||||
* **已落地的行:跳过,不拒**(2026-08-31,配方专用 —— 与 `draftWrite` 的两个旗同族)。
|
||||
* 「数据工单确认即入库」把行落进正式表后,草稿层那一笔只是「本单写过它」的记号;
|
||||
* 撤记号正式表一个字不动,读面反而少一行 —— 所以对人/模型那一路它是硬拒(拒回是
|
||||
* 行动指南)。而配方 `prune` 的语义是「陈旧的投影行清掉」:已落地 = 已经是世界的
|
||||
* 一部分,不是陈旧草稿 —— 对配方来说跳过是常态,抛错就是整单停摆(2026-08-30
|
||||
* e2e 案例 2 的死锁:人确认入库后改了类型选择,prune 想撤已落地的旧类型行,当场炸;
|
||||
* 当时以「②③ 不带 prune」绕开,代价是**没落地的**陈旧行也跟着不清了 ——
|
||||
* 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」正是这半截代价)。
|
||||
* 模型那一路不传这个旗:行为一个字不变。
|
||||
*/
|
||||
skipLanded?: boolean | undefined;
|
||||
}): Promise<{
|
||||
dropped: boolean;
|
||||
landed?: boolean;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
@@ -207,6 +241,8 @@ export type CardGateway = {
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
|
||||
itemId?: string | undefined;
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
@@ -258,9 +294,28 @@ export type CardGateway = {
|
||||
v: string;
|
||||
}>;
|
||||
allowCustom?: boolean;
|
||||
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
|
||||
itemId?: string | undefined;
|
||||
/**
|
||||
* **这张卡穿哪副面孔,引擎已经按剧本定好**(2026-08-27):模型没给 itemId、或给的项挂不上时,
|
||||
* 组装层按 itemId 查剧本就查不到,卡就退成「需要你定」—— 而这一步的剧本明明写着 `cardVariant: 'review'`。
|
||||
* 引擎知道当下在办哪一步,由它把面孔定下来递过去;组装层只在这里没给时才自己按 itemId 查。
|
||||
*/
|
||||
variant?: 'review' | undefined;
|
||||
/** 人工验收清单(剧本 `acceptanceChecklist`)—— 审核卡上一条一枚「达标 / 不达标」开关 */
|
||||
checklist?: ReadonlyArray<string> | undefined;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
/**
|
||||
* **这一单还有哪几张卡在等人交回**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
|
||||
* 每拍开头问一次:有卡没答,这一拍不问模型 —— 叫了人就等人;
|
||||
* 任何多余的唤醒(迟到的回传事件、另一台后端的推进器)都推不动它。
|
||||
*/
|
||||
unanswered(taskId: TaskId): Promise<ReadonlyArray<{
|
||||
id: string;
|
||||
title: string;
|
||||
}>>;
|
||||
};
|
||||
export type ListenerRegistrar = {
|
||||
register(input: {
|
||||
@@ -368,3 +423,25 @@ export type TaskContext = {
|
||||
*/
|
||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||
};
|
||||
/**
|
||||
* **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。
|
||||
*
|
||||
* 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账;
|
||||
* 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。
|
||||
* **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。
|
||||
*/
|
||||
export type AnalyzeGateway = {
|
||||
run(input: {
|
||||
readonly taskId: TaskId;
|
||||
/** 活动声明过的模型名 */
|
||||
readonly model: string;
|
||||
readonly instruction: string;
|
||||
/** 系统组装好的入参 —— 一行一条 */
|
||||
readonly payload: readonly unknown[];
|
||||
readonly outputSchema: Readonly<Record<string, unknown>>;
|
||||
/** 归因:`<步名>#<第几笔>` */
|
||||
readonly label: string;
|
||||
}): Promise<{
|
||||
readonly object: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。
|
||||
*/
|
||||
import type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult } from '@contracts';
|
||||
export type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, };
|
||||
export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './internal/domain/errors.ts';
|
||||
/**
|
||||
* 订阅方回调 —— 「注册回调读到邮件内容」的落点。
|
||||
* 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。
|
||||
* **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。
|
||||
*/
|
||||
export type InboundHandler = (mail: MailInboundDetail, ctx: {
|
||||
readonly accountId: string;
|
||||
readonly channelId: string | undefined;
|
||||
}) => Promise<void | {
|
||||
readonly skipped: string;
|
||||
}>;
|
||||
export type MailService = {
|
||||
listAccounts(): Promise<MailAccountView[]>;
|
||||
getAccount(accountId: string): Promise<MailAccountView>;
|
||||
createAccount(input: MailAccountInput): Promise<MailAccountView>;
|
||||
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
|
||||
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
|
||||
/** 留痕不删(入站 / 出站表对账号不设外键) */
|
||||
deleteAccount(accountId: string): Promise<void>;
|
||||
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
|
||||
probe(target: {
|
||||
accountId: string;
|
||||
} | {
|
||||
input: MailAccountInput;
|
||||
accountId?: string | undefined;
|
||||
}): Promise<MailProbeReport>;
|
||||
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
|
||||
pollDue(now: Date): Promise<MailPollOutcome[]>;
|
||||
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
|
||||
pollNow(accountId: string): Promise<MailPollOutcome>;
|
||||
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
|
||||
subscribe(name: string, handler: InboundHandler): () => void;
|
||||
listInbound(query: {
|
||||
accountId?: string | undefined;
|
||||
limit?: number | undefined;
|
||||
}): Promise<MailInboundSummary[]>;
|
||||
getInbound(mailId: string): Promise<MailInboundDetail>;
|
||||
attachment(mailId: string, index: number): Promise<{
|
||||
name: string;
|
||||
contentType: string;
|
||||
bytes: Uint8Array;
|
||||
}>;
|
||||
/** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */
|
||||
raw(mailId: string): Promise<{
|
||||
name: string;
|
||||
bytes: Uint8Array;
|
||||
}>;
|
||||
/** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */
|
||||
redeliver(mailId: string, subscriber?: string): Promise<MailDeliveryView[]>;
|
||||
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
|
||||
send(input: MailSendInput): Promise<MailSendResult>;
|
||||
listOutbound(query: {
|
||||
accountId?: string | undefined;
|
||||
limit?: number | undefined;
|
||||
}): Promise<MailOutboundView[]>;
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { InboundHandler, MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, MailService, } from './contract.ts';
|
||||
export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './contract.ts';
|
||||
export type { AttachmentStore, ChangeNotifier, Clock, DeliveryRecord, IdGenerator, InboundRecord, InboundStore, MailAccountRecord, MailAccountStore, MailCursor, MailProvider, MimeParser, OutboundMessage, OutboundRecord, OutboundStore, ParsedMail, ProviderAccount, RawMessage, SecretBox, } from './port.ts';
|
||||
export { advanceImap, cursorSummary, imapCursor, imapNeedsReanchor, newUids, parseCursor, wecomCursor, wecomWindow, } from './internal/domain/cursor.ts';
|
||||
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts';
|
||||
export { ATTACHMENT_CAP, bodyTextOf, buildInboundRecord, detailOf, planAttachments, RAW_CAP, stripHtml, summaryOf, threadIdOf, } from './internal/domain/inbound-record.ts';
|
||||
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts';
|
||||
export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts';
|
||||
export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts';
|
||||
export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts';
|
||||
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
|
||||
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
|
||||
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
|
||||
export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
|
||||
export { createMailService } from './internal/usecase/mail-service.ts';
|
||||
export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
|
||||
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
|
||||
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。
|
||||
*/
|
||||
import type { MailAccountKind, MailProbeReport } from '@contracts';
|
||||
import type { MailCursor } from './internal/domain/cursor.ts';
|
||||
import type { DeliveryRecord, InboundRecord, MailAccountRecord, OutboundMessage, OutboundRecord, ParsedMail } from './internal/domain/records.ts';
|
||||
export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail };
|
||||
/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */
|
||||
export type SecretBox = {
|
||||
seal(plaintext: string): string;
|
||||
open(cipher: string): string;
|
||||
};
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
export type IdGenerator = {
|
||||
next(): string;
|
||||
};
|
||||
export type MailAccountStore = {
|
||||
list(): Promise<MailAccountRecord[]>;
|
||||
get(id: string): Promise<MailAccountRecord | undefined>;
|
||||
findByName(name: string): Promise<MailAccountRecord | undefined>;
|
||||
/**
|
||||
* upsert **只写配置列**;`isDefault:true` 时先把别的置 false(同一事务)。
|
||||
* 运行列(cursor / lastPolledAt / lastPollError / lastInboundAt / pollingUntil)一概不动 —— 见 `savePoll`。
|
||||
*/
|
||||
save(record: MailAccountRecord): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
/** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
|
||||
listDue(now: Date): Promise<MailAccountRecord[]>;
|
||||
/** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
|
||||
claimPolling(id: string, leaseMs: number): Promise<boolean>;
|
||||
releasePolling(id: string): Promise<void>;
|
||||
/**
|
||||
* 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op。
|
||||
*
|
||||
* **运行列只走这条路**(与 `claimPolling` / `releasePolling` 一起)——`save()` 一个字都不碰它们:
|
||||
* 运营面 PUT 的同时另一台正拉着,整行 upsert 会把租约与游标一并覆盖回读那一刻的快照
|
||||
* (`sys.loop_state` 整份覆盖那一课的同款指纹)。
|
||||
* `cursor: null` 是**显式清游标**(改了收侧配置要重锚时用)。
|
||||
*/
|
||||
savePoll(id: string, patch: {
|
||||
cursor?: MailCursor | null;
|
||||
polledAt?: Date;
|
||||
error?: string | null;
|
||||
lastInboundAt?: Date;
|
||||
address?: string;
|
||||
}): Promise<void>;
|
||||
};
|
||||
export type InboundStore = {
|
||||
/** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */
|
||||
insert(record: InboundRecord): Promise<{
|
||||
inserted: boolean;
|
||||
}>;
|
||||
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>;
|
||||
/**
|
||||
* 列表 —— **不取 `raw` 原件字节**(一封上限 16MB,50 行就是几百 MB,只为算 200 字预览);
|
||||
* `rawAvailable` 由 `raw is not null` 投影出来,不靠把字节搬回来再判。
|
||||
*/
|
||||
list(query: {
|
||||
accountId?: string | undefined;
|
||||
limit: number;
|
||||
}): Promise<Array<{
|
||||
record: InboundRecord;
|
||||
deliveries: DeliveryRecord[];
|
||||
}>>;
|
||||
/** 只要元信息的读(分发 / 详情 / 附件 / 重投都走它)—— 同样不取 `raw` 字节 */
|
||||
getMeta(mailId: string): Promise<{
|
||||
record: InboundRecord;
|
||||
deliveries: DeliveryRecord[];
|
||||
} | undefined>;
|
||||
/** 全列读 —— 只有「下载原件」那一条路要它 */
|
||||
get(mailId: string): Promise<{
|
||||
record: InboundRecord;
|
||||
deliveries: DeliveryRecord[];
|
||||
} | undefined>;
|
||||
/** upsert on (mail_id, subscriber) */
|
||||
setDelivery(delivery: DeliveryRecord): Promise<void>;
|
||||
};
|
||||
export type OutboundStore = {
|
||||
insert(record: OutboundRecord): Promise<void>;
|
||||
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
|
||||
/**
|
||||
* 复用旧行重发之前**先抢**(一条条件更新,抢到才回 true):
|
||||
* `failed` 的行、或 `created_at <= staleBefore` 的 `sending` 行才抢得到;抢到即置 `sending` 并把
|
||||
* `created_at` 推到 `now` —— 这一手让那一行**离开可抢的集合**,第二个人当场抢不到。
|
||||
*
|
||||
* 本仓是两个后端连同一个库:没有这一抢,两台同时重放同一次放行都会判「超时可复用」,
|
||||
* 发出**两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。
|
||||
*/
|
||||
claimForResend(id: string, opts: {
|
||||
readonly staleBefore: Date;
|
||||
readonly now: Date;
|
||||
}): Promise<boolean>;
|
||||
setState(id: string, state: 'sent' | 'failed', patch: {
|
||||
error?: string;
|
||||
providerRef?: string;
|
||||
sentAt?: Date;
|
||||
}): Promise<void>;
|
||||
list(query: {
|
||||
accountId?: string | undefined;
|
||||
limit: number;
|
||||
}): Promise<OutboundRecord[]>;
|
||||
};
|
||||
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
|
||||
export type AttachmentStore = {
|
||||
put(file: {
|
||||
name: string;
|
||||
contentType: string;
|
||||
bytes: Uint8Array;
|
||||
note: string;
|
||||
}): Promise<{
|
||||
ref: string;
|
||||
}>;
|
||||
get(ref: string): Promise<{
|
||||
name: string;
|
||||
contentType: string;
|
||||
bytes: Uint8Array;
|
||||
} | undefined>;
|
||||
};
|
||||
/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */
|
||||
export type ProviderAccount = {
|
||||
readonly id: string;
|
||||
readonly kind: MailAccountKind;
|
||||
readonly address: string;
|
||||
readonly displayName: string;
|
||||
readonly settings: Record<string, unknown>;
|
||||
readonly secret: Record<string, string>;
|
||||
};
|
||||
export type RawMessage = {
|
||||
readonly providerRef: string;
|
||||
readonly eml: Uint8Array;
|
||||
readonly receivedAt: Date;
|
||||
/** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */
|
||||
readonly cursorAfter?: MailCursor | undefined;
|
||||
};
|
||||
/** 外界:一种账号类型一个适配器(设计稿 §5.5) */
|
||||
export type MailProvider = {
|
||||
readonly kind: MailAccountKind;
|
||||
probe(account: ProviderAccount): Promise<MailProbeReport>;
|
||||
/** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
|
||||
anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
|
||||
/**
|
||||
* 拉一批。回参的 `note` 是这一轮**不算失败、但人必须知道**的事
|
||||
* (当前唯一一种:IMAP 的 UIDVALIDITY 变了、已重锚在当下、期间来信不回填)——
|
||||
* 用例把它落进 `last_poll_error`。丢了一段信而界面一片绿,那是界面撒谎。
|
||||
*/
|
||||
pull(account: ProviderAccount, cursor: MailCursor, opts: {
|
||||
readonly limit: number;
|
||||
readonly now: Date;
|
||||
readonly backfillFrom: Date | null;
|
||||
/** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */
|
||||
readonly known: (refs: readonly string[]) => Promise<Set<string>>;
|
||||
}): Promise<{
|
||||
messages: RawMessage[];
|
||||
cursor: MailCursor;
|
||||
more: boolean;
|
||||
note?: string | undefined;
|
||||
}>;
|
||||
send(account: ProviderAccount, message: OutboundMessage): Promise<{
|
||||
providerRef?: string | undefined;
|
||||
}>;
|
||||
};
|
||||
export type MimeParser = {
|
||||
parse(eml: Uint8Array): Promise<ParsedMail>;
|
||||
};
|
||||
/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */
|
||||
export type ChangeNotifier = {
|
||||
changed(reason: string): void;
|
||||
};
|
||||
+120
-35
@@ -1,16 +1,45 @@
|
||||
/**
|
||||
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
||||
*
|
||||
* 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。
|
||||
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
|
||||
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
|
||||
*/
|
||||
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
|
||||
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, 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;
|
||||
};
|
||||
export type CreateStaffInput = {
|
||||
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
|
||||
export type Identity = {
|
||||
readonly sub: string;
|
||||
/** preferred_username */
|
||||
readonly account: string;
|
||||
/** name claim,缺则回退 account */
|
||||
readonly name: string;
|
||||
readonly positionId: string;
|
||||
readonly email?: string;
|
||||
/** realm 角色,噪声角色已滤、已排序 */
|
||||
readonly roles: readonly string[];
|
||||
};
|
||||
export declare class StaffNotFoundError extends Error {
|
||||
readonly staffId: StaffId;
|
||||
@@ -20,45 +49,102 @@ export declare class PositionNotFoundError extends Error {
|
||||
readonly positionCode: string;
|
||||
constructor(positionCode: string);
|
||||
}
|
||||
export declare class AccountTakenError extends Error {
|
||||
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
|
||||
export declare class TokenRejectedError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */
|
||||
export declare class AccountDisabledError extends Error {
|
||||
readonly account: string;
|
||||
constructor(account: string);
|
||||
}
|
||||
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
|
||||
export declare class DisableBlockedError extends Error {
|
||||
readonly check: DisableCheck;
|
||||
constructor(check: DisableCheck);
|
||||
/**
|
||||
* 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。
|
||||
* 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。
|
||||
* `message` 可覆盖:拒绝的理由不同,给人的下一步也不同。
|
||||
*/
|
||||
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);
|
||||
}
|
||||
/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */
|
||||
export type IssuedSession = {
|
||||
readonly token: string;
|
||||
readonly expiresAt: string;
|
||||
};
|
||||
export type OrgService = {
|
||||
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
|
||||
authenticate(token: string): Promise<Principal>;
|
||||
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
|
||||
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||||
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
|
||||
registerIdentity(identity: Identity): Promise<Staff>;
|
||||
listStaff(): Promise<{
|
||||
staff: StaffWithPosition[];
|
||||
positions: Position[];
|
||||
}>;
|
||||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||||
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
|
||||
createStaff(input: CreateStaffInput): Promise<Staff>;
|
||||
/** 停用前置检查 —— 只读,治理台先看再点 */
|
||||
checkDisable(staffId: StaffId): Promise<DisableCheck>;
|
||||
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
|
||||
disableStaff(staffId: StaffId): Promise<void>;
|
||||
listGrants(positionId: string): Promise<Grant[]>;
|
||||
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
|
||||
/** 岗位权限校验 —— app 层鉴权中间件读它 */
|
||||
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
|
||||
loadSession(account: string): Promise<Session | undefined>;
|
||||
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
|
||||
login(account: string, password: string): Promise<IssuedSession | undefined>;
|
||||
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
|
||||
logout(token: string): Promise<void>;
|
||||
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
|
||||
resolveSession(token: string): Promise<Session | undefined>;
|
||||
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
|
||||
setPassword(staffId: StaffId, password: string): Promise<void>;
|
||||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
|
||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||||
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
|
||||
/** Admin API 同步;未配 provisioning 回 configured:false */
|
||||
syncRoster(): Promise<RosterSyncOutcome>;
|
||||
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
|
||||
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
|
||||
listRoleAccess(): Promise<{
|
||||
roles: RoleAccess[];
|
||||
knownRoles: string[];
|
||||
}>;
|
||||
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
|
||||
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
|
||||
deleteRoleAccess(role: string): Promise<void>;
|
||||
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
||||
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
||||
/**
|
||||
* 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError`
|
||||
* (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。
|
||||
* 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。
|
||||
*/
|
||||
directoryUsers(query: {
|
||||
q?: string;
|
||||
includeDisabled?: boolean;
|
||||
}): Promise<{
|
||||
users: OrgUser[];
|
||||
}>;
|
||||
directoryUser(sub: string): Promise<OrgUserDetail>;
|
||||
createDirectoryUser(input: CreateDirectoryUser): Promise<OrgUser>;
|
||||
patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise<OrgUser>;
|
||||
/** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */
|
||||
setDirectoryUserEnabled(sub: string, enabled: boolean): Promise<OrgUser>;
|
||||
resetDirectoryUserPassword(sub: string, password: string): Promise<void>;
|
||||
deleteDirectoryUser(sub: string): Promise<void>;
|
||||
directoryRoles(): Promise<{
|
||||
roles: OrgRole[];
|
||||
}>;
|
||||
/** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */
|
||||
setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise<OrgUser>;
|
||||
directoryGroups(): Promise<{
|
||||
groups: OrgGroup[];
|
||||
}>;
|
||||
directoryGroupMembers(groupId: string): Promise<{
|
||||
members: OrgUser[];
|
||||
}>;
|
||||
createDirectoryGroup(name: string, parentId?: string): Promise<OrgGroup>;
|
||||
renameDirectoryGroup(groupId: string, name: string): Promise<OrgGroup>;
|
||||
deleteDirectoryGroup(groupId: string): Promise<void>;
|
||||
setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
|
||||
/** 建组 / 改组;id 留空即新建 */
|
||||
saveWorkGroup(input: {
|
||||
id?: string;
|
||||
@@ -70,7 +156,6 @@ export type OrgService = {
|
||||
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
||||
/** 装进工作流的快照:改组不改已起的单 */
|
||||
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
||||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
|
||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||||
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
|
||||
ensureSeeded(): Promise<void>;
|
||||
};
|
||||
|
||||
+17
-8
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { CreateStaffInput, DisableCheck, Grant, GrantAction, GrantObject, IssuedSession, OrgService, Position, Session, Staff, StaffWithPosition, WorkGroup, WorkGroupBinding, } from './contract.ts';
|
||||
export { AccountTakenError, DisableBlockedError, PositionNotFoundError, StaffNotFoundError, } from './contract.ts';
|
||||
export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PasswordHasher, PositionRepository, SessionRepository, StaffRepository, 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 { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
|
||||
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
|
||||
export { createGrantRepository, createPositionRepository, createSessionRepository, createStaffRepository, createWorkGroupRepository, objectKeyOf, } from './internal/adapter/org-repository.ts';
|
||||
export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts';
|
||||
export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.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';
|
||||
|
||||
+120
-35
@@ -2,10 +2,104 @@
|
||||
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||
*/
|
||||
import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
|
||||
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[];
|
||||
};
|
||||
};
|
||||
/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */
|
||||
export type TokenVerifier = {
|
||||
verify(token: string): Promise<VerifiedClaims>;
|
||||
};
|
||||
/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */
|
||||
export type RosterUser = Identity & {
|
||||
readonly enabled: boolean;
|
||||
};
|
||||
export type RosterSource = {
|
||||
listUsers(): Promise<readonly RosterUser[]>;
|
||||
};
|
||||
/**
|
||||
* 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<readonly DirectoryUser[]>;
|
||||
getUser(sub: string): Promise<DirectoryUser & {
|
||||
readonly groups: ReadonlyArray<{
|
||||
id: string;
|
||||
path: string;
|
||||
}>;
|
||||
}>;
|
||||
createUser(input: CreateDirectoryUser): Promise<string>;
|
||||
updateUser(sub: string, patch: DirectoryUserPatch): Promise<void>;
|
||||
setEnabled(sub: string, enabled: boolean): Promise<void>;
|
||||
resetPassword(sub: string, password: string): Promise<void>;
|
||||
deleteUser(sub: string): Promise<void>;
|
||||
/** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */
|
||||
logout(sub: string): Promise<void>;
|
||||
listRoles(): Promise<readonly DirectoryRole[]>;
|
||||
setRole(sub: string, role: string, granted: boolean): Promise<void>;
|
||||
listGroups(): Promise<readonly DirectoryGroup[]>;
|
||||
groupMembers(groupId: string): Promise<readonly DirectoryUser[]>;
|
||||
/** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */
|
||||
groupMemberUsernames(groupId: string): Promise<readonly string[]>;
|
||||
createGroup(name: string, parentId?: string): Promise<DirectoryGroup>;
|
||||
renameGroup(groupId: string, name: string): Promise<DirectoryGroup>;
|
||||
deleteGroup(groupId: string): Promise<void>;
|
||||
setUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
|
||||
};
|
||||
/** 名册即时更新 / 同步要改的那几列 */
|
||||
export type IdentityPatch = {
|
||||
readonly account: string;
|
||||
readonly name: string;
|
||||
readonly email?: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly lastSeenAt?: Date;
|
||||
};
|
||||
export type PositionRepository = {
|
||||
list(): Promise<Position[]>;
|
||||
findByCode(code: string): Promise<Position | undefined>;
|
||||
@@ -14,46 +108,37 @@ export type PositionRepository = {
|
||||
id?: PositionId;
|
||||
}): Promise<Position>;
|
||||
};
|
||||
export type StaffRepository = {
|
||||
list(): Promise<Staff[]>;
|
||||
findById(id: StaffId): Promise<Staff | undefined>;
|
||||
findByAccount(account: string): Promise<Staff | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<Staff[]>;
|
||||
insert(staff: Staff): Promise<Staff>;
|
||||
setState(id: StaffId, state: Staff['state']): Promise<void>;
|
||||
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
|
||||
passwordHashOf(account: string): Promise<string | undefined>;
|
||||
setPasswordHash(id: StaffId, hash: string): Promise<void>;
|
||||
};
|
||||
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||||
/**
|
||||
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
|
||||
* token 即主键,不透明随机串,不编码任何身份信息。
|
||||
* 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`),
|
||||
* 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。
|
||||
*/
|
||||
export type SessionRepository = {
|
||||
issue(input: {
|
||||
token: string;
|
||||
staffId: StaffId;
|
||||
expiresAt: Date;
|
||||
}): Promise<void>;
|
||||
/** 未过期才返回;命中同时滑动续期 */
|
||||
touch(token: string, now: Date, expiresAt: Date): Promise<{
|
||||
staffId: StaffId;
|
||||
} | undefined>;
|
||||
revoke(token: string): Promise<void>;
|
||||
revokeAllOf(staffId: StaffId): Promise<void>;
|
||||
export type StoredStaff = Omit<Staff, 'kind'>;
|
||||
export type StaffRecord = StoredStaff & {
|
||||
readonly lastSeenAt?: Date;
|
||||
readonly syncedAt?: Date;
|
||||
};
|
||||
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
|
||||
export type PasswordHasher = {
|
||||
hash(password: string): Promise<string>;
|
||||
verify(password: string, stored: string | undefined): Promise<boolean>;
|
||||
newToken(): string;
|
||||
export type StaffRepository = {
|
||||
list(): Promise<StaffRecord[]>;
|
||||
findById(id: StaffId): Promise<StaffRecord | undefined>;
|
||||
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
|
||||
findByAccount(account: string): Promise<StaffRecord | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||
insert(staff: StoredStaff, marks: {
|
||||
readonly lastSeenAt?: Date;
|
||||
readonly syncedAt?: Date;
|
||||
}): Promise<StaffRecord>;
|
||||
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
|
||||
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
|
||||
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
|
||||
};
|
||||
export type GrantRepository = {
|
||||
listByPosition(positionId: PositionId): Promise<Grant[]>;
|
||||
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
|
||||
export type RoleAccessRepository = {
|
||||
list(): Promise<RoleAccess[]>;
|
||||
upsert(row: RoleAccess): Promise<RoleAccess>;
|
||||
remove(role: string): Promise<void>;
|
||||
};
|
||||
/**
|
||||
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
|
||||
* 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`,
|
||||
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
||||
*/
|
||||
export type InFlightWorkProbe = {
|
||||
|
||||
@@ -26,6 +26,10 @@ export type SubmitForReleaseInput = {
|
||||
}>;
|
||||
readonly deliverables: readonly DeliverableCheck[];
|
||||
readonly criteria: readonly CriterionCheck[];
|
||||
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
|
||||
readonly checklist?: ReadonlyArray<string>;
|
||||
/** 这张卡打回得了吗 —— `false` = 打回之后没人接得住(见契约 `AcceptanceWorkOrder.redoable`) */
|
||||
readonly redoable?: boolean;
|
||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,8 @@ export type LedgerGateway = {
|
||||
emittedEvents: number;
|
||||
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
||||
simulatedRows: number;
|
||||
/** 早已在数据工单确认时入库、这次不再写的行数 —— 放行痕上分开说,「合并 N 行」不多报 */
|
||||
landedEarlierRows: number;
|
||||
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
||||
merged: ReadonlyArray<{
|
||||
domain: string;
|
||||
@@ -89,6 +91,10 @@ export type CardGateway = {
|
||||
effect: 'read' | 'write';
|
||||
detail: string;
|
||||
}>;
|
||||
/** 人工验收清单 —— 原样带上卡 */
|
||||
checklist?: ReadonlyArray<string> | undefined;
|
||||
/** 这张卡打回得了吗(`false` = 那颗钮不摆)—— 端口不声明它,值就在装配层被丢掉,这行不能省 */
|
||||
redoable?: boolean | undefined;
|
||||
/**
|
||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||
|
||||
@@ -77,14 +77,26 @@ export type TaskService = {
|
||||
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
|
||||
tick(taskId: TaskId): Promise<TickOutcome>;
|
||||
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
|
||||
/**
|
||||
* 投递一条事件并推一拍。`occurredAt` = 事件发生时刻(信封的 occurredAt):人事件要拿它与任务的
|
||||
* 最近推进时刻比 —— 回传那一刻已经 wake、推进器抢先跑了那一拍的,这条事件只是迟到的回声,不再推。
|
||||
*/
|
||||
deliver(taskId: TaskId, event: {
|
||||
kind: 'data' | 'time' | 'human' | 'control';
|
||||
ref: string;
|
||||
payload: Record<string, unknown>;
|
||||
occurredAt?: string;
|
||||
}): Promise<TickOutcome>;
|
||||
/** 控制三语义:暂停 / 叫停 / 终止 */
|
||||
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
||||
resume(taskId: TaskId, by: StaffId): Promise<Task>;
|
||||
/**
|
||||
* **人答复落库那一刻就把「在办事」写进投影**(2026-08-25):不等派发器投递、推进器
|
||||
* 抢到租约再翻 —— 那几秒里对话区(卡状态已落)与工作区(读 runtime)说两句相反的话。
|
||||
* 只在挂起等人 / 等外部时翻;已终结、暂停(suspended)、已在跑的原样不动。
|
||||
* 返回翻没翻 —— 调用方据此决定要不要广播。
|
||||
*/
|
||||
wake(taskId: TaskId): Promise<boolean>;
|
||||
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
|
||||
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
|
||||
/** 终态裁定 —— 完成由框架裁定,终态由系统命名 */
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 运营中心 · 活动模块的契约(L1 活动清单 / L2 活动详情 / L3 实例详情;`docs/API文档-活动模块.md`)。
|
||||
*
|
||||
* 只有 zod schema 与推导类型。两个概念(2026-08-25 用户定):
|
||||
* · **活动** = 装进来的活动包在运营面上的投影 —— 名称 / 分类 / 版本 / 模型来自清单,
|
||||
* 负责人 / 说明 / 图标 / 色相来自运营侧档案(`sys.activity_profile`,活动包本身不载这些);
|
||||
* · **实例** = 这个活动的一次运行(一张 task)—— 五态是对 task 运行态 / 终态的**如实映射**,
|
||||
* 不是第六套状态机(见 `INSTANCE_STATE_LABEL` 旁的口径)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/** 运营态:激活中 / 已禁用 —— 禁用只拦「新启动」,已完成与在途实例都不受影响(2026-08-27 用户定) */
|
||||
export declare const ACTIVITY_STATUSES: readonly ["active", "disabled"];
|
||||
export declare const activityStatusSchema: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
export type ActivityStatus = z.infer<typeof activityStatusSchema>;
|
||||
export declare const ACTIVITY_STATUS_LABEL: Readonly<Record<ActivityStatus, string>>;
|
||||
/** 业务色相 —— 一卡一色;缺省由前端兜底(`blue`) */
|
||||
export declare const ACTIVITY_HUES: readonly ["blue", "teal", "amber", "violet"];
|
||||
export declare const activityHueSchema: z.ZodEnum<{
|
||||
blue: "blue";
|
||||
teal: "teal";
|
||||
amber: "amber";
|
||||
violet: "violet";
|
||||
}>;
|
||||
export type ActivityHue = z.infer<typeof activityHueSchema>;
|
||||
/** 凡出人都是两段:展示用 name,筛选 / 提交用 id */
|
||||
export declare const staffRefSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type StaffRef = z.infer<typeof staffRefSchema>;
|
||||
export declare const activitySummarySchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
biz: z.ZodString;
|
||||
status: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
version: z.ZodString;
|
||||
owner: z.ZodNullable<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
llm: z.ZodString;
|
||||
instanceCount: z.ZodNumber;
|
||||
createdAt: z.ZodString;
|
||||
ico: z.ZodOptional<z.ZodString>;
|
||||
hue: z.ZodOptional<z.ZodEnum<{
|
||||
blue: "blue";
|
||||
teal: "teal";
|
||||
amber: "amber";
|
||||
violet: "violet";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type ActivitySummary = z.infer<typeof activitySummarySchema>;
|
||||
export declare const activityDetailSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
id: z.ZodString;
|
||||
version: z.ZodString;
|
||||
status: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
createdAt: z.ZodString;
|
||||
biz: z.ZodString;
|
||||
owner: z.ZodNullable<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
llm: z.ZodString;
|
||||
ico: z.ZodOptional<z.ZodString>;
|
||||
hue: z.ZodOptional<z.ZodEnum<{
|
||||
blue: "blue";
|
||||
teal: "teal";
|
||||
amber: "amber";
|
||||
violet: "violet";
|
||||
}>>;
|
||||
desc: z.ZodString;
|
||||
view: z.ZodNullable<z.ZodObject<{
|
||||
url: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type ActivityDetail = z.infer<typeof activityDetailSchema>;
|
||||
/**
|
||||
* 沙箱视图向父页报高的消息类型(postMessage 载荷 `{ type, height }`)。
|
||||
* 视图模版本身**无脚本**(安装静态审拦着);报高脚本由服务端在出口那一刻注入。
|
||||
*/
|
||||
export declare const ACTIVITY_VIEW_HEIGHT_MESSAGE = "jcai:view-height";
|
||||
/**
|
||||
* 实例五态(2026-08-25 用户定),对 task 的如实映射:
|
||||
* pending 已建单、推进器还没领到(`used.steps = 0`)
|
||||
* running 在途(含等人回传 / 监听中 / 挂起 / 落地受阻)
|
||||
* completed 终态 released
|
||||
* rejected 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 running)
|
||||
* failed 终态 blocked / stalled / exhausted,以及手动终止(靠 `reason.kind='terminated'` 区分)
|
||||
*/
|
||||
export declare const INSTANCE_STATES: readonly ["pending", "running", "completed", "rejected", "failed"];
|
||||
export declare const instanceStateSchema: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
export type InstanceState = z.infer<typeof instanceStateSchema>;
|
||||
export declare const INSTANCE_STATE_LABEL: Readonly<Record<InstanceState, string>>;
|
||||
export declare const instanceSummarySchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
no: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
owner: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
input: z.ZodString;
|
||||
output: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceSummary = z.infer<typeof instanceSummarySchema>;
|
||||
export declare const instanceStatsSchema: z.ZodObject<{
|
||||
total: z.ZodNumber;
|
||||
byState: z.ZodRecord<z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>, z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceStats = z.infer<typeof instanceStatsSchema>;
|
||||
export declare const instanceListSchema: z.ZodObject<{
|
||||
stats: z.ZodObject<{
|
||||
total: z.ZodNumber;
|
||||
byState: z.ZodRecord<z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>, z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
total: z.ZodNumber;
|
||||
page: z.ZodNumber;
|
||||
size: z.ZodNumber;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
no: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
owner: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
input: z.ZodString;
|
||||
output: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceList = z.infer<typeof instanceListSchema>;
|
||||
export declare const instanceFileSchema: z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceFile = z.infer<typeof instanceFileSchema>;
|
||||
export declare const INSTANCE_REASON_KINDS: readonly ["rejected", "failed", "terminated"];
|
||||
export declare const instanceReasonSchema: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
terminated: "terminated";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceReason = z.infer<typeof instanceReasonSchema>;
|
||||
export declare const STEP_RESULTS: readonly ["done", "fail", "current", "pending"];
|
||||
export declare const instanceStepSchema: z.ZodObject<{
|
||||
seq: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
by: z.ZodEnum<{
|
||||
human: "human";
|
||||
agent: "agent";
|
||||
}>;
|
||||
byName: z.ZodString;
|
||||
at: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
res: z.ZodEnum<{
|
||||
pending: "pending";
|
||||
done: "done";
|
||||
fail: "fail";
|
||||
current: "current";
|
||||
}>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceStep = z.infer<typeof instanceStepSchema>;
|
||||
/** 起单来源:manual(运营端手动启动)/ upstream(工作流节点)/ mail 等通道名 / testbed(测试台试跑)/ system */
|
||||
export declare const instanceSourceSchema: z.ZodObject<{
|
||||
kind: z.ZodString;
|
||||
label: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceSource = z.infer<typeof instanceSourceSchema>;
|
||||
export declare const instanceDetailSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
startedAt: z.ZodString;
|
||||
owner: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
no: z.ZodString;
|
||||
activityId: z.ZodString;
|
||||
llm: z.ZodString;
|
||||
endedAt: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
reason: z.ZodNullable<z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
terminated: "terminated";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
input: z.ZodObject<{
|
||||
content: z.ZodString;
|
||||
source: z.ZodObject<{
|
||||
kind: z.ZodString;
|
||||
label: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
submittedAt: z.ZodString;
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodArray<z.ZodObject<{
|
||||
seq: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
by: z.ZodEnum<{
|
||||
human: "human";
|
||||
agent: "agent";
|
||||
}>;
|
||||
byName: z.ZodString;
|
||||
at: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
res: z.ZodEnum<{
|
||||
pending: "pending";
|
||||
done: "done";
|
||||
fail: "fail";
|
||||
current: "current";
|
||||
}>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type InstanceDetail = z.infer<typeof instanceDetailSchema>;
|
||||
/**
|
||||
* 控件种类:text 单行 / textarea 多行 / select 单选(只能选候选)/ combo 选择 + 新增 / list 字符串数组。
|
||||
* 新增种类前后端一起升级;前端遇到不认识的 type 摆「暂不支持的控件」。
|
||||
*/
|
||||
export declare const LAUNCH_PARAM_TYPES: readonly ["text", "textarea", "select", "combo", "list"];
|
||||
export declare const launchParamTypeSchema: z.ZodEnum<{
|
||||
text: "text";
|
||||
textarea: "textarea";
|
||||
select: "select";
|
||||
combo: "combo";
|
||||
list: "list";
|
||||
}>;
|
||||
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
||||
/**
|
||||
* 一条候选。`group` 是**联动分组键**(2026-08-31 用户定):清单 `x-options.group` 指了哪一列,
|
||||
* 这颗候选就带上它在那一列的值(如料号的 `chip_type_id`);空串 = 那一列没值(「未标类型」)。
|
||||
* `groupLabel` 是这枚键的人读名(从字典表现读,如「DC-DC 降压稳压器」),读不到就没有 —— 界面退回摆键本身。
|
||||
*/
|
||||
export declare const launchOptionSchema: z.ZodObject<{
|
||||
value: z.ZodString;
|
||||
label: z.ZodString;
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
groupLabel: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type LaunchOption = z.infer<typeof launchOptionSchema>;
|
||||
export declare const launchParamSchema: z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
text: "text";
|
||||
textarea: "textarea";
|
||||
select: "select";
|
||||
combo: "combo";
|
||||
list: "list";
|
||||
}>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
desc: z.ZodOptional<z.ZodString>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
value: z.ZodString;
|
||||
label: z.ZodString;
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
groupLabel: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||
linkGroup: z.ZodOptional<z.ZodString>;
|
||||
distinctGroup: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
||||
/**
|
||||
* 「算不算同一个」的归一:去空白 + 不分大小写 —— **前后端同一把尺**。
|
||||
* 大小写要归:料号这类身份不因字母大小写而不同,只挡精确相等的话,`lm2596s-5.0` 会照样进去,
|
||||
* 混一颗垃圾主数据出来,人还得到一份自己跟自己比的评估。
|
||||
*/
|
||||
export declare function launchDistinctKey(value: string): string;
|
||||
/** 撞上了那一格的话 —— 前端(填的当口)与服务端(400 的 detail)同一句,不许分叉 */
|
||||
export declare function launchDistinctMessage(otherLabel: string): string;
|
||||
export declare const launchParamsSchema: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
version: z.ZodString;
|
||||
params: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
text: "text";
|
||||
textarea: "textarea";
|
||||
select: "select";
|
||||
combo: "combo";
|
||||
list: "list";
|
||||
}>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
desc: z.ZodOptional<z.ZodString>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
value: z.ZodString;
|
||||
label: z.ZodString;
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
groupLabel: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||
linkGroup: z.ZodOptional<z.ZodString>;
|
||||
distinctGroup: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type LaunchParams = z.infer<typeof launchParamsSchema>;
|
||||
export declare const launchInputSchema: z.ZodObject<{
|
||||
idempotencyKey: z.ZodString;
|
||||
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||
}, z.core.$strip>;
|
||||
export type LaunchInput = z.infer<typeof launchInputSchema>;
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* 运营中心 · 活动模块的路由契约(`docs/API文档-活动模块.md`)。
|
||||
*
|
||||
* 全部 `access: 'ops'` —— 活动区落在运营中心,与工作流 / 任务看板同端。
|
||||
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
|
||||
* 设计端的活动库(包版本 / 资产版本 / 装入)在 `/api/gov/activity-packages`(governance.ts),
|
||||
* `/api/gov/activities/*` 整个归运营端(2026-08-27 用户定)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { RouteContract } from './http.ts';
|
||||
export declare const listOpsActivitiesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities";
|
||||
readonly access: "ops";
|
||||
/** 不带 status = 全量;量小不分页 */
|
||||
readonly query: z.ZodObject<{
|
||||
status: z.ZodOptional<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
activities: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
biz: z.ZodString;
|
||||
status: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
version: z.ZodString;
|
||||
owner: z.ZodNullable<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
llm: z.ZodString;
|
||||
instanceCount: z.ZodNumber;
|
||||
createdAt: z.ZodString;
|
||||
ico: z.ZodOptional<z.ZodString>;
|
||||
hue: z.ZodOptional<z.ZodEnum<{
|
||||
blue: "blue";
|
||||
teal: "teal";
|
||||
amber: "amber";
|
||||
violet: "violet";
|
||||
}>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const getOpsActivityRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities/:activityId";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
id: z.ZodString;
|
||||
version: z.ZodString;
|
||||
status: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
createdAt: z.ZodString;
|
||||
biz: z.ZodString;
|
||||
owner: z.ZodNullable<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
llm: z.ZodString;
|
||||
ico: z.ZodOptional<z.ZodString>;
|
||||
hue: z.ZodOptional<z.ZodEnum<{
|
||||
blue: "blue";
|
||||
teal: "teal";
|
||||
amber: "amber";
|
||||
violet: "violet";
|
||||
}>>;
|
||||
desc: z.ZodString;
|
||||
view: z.ZodNullable<z.ZodObject<{
|
||||
url: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 沙箱要渲染的活动视图 —— 处理器回 `text/html`(不是 JSON),`output` 只是形式上的字符串。
|
||||
* iframe 带不了 Authorization 头,走票据 `?ticket=`(GET,中间件已认)。404 = 该活动没配视图。
|
||||
*/
|
||||
export declare const opsActivityViewRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities/:activityId/view";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodString;
|
||||
};
|
||||
export declare const listOpsInstancesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities/:activityId/instances";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly query: z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
ownerId: z.ZodOptional<z.ZodString>;
|
||||
state: z.ZodOptional<z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>>;
|
||||
startedFrom: z.ZodOptional<z.ZodString>;
|
||||
startedTo: z.ZodOptional<z.ZodString>;
|
||||
page: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
||||
size: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
stats: z.ZodObject<{
|
||||
total: z.ZodNumber;
|
||||
byState: z.ZodRecord<z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>, z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
total: z.ZodNumber;
|
||||
page: z.ZodNumber;
|
||||
size: z.ZodNumber;
|
||||
items: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
no: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
owner: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
input: z.ZodString;
|
||||
output: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const opsLaunchParamsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities/:activityId/launch-params";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
version: z.ZodString;
|
||||
params: z.ZodArray<z.ZodObject<{
|
||||
key: z.ZodString;
|
||||
label: z.ZodString;
|
||||
type: z.ZodEnum<{
|
||||
text: "text";
|
||||
textarea: "textarea";
|
||||
select: "select";
|
||||
combo: "combo";
|
||||
list: "list";
|
||||
}>;
|
||||
required: z.ZodOptional<z.ZodBoolean>;
|
||||
desc: z.ZodOptional<z.ZodString>;
|
||||
placeholder: z.ZodOptional<z.ZodString>;
|
||||
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
value: z.ZodString;
|
||||
label: z.ZodString;
|
||||
group: z.ZodOptional<z.ZodString>;
|
||||
groupLabel: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||
linkGroup: z.ZodOptional<z.ZodString>;
|
||||
distinctGroup: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 启动活动 = 创建实例。同一 `idempotencyKey` 重放返回第一次创建的实例;
|
||||
* 412 活动已禁用;400 缺必填 / 类型不符 / select 值不在候选 / 责任人不在职;409 同键正在创建中。
|
||||
*/
|
||||
export declare const launchOpsInstanceRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/activities/:activityId/instances";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodObject<{
|
||||
idempotencyKey: z.ZodString;
|
||||
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
startedAt: z.ZodString;
|
||||
owner: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
no: z.ZodString;
|
||||
activityId: z.ZodString;
|
||||
llm: z.ZodString;
|
||||
endedAt: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
reason: z.ZodNullable<z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
terminated: "terminated";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
input: z.ZodObject<{
|
||||
content: z.ZodString;
|
||||
source: z.ZodObject<{
|
||||
kind: z.ZodString;
|
||||
label: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
submittedAt: z.ZodString;
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodArray<z.ZodObject<{
|
||||
seq: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
by: z.ZodEnum<{
|
||||
human: "human";
|
||||
agent: "agent";
|
||||
}>;
|
||||
byName: z.ZodString;
|
||||
at: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
res: z.ZodEnum<{
|
||||
pending: "pending";
|
||||
done: "done";
|
||||
fail: "fail";
|
||||
current: "current";
|
||||
}>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const getOpsInstanceRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities/:activityId/instances/:instanceId";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
instanceId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
startedAt: z.ZodString;
|
||||
owner: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
no: z.ZodString;
|
||||
activityId: z.ZodString;
|
||||
llm: z.ZodString;
|
||||
endedAt: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
reason: z.ZodNullable<z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
terminated: "terminated";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
input: z.ZodObject<{
|
||||
content: z.ZodString;
|
||||
source: z.ZodObject<{
|
||||
kind: z.ZodString;
|
||||
label: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
submittedAt: z.ZodString;
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodArray<z.ZodObject<{
|
||||
seq: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
by: z.ZodEnum<{
|
||||
human: "human";
|
||||
agent: "agent";
|
||||
}>;
|
||||
byName: z.ZodString;
|
||||
at: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
res: z.ZodEnum<{
|
||||
pending: "pending";
|
||||
done: "done";
|
||||
fail: "fail";
|
||||
current: "current";
|
||||
}>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 实例文件:默认 JSON + base64(与 `GET /api/gov/upload/:id` 同形);`?raw=1` 直出字节
|
||||
* (RFC 5987 文件名),配 `?ticket=` 给 `<a href download>` 用。
|
||||
*/
|
||||
export declare const opsInstanceFileRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
instanceId: z.ZodString;
|
||||
io: z.ZodEnum<{
|
||||
output: "output";
|
||||
input: "input";
|
||||
}>;
|
||||
index: z.ZodCoercedNumber<unknown>;
|
||||
}, z.core.$strip>;
|
||||
readonly query: z.ZodObject<{
|
||||
raw: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
dataBase64: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 任何时刻都可禁用:只拦新启动,在途实例继续跑完;已禁用再禁用 = 幂等 200 */
|
||||
export declare const disableOpsActivityRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/activities/:activityId/disable";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
id: z.ZodString;
|
||||
version: z.ZodString;
|
||||
status: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
createdAt: z.ZodString;
|
||||
biz: z.ZodString;
|
||||
owner: z.ZodNullable<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
llm: z.ZodString;
|
||||
ico: z.ZodOptional<z.ZodString>;
|
||||
hue: z.ZodOptional<z.ZodEnum<{
|
||||
blue: "blue";
|
||||
teal: "teal";
|
||||
amber: "amber";
|
||||
violet: "violet";
|
||||
}>>;
|
||||
desc: z.ZodString;
|
||||
view: z.ZodNullable<z.ZodObject<{
|
||||
url: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const activateOpsActivityRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/activities/:activityId/activate";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
id: z.ZodString;
|
||||
version: z.ZodString;
|
||||
status: z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
createdAt: z.ZodString;
|
||||
biz: z.ZodString;
|
||||
owner: z.ZodNullable<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
llm: z.ZodString;
|
||||
ico: z.ZodOptional<z.ZodString>;
|
||||
hue: z.ZodOptional<z.ZodEnum<{
|
||||
blue: "blue";
|
||||
teal: "teal";
|
||||
amber: "amber";
|
||||
violet: "violet";
|
||||
}>>;
|
||||
desc: z.ZodString;
|
||||
view: z.ZodNullable<z.ZodObject<{
|
||||
url: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** body 空;→ `state='failed'` + `reason.kind='terminated'`;412 实例已终态 */
|
||||
export declare const terminateOpsInstanceRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/activities/:activityId/instances/:instanceId/terminate";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
instanceId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
running: "running";
|
||||
completed: "completed";
|
||||
pending: "pending";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
startedAt: z.ZodString;
|
||||
owner: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
no: z.ZodString;
|
||||
activityId: z.ZodString;
|
||||
llm: z.ZodString;
|
||||
endedAt: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
reason: z.ZodNullable<z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
terminated: "terminated";
|
||||
failed: "failed";
|
||||
rejected: "rejected";
|
||||
}>;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
input: z.ZodObject<{
|
||||
content: z.ZodString;
|
||||
source: z.ZodObject<{
|
||||
kind: z.ZodString;
|
||||
label: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
submittedAt: z.ZodString;
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
output: z.ZodObject<{
|
||||
files: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
steps: z.ZodArray<z.ZodObject<{
|
||||
seq: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
by: z.ZodEnum<{
|
||||
human: "human";
|
||||
agent: "agent";
|
||||
}>;
|
||||
byName: z.ZodString;
|
||||
at: z.ZodNullable<z.ZodString>;
|
||||
durationMs: z.ZodNullable<z.ZodNumber>;
|
||||
res: z.ZodEnum<{
|
||||
pending: "pending";
|
||||
done: "done";
|
||||
fail: "fail";
|
||||
current: "current";
|
||||
}>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 全部 11 条 —— 文档总表与覆盖测试按它数 */
|
||||
export declare const ACTIVITY_OPS_ROUTES: readonly RouteContract[];
|
||||
+250
-2
@@ -6,16 +6,23 @@ import { z } from 'zod';
|
||||
export declare const listStaffRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/staff";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
readonly output: z.ZodObject<{
|
||||
staff: z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
@@ -31,9 +38,11 @@ export declare const listStaffRoute: {
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;人来自 Keycloak,改用 syncStaffRoute */
|
||||
export declare const createStaffRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/staff";
|
||||
readonly access: "public";
|
||||
readonly body: z.ZodObject<{
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
@@ -43,16 +52,24 @@ export declare const createStaffRoute: {
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */
|
||||
export declare const disableStaffCheckRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/staff/:staffId/disable-check";
|
||||
readonly access: "public";
|
||||
readonly params: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -73,9 +90,11 @@ export declare const disableStaffCheckRoute: {
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;停用改在 Keycloak 做,名册同步据此落 disabled */
|
||||
export declare const disableStaffRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/staff/:staffId/disable";
|
||||
readonly access: "public";
|
||||
readonly params: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -99,9 +118,11 @@ export declare const disableStaffRoute: {
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 listRoleAccessRoute */
|
||||
export declare const listGrantsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/positions/:positionId/grants";
|
||||
readonly access: "public";
|
||||
readonly params: z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -138,9 +159,11 @@ export declare const listGrantsRoute: {
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 setRoleAccessRoute */
|
||||
export declare const setGrantsRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/positions/:positionId/grants";
|
||||
readonly access: "public";
|
||||
readonly params: z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -209,6 +232,136 @@ export declare const setGrantsRoute: {
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 从 Keycloak Admin API 同步名册;没配 provisioning 服务账号就如实回 configured:false */
|
||||
export declare const syncStaffRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/staff/sync";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
configured: z.ZodLiteral<false>;
|
||||
message: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
configured: z.ZodLiteral<true>;
|
||||
inserted: z.ZodNumber;
|
||||
updated: z.ZodNumber;
|
||||
disabled: z.ZodNumber;
|
||||
total: z.ZodNumber;
|
||||
failed: z.ZodArray<z.ZodObject<{
|
||||
account: z.ZodString;
|
||||
reason: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "configured">;
|
||||
};
|
||||
/** 指派岗位;指回哨兵岗 NONE = 取消指派 */
|
||||
export declare const setStaffPositionRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/staff/:staffId/position";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 此人身上还挂着什么(只读)—— 原「停用前置检查」;停用本身在 Keycloak 做 */
|
||||
export declare const staffInFlightRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/staff/:staffId/in-flight";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
blocked: z.ZodBoolean;
|
||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
cardId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
approvalId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const listRoleAccessRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/access/roles";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
roles: z.ZodArray<z.ZodObject<{
|
||||
role: z.ZodString;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
}, z.core.$strip>>;
|
||||
knownRoles: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 锁死不变量不过(改完没人能进运营中心)→ 409 conflict */
|
||||
export declare const setRoleAccessRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/access/roles/:role";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
role: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodObject<{
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
role: z.ZodString;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const deleteRoleAccessRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/access/roles/:role";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
role: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const LLM_PROVIDERS: readonly ["openai-compatible", "openai", "deepseek", "anthropic", "qwen"];
|
||||
export declare const llmProviderSchema: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
@@ -243,6 +396,7 @@ export type LlmInstanceView = z.infer<typeof llmInstanceViewSchema>;
|
||||
export declare const listLlmProvidersRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/llm/providers";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
providers: z.ZodArray<z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
@@ -264,6 +418,7 @@ export declare const listLlmProvidersRoute: {
|
||||
export declare const listLlmInstancesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/llm";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
instances: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -286,6 +441,7 @@ export declare const listLlmInstancesRoute: {
|
||||
export declare const upsertLlmInstanceRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodString;
|
||||
@@ -331,6 +487,7 @@ export declare const upsertLlmInstanceRoute: {
|
||||
export declare const deleteLlmInstanceRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/llm/:id";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -341,6 +498,7 @@ export declare const deleteLlmInstanceRoute: {
|
||||
export declare const probeLlmDraftRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm/probe";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
@@ -364,6 +522,7 @@ export declare const probeLlmDraftRoute: {
|
||||
export declare const probeLlmInstanceRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm/:id/probe";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -402,6 +561,7 @@ export type GovTaskRow = z.infer<typeof govTaskRowSchema>;
|
||||
export declare const listGovTasksRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/tasks";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
tasks: z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
@@ -447,6 +607,7 @@ export declare const listGovTasksRoute: {
|
||||
export declare const controlTaskRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/tasks/:taskId/control";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -466,6 +627,7 @@ export declare const controlTaskRoute: {
|
||||
export declare const resumeTaskRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/tasks/:taskId/resume";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -485,6 +647,7 @@ export declare const resumeTaskRoute: {
|
||||
export declare const reassignTaskRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/tasks/:taskId/reassign";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -500,6 +663,7 @@ export declare const reassignTaskRoute: {
|
||||
export declare const govTaskLogRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/tasks/:taskId/log";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -516,6 +680,7 @@ export declare const govTaskLogRoute: {
|
||||
export declare const listProcessPackagesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/process";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
readonly output: z.ZodObject<{
|
||||
packages: z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||
@@ -635,6 +800,7 @@ export declare const listProcessPackagesRoute: {
|
||||
export declare const saveProcessRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/process";
|
||||
readonly access: "design";
|
||||
/**
|
||||
* 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。
|
||||
* `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。
|
||||
@@ -879,6 +1045,7 @@ export declare const saveProcessRoute: {
|
||||
export declare const setProcessStateRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/process/:id/:version/state";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
version: z.ZodString;
|
||||
@@ -1005,6 +1172,7 @@ export declare const setProcessStateRoute: {
|
||||
export declare const deleteProcessVersionRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/process/:id/:version";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
version: z.ZodString;
|
||||
@@ -1121,6 +1289,7 @@ export declare const deleteProcessVersionRoute: {
|
||||
export declare const listApprovalTemplatesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/approval-templates";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
templates: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -1151,6 +1320,7 @@ export declare const listApprovalTemplatesRoute: {
|
||||
export declare const upsertApprovalTemplateRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/approval-templates";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
template: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -1198,6 +1368,7 @@ export declare const upsertApprovalTemplateRoute: {
|
||||
export declare const deleteApprovalTemplateRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/approval-templates/:id";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -1213,6 +1384,7 @@ export declare const deleteApprovalTemplateRoute: {
|
||||
export declare const deleteProcessFlowRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/process/:id";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -1335,6 +1507,7 @@ export declare const deleteProcessFlowRoute: {
|
||||
export declare const editProcessVersionRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/process/:id/:version/edit";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
version: z.ZodString;
|
||||
@@ -1455,6 +1628,7 @@ export declare const editProcessVersionRoute: {
|
||||
export declare const validateProcessRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/process/validate";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
pkg: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||
@@ -1580,6 +1754,7 @@ export declare const validateProcessRoute: {
|
||||
export declare const listTriggersRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/triggers";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
readonly output: z.ZodObject<{
|
||||
triggers: z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
@@ -1639,6 +1814,7 @@ export declare const listTriggersRoute: {
|
||||
export declare const toggleTriggerRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/triggers/:id/toggle";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -1659,6 +1835,7 @@ export declare const toggleTriggerRoute: {
|
||||
export declare const upsertTriggerRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/triggers";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
trigger: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||
@@ -1779,6 +1956,7 @@ export declare const upsertTriggerRoute: {
|
||||
export declare const listWorkGroupsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/work-groups";
|
||||
readonly access: "ops";
|
||||
/** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */
|
||||
readonly output: z.ZodObject<{
|
||||
groups: z.ZodArray<z.ZodObject<{
|
||||
@@ -1794,6 +1972,7 @@ export declare const listWorkGroupsRoute: {
|
||||
export declare const saveWorkGroupRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/work-groups";
|
||||
readonly access: "ops";
|
||||
readonly body: z.ZodObject<{
|
||||
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
name: z.ZodString;
|
||||
@@ -1814,10 +1993,13 @@ export declare const saveWorkGroupRoute: {
|
||||
* 安装两审在服务端跑:静态审读清单(工具契约在否、模型名绑没绑、模版有没有脚本、
|
||||
* 声明与已物化的台账冲不冲),回放审重演历史(首装无历史即免,以样例集替代)。
|
||||
* **恒为新增、版本并存**:装入不覆盖旧版本,在途任务按其启动时版本跑完。
|
||||
* **路径(2026-08-27)**:`/api/gov/activity-packages` —— 从前叫 `/api/gov/activities`;那个前缀整个让给了运营端
|
||||
* 活动模块(L1/L2/L3,`api/activity-ops.ts`),这里管的是**包**(版本 / 资产 / 装入),名副其实。
|
||||
*/
|
||||
export declare const installActivityRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/activities";
|
||||
readonly path: "/api/gov/activity-packages";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
manifest: z.ZodObject<{
|
||||
identity: z.ZodObject<{
|
||||
@@ -1935,6 +2117,7 @@ export declare const installActivityRoute: {
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||
file: z.ZodOptional<z.ZodObject<{
|
||||
titleColumn: z.ZodString;
|
||||
bodyColumn: z.ZodString;
|
||||
@@ -2049,6 +2232,13 @@ export declare const installActivityRoute: {
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("../auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("../auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -2070,6 +2260,32 @@ export declare const installActivityRoute: {
|
||||
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
data: z.ZodOptional<z.ZodObject<{
|
||||
case: z.ZodObject<{
|
||||
ledger: z.ZodString;
|
||||
key: z.ZodString;
|
||||
prefix: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
rows: z.ZodArray<z.ZodObject<{
|
||||
as: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
by: z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
not: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
||||
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
@@ -2126,6 +2342,7 @@ export declare const installActivityRoute: {
|
||||
export declare const registerToolRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/tools";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
contract: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -2167,9 +2384,11 @@ export declare const registerToolRoute: {
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 路径同 `installActivityRoute` 的说明:`/api/gov/activity-packages`(2026-08-27 让位给运营端活动模块) */
|
||||
export declare const listActivitiesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/activities";
|
||||
readonly path: "/api/gov/activity-packages";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
activities: z.ZodArray<z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
@@ -2228,6 +2447,7 @@ export declare const listActivitiesRoute: {
|
||||
export declare const listToolsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/tools";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
tools: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -2246,6 +2466,7 @@ export declare const listToolsRoute: {
|
||||
export declare const listLedgerObjectsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/data/objects";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
|
||||
readonly output: z.ZodObject<{
|
||||
objects: z.ZodArray<z.ZodObject<{
|
||||
@@ -2278,6 +2499,7 @@ export declare const listLedgerObjectsRoute: {
|
||||
export declare const ledgerByActivityRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/data/by-activity";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
readonly output: z.ZodObject<{
|
||||
activities: z.ZodArray<z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
@@ -2313,6 +2535,7 @@ export declare const ledgerByActivityRoute: {
|
||||
export declare const browseLedgerRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/data/:domain/:object";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
readonly params: z.ZodObject<{
|
||||
domain: z.ZodString;
|
||||
object: z.ZodString;
|
||||
@@ -2332,6 +2555,7 @@ export declare const browseLedgerRoute: {
|
||||
export declare const listActivityMemoryRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/memory/:activityId";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2350,6 +2574,7 @@ export declare const listActivityMemoryRoute: {
|
||||
export declare const listChannelsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/channels";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
readonly output: z.ZodObject<{
|
||||
channels: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -2382,6 +2607,7 @@ export declare const listChannelsRoute: {
|
||||
export declare const listApprovalsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/approvals";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
approvals: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -2409,6 +2635,7 @@ export declare const listApprovalsRoute: {
|
||||
export declare const setTriggerConfirmRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/triggers/:id/confirm";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2424,6 +2651,7 @@ export declare const setTriggerConfirmRoute: {
|
||||
export declare const listNodeOwnersRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/packages/:id/owners";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2437,6 +2665,7 @@ export declare const listNodeOwnersRoute: {
|
||||
export declare const setNodeOwnerRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/packages/:id/owners/:nodeId";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
nodeId: z.ZodString;
|
||||
@@ -2452,6 +2681,7 @@ export declare const setNodeOwnerRoute: {
|
||||
export declare const testRunRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/testbed/run";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
packageId: z.ZodString;
|
||||
mail: z.ZodObject<{
|
||||
@@ -2491,6 +2721,7 @@ export declare const testRunRoute: {
|
||||
export declare const testRunActivityRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/testbed/run-activity";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
activityId: z.ZodString;
|
||||
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
@@ -2510,6 +2741,7 @@ export declare const testRunActivityRoute: {
|
||||
export declare const testStopRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/testbed/stop";
|
||||
readonly access: "design";
|
||||
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
|
||||
readonly body: z.ZodObject<{
|
||||
workflowId: z.ZodDefault<z.ZodString>;
|
||||
@@ -2532,6 +2764,7 @@ export declare const testStopRoute: {
|
||||
export declare const testCurrentRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/current";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
workflowId: z.ZodOptional<z.ZodString>;
|
||||
subject: z.ZodOptional<z.ZodString>;
|
||||
@@ -2551,6 +2784,7 @@ export declare const testCurrentRoute: {
|
||||
export declare const testActivityStateRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/activity/:taskId";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2601,6 +2835,11 @@ export declare const testActivityStateRoute: {
|
||||
execution: "execution";
|
||||
acceptance: "acceptance";
|
||||
}>;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
accept: "accept";
|
||||
}>>;
|
||||
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||
title: z.ZodString;
|
||||
done: z.ZodBoolean;
|
||||
result: z.ZodOptional<z.ZodString>;
|
||||
@@ -2617,6 +2856,7 @@ export declare const testActivityStateRoute: {
|
||||
export declare const uploadFileRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/upload";
|
||||
readonly access: "design";
|
||||
readonly body: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodDefault<z.ZodString>;
|
||||
@@ -2636,6 +2876,7 @@ export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
|
||||
export declare const downloadFileRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/upload/:id";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2685,6 +2926,7 @@ export type TestLogRow = z.infer<typeof testLogRowSchema>;
|
||||
export declare const listTestLogsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/logs";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
runs: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -2720,6 +2962,7 @@ export declare const listTestLogsRoute: {
|
||||
export declare const testLogDetailRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/testbed/logs/:id";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2774,6 +3017,7 @@ export declare const testLogDetailRoute: {
|
||||
export declare const deleteTestLogRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/testbed/logs/:id";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2784,6 +3028,7 @@ export declare const deleteTestLogRoute: {
|
||||
export declare const clearTestLogsRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/testbed/logs";
|
||||
readonly access: "design";
|
||||
readonly output: z.ZodObject<{
|
||||
deleted: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
@@ -2792,6 +3037,7 @@ export declare const clearTestLogsRoute: {
|
||||
export declare const traceOfWorkflowRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/workflows/:id/trace";
|
||||
readonly access: "design";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2825,6 +3071,7 @@ export declare const traceOfWorkflowRoute: {
|
||||
export declare const govWorkflowRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/workflows/:id";
|
||||
readonly access: readonly ["design", "ops"];
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -2986,6 +3233,7 @@ export declare const govWorkflowRoute: {
|
||||
export declare const govMetricsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/metrics";
|
||||
readonly access: "ops";
|
||||
/** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */
|
||||
readonly query: z.ZodObject<{
|
||||
period: z.ZodOptional<z.ZodEnum<{
|
||||
|
||||
+23
-4
@@ -4,16 +4,35 @@
|
||||
* 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。
|
||||
* **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。
|
||||
*/
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
export declare const HTTP_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
/**
|
||||
* 三个端(ADR-012):设计中心 / 运营中心 / 工作台。
|
||||
* 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。
|
||||
*/
|
||||
export declare const SURFACES: readonly ["design", "ops", "workbench"];
|
||||
export declare const surfaceSchema: z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>;
|
||||
export type Surface = z.infer<typeof surfaceSchema>;
|
||||
export declare const SURFACE_LABEL: Readonly<Record<Surface, string>>;
|
||||
/**
|
||||
* 一条路由归哪个端(ADR-012):
|
||||
* `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。
|
||||
* 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。
|
||||
*/
|
||||
export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[];
|
||||
/**
|
||||
* 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。
|
||||
* body 只在 POST/PATCH 上有意义;GET 用 query。
|
||||
* body 只在 POST/PUT/PATCH 上有意义;GET 用 query。
|
||||
*/
|
||||
export type RouteContract<TParams extends z.ZodTypeAny = z.ZodTypeAny, TQuery extends z.ZodTypeAny = z.ZodTypeAny, TBody extends z.ZodTypeAny = z.ZodTypeAny, TOutput extends z.ZodTypeAny = z.ZodTypeAny> = {
|
||||
readonly method: HttpMethod;
|
||||
readonly path: string;
|
||||
readonly access: RouteAccess;
|
||||
readonly params?: TParams;
|
||||
readonly query?: TQuery;
|
||||
readonly body?: TBody;
|
||||
@@ -21,8 +40,8 @@ export type RouteContract<TParams extends z.ZodTypeAny = z.ZodTypeAny, TQuery ex
|
||||
};
|
||||
export type RouteOutput<R extends RouteContract> = z.infer<R['output']>;
|
||||
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny ? z.infer<R['body']> : never;
|
||||
/** 结构化错误:前端按 code 分辨,不解析文案 */
|
||||
export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal"];
|
||||
/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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';
|
||||
export * from './activity-ops.ts';
|
||||
|
||||
@@ -26,6 +26,7 @@ export type InboundMail = z.infer<typeof inboundMailSchema>;
|
||||
export declare const ingestMailRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/ingest/channel/:channelId/mail";
|
||||
readonly access: "public";
|
||||
readonly params: z.ZodObject<{
|
||||
channelId: z.core.$ZodBranded<z.ZodString, "ChannelId", "out">;
|
||||
}, z.core.$strip>;
|
||||
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
/**
|
||||
* 邮件收发的路由契约(设计稿 §7.2;ADR-013)。
|
||||
*
|
||||
* 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决):邮箱那一屏落在运营中心,设计端没有消费方;
|
||||
* 而入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处后果,不再无故加宽。
|
||||
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
|
||||
* 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { RouteContract } from './http.ts';
|
||||
export declare const listMailAccountsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/mail/accounts";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
accounts: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const createMailAccountRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/mail/accounts";
|
||||
readonly access: "ops";
|
||||
readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodPrefault<z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
password: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
address: z.ZodEmail;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
corpSecret: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
};
|
||||
/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */
|
||||
export declare const probeMailDraftRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/mail/accounts/probe";
|
||||
readonly access: "ops";
|
||||
/** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */
|
||||
readonly query: z.ZodObject<{
|
||||
accountId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodPrefault<z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
password: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
address: z.ZodEmail;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
corpSecret: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
readonly output: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"imap-smtp": "imap-smtp";
|
||||
wecom: "wecom";
|
||||
}>;
|
||||
address: z.ZodOptional<z.ZodString>;
|
||||
receive: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
send: z.ZodObject<{
|
||||
checked: z.ZodBoolean;
|
||||
ok: z.ZodOptional<z.ZodBoolean>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const getMailAccountRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/mail/accounts/:accountId";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
};
|
||||
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
|
||||
export declare const updateMailAccountRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/mail/accounts/:accountId";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodPrefault<z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
password: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
address: z.ZodEmail;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
corpSecret: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
};
|
||||
export declare const deleteMailAccountRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/mail/accounts/:accountId";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodLiteral<true>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 已存账号探测(用库里凭证);凭证空 412 */
|
||||
export declare const probeMailAccountRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/mail/accounts/:accountId/probe";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"imap-smtp": "imap-smtp";
|
||||
wecom: "wecom";
|
||||
}>;
|
||||
address: z.ZodOptional<z.ZodString>;
|
||||
receive: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
send: z.ZodObject<{
|
||||
checked: z.ZodBoolean;
|
||||
ok: z.ZodOptional<z.ZodBoolean>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 立即拉一轮;另一进程占着租约 409;停用 412 */
|
||||
export declare const pollMailAccountRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/mail/accounts/:accountId/poll";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
pulled: z.ZodNumber;
|
||||
stored: z.ZodNumber;
|
||||
dispatched: z.ZodNumber;
|
||||
more: z.ZodBoolean;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const sendTestMailRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/mail/accounts/:accountId/send-test";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodObject<{
|
||||
to: z.ZodEmail;
|
||||
subject: z.ZodString;
|
||||
text: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
outboundId: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
sent: "sent";
|
||||
}>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
providerRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const listMailInboundRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/mail/inbound";
|
||||
readonly access: "ops";
|
||||
readonly query: z.ZodObject<{
|
||||
accountId: z.ZodOptional<z.ZodString>;
|
||||
limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
mails: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
from: z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
to: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
cc: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
subject: z.ZodString;
|
||||
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
receivedAt: z.ZodISODateTime;
|
||||
textPreview: z.ZodString;
|
||||
hasHtml: z.ZodBoolean;
|
||||
attachments: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
ref: z.ZodNullable<z.ZodString>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
deliveries: z.ZodArray<z.ZodObject<{
|
||||
subscriber: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
done: "done";
|
||||
dispatching: "dispatching";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodISODateTime;
|
||||
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const getMailInboundRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/mail/inbound/:mailId";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
mailId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
from: z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
to: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
cc: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
subject: z.ZodString;
|
||||
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
receivedAt: z.ZodISODateTime;
|
||||
textPreview: z.ZodString;
|
||||
hasHtml: z.ZodBoolean;
|
||||
attachments: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
ref: z.ZodNullable<z.ZodString>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
deliveries: z.ZodArray<z.ZodObject<{
|
||||
subscriber: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
done: "done";
|
||||
dispatching: "dispatching";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodISODateTime;
|
||||
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>>;
|
||||
inReplyTo: z.ZodNullable<z.ZodString>;
|
||||
references: z.ZodArray<z.ZodString>;
|
||||
text: z.ZodNullable<z.ZodString>;
|
||||
html: z.ZodNullable<z.ZodString>;
|
||||
size: z.ZodNumber;
|
||||
rawAvailable: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */
|
||||
export declare const mailAttachmentRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/mail/inbound/:mailId/attachments/:index";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
mailId: z.ZodString;
|
||||
index: z.ZodCoercedNumber<unknown>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
dataBase64: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 原件 .eml;超限没存 404 */
|
||||
export declare const mailRawRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/mail/inbound/:mailId/raw";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
mailId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodLiteral<"message/rfc822">;
|
||||
dataBase64: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */
|
||||
export declare const redeliverMailInboundRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/mail/inbound/:mailId/redeliver";
|
||||
readonly access: "ops";
|
||||
readonly params: z.ZodObject<{
|
||||
mailId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodDefault<z.ZodObject<{
|
||||
subscriber: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
readonly output: z.ZodObject<{
|
||||
deliveries: z.ZodArray<z.ZodObject<{
|
||||
subscriber: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
done: "done";
|
||||
dispatching: "dispatching";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodISODateTime;
|
||||
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const listMailOutboundRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/mail/outbound";
|
||||
readonly access: "ops";
|
||||
readonly query: z.ZodObject<{
|
||||
accountId: z.ZodOptional<z.ZodString>;
|
||||
limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
mails: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
to: z.ZodArray<z.ZodString>;
|
||||
cc: z.ZodArray<z.ZodString>;
|
||||
bcc: z.ZodArray<z.ZodString>;
|
||||
subject: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
sending: "sending";
|
||||
sent: "sent";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
providerRef: z.ZodNullable<z.ZodString>;
|
||||
requestedBy: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 全部 15 条 —— 文档总表与覆盖测试按它数 */
|
||||
export declare const MAIL_ROUTES: readonly RouteContract[];
|
||||
@@ -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<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type OrgUser = z.infer<typeof orgUserSchema>;
|
||||
export declare const orgUserDetailSchema: z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
firstName: z.ZodOptional<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
emailVerified: z.ZodBoolean;
|
||||
groups: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type OrgUserDetail = z.infer<typeof orgUserDetailSchema>;
|
||||
export declare const listOrgUsersRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/org/users";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
users: z.ZodArray<z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
firstName: z.ZodOptional<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
emailVerified: z.ZodBoolean;
|
||||
groups: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, 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<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
password: z.ZodOptional<z.ZodString>;
|
||||
roles: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<true>;
|
||||
}, 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<true>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const orgRoleSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
composite: z.ZodBoolean;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
holders: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
export type OrgRole = z.infer<typeof orgRoleSchema>;
|
||||
export declare const listOrgRolesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/org/roles";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
roles: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
composite: z.ZodBoolean;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
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<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const orgGroupSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
path: z.ZodString;
|
||||
parentId: z.ZodOptional<z.ZodString>;
|
||||
depth: z.ZodNumber;
|
||||
memberCount: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
export type OrgGroup = z.infer<typeof orgGroupSchema>;
|
||||
/** 组树平表,按 `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<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
path: z.ZodString;
|
||||
parentId: z.ZodOptional<z.ZodString>;
|
||||
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<z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
path: z.ZodString;
|
||||
parentId: z.ZodOptional<z.ZodString>;
|
||||
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<z.ZodString>;
|
||||
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<true>;
|
||||
}, 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<true>;
|
||||
}, 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<z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
firstName: z.ZodOptional<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
emailVerified: z.ZodBoolean;
|
||||
groups: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
path: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, 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<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
password: z.ZodOptional<z.ZodString>;
|
||||
roles: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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<true>;
|
||||
}, 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<true>;
|
||||
}, z.core.$strip>;
|
||||
}, {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/org/roles";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
roles: z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
composite: z.ZodBoolean;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
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<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/org/groups";
|
||||
readonly access: "ops";
|
||||
readonly output: z.ZodObject<{
|
||||
groups: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
path: z.ZodString;
|
||||
parentId: z.ZodOptional<z.ZodString>;
|
||||
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<z.ZodObject<{
|
||||
sub: z.ZodString;
|
||||
username: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
enabled: z.ZodBoolean;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
lastSeenAt: z.ZodOptional<z.ZodString>;
|
||||
positionCode: z.ZodOptional<z.ZodString>;
|
||||
}, 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.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
path: z.ZodString;
|
||||
parentId: z.ZodOptional<z.ZodString>;
|
||||
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<z.ZodString>;
|
||||
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<true>;
|
||||
}, 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<true>;
|
||||
}, z.core.$strip>;
|
||||
}];
|
||||
+150
-6
@@ -3,20 +3,27 @@
|
||||
* 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
|
||||
export declare const sessionRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/session";
|
||||
readonly access: "public";
|
||||
readonly output: z.ZodObject<{
|
||||
staff: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
@@ -56,13 +63,11 @@ export declare const sessionRoute: {
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。
|
||||
* 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。
|
||||
*/
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
|
||||
export declare const loginRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/login";
|
||||
readonly access: "public";
|
||||
readonly body: z.ZodObject<{
|
||||
account: z.ZodString;
|
||||
password: z.ZodString;
|
||||
@@ -72,11 +77,17 @@ export declare const loginRoute: {
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
@@ -116,10 +127,11 @@ export declare const loginRoute: {
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */
|
||||
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
|
||||
export declare const logoutRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/logout";
|
||||
readonly access: "public";
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
@@ -127,6 +139,7 @@ export declare const logoutRoute: {
|
||||
export declare const healthRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/health";
|
||||
readonly access: "public";
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
version: z.ZodString;
|
||||
@@ -136,6 +149,55 @@ export declare const healthRoute: {
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 我是谁、我能进哪几个端(ADR-012)—— 中间件解好 principal 放进上下文,这里只回投影 */
|
||||
export declare const meRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/me";
|
||||
readonly access: "any";
|
||||
readonly output: z.ZodObject<{
|
||||
staff: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路:
|
||||
* `EventSource('/api/stream')` 与 `<a href="…&raw=1" download>`。GET 上 `?ticket=` 认它。
|
||||
*/
|
||||
export declare const authTicketRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/auth/ticket";
|
||||
readonly access: "any";
|
||||
readonly output: z.ZodObject<{
|
||||
ticket: z.ZodString;
|
||||
expiresAt: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 任务卡 —— 清单里一条 = 一个活动的一次运行,不是一整条工作流 */
|
||||
export declare const taskCardSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
@@ -177,6 +239,7 @@ export type TaskCard = z.infer<typeof taskCardSchema>;
|
||||
export declare const listTasksRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/desk/tasks";
|
||||
readonly access: "workbench";
|
||||
readonly output: z.ZodObject<{
|
||||
tasks: z.ZodArray<z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
@@ -595,6 +658,10 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
preset: z.ZodOptional<z.ZodEnum<{
|
||||
"landing-failure": "landing-failure";
|
||||
}>>;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
accept: "accept";
|
||||
}>>;
|
||||
ask: z.ZodString;
|
||||
standard: z.ZodOptional<z.ZodString>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -840,6 +907,9 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
gateWhen: z.ZodOptional<z.ZodString>;
|
||||
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -863,6 +933,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
@@ -881,6 +952,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
effect: z.ZodEnum<{
|
||||
@@ -1153,6 +1225,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -1171,6 +1244,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "mode">>;
|
||||
@@ -1179,6 +1253,10 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
preset: z.ZodOptional<z.ZodEnum<{
|
||||
"landing-failure": "landing-failure";
|
||||
}>>;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
accept: "accept";
|
||||
}>>;
|
||||
ask: z.ZodString;
|
||||
standard: z.ZodOptional<z.ZodString>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -1424,6 +1502,9 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
gateWhen: z.ZodOptional<z.ZodString>;
|
||||
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -1447,6 +1528,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
@@ -1465,6 +1547,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
effect: z.ZodEnum<{
|
||||
@@ -1737,6 +1820,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -1755,6 +1839,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "mode">>>;
|
||||
@@ -1798,6 +1883,10 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"decision">;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
review: "review";
|
||||
}>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
@@ -1817,6 +1906,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
@@ -2138,6 +2228,7 @@ export type TaskDetail = z.infer<typeof taskDetailSchema>;
|
||||
export declare const taskDetailRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/desk/tasks/:taskId";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -2499,6 +2590,10 @@ export declare const taskDetailRoute: {
|
||||
preset: z.ZodOptional<z.ZodEnum<{
|
||||
"landing-failure": "landing-failure";
|
||||
}>>;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
accept: "accept";
|
||||
}>>;
|
||||
ask: z.ZodString;
|
||||
standard: z.ZodOptional<z.ZodString>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -2744,6 +2839,9 @@ export declare const taskDetailRoute: {
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
gateWhen: z.ZodOptional<z.ZodString>;
|
||||
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -2767,6 +2865,7 @@ export declare const taskDetailRoute: {
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
@@ -2785,6 +2884,7 @@ export declare const taskDetailRoute: {
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
effect: z.ZodEnum<{
|
||||
@@ -3057,6 +3157,7 @@ export declare const taskDetailRoute: {
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -3075,6 +3176,7 @@ export declare const taskDetailRoute: {
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "mode">>;
|
||||
@@ -3083,6 +3185,10 @@ export declare const taskDetailRoute: {
|
||||
preset: z.ZodOptional<z.ZodEnum<{
|
||||
"landing-failure": "landing-failure";
|
||||
}>>;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
accept: "accept";
|
||||
}>>;
|
||||
ask: z.ZodString;
|
||||
standard: z.ZodOptional<z.ZodString>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -3328,6 +3434,9 @@ export declare const taskDetailRoute: {
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
gateWhen: z.ZodOptional<z.ZodString>;
|
||||
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -3351,6 +3460,7 @@ export declare const taskDetailRoute: {
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
@@ -3369,6 +3479,7 @@ export declare const taskDetailRoute: {
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
effect: z.ZodEnum<{
|
||||
@@ -3641,6 +3752,7 @@ export declare const taskDetailRoute: {
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -3659,6 +3771,7 @@ export declare const taskDetailRoute: {
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "mode">>>;
|
||||
@@ -3702,6 +3815,10 @@ export declare const taskDetailRoute: {
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"decision">;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
review: "review";
|
||||
}>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
@@ -3721,6 +3838,7 @@ export declare const taskDetailRoute: {
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
@@ -4043,6 +4161,7 @@ export declare const taskDetailRoute: {
|
||||
export declare const replyWorkOrderRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/work-orders/reply";
|
||||
readonly access: "workbench";
|
||||
readonly body: z.ZodObject<{
|
||||
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>;
|
||||
@@ -4058,6 +4177,7 @@ export declare const replyWorkOrderRoute: {
|
||||
export declare const replyChatCardRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/chat-cards/reply";
|
||||
readonly access: "workbench";
|
||||
readonly body: z.ZodObject<{
|
||||
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
}, z.core.$strip>;
|
||||
@@ -4072,6 +4192,7 @@ export declare const replyChatCardRoute: {
|
||||
export declare const markCommReadRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/comms/:commId/read";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
commId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -4120,6 +4241,7 @@ export declare const markCommReadRoute: {
|
||||
export declare const saveTableRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/tasks/:taskId/table";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -4149,6 +4271,7 @@ export declare const saveTableRoute: {
|
||||
export declare const openAssetFileRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/desk/tasks/:taskId/file";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -4199,6 +4322,7 @@ export declare const openAssetFileRoute: {
|
||||
export declare const sayRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/tasks/:taskId/say";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||
}, z.core.$strip>;
|
||||
@@ -4236,6 +4360,10 @@ export declare const sayRoute: {
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"decision">;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
review: "review";
|
||||
}>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
@@ -4255,6 +4383,7 @@ export declare const sayRoute: {
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
@@ -4393,6 +4522,7 @@ export type SystemMessageView = z.infer<typeof systemMessageSchema>;
|
||||
export declare const listMessagesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/desk/messages";
|
||||
readonly access: "workbench";
|
||||
readonly output: z.ZodObject<{
|
||||
messages: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -4448,6 +4578,7 @@ export declare const listMessagesRoute: {
|
||||
export declare const markMessageReadRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/messages/:messageId/read";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
messageId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -4459,6 +4590,7 @@ export declare const markMessageReadRoute: {
|
||||
export declare const answerMessageRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/messages/:messageId/answer";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
messageId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -4473,6 +4605,7 @@ export declare const answerMessageRoute: {
|
||||
export declare const confirmTriggerPendingRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/trigger-pending/:id/confirm";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -4521,6 +4654,7 @@ export type MinuteView = z.infer<typeof minuteSchema>;
|
||||
export declare const listMinutesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/minutes";
|
||||
readonly access: "workbench";
|
||||
readonly output: z.ZodObject<{
|
||||
minutes: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -4550,6 +4684,7 @@ export declare const listMinutesRoute: {
|
||||
export declare const createMinuteRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/minutes";
|
||||
readonly access: "workbench";
|
||||
readonly body: z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
body: z.ZodString;
|
||||
@@ -4587,6 +4722,7 @@ export declare const createMinuteRoute: {
|
||||
export declare const appendResolutionRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/minutes/:minuteId/resolutions";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
minuteId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -4622,6 +4758,7 @@ export declare const appendResolutionRoute: {
|
||||
export declare const registerResolutionRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/minutes/resolutions/:resolutionId/register";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
resolutionId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -4704,6 +4841,7 @@ export type ReportView = z.infer<typeof reportSchema>;
|
||||
export declare const draftReportRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/reports/:period";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
period: z.ZodEnum<{
|
||||
day: "day";
|
||||
@@ -4759,6 +4897,7 @@ export declare const draftReportRoute: {
|
||||
export declare const listReportsRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/reports";
|
||||
readonly access: "workbench";
|
||||
readonly output: z.ZodObject<{
|
||||
reports: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -4809,6 +4948,7 @@ export declare const listReportsRoute: {
|
||||
export declare const submitReportRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/reports/:reportId/submit";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
reportId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
@@ -4884,6 +5024,7 @@ export type ApprovalItem = z.infer<typeof approvalItemSchema>;
|
||||
export declare const listApprovalsForMeRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/approvals";
|
||||
readonly access: "workbench";
|
||||
readonly output: z.ZodObject<{
|
||||
approvals: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -4910,6 +5051,7 @@ export declare const listApprovalsForMeRoute: {
|
||||
export declare const decideApprovalRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/approvals/:workflowId/:nodeId/decide";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
workflowId: z.ZodString;
|
||||
nodeId: z.ZodString;
|
||||
@@ -4940,6 +5082,7 @@ export type ListApprovalsForMeOutput = z.infer<typeof listApprovalsForMeRoute.ou
|
||||
export declare const listRegistrationTriggersRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/triggers/registration";
|
||||
readonly access: "workbench";
|
||||
readonly output: z.ZodObject<{
|
||||
triggers: z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
@@ -4953,6 +5096,7 @@ export declare const listRegistrationTriggersRoute: {
|
||||
export declare const fireRegistrationRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/triggers/:triggerId/register";
|
||||
readonly access: "workbench";
|
||||
readonly params: z.ZodObject<{
|
||||
triggerId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 确定性步骤的**声明式配方**(ADR-014 / spec 2026-08-29 §5.1)。
|
||||
*
|
||||
* 配方是**数据不是代码**:装进来仍然只是数据,可静态审、可回放、可在治理台上摆出
|
||||
* 「这一步系统会做什么」。执行第三方代码则相反 —— ADR-004 的立场是「不执行包代码即知包为何物」。
|
||||
*
|
||||
* **只声明真实现了的那几式。** 表达力有上限是刻意的:上限之外的不要硬塞,
|
||||
* 退回模型,或者把那一段判断从这一步里切出去。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/** 表达式 —— 递归,`z.lazy` 是唯一写法(zod 的自引用) */
|
||||
export type AutoExpr = string | number | boolean
|
||||
/** 入参 / 基座读数 / `let` 里算好的值 —— 三者同一个命名空间,`let` 优先 */
|
||||
| {
|
||||
readonly in: string;
|
||||
}
|
||||
/** 本行的列 */
|
||||
| {
|
||||
readonly col: string;
|
||||
}
|
||||
/** 外层那一行的列(两层嵌套时) */
|
||||
| {
|
||||
readonly outer: string;
|
||||
}
|
||||
/** 拼接,一律按字符串 */
|
||||
| {
|
||||
readonly cat: readonly AutoExpr[];
|
||||
}
|
||||
/** 只留数字(铸号:`case:openedAt` → `20260829215103`) */
|
||||
| {
|
||||
readonly fmt: AutoExpr;
|
||||
readonly as: 'digits';
|
||||
}
|
||||
/** 查另一张表的一列;查不到给 `else`(缺省空串) */
|
||||
| {
|
||||
readonly lookup: string;
|
||||
readonly by: Readonly<Record<string, AutoExpr>>;
|
||||
readonly take: string;
|
||||
readonly else?: AutoExpr | undefined;
|
||||
}
|
||||
/** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */
|
||||
| {
|
||||
readonly when: string;
|
||||
readonly then: AutoExpr;
|
||||
readonly else: AutoExpr;
|
||||
};
|
||||
export declare const autoExprSchema: z.ZodType<AutoExpr>;
|
||||
/**
|
||||
* 数据源 —— 这一笔要对着几行办。
|
||||
*
|
||||
* 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)·
|
||||
* `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
|
||||
* **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。
|
||||
*/
|
||||
export type AutoSource = {
|
||||
readonly once: true;
|
||||
} | {
|
||||
readonly rows: readonly Readonly<Record<string, AutoExpr>>[];
|
||||
} | {
|
||||
readonly each: string;
|
||||
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
/** 第二层,**到此为止** —— 三层以上就别 auto 了 */
|
||||
readonly then?: {
|
||||
readonly each: string;
|
||||
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
} | undefined;
|
||||
};
|
||||
export declare const autoSourceSchema: z.ZodType<AutoSource>;
|
||||
/** 五个动词 —— `when` 为假就跳过这一笔 */
|
||||
export type AutoAction = {
|
||||
readonly kind: 'project';
|
||||
readonly when?: string | undefined;
|
||||
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
|
||||
readonly into: string;
|
||||
/**
|
||||
* 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层。
|
||||
* `true`:只在非 quiet 拍撤 —— 人正对着件编辑的那几拍不动他的行(2026-08-29 终审 F3:
|
||||
* 人刚在件上加的行,配方的数据源里当然没有,quiet 撤行就是静默数据丢失)。
|
||||
* `'live'`(2026-08-31):quiet 拍也撤 —— **只给人不会在件上加行的表用**(行集全由配方铺,
|
||||
* 如 W05 的参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表没有这个风险,
|
||||
* 而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎 —— 跟手比守候更诚实。
|
||||
* 已落地的行两档都跳过并出声(draftDrop 的 skipLanded)。
|
||||
*/
|
||||
readonly prune?: boolean | 'live' | undefined;
|
||||
readonly from: AutoSource;
|
||||
readonly fields: Readonly<Record<string, AutoExpr>>;
|
||||
} | {
|
||||
readonly kind: 'analyze';
|
||||
readonly when?: string | undefined;
|
||||
/** 活动声明过的模型名 */
|
||||
readonly model: string;
|
||||
/** 出参落进拍内作用域 `result:<into>`,供后面的 project 用 `each`/`lookup` 消费 */
|
||||
readonly into: string;
|
||||
/** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */
|
||||
readonly instruction: string;
|
||||
/** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */
|
||||
readonly input: {
|
||||
readonly from: AutoSource;
|
||||
readonly fields: Readonly<Record<string, AutoExpr>>;
|
||||
};
|
||||
/** 出参 JSON Schema —— 顶层必须是 object */
|
||||
readonly output: Readonly<Record<string, unknown>>;
|
||||
} | {
|
||||
readonly kind: 'card';
|
||||
readonly when?: string | undefined;
|
||||
readonly as: 'execution' | 'decide';
|
||||
/**
|
||||
* 卡的面孔:`data` 数据工单 / `accept` 验收工单(都是执行工单的变体)· `review` 审核卡(决策卡的)。
|
||||
* 不写就是普通执行工单 / 普通决策卡。
|
||||
*/
|
||||
readonly variant?: ('data' | 'review' | 'accept') | undefined;
|
||||
readonly title: AutoExpr;
|
||||
/** 决策卡问的那句话 */
|
||||
readonly ask?: AutoExpr | undefined;
|
||||
/** 作业指导书 —— 一条一句,静态文本 */
|
||||
readonly guide?: readonly string[] | undefined;
|
||||
readonly standard?: AutoExpr | undefined;
|
||||
/** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */
|
||||
readonly basis?: readonly {
|
||||
readonly k: string;
|
||||
readonly v: AutoExpr;
|
||||
}[] | undefined;
|
||||
/** 这张卡的提交闸盯哪几张表 */
|
||||
readonly gateLedgers?: readonly string[] | undefined;
|
||||
/** 确认闸的条件档(S-FEEL,只读基座读数;为假不受理确认)+ 拒回那句话 —— 见 card.ts 同名字段 */
|
||||
readonly gateWhen?: string | undefined;
|
||||
readonly gateWhenSay?: string | undefined;
|
||||
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */
|
||||
readonly options?: readonly {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly detail?: string | undefined;
|
||||
}[] | undefined;
|
||||
} | {
|
||||
readonly kind: 'submit';
|
||||
readonly when?: string | undefined;
|
||||
readonly rationale: AutoExpr;
|
||||
} | {
|
||||
readonly kind: 'say';
|
||||
readonly when?: string | undefined;
|
||||
readonly text: AutoExpr;
|
||||
};
|
||||
export declare const autoActionSchema: z.ZodType<AutoAction>;
|
||||
export type AutoRecipe = {
|
||||
/** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */
|
||||
readonly let?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
/** 按序执行 */
|
||||
readonly do: readonly AutoAction[];
|
||||
};
|
||||
export declare const autoRecipeSchema: z.ZodType<AutoRecipe>;
|
||||
+40
@@ -604,6 +604,7 @@ export declare const workDoneSchema: z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
export type WorkDone = z.infer<typeof workDoneSchema>;
|
||||
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
|
||||
@@ -612,6 +613,10 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
preset: z.ZodOptional<z.ZodEnum<{
|
||||
"landing-failure": "landing-failure";
|
||||
}>>;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
accept: "accept";
|
||||
}>>;
|
||||
ask: z.ZodString;
|
||||
standard: z.ZodOptional<z.ZodString>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -857,6 +862,9 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
gateWhen: z.ZodOptional<z.ZodString>;
|
||||
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -880,6 +888,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
@@ -926,6 +935,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
effect: z.ZodEnum<{
|
||||
@@ -1198,6 +1208,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -1216,6 +1227,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
@@ -1225,6 +1237,10 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
preset: z.ZodOptional<z.ZodEnum<{
|
||||
"landing-failure": "landing-failure";
|
||||
}>>;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
accept: "accept";
|
||||
}>>;
|
||||
ask: z.ZodString;
|
||||
standard: z.ZodOptional<z.ZodString>;
|
||||
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -1470,6 +1486,9 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||
}, z.core.$strip>>;
|
||||
submitLabel: z.ZodOptional<z.ZodString>;
|
||||
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
gateWhen: z.ZodOptional<z.ZodString>;
|
||||
gateWhenSay: z.ZodOptional<z.ZodString>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -1493,6 +1512,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
@@ -1511,6 +1531,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settled: z.ZodOptional<z.ZodBoolean>;
|
||||
rationale: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
label: z.ZodString;
|
||||
effect: z.ZodEnum<{
|
||||
@@ -1783,6 +1804,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
because: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
passLabel: z.ZodOptional<z.ZodString>;
|
||||
redoable: z.ZodOptional<z.ZodBoolean>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||
at: z.ZodISODateTime;
|
||||
title: z.ZodString;
|
||||
@@ -1801,6 +1823,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
pass: "pass";
|
||||
}>;
|
||||
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "mode">;
|
||||
@@ -1817,6 +1840,10 @@ export declare const AI_TAG_LABEL: Readonly<Record<AiTag, string>>;
|
||||
/** 决策卡:分支条件选择表;含自定义收尾项 */
|
||||
export declare const decisionCardSchema: z.ZodObject<{
|
||||
k: z.ZodLiteral<"decision">;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
review: "review";
|
||||
}>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
@@ -1836,6 +1863,7 @@ export declare const decisionCardSchema: z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
@@ -1929,6 +1957,10 @@ export declare const ratingCardSchema: z.ZodObject<{
|
||||
export type RatingCard = z.infer<typeof ratingCardSchema>;
|
||||
export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
k: z.ZodLiteral<"decision">;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
review: "review";
|
||||
}>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
@@ -1948,6 +1980,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
@@ -2062,6 +2095,10 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"decision">;
|
||||
variant: z.ZodOptional<z.ZodEnum<{
|
||||
review: "review";
|
||||
}>>;
|
||||
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
ask: z.ZodString;
|
||||
basis: z.ZodArray<z.ZodObject<{
|
||||
k: z.ZodString;
|
||||
@@ -2081,6 +2118,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
at: z.ZodISODateTime;
|
||||
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>;
|
||||
finalHtml: z.ZodOptional<z.ZodString>;
|
||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
@@ -2178,6 +2216,7 @@ export declare const workOrderReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
||||
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
comment: z.ZodOptional<z.ZodString>;
|
||||
selectedPorts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>], "mode">;
|
||||
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
|
||||
export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
@@ -2185,6 +2224,7 @@ export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
value: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
k: z.ZodLiteral<"elicitation">;
|
||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||
|
||||
@@ -13,4 +13,7 @@ export * from './ledger.ts';
|
||||
export * from './process-package.ts';
|
||||
export * from './manifest.ts';
|
||||
export * from './org.ts';
|
||||
export * from './mail.ts';
|
||||
export * from './activity-ops.ts';
|
||||
export * from './auto-recipe.ts';
|
||||
export * from './api/index.ts';
|
||||
|
||||
@@ -100,6 +100,7 @@ export declare const objectDeclarationSchema: z.ZodObject<{
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||
file: z.ZodOptional<z.ZodObject<{
|
||||
titleColumn: z.ZodString;
|
||||
bodyColumn: z.ZodString;
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。
|
||||
*
|
||||
* 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。
|
||||
* 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱),
|
||||
* 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const MAIL_ACCOUNT_KINDS: readonly ["imap-smtp", "wecom"];
|
||||
export declare const mailAccountKindSchema: z.ZodEnum<{
|
||||
"imap-smtp": "imap-smtp";
|
||||
wecom: "wecom";
|
||||
}>;
|
||||
export type MailAccountKind = z.infer<typeof mailAccountKindSchema>;
|
||||
export declare const MAIL_ACCOUNT_KIND_LABEL: Readonly<Record<MailAccountKind, string>>;
|
||||
/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */
|
||||
export declare const imapSmtpSettingsSchema: z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type ImapSmtpSettings = z.infer<typeof imapSmtpSettingsSchema>;
|
||||
/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */
|
||||
export declare const wecomSettingsSchema: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
export type WecomSettings = z.infer<typeof wecomSettingsSchema>;
|
||||
export declare const mailAccountInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodPrefault<z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
password: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
address: z.ZodEmail;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
secret: z.ZodOptional<z.ZodObject<{
|
||||
corpSecret: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
name: z.ZodString;
|
||||
displayName: z.ZodDefault<z.ZodString>;
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
export type MailAccountInput = z.infer<typeof mailAccountInputSchema>;
|
||||
export declare const mailAccountStatusSchema: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
export declare const mailAccountViewSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
imapHost: z.ZodDefault<z.ZodString>;
|
||||
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||
smtpHost: z.ZodDefault<z.ZodString>;
|
||||
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||
login: z.ZodOptional<z.ZodString>;
|
||||
folder: z.ZodDefault<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"imap-smtp">;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
settings: z.ZodObject<{
|
||||
corpId: z.ZodString;
|
||||
apiBase: z.ZodDefault<z.ZodURL>;
|
||||
}, z.core.$strip>;
|
||||
id: z.ZodString;
|
||||
name: z.ZodString;
|
||||
address: z.ZodString;
|
||||
displayName: z.ZodString;
|
||||
enabled: z.ZodBoolean;
|
||||
isDefault: z.ZodBoolean;
|
||||
pollIntervalSec: z.ZodNumber;
|
||||
channelId: z.ZodNullable<z.ZodString>;
|
||||
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||
secretBound: z.ZodBoolean;
|
||||
status: z.ZodObject<{
|
||||
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
lastPollError: z.ZodNullable<z.ZodString>;
|
||||
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
cursor: z.ZodNullable<z.ZodString>;
|
||||
polling: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
updatedAt: z.ZodISODateTime;
|
||||
kind: z.ZodLiteral<"wecom">;
|
||||
}, z.core.$strip>], "kind">;
|
||||
export type MailAccountView = z.infer<typeof mailAccountViewSchema>;
|
||||
/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */
|
||||
export declare const mailProbeReportSchema: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
"imap-smtp": "imap-smtp";
|
||||
wecom: "wecom";
|
||||
}>;
|
||||
address: z.ZodOptional<z.ZodString>;
|
||||
receive: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
send: z.ZodObject<{
|
||||
checked: z.ZodBoolean;
|
||||
ok: z.ZodOptional<z.ZodBoolean>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
export type MailProbeReport = z.infer<typeof mailProbeReportSchema>;
|
||||
export declare const MAIL_DELIVERY_STATES: readonly ["dispatching", "done", "failed", "skipped"];
|
||||
export declare const mailDeliveryStateSchema: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
done: "done";
|
||||
dispatching: "dispatching";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
export type MailDeliveryState = z.infer<typeof mailDeliveryStateSchema>;
|
||||
export declare const mailDeliveryViewSchema: z.ZodObject<{
|
||||
subscriber: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
done: "done";
|
||||
dispatching: "dispatching";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodISODateTime;
|
||||
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>;
|
||||
export type MailDeliveryView = z.infer<typeof mailDeliveryViewSchema>;
|
||||
export declare const mailAddressSchema: z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type MailAddress = z.infer<typeof mailAddressSchema>;
|
||||
export declare const mailInboundAttachmentSchema: z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
ref: z.ZodNullable<z.ZodString>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type MailInboundAttachment = z.infer<typeof mailInboundAttachmentSchema>;
|
||||
export declare const mailInboundSummarySchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
from: z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
to: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
cc: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
subject: z.ZodString;
|
||||
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
receivedAt: z.ZodISODateTime;
|
||||
textPreview: z.ZodString;
|
||||
hasHtml: z.ZodBoolean;
|
||||
attachments: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
ref: z.ZodNullable<z.ZodString>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
deliveries: z.ZodArray<z.ZodObject<{
|
||||
subscriber: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
done: "done";
|
||||
dispatching: "dispatching";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodISODateTime;
|
||||
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type MailInboundSummary = z.infer<typeof mailInboundSummarySchema>;
|
||||
export declare const mailInboundDetailSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
from: z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
to: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
cc: z.ZodArray<z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
name: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
subject: z.ZodString;
|
||||
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
receivedAt: z.ZodISODateTime;
|
||||
textPreview: z.ZodString;
|
||||
hasHtml: z.ZodBoolean;
|
||||
attachments: z.ZodArray<z.ZodObject<{
|
||||
index: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodString;
|
||||
size: z.ZodNumber;
|
||||
ref: z.ZodNullable<z.ZodString>;
|
||||
note: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
deliveries: z.ZodArray<z.ZodObject<{
|
||||
subscriber: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
done: "done";
|
||||
dispatching: "dispatching";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
startedAt: z.ZodISODateTime;
|
||||
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>>;
|
||||
inReplyTo: z.ZodNullable<z.ZodString>;
|
||||
references: z.ZodArray<z.ZodString>;
|
||||
text: z.ZodNullable<z.ZodString>;
|
||||
html: z.ZodNullable<z.ZodString>;
|
||||
size: z.ZodNumber;
|
||||
rawAvailable: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
export type MailInboundDetail = z.infer<typeof mailInboundDetailSchema>;
|
||||
export declare const mailSendInputSchema: z.ZodObject<{
|
||||
accountId: z.ZodOptional<z.ZodString>;
|
||||
from: z.ZodOptional<z.ZodString>;
|
||||
to: z.ZodArray<z.ZodEmail>;
|
||||
cc: z.ZodDefault<z.ZodArray<z.ZodEmail>>;
|
||||
bcc: z.ZodDefault<z.ZodArray<z.ZodEmail>>;
|
||||
subject: z.ZodString;
|
||||
text: z.ZodOptional<z.ZodString>;
|
||||
html: z.ZodOptional<z.ZodString>;
|
||||
inReplyTo: z.ZodOptional<z.ZodString>;
|
||||
references: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
contentType: z.ZodDefault<z.ZodString>;
|
||||
dataBase64: z.ZodOptional<z.ZodString>;
|
||||
uploadRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>>;
|
||||
idempotencyKey: z.ZodOptional<z.ZodString>;
|
||||
requestedBy: z.ZodObject<{
|
||||
kind: z.ZodEnum<{
|
||||
channel: "channel";
|
||||
tool: "tool";
|
||||
ops: "ops";
|
||||
}>;
|
||||
taskId: z.ZodOptional<z.ZodString>;
|
||||
staffId: z.ZodOptional<z.ZodString>;
|
||||
tool: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
}, z.core.$strip>;
|
||||
export type MailSendInput = z.infer<typeof mailSendInputSchema>;
|
||||
export declare const MAIL_OUTBOUND_STATES: readonly ["sending", "sent", "failed"];
|
||||
export declare const mailOutboundStateSchema: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
sending: "sending";
|
||||
sent: "sent";
|
||||
}>;
|
||||
export type MailOutboundState = z.infer<typeof mailOutboundStateSchema>;
|
||||
export declare const mailSendResultSchema: z.ZodObject<{
|
||||
outboundId: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
sent: "sent";
|
||||
}>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
providerRef: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type MailSendResult = z.infer<typeof mailSendResultSchema>;
|
||||
export declare const mailOutboundViewSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
accountId: z.ZodString;
|
||||
messageId: z.ZodString;
|
||||
to: z.ZodArray<z.ZodString>;
|
||||
cc: z.ZodArray<z.ZodString>;
|
||||
bcc: z.ZodArray<z.ZodString>;
|
||||
subject: z.ZodString;
|
||||
state: z.ZodEnum<{
|
||||
failed: "failed";
|
||||
sending: "sending";
|
||||
sent: "sent";
|
||||
}>;
|
||||
error: z.ZodNullable<z.ZodString>;
|
||||
providerRef: z.ZodNullable<z.ZodString>;
|
||||
requestedBy: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||
createdAt: z.ZodISODateTime;
|
||||
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||
}, z.core.$strip>;
|
||||
export type MailOutboundView = z.infer<typeof mailOutboundViewSchema>;
|
||||
export declare const mailPollOutcomeSchema: z.ZodObject<{
|
||||
accountId: z.ZodString;
|
||||
pulled: z.ZodNumber;
|
||||
stored: z.ZodNumber;
|
||||
dispatched: z.ZodNumber;
|
||||
more: z.ZodBoolean;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export type MailPollOutcome = z.infer<typeof mailPollOutcomeSchema>;
|
||||
+179
@@ -48,6 +48,7 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||
file: z.ZodOptional<z.ZodObject<{
|
||||
titleColumn: z.ZodString;
|
||||
bodyColumn: z.ZodString;
|
||||
@@ -201,6 +202,13 @@ export declare const planStepSchema: z.ZodObject<{
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>;
|
||||
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||
/**
|
||||
@@ -261,6 +269,13 @@ export declare const loopModelSchema: z.ZodObject<{
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -315,6 +330,13 @@ export declare const businessModelSchema: z.ZodObject<{
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -335,6 +357,95 @@ export declare const skillSchema: z.ZodObject<{
|
||||
version: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type Skill = z.infer<typeof skillSchema>;
|
||||
/**
|
||||
* 运营中心沙箱看板的**取数声明**(2026-08-27):模版归活动,「哪几行是这一单的」也归活动 —— 案号前缀、
|
||||
* 哪张表按哪一列圈,平台不该写死;基座只照声明逐表查**正式表**、把行集摊成扁平作用域交渲染器。
|
||||
*
|
||||
* 求值:先定案号 `case.prefix + 实例开案时刻 YYYYMMDDHHmmss`(与引擎注给模型铸主键的 `case:openedAt` 同一串);
|
||||
* `rows` 按序求值,每条 = `ledger` 里 `by.column ∈ values(by.from)` 的行(`from` 只能引用 `case` 或前面已求出的
|
||||
* 集合),可再用 `not` 排除一批、`orderBy` 排序。作用域里 `<as>` = 行数组、`<as>.<列>` = 首行标量、
|
||||
* `<as 去符号>_rows` = 行数、`<as 去符号>_<列>_<值>` = tally 计数。案号那一行查无 → 这一单没落账,看板如实说。
|
||||
*/
|
||||
export declare const viewDataRowsSchema: z.ZodObject<{
|
||||
as: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
by: z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
not: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type ViewDataRows = z.infer<typeof viewDataRowsSchema>;
|
||||
export declare const viewDataSchema: z.ZodObject<{
|
||||
case: z.ZodObject<{
|
||||
ledger: z.ZodString;
|
||||
key: z.ZodString;
|
||||
prefix: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
rows: z.ZodArray<z.ZodObject<{
|
||||
as: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
by: z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
not: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type ViewData = z.infer<typeof viewDataSchema>;
|
||||
/** `by.from` / `not.from` 里指案号本身的那个字 */
|
||||
export declare const VIEW_DATA_CASE_REF = "case";
|
||||
export declare const viewTemplateSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
data: z.ZodOptional<z.ZodObject<{
|
||||
case: z.ZodObject<{
|
||||
ledger: z.ZodString;
|
||||
key: z.ZodString;
|
||||
prefix: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
rows: z.ZodArray<z.ZodObject<{
|
||||
as: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
by: z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
not: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type ViewTemplate = z.infer<typeof viewTemplateSchema>;
|
||||
export declare const manifestAssetsSchema: z.ZodObject<{
|
||||
businessModel: z.ZodObject<{
|
||||
cmmnXml: z.ZodString;
|
||||
@@ -378,6 +489,13 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -399,6 +517,32 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
||||
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
data: z.ZodOptional<z.ZodObject<{
|
||||
case: z.ZodObject<{
|
||||
ledger: z.ZodString;
|
||||
key: z.ZodString;
|
||||
prefix: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
rows: z.ZodArray<z.ZodObject<{
|
||||
as: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
by: z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
not: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
||||
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
@@ -537,6 +681,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
derived: "derived";
|
||||
}>>;
|
||||
caseKey: z.ZodOptional<z.ZodString>;
|
||||
createOnly: z.ZodOptional<z.ZodBoolean>;
|
||||
file: z.ZodOptional<z.ZodObject<{
|
||||
titleColumn: z.ZodString;
|
||||
bodyColumn: z.ZodString;
|
||||
@@ -651,6 +796,13 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
view: z.ZodOptional<z.ZodString>;
|
||||
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
doneWhen: z.ZodOptional<z.ZodString>;
|
||||
cardVariant: z.ZodOptional<z.ZodEnum<{
|
||||
data: "data";
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -672,6 +824,32 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
html: z.ZodString;
|
||||
data: z.ZodOptional<z.ZodObject<{
|
||||
case: z.ZodObject<{
|
||||
ledger: z.ZodString;
|
||||
key: z.ZodString;
|
||||
prefix: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
rows: z.ZodArray<z.ZodObject<{
|
||||
as: z.ZodString;
|
||||
ledger: z.ZodString;
|
||||
by: z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
not: z.ZodOptional<z.ZodObject<{
|
||||
column: z.ZodString;
|
||||
from: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
field: z.ZodString;
|
||||
dir: z.ZodEnum<{
|
||||
desc: "desc";
|
||||
asc: "asc";
|
||||
}>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>>;
|
||||
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
||||
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
@@ -778,5 +956,6 @@ export declare const installVerdictSchema: z.ZodObject<{
|
||||
}, z.core.$strip>>>;
|
||||
skippedNoHistory: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
stored: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
export type InstallVerdict = z.infer<typeof installVerdictSchema>;
|
||||
|
||||
+143
-23
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 员工、岗位与权限(编码规范 §4 · `org`;语义出处 OS 02 员工管理)。
|
||||
* 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。
|
||||
*
|
||||
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
|
||||
* 任务负责人与被征答人对所涉内容**天然可见**,不另行授权。
|
||||
* 单组织、≤50 人:不做多租户,不引 OAuth/OIDC(编码规范 §4 鉴权定调)。
|
||||
* 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench),
|
||||
* 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。
|
||||
* 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const positionSchema: z.ZodObject<{
|
||||
@@ -19,27 +19,151 @@ export declare const staffStateSchema: z.ZodEnum<{
|
||||
disabled: "disabled";
|
||||
}>;
|
||||
export type StaffState = z.infer<typeof staffStateSchema>;
|
||||
/**
|
||||
* 人,还是服务账号。
|
||||
*
|
||||
* Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为
|
||||
* `service-account-<clientId>` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在
|
||||
* 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。
|
||||
*
|
||||
* 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**:
|
||||
* 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。
|
||||
* 这条判断归后端 —— 界面不该去猜账号名长什么样。
|
||||
*/
|
||||
export declare const staffKindSchema: z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>;
|
||||
export type StaffKind = z.infer<typeof staffKindSchema>;
|
||||
export declare const staffSchema: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type Staff = z.infer<typeof staffSchema>;
|
||||
export declare const principalSchema: z.ZodObject<{
|
||||
staff: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
code: z.ZodString;
|
||||
name: z.ZodString;
|
||||
note: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
roles: z.ZodArray<z.ZodString>;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type Principal = z.infer<typeof principalSchema>;
|
||||
/** 角色 → 端:一行一个 realm 角色 */
|
||||
export declare const roleAccessSchema: z.ZodObject<{
|
||||
role: z.ZodString;
|
||||
ends: z.ZodArray<z.ZodEnum<{
|
||||
design: "design";
|
||||
ops: "ops";
|
||||
workbench: "workbench";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type RoleAccess = z.infer<typeof roleAccessSchema>;
|
||||
/**
|
||||
* 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。
|
||||
* 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数,
|
||||
* 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。
|
||||
*/
|
||||
export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
configured: z.ZodLiteral<false>;
|
||||
message: z.ZodString;
|
||||
}, z.core.$strip>, z.ZodObject<{
|
||||
configured: z.ZodLiteral<true>;
|
||||
inserted: z.ZodNumber;
|
||||
updated: z.ZodNumber;
|
||||
disabled: z.ZodNumber;
|
||||
total: z.ZodNumber;
|
||||
failed: z.ZodArray<z.ZodObject<{
|
||||
account: z.ZodString;
|
||||
reason: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>], "configured">;
|
||||
export type RosterSyncOutcome = z.infer<typeof rosterSyncOutcomeSchema>;
|
||||
export declare const inFlightWorkSchema: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
blocked: z.ZodBoolean;
|
||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
cardId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
approvalId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type InFlightWork = z.infer<typeof inFlightWorkSchema>;
|
||||
/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */
|
||||
export declare const disableCheckSchema: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
blocked: z.ZodBoolean;
|
||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
cardId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
approvalId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
/** @deprecated 改用 `InFlightWork` */
|
||||
export type DisableCheck = InFlightWork;
|
||||
/** @deprecated 权限只到端,动作三分已退役 */
|
||||
export declare const GRANT_ACTIONS: readonly ["read", "write", "govern"];
|
||||
/** @deprecated */
|
||||
export declare const grantActionSchema: z.ZodEnum<{
|
||||
read: "read";
|
||||
write: "write";
|
||||
govern: "govern";
|
||||
}>;
|
||||
/** @deprecated */
|
||||
export type GrantAction = z.infer<typeof grantActionSchema>;
|
||||
/** @deprecated */
|
||||
export declare const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>>;
|
||||
/** 治理台九功能 —— 授权客体之一 */
|
||||
/** @deprecated 治理台九功能不再是授权客体 */
|
||||
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
|
||||
/** @deprecated */
|
||||
export declare const governanceFunctionSchema: z.ZodEnum<{
|
||||
trigger: "trigger";
|
||||
data: "data";
|
||||
@@ -51,8 +175,11 @@ export declare const governanceFunctionSchema: z.ZodEnum<{
|
||||
process: "process";
|
||||
memory: "memory";
|
||||
}>;
|
||||
/** @deprecated */
|
||||
export type GovernanceFunction = z.infer<typeof governanceFunctionSchema>;
|
||||
/** @deprecated */
|
||||
export declare const GOVERNANCE_FUNCTION_LABEL: Readonly<Record<GovernanceFunction, string>>;
|
||||
/** @deprecated */
|
||||
export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"data">;
|
||||
domain: z.ZodString;
|
||||
@@ -75,7 +202,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"task">;
|
||||
activityId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>], "kind">;
|
||||
/** @deprecated */
|
||||
export type GrantObject = z.infer<typeof grantObjectSchema>;
|
||||
/** @deprecated */
|
||||
export declare const grantSchema: z.ZodObject<{
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
@@ -106,35 +235,25 @@ export declare const grantSchema: z.ZodObject<{
|
||||
govern: "govern";
|
||||
}>;
|
||||
}, z.core.$strip>;
|
||||
/** @deprecated */
|
||||
export type Grant = z.infer<typeof grantSchema>;
|
||||
/** 员工停用前,其在途任务与待答卡须先改派完毕 */
|
||||
export declare const disableCheckSchema: z.ZodObject<{
|
||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
blocked: z.ZodBoolean;
|
||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
taskId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
cardId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
approvalId: z.ZodString;
|
||||
title: z.ZodString;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
export type DisableCheck = z.infer<typeof disableCheckSchema>;
|
||||
/** @deprecated 改用 `principalSchema` / `GET /api/me` */
|
||||
export declare const sessionSchema: z.ZodObject<{
|
||||
staff: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||
account: z.ZodString;
|
||||
name: z.ZodString;
|
||||
email: z.ZodOptional<z.ZodString>;
|
||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
state: z.ZodDefault<z.ZodEnum<{
|
||||
active: "active";
|
||||
disabled: "disabled";
|
||||
}>>;
|
||||
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
kind: z.ZodDefault<z.ZodEnum<{
|
||||
person: "person";
|
||||
service: "service";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
position: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||
@@ -173,6 +292,7 @@ export declare const sessionSchema: z.ZodObject<{
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
/** @deprecated 改用 `Principal` */
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
/**
|
||||
* 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。
|
||||
|
||||
+22
@@ -10,3 +10,25 @@ export declare function isDefined<T>(value: T | null | undefined): value is T;
|
||||
export declare function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]>;
|
||||
/** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */
|
||||
export declare function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]>;
|
||||
export type TopoOrderResult = {
|
||||
readonly ok: true;
|
||||
readonly order: readonly string[];
|
||||
} | {
|
||||
readonly ok: false;
|
||||
readonly cycle: readonly string[];
|
||||
};
|
||||
/**
|
||||
* 拓扑排序(Kahn 算法)—— 给定节点集合与「这个节点依赖谁」,排出一个安全的处理序
|
||||
* (被依赖的排前面)。
|
||||
*
|
||||
* 同族算法已有一份在 `apps/api/src/modules/process/internal/domain/routing.ts` 的
|
||||
* `advanceOrder`(那是流程图专用的,找不齐拓扑序时把剩下的节点原样缀在末尾 ——
|
||||
* 流程图的容错策略)。这里是它的**通用版**,故意搬到叶子包:两个不同层级的模块
|
||||
* (`activity-registry` 装包静态审 / `loop-engine` 配方求值)都要用同一个算法判「排不排得出来」,
|
||||
* 而模块分层不许它们互相 import ——图算法本身不含业务语义,该住在 `@utils`。
|
||||
*
|
||||
* **不兜底**:排不出来就是这几个节点之间有环,原样报出来,由调用方决定怎么处置
|
||||
* (装包时机检报错 / 运行时交人),不在这一层替调用方决定「怎么办」。
|
||||
* `dependsOn` 返回的名字如果不在 `nodes` 里,当作没这条依赖(忽略,不会因此误报成环)。
|
||||
*/
|
||||
export declare function topoOrder(nodes: readonly string[], dependsOn: (node: string) => readonly string[]): TopoOrderResult;
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"start": "node dist/main.js",
|
||||
"seed": "node dist/seed.js",
|
||||
"migrate": "node dist/migrate.js",
|
||||
"set-password": "node dist/set-password.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",
|
||||
@@ -26,11 +26,16 @@
|
||||
"ajv-formats": "^3.0.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.12.32",
|
||||
"imapflow": "^1.7.6",
|
||||
"jose": "^5.10.0",
|
||||
"nodemailer": "^8.0.11",
|
||||
"pg": "^8.22.0",
|
||||
"pg-boss": "^12.26.3",
|
||||
"postal-mime": "^3.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** 实例五态:对 task 运行态 / 终态的如实映射(含「驳回是环内回转」那一段) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { instanceStateOf, verdictFactOf } from '../activity-ops/instance-state.ts';
|
||||
|
||||
const base = { terminal: undefined, reason: undefined, runtime: 'running', usedSteps: 3, lastVerdict: undefined, pending: undefined };
|
||||
|
||||
describe('instanceStateOf', () => {
|
||||
it('终态:released 完成;terminated 失败且 kind=terminated;其余终态失败带原话', () => {
|
||||
expect(instanceStateOf({ ...base, terminal: 'released' })).toEqual({ state: 'completed', reason: null });
|
||||
expect(instanceStateOf({ ...base, terminal: 'terminated', reason: '由 柴浩 手动终止' })).toEqual({ state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } });
|
||||
// exhausted 存的是预算执行器的 JSON —— 摆给人的要说哪一项用了多少;认不出的形状原样交
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"kind":"exhausted","breaches":[{"item":"steps","used":90,"cap":90},{"item":"activeMs","used":7200000,"cap":7200000}]}' }).reason)
|
||||
.toEqual({ kind: 'failed', text: '预算耗尽:步数 90/90、活跃时长 120/120 分钟' });
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"breaches":[]}' }).reason?.text).toBe('{"breaches":[]}');
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: undefined }).reason?.text).toBe('预算耗尽');
|
||||
expect(instanceStateOf({ ...base, terminal: 'stalled' }).reason).toEqual({ kind: 'failed', text: '停滞熔断' });
|
||||
});
|
||||
|
||||
it('待执行 = 建了单还没被推进器领到;等人回传仍是进行中', () => {
|
||||
expect(instanceStateOf({ ...base, usedSteps: 0 }).state).toBe('pending');
|
||||
expect(instanceStateOf({ ...base, runtime: 'awaiting-human', usedSteps: 0 }).state).toBe('running');
|
||||
expect(instanceStateOf({ ...base, runtime: 'awaiting-event' }).state).toBe('running');
|
||||
});
|
||||
|
||||
it('驳回:裁决刚到(pending.verdict=reject)或重做已被消费(pending 清空)且最近裁决不是 pass;重新提交后回进行中', () => {
|
||||
const redo = { pass: false, comment: '毛利低于阈值,退回重算', byName: '柴浩' };
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: 'reject', comment: redo.comment } })).toEqual({
|
||||
state: 'rejected',
|
||||
reason: { kind: 'rejected', text: '验收驳回:毛利低于阈值,退回重算 —— 负责人 柴浩' },
|
||||
});
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: undefined }).state).toBe('rejected');
|
||||
// 新验收卡已发(pending 立起、还没裁决)→ running
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: undefined, comment: undefined } }).state).toBe('running');
|
||||
// 上一次裁决是 pass(落地中)→ running
|
||||
expect(instanceStateOf({ ...base, lastVerdict: { pass: true, comment: '', byName: '柴浩' }, pending: { verdict: 'pass', comment: undefined } }).state).toBe('running');
|
||||
// 没写意见也要说人话
|
||||
expect(instanceStateOf({ ...base, lastVerdict: { pass: false, comment: '', byName: undefined } }).reason?.text).toBe('验收驳回:负责人未写明具体意见,已退回重做');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdictFactOf', () => {
|
||||
it('取最近一条 `裁决:`;落地那一路同 kind 但标题不同,不算', () => {
|
||||
const entries = [
|
||||
{ kind: 'release-verdict', title: '裁决:redo', detail: { by: 's-1', comment: '再核一遍' } },
|
||||
{ kind: 'release-verdict', title: '裁决:pass', detail: { by: 's-1' } },
|
||||
{ kind: 'release-verdict', title: '落地收敛,发射输出口:assessment-released', detail: { ports: ['assessment-released'] } },
|
||||
];
|
||||
expect(verdictFactOf(entries, (id) => (id === 's-1' ? '柴浩' : undefined))).toEqual({ pass: true, comment: '', byName: '柴浩' });
|
||||
expect(verdictFactOf(entries.slice(0, 1))).toEqual({ pass: false, comment: '再核一遍', byName: undefined });
|
||||
expect(verdictFactOf([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* 启动参数表:从清单 `input.data` 派生 + 服务端校验。
|
||||
* 夹具照 W05 / E04 的清单形状手写(apps 不得 import activities —— depcruise 在守)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { controlOf, fieldSpecsOf, launchParamOf, launchParamsOf, validateLaunchParams } from '../activity-ops/launch-params.ts';
|
||||
|
||||
/** W05 的四参 + 选填文件,按 2026-08-27 的清单写法 */
|
||||
const W05_INPUT = {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
|
||||
properties: {
|
||||
customerCode: {
|
||||
type: 'string',
|
||||
title: '客户',
|
||||
description: '台账里有就直接选;新客户自己填',
|
||||
examples: ['CU-0142'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' },
|
||||
},
|
||||
customerName: { type: 'string', title: '客户名称', examples: ['深圳恒星电源有限公司'] },
|
||||
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea', examples: ['工业电源项目在用 LM2596S-5.0'] },
|
||||
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' } },
|
||||
candidateInternalPns: { type: 'array', title: '候选内部料号', items: { type: 'string' }, examples: [['XL2596S-5.0', 'LM2596S-ADJ']] },
|
||||
datasheets: { type: 'array', format: 'file', title: 'datasheet', items: { type: 'object' } },
|
||||
},
|
||||
};
|
||||
|
||||
/** E04:两级下拉 + 数组(元素形状不声明)+ 选填说明 */
|
||||
const E04_INPUT = {
|
||||
type: 'object',
|
||||
required: ['trigger'],
|
||||
properties: {
|
||||
trigger: { type: 'string', enum: ['monthly', 'event'], enumNames: ['月度例行', '事件驱动'], title: '触发方式', default: 'monthly' },
|
||||
eventType: { type: 'string', enum: ['new_customer', 'major_deal'], title: '事件类型', showWhen: { field: 'trigger', equals: 'event' } },
|
||||
customers: { type: 'array', title: '本轮涉及的客户', examples: [['CU-0142']] },
|
||||
note: { type: 'string', title: '本轮说明' },
|
||||
budget: { type: 'integer', title: '预算' },
|
||||
},
|
||||
};
|
||||
|
||||
const noResolve = async () => undefined;
|
||||
|
||||
describe('fieldSpecsOf / controlOf', () => {
|
||||
it('保属性序;必填从 required[] 认;x-control / x-options / description 都读到', () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets']);
|
||||
expect(specs[0]).toMatchObject({ required: true, control: 'combo', description: '台账里有就直接选;新客户自己填', optionsSource: { ledger: 'customer.profile', value: 'customer_code', label: 'name' } });
|
||||
// label 列没写就用 value 列当标签
|
||||
expect(specs[3]?.optionsSource).toEqual({ ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' });
|
||||
expect(specs[1]?.required).toBe(false);
|
||||
});
|
||||
|
||||
it('控件推断:文件不上表单、字符串数组默认 list、enum 是 select、其余 text;数组标了 combo/select 就摆成单选', () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
expect(specs.map((s) => controlOf(s))).toEqual(['combo', 'text', 'textarea', 'combo', 'list', undefined]);
|
||||
const e04 = fieldSpecsOf(E04_INPUT);
|
||||
expect(e04.map((s) => controlOf(s))).toEqual(['select', 'select', 'list', 'text', 'text']);
|
||||
// 数组字段标 combo → 摆成单选(提交时后端包成数组);标 list 或不标 → list
|
||||
expect(controlOf({ ...specs[4]!, control: 'combo' })).toBe('combo');
|
||||
expect(controlOf({ ...specs[4]!, control: 'list' })).toBe('list');
|
||||
expect(controlOf({ ...specs[4]!, itemType: 'object' })).toBeUndefined();
|
||||
expect(controlOf({ ...specs[1]!, type: 'object' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('x-order', () => {
|
||||
it('声明了次序的按数字排,没声明的殿后保持原序(jsonb 不保键序,书写顺序靠不住)', () => {
|
||||
const specs = fieldSpecsOf({
|
||||
properties: {
|
||||
customerCode: { type: 'string', 'x-order': 1 },
|
||||
customerName: { type: 'string', 'x-order': 2 },
|
||||
originalInternalPn: { type: 'string', 'x-order': 4 },
|
||||
requestDescription: { type: 'string', 'x-order': 3 },
|
||||
zz: { type: 'string' },
|
||||
aa: { type: 'string' },
|
||||
},
|
||||
});
|
||||
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'zz', 'aa']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchParamsOf', () => {
|
||||
it('W05:五参(datasheets 省略);combo 候选来自 resolver;placeholder 取 examples[0]', async () => {
|
||||
const asked: string[] = [];
|
||||
const params = await launchParamsOf(W05_INPUT, async (src) => {
|
||||
asked.push(src.ledger);
|
||||
return src.ledger === 'customer.profile' ? [{ value: 'CU-0142', label: 'Itelma' }] : undefined;
|
||||
});
|
||||
expect(params.map((p) => p.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
|
||||
expect(asked).toEqual(['customer.profile', 'parts.profile']);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'customerCode',
|
||||
label: '客户',
|
||||
type: 'combo',
|
||||
required: true,
|
||||
desc: '台账里有就直接选;新客户自己填',
|
||||
placeholder: 'CU-0142',
|
||||
options: [{ value: 'CU-0142', label: 'Itelma' }],
|
||||
});
|
||||
// 读不到候选的 combo 仍是 combo(自由填),只是没有 options
|
||||
expect(params[3]).toEqual({ key: 'originalInternalPn', label: '原内部料号', type: 'combo', required: true });
|
||||
expect(params[4]).toMatchObject({ type: 'list', placeholder: 'XL2596S-5.0', required: true });
|
||||
expect(params[2]).toMatchObject({ type: 'textarea', placeholder: '工业电源项目在用 LM2596S-5.0' });
|
||||
});
|
||||
|
||||
it('E04:enum → select 带人读名与 default;没 enumNames 拿值当标签;数组元素形状不声明也当 list', async () => {
|
||||
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'trigger',
|
||||
label: '触发方式',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [{ value: 'monthly', label: '月度例行' }, { value: 'event', label: '事件驱动' }],
|
||||
default: 'monthly',
|
||||
});
|
||||
expect(params[1]?.options).toEqual([{ value: 'new_customer', label: 'new_customer' }, { value: 'major_deal', label: 'major_deal' }]);
|
||||
expect(params[2]).toMatchObject({ type: 'list', placeholder: 'CU-0142' });
|
||||
});
|
||||
|
||||
it('x-control:select 却一个候选都没有 → 降成 text(只能选却无可选等于锁死)', () => {
|
||||
const spec = fieldSpecsOf({ properties: { k: { type: 'string', 'x-control': 'select' } } })[0]!;
|
||||
expect(launchParamOf(spec, undefined)).toEqual({ key: 'k', label: 'k', type: 'text' });
|
||||
expect(launchParamOf(spec, [])?.type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数组字段摆成单选(2026-08-27 用户定:候选料号与原料号同款下拉,只选一颗)', () => {
|
||||
const INPUT = {
|
||||
required: ['candidateInternalPns'],
|
||||
properties: {
|
||||
candidateInternalPns: {
|
||||
type: 'array',
|
||||
title: '候选内部料号',
|
||||
items: { type: 'string' },
|
||||
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
|
||||
default: ['LM2596S-ADJ'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn' },
|
||||
},
|
||||
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], 'x-control': 'select' },
|
||||
},
|
||||
};
|
||||
|
||||
it('数组 + x-control:combo → combo 控件,占位与预填取第一颗,候选照读', async () => {
|
||||
const params = await launchParamsOf(INPUT, async () => [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }]);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'candidateInternalPns',
|
||||
label: '候选内部料号',
|
||||
type: 'combo',
|
||||
required: true,
|
||||
placeholder: 'XL2596S-5.0',
|
||||
options: [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }],
|
||||
default: 'LM2596S-ADJ',
|
||||
});
|
||||
expect(params[1]).toMatchObject({ type: 'select', options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] });
|
||||
});
|
||||
|
||||
it('提交单个字符串包成一元数组;传数组也照收;空的按必填拦;select 逐项对候选', async () => {
|
||||
const specs = fieldSpecsOf(INPUT);
|
||||
const params = await launchParamsOf(INPUT, noResolve);
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: 'XL2596S-5.0' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0'] } });
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: ['XL2596S-5.0', ' LM2596S-ADJ '] }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] } });
|
||||
const empty = validateLaunchParams(params, { candidateInternalPns: ' ' }, specs);
|
||||
expect(empty).toEqual({ ok: false, issues: [{ key: 'candidateInternalPns', message: '必填' }] });
|
||||
const off = validateLaunchParams(params, { candidateInternalPns: 'x', tags: ['a', 'z'] }, specs);
|
||||
expect(off).toEqual({ ok: false, issues: [{ key: 'tags', message: '不在候选里(单选只能选候选)' }] });
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: 'x', tags: 'b' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['x'], tags: ['b'] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLaunchParams', () => {
|
||||
it('必填空 / 未知键 / 类型不符 / select 不在候选 各点名一格;combo 候选之外合法', async () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
const params = await launchParamsOf(W05_INPUT, async () => [{ value: 'CU-0142', label: 'Itelma' }]);
|
||||
const bad = validateLaunchParams(params, { customerCode: 'CU-NEW', requestDescription: '', candidateInternalPns: [' '], oops: 'x' }, specs);
|
||||
expect(bad.ok).toBe(false);
|
||||
if (!bad.ok) {
|
||||
expect(bad.issues.map((i) => i.key).sort()).toEqual(['candidateInternalPns', 'oops', 'originalInternalPn', 'requestDescription']);
|
||||
}
|
||||
const typed = validateLaunchParams(params, { customerCode: ['CU-1'], requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: 'PN-1' }, specs);
|
||||
expect(typed.ok).toBe(false);
|
||||
// 标量格传数组是错;list 格传单个字符串**照收**(包成一元数组)
|
||||
if (!typed.ok) expect(typed.issues).toEqual([{ key: 'customerCode', message: '要传字符串' }]);
|
||||
const wrapped = validateLaunchParams(params, { customerCode: 'CU-1', requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: ' PN-1 ' }, specs);
|
||||
expect(wrapped).toMatchObject({ ok: true, data: { candidateInternalPns: ['PN-1'] } });
|
||||
|
||||
const good = validateLaunchParams(
|
||||
params,
|
||||
{ customerCode: 'CU-NEW', customerName: '', requestDescription: ' 客户要求验证替代料 ', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', '', ' LM2596S-ADJ '] },
|
||||
specs,
|
||||
);
|
||||
expect(good).toEqual({
|
||||
ok: true,
|
||||
// 可选空串不落入参;list 逐项去空白、丢空项;combo 候选之外原样收
|
||||
data: { customerCode: 'CU-NEW', requestDescription: '客户要求验证替代料', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('select 只认候选;整数按清单类型转回数字', async () => {
|
||||
const specs = fieldSpecsOf(E04_INPUT);
|
||||
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||
const bad = validateLaunchParams(params, { trigger: 'weekly' }, specs);
|
||||
expect(bad.ok).toBe(false);
|
||||
if (!bad.ok) expect(bad.issues[0]).toMatchObject({ key: 'trigger', message: expect.stringContaining('候选') });
|
||||
expect(validateLaunchParams(params, { trigger: 'event', budget: '12' }, specs)).toEqual({ ok: true, data: { trigger: 'event', budget: 12 } });
|
||||
const nan = validateLaunchParams(params, { trigger: 'event', budget: '1.5' }, specs);
|
||||
expect(nan.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 按分组键联动(2026-08-31 用户定:原料号 / 候选料号按芯片类型互相收窄)────────────
|
||||
派生层只负责把两样东西送到前端:①候选带上它的分组键与人读名(`x-options.group`);
|
||||
②这一格属于哪个联动组(`x-link`)。收窄怎么摆是前端的事,**服务端不据此立墙**。 */
|
||||
describe('x-options.group / x-link', () => {
|
||||
const LINKED = {
|
||||
type: 'object',
|
||||
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||
properties: {
|
||||
originalInternalPn: {
|
||||
type: 'string',
|
||||
title: '原内部料号',
|
||||
'x-control': 'combo',
|
||||
'x-options': {
|
||||
ledger: 'parts.profile',
|
||||
value: 'internal_pn',
|
||||
label: 'internal_pn',
|
||||
group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' },
|
||||
},
|
||||
'x-link': 'chip-type',
|
||||
'x-order': 1,
|
||||
},
|
||||
candidateInternalPn: {
|
||||
type: 'string',
|
||||
title: '候选内部料号',
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: 'chip_type_id' },
|
||||
'x-link': 'chip-type',
|
||||
'x-order': 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('分组键两种写法都认:整份声明(带字典)与只写列名;x-link 读成联动组名', () => {
|
||||
const specs = fieldSpecsOf(LINKED);
|
||||
expect(specs[0]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: { ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } });
|
||||
// 只写列名 = 有分组键、没有人读名字典
|
||||
expect(specs[1]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: undefined });
|
||||
expect(specs.map((s) => s.linkGroup)).toEqual(['chip-type', 'chip-type']);
|
||||
});
|
||||
|
||||
it('分组键写残(没有 column,或字典缺 ledger/value)就当没声明 —— 候选照出,只是不联动', () => {
|
||||
const bad = fieldSpecsOf({
|
||||
properties: {
|
||||
a: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: {} } },
|
||||
b: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: { column: 'c', ledger: 'd.t' } } },
|
||||
},
|
||||
});
|
||||
expect(bad[0]?.optionsSource).toEqual({ ledger: 'l.o', value: 'v', label: 'v', group: undefined });
|
||||
// 字典写残 → 只留分组键,人读名没有(不炸)
|
||||
expect(bad[1]?.optionsSource?.group).toEqual({ column: 'c', dict: undefined });
|
||||
});
|
||||
|
||||
it('候选带 group / groupLabel 原样交给前端;有候选才给 linkGroup(一个候选都没有,联动无从谈起)', async () => {
|
||||
const params = await launchParamsOf(LINKED, async (src) =>
|
||||
src.group === undefined
|
||||
? [{ value: 'AMS1117-3.3', label: 'AMS1117-3.3' }]
|
||||
: [
|
||||
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||
],
|
||||
);
|
||||
expect(params[0]).toMatchObject({
|
||||
key: 'originalInternalPn',
|
||||
linkGroup: 'chip-type',
|
||||
options: [
|
||||
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||
],
|
||||
});
|
||||
// 候选读不到 → 仍是 combo,但不摆联动组(前端没有可收窄的东西)
|
||||
const empty = await launchParamsOf(LINKED, noResolve);
|
||||
expect(empty[0]?.linkGroup).toBeUndefined();
|
||||
expect(empty[0]?.type).toBe('combo');
|
||||
});
|
||||
|
||||
it('服务端不据分组立墙:两格填不同类型的料号照样收(类型一致归活动的收步判据管)', async () => {
|
||||
const params = await launchParamsOf(LINKED, async () => [
|
||||
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1' },
|
||||
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' },
|
||||
]);
|
||||
const out = validateLaunchParams(params, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, fieldSpecsOf(LINKED));
|
||||
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' } });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 互斥组(2026-08-31 用户定:「不应该能选到同样的两个芯片」)。
|
||||
* 与 `x-link` 正相反 —— **这一条是墙**:W05 两格同一颗料会把单子做死(两行撞同一个主键收成一行,
|
||||
* S1 的 `exists_status_found = 2` 永不成立,而件上那两行人加不出来),而唯一能改的时机就是起单这一刻。
|
||||
*/
|
||||
describe('x-distinct', () => {
|
||||
const DISTINCT = {
|
||||
type: 'object',
|
||||
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||
properties: {
|
||||
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 1 },
|
||||
candidateInternalPn: { type: 'string', title: '候选内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 2 },
|
||||
/* 同一张表上不在这一组的格子:填成一样也与它无关 */
|
||||
note: { type: 'string', title: '备注' },
|
||||
},
|
||||
};
|
||||
const paramsOf = async () => launchParamsOf(DISTINCT, async () => [{ value: 'LM2596S-5.0', label: 'LM2596S-5.0' }, { value: 'XL2596S-5.0', label: 'XL2596S-5.0' }]);
|
||||
|
||||
it('读得到组名;**不以有候选为前提** —— 自由输入的两格同样归它管(墙管的是值,不是候选)', async () => {
|
||||
expect(fieldSpecsOf(DISTINCT).map((s) => s.distinctGroup)).toEqual(['part', 'part', undefined]);
|
||||
const bare = await launchParamsOf({ properties: { a: { type: 'string', 'x-distinct': 'g' }, b: { type: 'string', 'x-distinct': '' } } }, noResolve);
|
||||
expect(bare.map((p) => [p.type, p.distinctGroup])).toEqual([
|
||||
['text', 'g'],
|
||||
['text', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('两格同一个值 → 拒,点名**后填的那格**,话里指向它撞上了谁', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out).toEqual({ ok: false, issues: [{ key: 'candidateInternalPn', message: '与「原内部料号」填的是同一个 —— 这两格不能一样,换一个再启动' }] });
|
||||
});
|
||||
|
||||
it('**去空白 + 不分大小写**:只挡精确相等的话,`lm2596s-5.0` 会照样进去,混一颗垃圾主数据出来', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('两颗不同的料照收;不在这一组的格子填成一样也不管', async () => {
|
||||
const out = validateLaunchParams(
|
||||
await paramsOf(),
|
||||
{ originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' },
|
||||
fieldSpecsOf(DISTINCT),
|
||||
);
|
||||
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' } });
|
||||
});
|
||||
|
||||
it('空的不算撞上 —— 两格都没填该报的是必填,不是「填了同一个」', async () => {
|
||||
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: '', candidateInternalPn: '' }, fieldSpecsOf(DISTINCT));
|
||||
expect(out).toEqual({ ok: false, issues: [{ key: 'originalInternalPn', message: '必填' }, { key: 'candidateInternalPn', message: '必填' }] });
|
||||
});
|
||||
|
||||
it('数组格不参与(契约写死的边界:本期没有这种形状)', async () => {
|
||||
const arrays = { properties: { a: { type: 'array', items: { type: 'string' }, title: 'A', 'x-distinct': 'g' }, b: { type: 'array', items: { type: 'string' }, title: 'B', 'x-distinct': 'g' } } };
|
||||
const out = validateLaunchParams(await launchParamsOf(arrays, noResolve), { a: ['X'], b: ['X'] }, fieldSpecsOf(arrays));
|
||||
expect(out).toEqual({ ok: true, data: { a: ['X'], b: ['X'] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,602 @@
|
||||
/**
|
||||
* 活动模块用例:投影 / 启动(幂等三档)/ 实例清单与详情 / 终止 / 禁用。
|
||||
* 依赖全是内存字面量 —— 服务只声明用到的那几面,不用造整本清单。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LaunchInput, TaskId } from '@contracts';
|
||||
import { staffIdSchema, taskIdSchema } from '@contracts';
|
||||
import type { ActivityCard } from '@modules/activity-registry';
|
||||
import { parseCmmn } from '@modules/loop-engine';
|
||||
import type { ItemRuntime } from '@modules/loop-engine';
|
||||
import { StaffNotFoundError } from '@modules/org';
|
||||
import type { StartTaskInput } from '@modules/task';
|
||||
import { createActivityOps, LAUNCH_CLAIM_STALE_MS } from '../activity-ops/service.ts';
|
||||
import type { ActivityOpsDeps, HistoryEntry, LoopFace, ManifestFace } from '../activity-ops/service.ts';
|
||||
import type { ActivityOpsStore, ActivityProfile, LaunchClaim, TaskRow } from '../activity-ops/store.ts';
|
||||
|
||||
const W05 = 'part-substitution-assessment';
|
||||
const OWNER = staffIdSchema.parse('s-owner');
|
||||
const ME = { id: staffIdSchema.parse('s-me'), name: '冯兵' };
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||
<case id="c" name="试"><casePlanModel id="p" name="p">
|
||||
<humanTask id="S1" name="归一"/>
|
||||
<humanTask id="S2" name="交人确认"><itemControl><manualActivationRule id="m"/></itemControl></humanTask>
|
||||
<humanTask id="T_release" name="放行"/>
|
||||
</casePlanModel></case>
|
||||
</definitions>`;
|
||||
|
||||
const card = (over: Partial<ActivityCard> = {}): ActivityCard => ({
|
||||
activityId: W05,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
category: '需求分析与方案匹配',
|
||||
packageVersions: [
|
||||
{ activityId: W05, version: '1.4.9', installedAt: '2026-08-26T01:00:00.000Z', state: 'active' },
|
||||
{ activityId: W05, version: '1.5.0', installedAt: '2026-08-27T01:00:00.000Z', state: 'active' },
|
||||
],
|
||||
assetVersions: [],
|
||||
enabled: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
const manifest = (over: Partial<ManifestFace> = {}): ManifestFace => ({
|
||||
identity: { name: 'ACT-WORK-05 · 差异评估' },
|
||||
contract: {
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'requestDescription', 'candidateInternalPns'],
|
||||
properties: {
|
||||
customerCode: { type: 'string', title: '客户', 'x-control': 'combo', 'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' } },
|
||||
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea' },
|
||||
candidateInternalPns: { type: 'array', title: '候选料号', items: { type: 'string' } },
|
||||
datasheets: { type: 'array', format: 'file', items: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
ledger: [
|
||||
{ domain: 'sub_eval', object: 'verdict', fields: [{ name: 'eval_ref' }, { name: 'request_id' }] },
|
||||
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
|
||||
],
|
||||
},
|
||||
assets: { viewTemplates: [{ name: 'activity-ledger', html: '<html><body><h1>一览</h1></body></html>' }] },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** 一张带取数声明的看板模版:案号 → 核对表 → 料号圈主数据,页头摆实例与案号 */
|
||||
const BOARD_HTML = [
|
||||
'<html><body><div class="page" data-class-of="board:state">',
|
||||
'<p><b data-slot="case:instanceNo"></b> · <i data-slot="case:ref"></i> · <u data-slot="case:owner"></u></p>',
|
||||
'<p>需求 <span data-slot="sub_eval.verdict.request_id"></span> · 料号 <span data-slot="parts_profile_rows"></span> 条 · NRND <span data-slot="parts_profile_lifecycle_status_nrnd"></span></p>',
|
||||
'<table><tbody data-rows="parts.profile"><tr><td data-slot="internal_pn"></td><td><span class="st" data-class-of="lifecycle_status"></span></td></tr></tbody></table>',
|
||||
'</div></body></html>',
|
||||
].join('');
|
||||
const BOARD_DATA = {
|
||||
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
|
||||
rows: [
|
||||
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
|
||||
],
|
||||
};
|
||||
const boardManifest = (): ManifestFace => manifest({ assets: { viewTemplates: [{ name: 'activity-ledger', html: BOARD_HTML, data: BOARD_DATA }] } });
|
||||
/** 正式表的一角:两单的行并排躺着,看板只许圈出案号对上的那一单 */
|
||||
const FORMAL: Record<string, Array<Record<string, unknown>>> = {
|
||||
'sub_eval.verdict': [
|
||||
{ eval_ref: 'W05-20260827100000', request_id: 'REQ-20260827100000' },
|
||||
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000' },
|
||||
],
|
||||
'sub_eval.pn_check': [
|
||||
{ eval_ref: 'W05-20260827100000', internal_pn: 'LM2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827100000', internal_pn: 'XL2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827000000', internal_pn: 'OTHER' },
|
||||
],
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
|
||||
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
|
||||
{ internal_pn: 'OTHER', lifecycle_status: 'active' },
|
||||
],
|
||||
};
|
||||
const formalLedger: ActivityOpsDeps['ledger'] = {
|
||||
describe: async (address) => (FORMAL[`${address.domain}.${address.object}`] === undefined ? undefined : { columns: [] }),
|
||||
query: async (q) => ({
|
||||
rows: (FORMAL[`${q.address.domain}.${q.address.object}`] ?? []).filter((r) => Object.entries(q.where).every(([k, v]) => String(r[k]) === String(v))),
|
||||
}),
|
||||
};
|
||||
/** 本地时区里 2026-08-27 10:00:00 起的单 —— 案号 W05-20260827100000 */
|
||||
const RELEASED_AT = new Date(2026, 7, 27, 10, 0, 0).toISOString();
|
||||
|
||||
const row = (over: Partial<TaskRow> = {}): TaskRow => ({
|
||||
id: 't-1',
|
||||
no: 'INS-7',
|
||||
activityId: W05,
|
||||
workflowId: null,
|
||||
origin: 'ops',
|
||||
ownerId: OWNER,
|
||||
title: 'ACT-WORK-05 · 差异评估',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
data: { customerCode: 'CU-0142', requestDescription: '工业电源项目在用 LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0'], datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 3, ref: 'u-1' }] },
|
||||
usedSteps: 2,
|
||||
startedAt: '2026-08-27T02:00:00.000Z',
|
||||
finishedAt: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
function memoryStore(rows: TaskRow[] = [], profiles: ActivityProfile[] = []) {
|
||||
const claims = new Map<string, LaunchClaim>();
|
||||
const profileMap = new Map(profiles.map((p) => [p.activityId, p]));
|
||||
const store: ActivityOpsStore = {
|
||||
async countByActivity() {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of rows) m.set(r.activityId, (m.get(r.activityId) ?? 0) + 1);
|
||||
return m;
|
||||
},
|
||||
async listTasks(activityId) {
|
||||
return rows.filter((r) => r.activityId === activityId);
|
||||
},
|
||||
async getTask(activityId, taskId) {
|
||||
return rows.find((r) => r.activityId === activityId && r.id === taskId);
|
||||
},
|
||||
async latestReleased(activityId) {
|
||||
return rows
|
||||
.filter((r) => r.activityId === activityId && r.terminal === 'released')
|
||||
.sort((a, b) => Date.parse(b.finishedAt ?? b.startedAt) - Date.parse(a.finishedAt ?? a.startedAt))[0];
|
||||
},
|
||||
async claimLaunch(input) {
|
||||
const hit = claims.get(input.key);
|
||||
if (hit !== undefined) return hit;
|
||||
claims.set(input.key, { ...input, taskId: null, claimedAt: clock.now().toISOString() });
|
||||
return 'claimed';
|
||||
},
|
||||
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||
const hit = claims.get(key);
|
||||
if (hit === undefined || hit.taskId !== null || clock.now().getTime() - Date.parse(hit.claimedAt) < staleMs) return false;
|
||||
claims.set(key, { ...hit, ownerId, launchedBy, claimedAt: clock.now().toISOString() });
|
||||
return true;
|
||||
},
|
||||
async bindLaunch(key, taskId) {
|
||||
const hit = claims.get(key);
|
||||
if (hit !== undefined) claims.set(key, { ...hit, taskId });
|
||||
},
|
||||
async launchOf(taskId) {
|
||||
return [...claims.values()].find((c) => c.taskId === taskId);
|
||||
},
|
||||
async profilesOf() {
|
||||
return profileMap;
|
||||
},
|
||||
async upsertProfile(p) {
|
||||
profileMap.set(p.activityId, p);
|
||||
},
|
||||
async workflowOf() {
|
||||
return { subject: '统一报价 · Itelma', source: { kind: 'event' } };
|
||||
},
|
||||
async inboundCommOf(taskId) {
|
||||
return taskId === 't-mail' ? { channel: 'mail', party: 'quotes@itelma.ru', subject: 'RFQ' } : undefined;
|
||||
},
|
||||
};
|
||||
const clock = { at: Date.parse('2026-08-27T03:00:00.000Z'), now: () => new Date(clock.at) };
|
||||
return { store, claims, clock, rows };
|
||||
}
|
||||
|
||||
function harness(
|
||||
opts: {
|
||||
rows?: TaskRow[];
|
||||
profiles?: ActivityProfile[];
|
||||
enabled?: boolean;
|
||||
history?: Record<string, HistoryEntry[]>;
|
||||
loops?: Record<string, LoopFace>;
|
||||
manifest?: ManifestFace;
|
||||
ledger?: ActivityOpsDeps['ledger'];
|
||||
} = {},
|
||||
) {
|
||||
const mem = memoryStore(opts.rows ?? [], opts.profiles ?? []);
|
||||
const started: StartTaskInput[] = [];
|
||||
const controlled: Array<{ taskId: TaskId; reason: string | undefined }> = [];
|
||||
let enabled = opts.enabled ?? true;
|
||||
let n = 0;
|
||||
const staff = [
|
||||
{ id: OWNER, name: '柴浩', state: 'active' },
|
||||
{ id: ME.id, name: ME.name, state: 'active' },
|
||||
{ id: 's-gone', name: '离职者', state: 'disabled' },
|
||||
];
|
||||
const deps: ActivityOpsDeps = {
|
||||
registry: {
|
||||
list: async () => [card({ enabled })],
|
||||
get: async (id) => (id === W05 ? card({ enabled }) : undefined),
|
||||
manifestOf: async () => opts.manifest ?? manifest(),
|
||||
setEnabled: async (_id, on) => {
|
||||
enabled = on;
|
||||
},
|
||||
},
|
||||
task: {
|
||||
start: async (input) => {
|
||||
started.push(input);
|
||||
n += 1;
|
||||
const id = taskIdSchema.parse(`t-new-${n}`);
|
||||
mem.rows.push(row({ id, no: `INS-${100 + n}`, ownerId: input.ownerId, data: input.data, usedSteps: 0, origin: input.origin ?? null }));
|
||||
return { id };
|
||||
},
|
||||
control: async (taskId, _semantic, _by, reason) => {
|
||||
controlled.push({ taskId, reason });
|
||||
const at = mem.rows.findIndex((r) => r.id === taskId);
|
||||
const hit = mem.rows[at];
|
||||
if (hit !== undefined) mem.rows.splice(at, 1, { ...hit, terminal: 'terminated', reason: reason ?? null, finishedAt: mem.clock.now().toISOString() });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
org: {
|
||||
listStaff: async () => ({ staff }),
|
||||
getStaff: async (id) => {
|
||||
const hit = staff.find((s) => s.id === id);
|
||||
if (hit === undefined) throw new StaffNotFoundError(id);
|
||||
return hit;
|
||||
},
|
||||
},
|
||||
memory: { history: async (taskId) => opts.history?.[taskId] ?? [] },
|
||||
ledger: opts.ledger ?? {
|
||||
describe: async (address) => (address.object === 'profile' ? { columns: [{ name: 'customer_code' }, { name: 'name' }] } : undefined),
|
||||
query: async () => ({ rows: [{ customer_code: 'CU-0142', name: 'Itelma' }, { customer_code: 'CU-0001', name: '' }] }),
|
||||
},
|
||||
loopState: { load: async (taskId) => opts.loops?.[taskId] },
|
||||
store: mem.store,
|
||||
files: {
|
||||
readUpload: async (ref) => (ref === 'u-1' ? { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : undefined),
|
||||
listArchived: async (taskId) => (taskId === 't-done' ? [{ address: 'content.asset', key: { id: '1' }, format: 'docx', name: '报告.docx', size: 9 }] : []),
|
||||
readArchived: async () => ({ bytes: new Uint8Array([9]), name: '报告.docx', format: 'docx' }),
|
||||
},
|
||||
clock: mem.clock,
|
||||
warn: () => undefined,
|
||||
};
|
||||
return { ops: createActivityOps(deps), mem, started, controlled };
|
||||
}
|
||||
|
||||
const launchInput = (over: Partial<LaunchInput> = {}): LaunchInput => ({
|
||||
idempotencyKey: 'k-1',
|
||||
ownerId: OWNER,
|
||||
params: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] },
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('活动投影', () => {
|
||||
it('清单:版本取最新一版、创建时刻取最早一版、负责人与图标来自档案、实例按活动数;status 过滤', async () => {
|
||||
const { ops } = harness({
|
||||
rows: [row(), row({ id: 't-2', no: 'INS-8' })],
|
||||
profiles: [{ activityId: W05, ownerId: OWNER, description: '逐项差异评估', ico: 'git-compare', hue: 'teal' }],
|
||||
});
|
||||
const [a] = await ops.listActivities();
|
||||
expect(a).toEqual({
|
||||
id: W05,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
biz: '需求分析与方案匹配',
|
||||
status: 'active',
|
||||
version: '1.5.0',
|
||||
owner: { id: OWNER, name: '柴浩' },
|
||||
llm: 'deepseek-v4-flash',
|
||||
instanceCount: 2,
|
||||
createdAt: '2026-08-26T01:00:00.000Z',
|
||||
ico: 'git-compare',
|
||||
hue: 'teal',
|
||||
});
|
||||
expect(await ops.listActivities('disabled')).toEqual([]);
|
||||
const detail = await ops.getActivity(W05);
|
||||
expect(detail).toMatchObject({ desc: '逐项差异评估', view: { url: `/api/gov/activities/${W05}/view` } });
|
||||
expect(detail).not.toHaveProperty('instanceCount');
|
||||
expect(await ops.getActivity('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('没建档:负责人 null、说明空串、图标色相缺席;色相不在四色里也不摆', async () => {
|
||||
const { ops } = harness({ profiles: [{ activityId: W05, ownerId: null, description: '', ico: null, hue: 'pink' }] });
|
||||
const [a] = await ops.listActivities();
|
||||
expect(a).toMatchObject({ owner: null, instanceCount: 0 });
|
||||
expect(a).not.toHaveProperty('ico');
|
||||
expect(a).not.toHaveProperty('hue');
|
||||
});
|
||||
|
||||
it('禁用 / 激活幂等,回活动详情;视图注入报高脚本、没配视图 404', async () => {
|
||||
const { ops } = harness();
|
||||
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||
expect((await ops.setEnabled(W05, true))?.status).toBe('active');
|
||||
expect(await ops.setEnabled('nope', true)).toBeUndefined();
|
||||
const view = await ops.viewHtml(W05);
|
||||
expect(view.kind).toBe('html');
|
||||
if (view.kind === 'html') expect(view.html).toContain('jcai:view-height');
|
||||
expect((await ops.viewHtml('nope')).kind).toBe('no-activity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('沙箱看板:最近一次已放行实例的落账快照', () => {
|
||||
const released = (over: Partial<TaskRow> = {}) =>
|
||||
row({ id: 't-rel', no: 'INS-20', runtime: 'terminal', terminal: 'released', startedAt: RELEASED_AT, finishedAt: new Date(2026, 7, 27, 10, 6, 0).toISOString(), ...over });
|
||||
|
||||
it('按案号只圈这一单的行:页头实例号 / 案号 / 负责人,料号两颗、别单的 OTHER 不进来,徽标按值投影,读数摆出来', async () => {
|
||||
const older = released({ id: 't-old', no: 'INS-3', startedAt: new Date(2026, 7, 27, 0, 0, 0).toISOString(), finishedAt: new Date(2026, 7, 27, 0, 5, 0).toISOString() });
|
||||
const { ops } = harness({ rows: [row({ id: 't-live' }), older, released()], manifest: boardManifest(), ledger: formalLedger });
|
||||
const view = await ops.viewHtml(W05);
|
||||
expect(view.kind).toBe('html');
|
||||
if (view.kind !== 'html') return;
|
||||
expect(view.html).toContain('class="page v-ready"');
|
||||
expect(view.html).toContain('<b>INS-20</b> · <i>W05-20260827100000</i> · <u>柴浩</u>');
|
||||
expect(view.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>2</span> 条 · NRND <span>1</span>');
|
||||
expect(view.html).toContain('<td>LM2596S-5.0</td><td><span class="st v-nrnd"></span></td>');
|
||||
expect(view.html).toContain('<td>XL2596S-5.0</td><td><span class="st v-active"></span></td>');
|
||||
expect(view.html).not.toContain('OTHER');
|
||||
expect(view.html).not.toContain('data-rows=');
|
||||
expect(view.html).toContain('jcai:view-height');
|
||||
});
|
||||
|
||||
it('没放行过 → none 态、实例事实空着;案号在正式表里查无 → missing 态、表全空(不摆别单的行)', async () => {
|
||||
const none = harness({ rows: [row({ id: 't-live' })], manifest: boardManifest(), ledger: formalLedger });
|
||||
const v1 = await none.ops.viewHtml(W05);
|
||||
if (v1.kind === 'html') {
|
||||
expect(v1.html).toContain('class="page v-none"');
|
||||
expect(v1.html).toContain('<b>—</b>');
|
||||
expect(v1.html).toContain('料号 <span>0</span>');
|
||||
} else expect.unreachable();
|
||||
|
||||
const missing = harness({ rows: [released({ startedAt: new Date(2026, 7, 27, 11, 0, 0).toISOString() })], manifest: boardManifest(), ledger: formalLedger });
|
||||
const v2 = await missing.ops.viewHtml(W05);
|
||||
if (v2.kind === 'html') {
|
||||
expect(v2.html).toContain('class="page v-missing"');
|
||||
expect(v2.html).toContain('W05-20260827110000');
|
||||
expect(v2.html).not.toContain('LM2596S-5.0');
|
||||
} else expect.unreachable();
|
||||
});
|
||||
|
||||
it('一张表读不了不拖垮整屏:那一块空着,别的照摆;模版没带 data 就原样出', async () => {
|
||||
const half: ActivityOpsDeps['ledger'] = { ...formalLedger, describe: async (a) => (a.object === 'profile' ? undefined : formalLedger.describe(a)) };
|
||||
const { ops } = harness({ rows: [released()], manifest: boardManifest(), ledger: half });
|
||||
const v = await ops.viewHtml(W05);
|
||||
if (v.kind === 'html') {
|
||||
expect(v.html).toContain('class="page v-ready"');
|
||||
expect(v.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>0</span>');
|
||||
} else expect.unreachable();
|
||||
const plain = harness({ rows: [released()] });
|
||||
const p = await plain.ops.viewHtml(W05);
|
||||
if (p.kind === 'html') expect(p.html).toContain('<h1>一览</h1>');
|
||||
else expect.unreachable();
|
||||
});
|
||||
});
|
||||
|
||||
describe('启动', () => {
|
||||
it('参数表:combo 候选从台账读(空标签退回值);文件字段不上表', async () => {
|
||||
const { ops } = harness();
|
||||
const table = await ops.launchParams(W05);
|
||||
expect(table?.version).toBe('1.5.0');
|
||||
expect(table?.params.map((p) => p.key)).toEqual(['customerCode', 'requestDescription', 'candidateInternalPns']);
|
||||
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
||||
});
|
||||
|
||||
/* 联动(2026-08-31 用户定):候选行上多取一列当分组键,再从字典表换人读名交给前端 */
|
||||
const LINKED_MANIFEST = (): ManifestFace =>
|
||||
manifest({
|
||||
contract: {
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['originalInternalPn', 'candidateInternalPn'],
|
||||
properties: {
|
||||
originalInternalPn: {
|
||||
type: 'string',
|
||||
title: '原内部料号',
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||
'x-order': 1,
|
||||
'x-link': 'chip-type',
|
||||
},
|
||||
candidateInternalPn: {
|
||||
type: 'string',
|
||||
title: '候选内部料号',
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
|
||||
'x-order': 2,
|
||||
'x-link': 'chip-type',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const PARTS: Record<string, Record<string, unknown>[]> = {
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'AMS1117-3.3', chip_type_id: '2' },
|
||||
{ internal_pn: 'LM2596S-5.0', chip_type_id: '1' },
|
||||
{ internal_pn: 'OLD-001', chip_type_id: null },
|
||||
],
|
||||
'parts.chip_type': [
|
||||
{ type_id: '1', type_name: 'DC-DC 降压稳压器' },
|
||||
{ type_id: '2', type_name: 'LDO 线性稳压器' },
|
||||
],
|
||||
};
|
||||
const partsLedger = (over: Partial<ActivityOpsDeps['ledger']> = {}): ActivityOpsDeps['ledger'] => ({
|
||||
describe: async (address) => {
|
||||
const rows = PARTS[`${address.domain}.${address.object}`];
|
||||
return rows === undefined ? undefined : { columns: Object.keys(rows[0] ?? {}).map((name) => ({ name })) };
|
||||
},
|
||||
query: async (q) => ({ rows: PARTS[`${q.address.domain}.${q.address.object}`] ?? [] }),
|
||||
...over,
|
||||
});
|
||||
|
||||
it('候选带分组键 + 字典换来的人读名;那一列没值就是空串(未标类型),联动组名随参数下发', async () => {
|
||||
const { ops } = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger() });
|
||||
const table = await ops.launchParams(W05);
|
||||
expect(table?.params.map((p) => p.linkGroup)).toEqual(['chip-type', 'chip-type']);
|
||||
expect(table?.params[0]?.options).toEqual([
|
||||
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2', groupLabel: 'LDO 线性稳压器' },
|
||||
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
|
||||
{ value: 'OLD-001', label: 'OLD-001', group: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('分组那一列不存在 / 字典读不到 → 候选照出,只是没有键或没有名(不炸)', async () => {
|
||||
const noColumn = harness({
|
||||
manifest: LINKED_MANIFEST(),
|
||||
ledger: partsLedger({ describe: async (a) => (a.object === 'profile' ? { columns: [{ name: 'internal_pn' }] } : { columns: [{ name: 'type_id' }, { name: 'type_name' }] }) }),
|
||||
});
|
||||
const t1 = await noColumn.ops.launchParams(W05);
|
||||
expect(t1?.params[0]?.options?.every((o) => o.group === undefined)).toBe(true);
|
||||
// 分组键在、字典表没物化 → 有键没名
|
||||
const noDict = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger({ describe: async (a) => (a.object === 'chip_type' ? undefined : { columns: [{ name: 'internal_pn' }, { name: 'chip_type_id' }] }) }) });
|
||||
const t2 = await noDict.ops.launchParams(W05);
|
||||
expect(t2?.params[0]?.options?.[0]).toEqual({ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' });
|
||||
});
|
||||
|
||||
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
||||
const { ops, started } = harness({ enabled: false });
|
||||
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
||||
const live = harness();
|
||||
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-gone') }), ME)).toMatchObject({ kind: 'owner-invalid' });
|
||||
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-nobody') }), ME)).toMatchObject({ kind: 'owner-invalid', message: '责任人不在名册里' });
|
||||
const bad = await live.ops.launch(W05, launchInput({ params: { customerCode: 'CU-0142' } }), ME);
|
||||
expect(bad.kind).toBe('invalid');
|
||||
if (bad.kind === 'invalid') expect(bad.issues.map((i) => i.key)).toEqual(['requestDescription', 'candidateInternalPns']);
|
||||
expect((await live.ops.launch('nope', launchInput(), ME)).kind).toBe('not-found');
|
||||
expect(started).toEqual([]);
|
||||
expect(live.started).toEqual([]);
|
||||
});
|
||||
|
||||
it('起单:origin=ops、标题取清单名、入参归一后交 task.start;同键重放回同一单、不起第二个', async () => {
|
||||
const { ops, started } = harness();
|
||||
const first = await ops.launch(W05, launchInput(), ME);
|
||||
expect(first.kind).toBe('ok');
|
||||
if (first.kind !== 'ok') return;
|
||||
expect(first.created).toBe(true);
|
||||
expect(started).toEqual([
|
||||
{ activityId: W05, ownerId: OWNER, title: 'ACT-WORK-05 · 差异评估', origin: 'ops', data: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] } },
|
||||
]);
|
||||
expect(first.detail).toMatchObject({ id: 't-new-1', state: 'pending', owner: { id: OWNER, name: '柴浩' }, input: { source: { kind: 'manual', label: '手动启动 · 冯兵' } } });
|
||||
|
||||
const again = await ops.launch(W05, launchInput({ params: { customerCode: 'X', requestDescription: 'y', candidateInternalPns: ['z'] } }), ME);
|
||||
expect(again).toMatchObject({ kind: 'ok', created: false, detail: { id: 't-new-1' } });
|
||||
expect(started).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('键占了单还没建成:60 秒内 409;过期后重占再起', async () => {
|
||||
const h = harness();
|
||||
await h.mem.store.claimLaunch({ key: 'k-1', activityId: W05, ownerId: OWNER, launchedBy: 's-x' });
|
||||
expect(await h.ops.launch(W05, launchInput(), ME)).toMatchObject({ kind: 'busy' });
|
||||
h.mem.clock.at += LAUNCH_CLAIM_STALE_MS + 1;
|
||||
const late = await h.ops.launch(W05, launchInput(), ME);
|
||||
expect(late.kind).toBe('ok');
|
||||
expect(h.started).toHaveLength(1);
|
||||
expect(h.mem.claims.get('k-1')?.taskId).toBe('t-new-1');
|
||||
// 别的活动拿同一枚键 → 409
|
||||
await h.mem.store.claimLaunch({ key: 'k-2', activityId: 'other', ownerId: OWNER, launchedBy: 's-x' });
|
||||
expect(await h.ops.launch(W05, launchInput({ idempotencyKey: 'k-2' }), ME)).toMatchObject({ kind: 'busy' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('实例清单与详情', () => {
|
||||
const items = parseCmmn(XML).items;
|
||||
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
const loop = (states: Record<string, ItemRuntime['state']>, pending?: LoopFace['pending']): LoopFace => ({
|
||||
model: { steps: [{ item: 'S1', intent: '把四项绑定' }] },
|
||||
parsed: { items },
|
||||
runtime: { items: rt(states) },
|
||||
...(pending === undefined ? {} : { pending }),
|
||||
});
|
||||
const line = (kind: string, title: string, at: string, detail?: Record<string, unknown>): HistoryEntry => ({ kind, title, at, ...(detail === undefined ? {} : { detail }) });
|
||||
|
||||
const rows = [
|
||||
row({ id: 't-1', no: 'INS-7' }),
|
||||
row({ id: 't-redo', no: 'INS-6', startedAt: '2026-08-26T02:00:00.000Z' }),
|
||||
row({ id: 't-done', no: 'INS-5', terminal: 'released', runtime: 'terminal', finishedAt: '2026-08-25T03:00:00.000Z', startedAt: '2026-08-25T02:00:00.000Z' }),
|
||||
row({ id: 't-dead', no: 'INS-4', terminal: 'terminated', reason: '由 冯兵 手动终止', finishedAt: '2026-08-24T02:30:00.000Z', startedAt: '2026-08-24T02:00:00.000Z', ownerId: ME.id }),
|
||||
row({ id: 't-mail', no: 'INS-3', origin: null, usedSteps: 0, data: {}, startedAt: '2026-08-23T02:00:00.000Z' }),
|
||||
];
|
||||
const history = {
|
||||
't-redo': [line('release-verdict', '裁决:redo', '2026-08-26T03:00:00.000Z', { by: OWNER, comment: '证据不足' })],
|
||||
't-done': [line('release-verdict', '落地收敛,发射输出口:assessment-released', '2026-08-25T03:00:00.000Z', { ports: ['assessment-released'] })],
|
||||
't-1': [line('engine-transition', '开始办:归一', '2026-08-27T02:00:10.000Z'), line('engine-transition', '完成:归一', '2026-08-27T02:01:10.000Z'), line('engine-transition', '开始办:交人确认', '2026-08-27T02:01:20.000Z')],
|
||||
};
|
||||
const loops = { 't-1': loop({ S1: 'Completed', S2: 'Active' }), 't-redo': loop({ S1: 'Completed', S2: 'Completed', T_release: 'Completed' }) };
|
||||
|
||||
it('五态各归其位;统计不随筛选动;产出只给已完成的;分页按筛后走', async () => {
|
||||
const { ops } = harness({ rows, history, loops });
|
||||
const all = await ops.listInstances(W05, { page: 1, size: 8 });
|
||||
expect(all?.stats).toEqual({ total: 5, byState: { pending: 1, running: 1, completed: 1, rejected: 1, failed: 1 } });
|
||||
expect(all?.items.map((i) => [i.no, i.state, i.output])).toEqual([
|
||||
['INS-7', 'running', null],
|
||||
['INS-6', 'rejected', null],
|
||||
['INS-5', 'completed', '报告.docx'],
|
||||
['INS-4', 'failed', null],
|
||||
['INS-3', 'pending', null],
|
||||
]);
|
||||
expect(all?.items[0]?.input).toBe('工业电源项目在用 LM2596S-5.0');
|
||||
// 入参空的那单退回来件主题
|
||||
expect(all?.items[4]?.input).toBe('RFQ');
|
||||
const page2 = await ops.listInstances(W05, { page: 2, size: 2, ownerId: OWNER });
|
||||
expect(page2).toMatchObject({ total: 4, page: 2, size: 2 });
|
||||
expect(page2?.items.map((i) => i.no)).toEqual(['INS-5', 'INS-3']);
|
||||
expect(page2?.stats.total).toBe(5);
|
||||
expect((await ops.listInstances(W05, { page: 1, size: 8, id: 'ins-4' }))?.items.map((i) => i.no)).toEqual(['INS-4']);
|
||||
expect(await ops.listInstances('nope', { page: 1, size: 8 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('详情:时间线 / 来源 / 文件 / 原因 / 用时', async () => {
|
||||
const { ops } = harness({ rows, history, loops });
|
||||
const live = await ops.instanceDetail(W05, 't-1');
|
||||
expect(live).toMatchObject({
|
||||
no: 'INS-7',
|
||||
state: 'running',
|
||||
reason: null,
|
||||
endedAt: null,
|
||||
durationMs: null,
|
||||
llm: 'deepseek-v4-flash',
|
||||
input: { content: '工业电源项目在用 LM2596S-5.0', source: { kind: 'manual', label: '手动启动 · 运营端' }, files: [{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 3 }] },
|
||||
output: { files: [] },
|
||||
});
|
||||
expect(live?.steps.map((s) => [s.name, s.by, s.byName, s.res, s.durationMs, s.note])).toEqual([
|
||||
['归一', 'agent', 'deepseek-v4-flash', 'done', 60_000, '把四项绑定'],
|
||||
['交人确认', 'human', '柴浩', 'current', null, ''],
|
||||
['放行', 'agent', 'deepseek-v4-flash', 'pending', null, ''],
|
||||
]);
|
||||
expect(live).not.toHaveProperty('input.output');
|
||||
|
||||
const redo = await ops.instanceDetail(W05, 't-redo');
|
||||
expect(redo?.reason).toEqual({ kind: 'rejected', text: '验收驳回:证据不足 —— 负责人 柴浩' });
|
||||
expect(redo?.steps.map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||
|
||||
const dead = await ops.instanceDetail(W05, 't-dead');
|
||||
expect(dead).toMatchObject({ state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' }, endedAt: '2026-08-24T02:30:00.000Z', durationMs: 30 * 60_000, owner: { name: '冯兵' } });
|
||||
|
||||
const done = await ops.instanceDetail(W05, 't-done');
|
||||
expect(done?.output.files).toEqual([{ index: 0, name: '报告.docx', contentType: expect.stringContaining('wordprocessingml'), size: 9 }]);
|
||||
|
||||
const mail = await ops.instanceDetail(W05, 't-mail');
|
||||
expect(mail?.input.source).toEqual({ kind: 'mail', label: '邮件 · quotes@itelma.ru' });
|
||||
expect(await ops.instanceDetail(W05, 'nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('上游工作流起的单标 upstream;测试台起的标 testbed', async () => {
|
||||
const { ops } = harness({ rows: [row({ id: 't-wf', workflowId: 'wf-1', origin: null }), row({ id: 't-tb', origin: 'testbed', data: {} })] });
|
||||
expect((await ops.instanceDetail(W05, 't-wf'))?.input.source).toEqual({ kind: 'upstream', label: '上游 · 统一报价 · Itelma' });
|
||||
expect((await ops.instanceDetail(W05, 't-tb'))?.input.source).toEqual({ kind: 'testbed', label: '测试台试跑' });
|
||||
});
|
||||
|
||||
it('文件:input 走上传件、output 走存档;越界 / 无单各回明确结果', async () => {
|
||||
const { ops } = harness({ rows });
|
||||
expect(await ops.instanceFile(W05, 't-1', 'input', 0)).toMatchObject({ kind: 'file', name: 'a.pdf', contentType: 'application/pdf' });
|
||||
expect((await ops.instanceFile(W05, 't-1', 'input', 1)).kind).toBe('no-file');
|
||||
expect(await ops.instanceFile(W05, 't-done', 'output', 0)).toMatchObject({ kind: 'file', name: '报告.docx' });
|
||||
expect((await ops.instanceFile(W05, 't-1', 'output', 0)).kind).toBe('no-file');
|
||||
expect((await ops.instanceFile(W05, 'nope', 'output', 0)).kind).toBe('no-instance');
|
||||
});
|
||||
|
||||
it('终止:走 task.control,理由带人名;已终态 412;之后详情是 failed + terminated', async () => {
|
||||
const { ops, controlled } = harness({ rows: [row({ id: 't-1' }), row({ id: 't-done', terminal: 'released', runtime: 'terminal' })] });
|
||||
const done = await ops.terminate(W05, 't-1', ME);
|
||||
expect(done).toMatchObject({ kind: 'ok', detail: { state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' } } });
|
||||
expect(controlled).toEqual([{ taskId: 't-1', reason: '由 冯兵 手动终止' }]);
|
||||
expect((await ops.terminate(W05, 't-done', ME)).kind).toBe('terminal');
|
||||
expect((await ops.terminate(W05, 't-1', ME)).kind).toBe('terminal');
|
||||
expect((await ops.terminate(W05, 'nope', ME)).kind).toBe('not-found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/** L3 动作过程记录:计划项 × 运行态 × 引擎痕 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCmmn } from '@modules/loop-engine';
|
||||
import type { ItemRuntime } from '@modules/loop-engine';
|
||||
import { followRes, stepsOf } from '../activity-ops/instance-steps.ts';
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||
<case id="c" name="试">
|
||||
<casePlanModel id="p" name="p">
|
||||
<humanTask id="S1" name="归一"/>
|
||||
<humanTask id="S2" name="交人确认">
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="S3" name="放行"/>
|
||||
<milestone id="M1" name="里程碑"/>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
const line = (title: string, at: string) => ({ kind: 'engine-transition', title, at });
|
||||
const intents = new Map([['S1', '把来件归一成台账行'], ['S2', '派一张执行单交人']]);
|
||||
|
||||
describe('stepsOf', () => {
|
||||
const items = parseCmmn(XML).items;
|
||||
|
||||
it('只摆任务项(里程碑不算),按文档序;人工激活的项是人办的;时刻与用时来自引擎痕', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: rt({ S1: 'Completed', S2: 'Active', S3: 'Available' }),
|
||||
intents,
|
||||
history: [
|
||||
line('开始办:归一', '2026-08-27T02:00:00.000Z'),
|
||||
line('完成:归一', '2026-08-27T02:03:00.000Z'),
|
||||
line('开始办:交人确认', '2026-08-27T02:03:30.000Z'),
|
||||
],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'deepseek-v4-flash',
|
||||
});
|
||||
expect(steps.map((s) => [s.seq, s.name, s.by, s.byName, s.res])).toEqual([
|
||||
[1, '归一', 'agent', 'deepseek-v4-flash', 'done'],
|
||||
[2, '交人确认', 'human', '柴浩', 'current'],
|
||||
[3, '放行', 'agent', 'deepseek-v4-flash', 'pending'],
|
||||
]);
|
||||
expect(steps[0]).toMatchObject({ at: '2026-08-27T02:00:00.000Z', durationMs: 180_000, note: '把来件归一成台账行' });
|
||||
expect(steps[1]).toMatchObject({ at: '2026-08-27T02:03:30.000Z', durationMs: null, note: '派一张执行单交人' });
|
||||
expect(steps[2]).toMatchObject({ at: null, durationMs: null, note: '' });
|
||||
});
|
||||
|
||||
it('自动收步的项拿收步那一刻当时刻;没有 runtime 的项是 pending', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: rt({ S1: 'Completed' }),
|
||||
intents,
|
||||
history: [line('自动收步:S1 的成果已在台账里(rows > 0)—— 不必再问模型', '2026-08-27T02:01:00.000Z')],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'm',
|
||||
});
|
||||
expect(steps[0]).toMatchObject({ res: 'done', at: '2026-08-27T02:01:00.000Z', durationMs: null });
|
||||
expect(steps[1]?.res).toBe('pending');
|
||||
});
|
||||
|
||||
it('终止 / 失败:正在办的那一步记 fail;都没开就记第一个没到的;驳回记最后一个已完成的', () => {
|
||||
const facts = { items, intents, history: [], ownerName: '柴浩', llm: 'm' };
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Active' }), state: 'failed' }).map((s) => s.res)).toEqual(['done', 'fail', 'pending']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Terminated', S2: 'Terminated' }), state: 'failed' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Completed', S3: 'Completed' }), state: 'rejected' }).map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Failed' }), state: 'running' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||
});
|
||||
|
||||
it('重复实例化的项看最新那一实例', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: [{ id: 'S2', state: 'Completed', instance: 1 }, { id: 'S2', state: 'Active', instance: 2 }],
|
||||
intents,
|
||||
history: [],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'm',
|
||||
});
|
||||
expect(steps[1]?.res).toBe('current');
|
||||
});
|
||||
});
|
||||
|
||||
describe('followRes:第 9 步跟第 8 步(放行那一步没有自己的收步事件)', () => {
|
||||
const step = (seq: number, res: 'done' | 'current' | 'pending' | 'fail') => ({ seq, name: `S${seq}`, by: 'agent' as const, byName: 'llm', at: null, durationMs: null, res, note: '' });
|
||||
it('已放行的单:第 8 步 done → 第 9 步也 done、时刻跟第 8 步;别的字段不动', () => {
|
||||
const steps = [...Array.from({ length: 7 }, (_, i) => step(i + 1, 'done')), { ...step(8, 'done'), at: '2026-08-27T14:30:26.080Z' }, { ...step(9, 'pending'), by: 'human' as const, byName: '柴浩' }];
|
||||
const out = followRes(steps, { seq: 9, from: 8 });
|
||||
expect(out[8]).toEqual({ ...step(9, 'done'), at: '2026-08-27T14:30:26.080Z', by: 'human', byName: '柴浩' });
|
||||
expect(out.slice(0, 8)).toEqual(steps.slice(0, 8));
|
||||
});
|
||||
it('第 8 步 fail / pending 时第 9 步照样跟;步数不够 9 的活动什么都不动', () => {
|
||||
expect(followRes([step(1, 'done'), ...Array.from({ length: 7 }, (_, i) => step(i + 2, 'pending')), step(9, 'pending')].map((s) => (s.seq === 8 ? { ...s, res: 'fail' as const } : s)), { seq: 9, from: 8 })[8]?.res).toBe('fail');
|
||||
const seven = Array.from({ length: 7 }, (_, i) => step(i + 1, 'done'));
|
||||
expect(followRes(seven, { seq: 9, from: 8 })).toEqual(seven);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 启动幂等键在真库上的并发:两条连接同时占同一枚键,**恰一个赢**;单没建成的过期键可被重占。
|
||||
* 独占一个库(照 mail-store.it 的先例):建表 / 清表都在 `jcai_it_activity_ops` 里,谁的库都不碰。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||
|
||||
const IT_DB = 'jcai_it_activity_ops';
|
||||
|
||||
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
|
||||
const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
try {
|
||||
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
|
||||
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
|
||||
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
|
||||
await admin.end();
|
||||
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
|
||||
} catch {
|
||||
/* 开发实例连不上,试 Testcontainers */
|
||||
}
|
||||
try {
|
||||
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
|
||||
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
|
||||
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
|
||||
} catch (error) {
|
||||
process.stderr.write(`⚠️ activity-ops store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const database = await provisionDatabase();
|
||||
let poolA: Pool | undefined;
|
||||
let poolB: Pool | undefined;
|
||||
let dbA: Db;
|
||||
let dbB: Db;
|
||||
|
||||
const MIGRATION = new URL('../../../../../db/migrations/0035_activity_ops.sql', import.meta.url);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (database === undefined) return;
|
||||
poolA = new Pool({ connectionString: database.url, max: 2 });
|
||||
poolB = new Pool({ connectionString: database.url, max: 2 });
|
||||
dbA = drizzle(poolA);
|
||||
dbB = drizzle(poolB);
|
||||
await poolA.query('create schema if not exists sys');
|
||||
await poolA.query('drop table if exists sys.activity_launch; drop table if exists sys.activity_profile');
|
||||
await poolA.query(readFileSync(MIGRATION, 'utf8'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await poolA?.end();
|
||||
await poolB?.end();
|
||||
await database?.stop();
|
||||
});
|
||||
|
||||
describe.skipIf(database === undefined)('activity_launch 幂等键(真库)', () => {
|
||||
it('两条连接同时占同一枚键,恰一个 claimed,另一个读回那一行;绑定后重放拿到 task_id', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
const b = createActivityOpsStore(dbB);
|
||||
const input = { key: 'race-1', activityId: 'w05', ownerId: 'owner', launchedBy: 'me' };
|
||||
const results = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? a : b).claimLaunch(input)));
|
||||
expect(results.filter((r) => r === 'claimed')).toHaveLength(1);
|
||||
for (const r of results) if (r !== 'claimed') expect(r).toMatchObject({ key: 'race-1', activityId: 'w05', taskId: null });
|
||||
await a.bindLaunch('race-1', 't-1');
|
||||
expect(await b.claimLaunch(input)).toMatchObject({ taskId: 't-1' });
|
||||
expect(await b.launchOf('t-1')).toMatchObject({ key: 'race-1', launchedBy: 'me' });
|
||||
});
|
||||
|
||||
it('单没建成的键:没过期不许重占;过期后恰一个重占成功;已绑 task 的永远不许', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
const b = createActivityOpsStore(dbB);
|
||||
expect(await a.claimLaunch({ key: 'stale-1', activityId: 'w05', ownerId: 'o', launchedBy: 'x' })).toBe('claimed');
|
||||
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'y')).toBe(false);
|
||||
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||
const won = await Promise.all([a.reclaimLaunch('stale-1', 60_000, 'o', 'y'), b.reclaimLaunch('stale-1', 60_000, 'o', 'z')]);
|
||||
expect(won.filter(Boolean)).toHaveLength(1);
|
||||
await a.bindLaunch('stale-1', 't-2');
|
||||
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'q')).toBe(false);
|
||||
});
|
||||
|
||||
it('活动档案:upsert 冲突即更新,负责人可空', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
await a.upsertProfile({ activityId: 'w05', ownerId: null, description: '一', ico: 'git-compare', hue: 'teal' });
|
||||
await a.upsertProfile({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||
expect((await a.profilesOf()).get('w05')).toEqual({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 活动模块的 app 层存取:SQL 不求值,只看「几笔、带了什么」与行 → 视图的映射(时间戳归一、seq → 实例号)。
|
||||
* 真库上的并发抢键在 `activity-ops-store.it.test.ts`。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||
|
||||
type Fake = { calls: string[]; execute: (query: unknown) => Promise<{ rows: unknown[] }> };
|
||||
|
||||
/** 每次 execute 按顺序吐一份 rows;SQL 片段拼成字符串留着断言 */
|
||||
function fakeDb(...results: unknown[][]): Fake {
|
||||
const calls: string[] = [];
|
||||
const queue = [...results];
|
||||
return {
|
||||
calls,
|
||||
execute: async (query: unknown) => {
|
||||
const q = query as { queryChunks?: unknown[] };
|
||||
calls.push(JSON.stringify(q.queryChunks ?? query));
|
||||
return { rows: queue.shift() ?? [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RAW_TASK = {
|
||||
id: 't-1',
|
||||
seq: '17',
|
||||
activity_id: 'part-substitution-assessment',
|
||||
workflow_id: null,
|
||||
origin: 'ops',
|
||||
owner_id: 's-1',
|
||||
title: 'W05',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
input_data: { customerCode: 'CU-0142' },
|
||||
used_steps: '3',
|
||||
// pg 的 timestamptz 在 raw execute 这一路回文本(实测),也可能是 Date —— 两种都要归一成 ISO
|
||||
started_at: '2026-08-27 16:55:05.123+08',
|
||||
finished_at: new Date('2026-08-27T08:55:14.383Z'),
|
||||
};
|
||||
|
||||
describe('createActivityOpsStore', () => {
|
||||
it('listTasks:seq → INS-n、时间戳归一成 ISO、jsonb 取成对象、数字列转数', async () => {
|
||||
const db = fakeDb([RAW_TASK]);
|
||||
const rows = await createActivityOpsStore(db).listTasks('part-substitution-assessment');
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 't-1',
|
||||
no: 'INS-17',
|
||||
activityId: 'part-substitution-assessment',
|
||||
workflowId: null,
|
||||
origin: 'ops',
|
||||
ownerId: 's-1',
|
||||
title: 'W05',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
data: { customerCode: 'CU-0142' },
|
||||
usedSteps: 3,
|
||||
startedAt: '2026-08-27T08:55:05.123Z',
|
||||
finishedAt: '2026-08-27T08:55:14.383Z',
|
||||
},
|
||||
]);
|
||||
expect(db.calls[0]).toContain('order by started_at desc, seq desc');
|
||||
expect(db.calls[0]).toContain('activity_id = ');
|
||||
});
|
||||
|
||||
it('latestReleased 只看已放行的、按放行时刻倒序取一条;没有回 undefined', async () => {
|
||||
const db = fakeDb([RAW_TASK]);
|
||||
const one = await createActivityOpsStore(db).latestReleased('part-substitution-assessment');
|
||||
expect(one?.no).toBe('INS-17');
|
||||
expect(db.calls[0]).toContain("terminal = 'released'");
|
||||
expect(db.calls[0]).toContain('order by finished_at desc nulls last, started_at desc limit 1');
|
||||
expect(await createActivityOpsStore(fakeDb([])).latestReleased('x')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getTask 认活动 + 任务两把钥匙;没命中回 undefined', async () => {
|
||||
const db = fakeDb([]);
|
||||
expect(await createActivityOpsStore(db).getTask('a', 't')).toBeUndefined();
|
||||
expect(db.calls[0]).toContain('activity_id = ');
|
||||
expect(db.calls[0]).toContain('and id = ');
|
||||
});
|
||||
|
||||
it('countByActivity 按活动数;profilesOf 按活动键映射', async () => {
|
||||
const db = fakeDb([{ activity_id: 'a', n: '2' }, { activity_id: 'b', n: 1 }], [{ activity_id: 'a', owner_id: null, description: null, ico: 'target', hue: 'blue' }]);
|
||||
const store = createActivityOpsStore(db);
|
||||
expect([...(await store.countByActivity()).entries()]).toEqual([['a', 2], ['b', 1]]);
|
||||
const profiles = await store.profilesOf();
|
||||
expect(profiles.get('a')).toEqual({ activityId: 'a', ownerId: null, description: '', ico: 'target', hue: 'blue' });
|
||||
});
|
||||
|
||||
it('claimLaunch:一句 SQL 判 + 占(on conflict do nothing returning);插不进去就读回那一行', async () => {
|
||||
const claimed = fakeDb([{ key: 'k' }]);
|
||||
expect(await createActivityOpsStore(claimed).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toBe('claimed');
|
||||
expect(claimed.calls[0]).toContain('on conflict (key) do nothing');
|
||||
expect(claimed.calls[0]).toContain('returning key');
|
||||
expect(claimed.calls).toHaveLength(1);
|
||||
|
||||
const taken = fakeDb([], [{ key: 'k', activity_id: 'a', owner_id: 'o', launched_by: 'x', task_id: 't-9', claimed_at: new Date('2026-08-27T08:00:00.000Z') }]);
|
||||
expect(await createActivityOpsStore(taken).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toEqual({
|
||||
key: 'k',
|
||||
activityId: 'a',
|
||||
ownerId: 'o',
|
||||
launchedBy: 'x',
|
||||
taskId: 't-9',
|
||||
claimedAt: '2026-08-27T08:00:00.000Z',
|
||||
});
|
||||
expect(taken.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reclaimLaunch 只占「单没建成且已过期」的键;bindLaunch 落 task_id;upsertProfile 冲突即更新', async () => {
|
||||
const db = fakeDb([{ key: 'k' }], [], []);
|
||||
const store = createActivityOpsStore(db);
|
||||
expect(await store.reclaimLaunch('k', 60_000, 'o', 'me')).toBe(true);
|
||||
expect(db.calls[0]).toContain('task_id is null');
|
||||
expect(db.calls[0]).toContain("milliseconds')::interval");
|
||||
await store.bindLaunch('k', 't-1');
|
||||
expect(db.calls[1]).toContain('set task_id = ');
|
||||
await store.upsertProfile({ activityId: 'a', ownerId: null, description: 'd', ico: null, hue: null });
|
||||
expect(db.calls[2]).toContain('on conflict (activity_id) do update');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 清单一句话与检索 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstanceSummary } from '@contracts';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import { fieldSpecsOf } from '../activity-ops/launch-params.ts';
|
||||
import { applyInstanceFilters, clip, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from '../activity-ops/summaries.ts';
|
||||
|
||||
const specs = fieldSpecsOf({
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
requestDescription: { type: 'string', 'x-control': 'textarea' },
|
||||
datasheets: { type: 'array', format: 'file' },
|
||||
},
|
||||
});
|
||||
|
||||
describe('inputSummaryOf / inputFilesOf / outputSummaryOf', () => {
|
||||
it('textarea 那格优先;没有就最长的字符串;再没有退回来件主题;文件字段不算', () => {
|
||||
expect(inputSummaryOf({ customerCode: 'CU-0142', requestDescription: ' 工业电源项目\n在用 LM2596S-5.0 ' }, specs)).toBe('工业电源项目 在用 LM2596S-5.0');
|
||||
expect(inputSummaryOf({ customerCode: 'CU-0142', datasheets: [{ ref: 'r', name: 'a-very-long-file-name.pdf' }] }, specs)).toBe('CU-0142');
|
||||
expect(inputSummaryOf({}, specs, 'RE: 询价')).toBe('RE: 询价');
|
||||
expect(inputSummaryOf({}, specs)).toBe('');
|
||||
expect(clip('x'.repeat(100))).toHaveLength(80);
|
||||
expect(clip('x'.repeat(100)).endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('文件只认带 ref 的;index 跨字段连续', () => {
|
||||
const files = inputFilesOf({ datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }, { name: 'no-ref' }, 'junk'] }, specs);
|
||||
expect(files).toEqual([{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }]);
|
||||
});
|
||||
|
||||
it('产出:有文件摆文件名;没有摆发口;都没有 null', () => {
|
||||
expect(outputSummaryOf(['报告.docx', '对照表.xlsx'], ['x'])).toBe('报告.docx、对照表.xlsx');
|
||||
expect(outputSummaryOf([], ['assessment-released'])).toBe('已发口:assessment-released');
|
||||
expect(outputSummaryOf([], [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const owner = { id: 's-1', name: '柴浩' };
|
||||
const mk = (id: string, no: string, state: InstanceSummary['state'], startedAt: string, ownerId = 's-1'): InstanceSummary => ({
|
||||
id,
|
||||
no,
|
||||
state,
|
||||
owner: { id: staffIdSchema.parse(ownerId), name: owner.name },
|
||||
input: 'x',
|
||||
output: null,
|
||||
startedAt,
|
||||
});
|
||||
const items = [
|
||||
mk('t-aaa', 'INS-24', 'running', '2026-08-25T02:12:00.000Z'),
|
||||
mk('t-bbb', 'INS-17', 'completed', '2026-08-24T09:03:00.000Z', 's-2'),
|
||||
mk('t-ccc', 'INS-9', 'failed', '2026-08-20T01:00:00.000Z'),
|
||||
];
|
||||
|
||||
describe('applyInstanceFilters / statsOf / pageOf', () => {
|
||||
it('实例号模糊、负责人精确、状态精确、日期段含两端整日(本地时区)', () => {
|
||||
expect(applyInstanceFilters(items, { id: 'ins-2' }).map((i) => i.no)).toEqual(['INS-24']);
|
||||
expect(applyInstanceFilters(items, { id: 'bbb' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { ownerId: 's-2' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { state: 'failed' }).map((i) => i.no)).toEqual(['INS-9']);
|
||||
const day = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z'), startedTo: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||
expect(applyInstanceFilters(items, { startedTo: day('2026-08-20T01:00:00.000Z') }).map((i) => i.no)).toEqual(['INS-9']);
|
||||
expect(applyInstanceFilters(items, {})).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('统计五键齐;分页越界给空', () => {
|
||||
expect(statsOf(items)).toEqual({ total: 3, byState: { pending: 0, running: 1, completed: 1, rejected: 0, failed: 1 } });
|
||||
expect(pageOf(items, 1, 2).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||
expect(pageOf(items, 2, 2).map((i) => i.no)).toEqual(['INS-9']);
|
||||
expect(pageOf(items, 3, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 沙箱视图出口:模版原文不改,只在 </body> 前注入报高脚本 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||
import { injectHeightScript } from '../activity-ops/view-html.ts';
|
||||
|
||||
describe('injectHeightScript', () => {
|
||||
it('插在 </body> 之前;没有 body 就追加;原文一字不改', () => {
|
||||
const html = '<!doctype html><html><body><h1>一览</h1></body></html>';
|
||||
const out = injectHeightScript(html);
|
||||
expect(out.startsWith('<!doctype html><html><body><h1>一览</h1>')).toBe(true);
|
||||
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('</body>'));
|
||||
expect(out).toContain(ACTIVITY_VIEW_HEIGHT_MESSAGE);
|
||||
expect(out).toContain("postMessage({type:T,height:h()},'*')");
|
||||
const bare = injectHeightScript('<p>x</p>');
|
||||
expect(bare.startsWith('<p>x</p>')).toBe(true);
|
||||
expect(bare).toContain('<script>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 IO,读表用一张内存表替。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ViewData } from '@contracts';
|
||||
import { caseStamp, openedAtText } from '../case-stamp.ts';
|
||||
import { resolveViewData, viewScopeOf, slugOf, localMinute } from '../activity-ops/view-scope.ts';
|
||||
import type { ViewRead, ViewRow } from '../activity-ops/view-scope.ts';
|
||||
|
||||
const SPEC: ViewData = {
|
||||
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
|
||||
rows: [
|
||||
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }] },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
|
||||
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: 'sub_eval.verdict.request_id' } },
|
||||
{
|
||||
as: 'validation.history',
|
||||
ledger: 'validation.record',
|
||||
by: { column: 'validation_internal_pn', from: 'sub_eval.pn_check.internal_pn' },
|
||||
not: { column: 'request_id', from: 'sub_eval.verdict.request_id' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TABLES: Record<string, ViewRow[]> = {
|
||||
'sub_eval.verdict': [
|
||||
{ eval_ref: 'W05-20260827215103', request_id: 'REQ-20260827215103', original_internal_pn: 'LM2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000', original_internal_pn: 'X' },
|
||||
],
|
||||
'sub_eval.pn_check': [
|
||||
{ eval_ref: 'W05-20260827215103', internal_pn: 'XL2596S-5.0', role: 'candidate', exists_status: 'created' },
|
||||
{ eval_ref: 'W05-20260827215103', internal_pn: 'LM2596S-5.0', role: 'original', exists_status: 'found' },
|
||||
{ eval_ref: 'W05-20260827000000', internal_pn: 'X', role: 'original', exists_status: 'found' },
|
||||
],
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', brand_name: 'TI', lifecycle_status: 'nrnd' },
|
||||
{ internal_pn: 'XL2596S-5.0', brand_name: 'XLSEMI', lifecycle_status: 'active' },
|
||||
{ internal_pn: 'X', brand_name: '?', lifecycle_status: 'active' },
|
||||
],
|
||||
'substitution.relation': [
|
||||
{ substitution_relation_id: 'REL-1', eval_ref: 'W05-20260827215103', recommendation_level: 'conditional' },
|
||||
{ substitution_relation_id: 'REL-9', eval_ref: 'W05-20260827000000', recommendation_level: 'alternate' },
|
||||
],
|
||||
'validation.record': [
|
||||
{ validation_id: 'VAL-A', request_id: 'REQ-20260827215103', validation_internal_pn: 'XL2596S-5.0' },
|
||||
{ validation_id: 'VAL-OLD', request_id: 'REQ-20260101000000', validation_internal_pn: 'XL2596S-5.0' },
|
||||
{ validation_id: 'VAL-OTHER', request_id: 'REQ-20260101000001', validation_internal_pn: 'Z' },
|
||||
],
|
||||
};
|
||||
|
||||
/** 内存表:等值过滤 + 单列排序;记下每一次读 */
|
||||
function reader(tables: Record<string, ViewRow[]>, broken: ReadonlySet<string> = new Set()): { read: ViewRead; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const read: ViewRead = async (address, where, orderBy) => {
|
||||
const name = `${address.domain}.${address.object}`;
|
||||
calls.push(`${name} ${JSON.stringify(where)}`);
|
||||
if (broken.has(name)) return undefined;
|
||||
const rows = (tables[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => String(r[k]) === v));
|
||||
const order = orderBy[0];
|
||||
if (order === undefined) return rows;
|
||||
return [...rows].sort((a, b) => String(a[order.field]).localeCompare(String(b[order.field])) * (order.dir === 'asc' ? 1 : -1));
|
||||
};
|
||||
return { read, calls };
|
||||
}
|
||||
|
||||
const DECLARED = [
|
||||
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
|
||||
{ domain: 'substitution', object: 'relation', fields: [{ name: 'recommendation_level', tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }] },
|
||||
];
|
||||
|
||||
describe('caseStamp / openedAtText', () => {
|
||||
it('同一串:文本带分隔符、案号只留 14 位数字;认不出的时刻两者都是空串', () => {
|
||||
const iso = new Date(2026, 7, 27, 21, 51, 3).toISOString();
|
||||
expect(openedAtText(iso)).toBe('2026-08-27 21:51:03');
|
||||
expect(caseStamp(iso)).toBe('20260827215103');
|
||||
expect(openedAtText('not-a-date')).toBe('');
|
||||
expect(caseStamp('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveViewData', () => {
|
||||
it('按序链式求值:案号 → 核对表 → 料号圈主数据;行序随 from 值的顺序(原 PN 排第一)', async () => {
|
||||
const { read, calls } = reader(TABLES);
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.caseFound).toBe(true);
|
||||
expect(out.skipped).toEqual([]);
|
||||
expect(out.sets['sub_eval.verdict']).toHaveLength(1);
|
||||
expect(out.sets['sub_eval.pn_check']?.map((r) => r['internal_pn'])).toEqual(['LM2596S-5.0', 'XL2596S-5.0']);
|
||||
expect(out.sets['parts.profile']?.map((r) => r['brand_name'])).toEqual(['TI', 'XLSEMI']);
|
||||
expect(out.sets['substitution.relation']?.map((r) => r['substitution_relation_id'])).toEqual(['REL-1']);
|
||||
// 每个值单独一次等值读:别单的行一条都不进来
|
||||
expect(calls).toContain('parts.profile {"internal_pn":"LM2596S-5.0"}');
|
||||
expect(calls).toContain('parts.profile {"internal_pn":"XL2596S-5.0"}');
|
||||
expect(calls.filter((c) => c.startsWith('parts.profile'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('not 排除:同料号的历史验证记录不含本单挂的那条;别的料号不进来', async () => {
|
||||
const { read } = reader(TABLES);
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.sets['validation.record']?.map((r) => r['validation_id'])).toEqual(['VAL-A']);
|
||||
expect(out.sets['validation.history']?.map((r) => r['validation_id'])).toEqual(['VAL-OLD']);
|
||||
});
|
||||
|
||||
it('案号查无 → caseFound=false,下游集合全空、一次多余的读都不发', async () => {
|
||||
const { read, calls } = reader(TABLES);
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-19990101000000', read });
|
||||
expect(out.caseFound).toBe(false);
|
||||
expect(out.sets['parts.profile']).toEqual([]);
|
||||
expect(calls.some((c) => c.startsWith('parts.profile'))).toBe(false);
|
||||
});
|
||||
|
||||
it('某张表读不了:那个集合为空并点名,别的集合照常', async () => {
|
||||
const { read } = reader(TABLES, new Set(['parts.profile']));
|
||||
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.sets['parts.profile']).toEqual([]);
|
||||
expect(out.sets['substitution.relation']).toHaveLength(1);
|
||||
expect(out.skipped).toEqual(['parts.profile']);
|
||||
});
|
||||
|
||||
it('from 指向没定义过的集合 → 点名、集合为空(声明写错不炸)', async () => {
|
||||
const { read } = reader(TABLES);
|
||||
const spec: ViewData = { ...SPEC, rows: [{ as: 'x', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'nope.internal_pn' } }] };
|
||||
const out = await resolveViewData(spec, { caseRef: 'W05-20260827215103', read });
|
||||
expect(out.sets['x']).toEqual([]);
|
||||
expect(out.skipped).toEqual(['x']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewScopeOf', () => {
|
||||
it('行数组 / 首行标量 / 行数 / tally 计数(先铺 0)/ 实例事实 / 三态', () => {
|
||||
const sets = {
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
|
||||
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
|
||||
],
|
||||
'substitution.relation': [],
|
||||
};
|
||||
const scope = viewScopeOf({
|
||||
spec: SPEC,
|
||||
sets,
|
||||
declared: DECLARED,
|
||||
instance: { no: 'INS-20', startedAt: new Date(2026, 7, 27, 21, 51, 3).toISOString(), finishedAt: new Date(2026, 7, 27, 21, 57, 56).toISOString(), owner: '柴浩' },
|
||||
caseRef: 'W05-20260827215103',
|
||||
state: 'ready',
|
||||
});
|
||||
expect(scope['board:state']).toBe('ready');
|
||||
expect(scope['case:instanceNo']).toBe('INS-20');
|
||||
expect(scope['case:openedAt']).toBe('2026-08-27 21:51');
|
||||
expect(scope['case:releasedAt']).toBe('2026-08-27 21:57');
|
||||
expect(scope['case:owner']).toBe('柴浩');
|
||||
expect(scope['case:ref']).toBe('W05-20260827215103');
|
||||
expect(scope['parts.profile']).toBe(sets['parts.profile']);
|
||||
expect(scope['parts.profile.internal_pn']).toBe('LM2596S-5.0');
|
||||
expect(scope['parts_profile_rows']).toBe(2);
|
||||
expect(scope['parts_profile_lifecycle_status_nrnd']).toBe(1);
|
||||
expect(scope['parts_profile_lifecycle_status_active']).toBe(1);
|
||||
expect(scope['parts_profile_lifecycle_status_eol']).toBe(0);
|
||||
expect(scope['substitution_relation_rows']).toBe(0);
|
||||
expect(scope['substitution_relation_recommendation_level_not_recommended']).toBe(0);
|
||||
// 声明里没求出来的集合也铺空数组:模版的 data-rows 不会对着 undefined
|
||||
expect(scope['sub_eval.verdict']).toEqual([]);
|
||||
});
|
||||
|
||||
it('没有已放行实例:none 态,实例事实全空串', () => {
|
||||
const scope = viewScopeOf({ spec: SPEC, sets: {}, declared: DECLARED, state: 'none' });
|
||||
expect(scope['board:state']).toBe('none');
|
||||
expect(scope['case:instanceNo']).toBe('');
|
||||
expect(scope['case:releasedAt']).toBe('');
|
||||
expect(scope['parts_profile_rows']).toBe(0);
|
||||
});
|
||||
|
||||
it('slug 与时刻', () => {
|
||||
expect(slugOf('sub_eval.verdict')).toBe('sub_eval_verdict');
|
||||
expect(slugOf('validation.history')).toBe('validation_history');
|
||||
expect(slugOf('not_recommended')).toBe('not_recommended');
|
||||
expect(localMinute(null)).toBe('');
|
||||
expect(localMinute('garbage')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 活动模块路由的归属:11 条全部进契约、注册表建得起来(与设计端的 activity-packages 互不撞),全归运营中心。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as contracts from '@contracts';
|
||||
import { ACTIVITY_OPS_ROUTES } from '@contracts';
|
||||
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
|
||||
|
||||
describe('活动模块路由契约', () => {
|
||||
it('11 条全在契约命名空间里,注册表能建起来', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
expect(ACTIVITY_OPS_ROUTES).toHaveLength(11);
|
||||
for (const route of ACTIVITY_OPS_ROUTES) expect(registry.has(route.method, route.path), route.path).toBe(true);
|
||||
});
|
||||
|
||||
it('全归 ops;设计端活动库让位到 activity-packages 后仍归 design', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
for (const route of ACTIVITY_OPS_ROUTES) expect(route.access).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activities')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activity-packages')).toBe('design');
|
||||
expect(registry.lookup('POST', '/api/gov/activity-packages')).toBe('design');
|
||||
expect(registry.lookup('GET', '/api/gov/activities/part-substitution-assessment/view')).toBe('ops');
|
||||
expect(registry.lookup('POST', '/api/gov/activities/part-substitution-assessment/instances/t-1/terminate')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activities/x/instances/t-1/files/output/0')).toBe('ops');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 活动模块路由:zod 校验 → 用例 → 结果翻状态码。用例是替身(用例本身另有测试),不挂鉴权中间件,
|
||||
* 身份由测试自己塞进上下文(终止 / 启动要知道是谁按的)。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstanceDetail, Principal } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { registerActivityRoutes } from '../routes/activities.ts';
|
||||
import type { ActivityOps } from '../activity-ops/service.ts';
|
||||
|
||||
const W05 = 'part-substitution-assessment';
|
||||
const owner = { id: staffIdSchema.parse('s-1'), name: '柴浩' };
|
||||
const detail: InstanceDetail = {
|
||||
id: 't-1',
|
||||
no: 'INS-7',
|
||||
state: 'running',
|
||||
owner,
|
||||
output: { files: [] },
|
||||
startedAt: '2026-08-27T02:00:00.000Z',
|
||||
activityId: W05,
|
||||
llm: 'deepseek-v4-flash',
|
||||
endedAt: null,
|
||||
durationMs: null,
|
||||
reason: null,
|
||||
input: { content: 'x', source: { kind: 'manual', label: '手动启动 · 柴浩' }, submittedAt: '2026-08-27T02:00:00.000Z', files: [] },
|
||||
steps: [],
|
||||
};
|
||||
const activity = { id: W05, name: 'W05', biz: '需求分析', status: 'active' as const, version: '1.5.0', owner, llm: 'm', createdAt: 'c', desc: '', view: null };
|
||||
|
||||
function fakeOps(over: Partial<ActivityOps> = {}): ActivityOps {
|
||||
return {
|
||||
listActivities: async () => [{ ...activity, instanceCount: 1 }],
|
||||
getActivity: async (id) => (id === W05 ? activity : undefined),
|
||||
viewHtml: async (id) => (id === W05 ? { kind: 'html', html: '<html><body>一览<script>x</script></body></html>' } : { kind: 'no-view' }),
|
||||
listInstances: async (id, q) => (id === W05 ? { stats: { total: 1, byState: { pending: 0, running: 1, completed: 0, rejected: 0, failed: 0 } }, total: 1, page: q.page, size: q.size, items: [] } : undefined),
|
||||
launchParams: async (id) => (id === W05 ? { activityId: id, version: '1.5.0', params: [] } : undefined),
|
||||
launch: async (id, input) => {
|
||||
if (id !== W05) return { kind: 'not-found' };
|
||||
if (input.idempotencyKey === 'busy') return { kind: 'busy', message: '正在创建' };
|
||||
if (input.idempotencyKey === 'bad') return { kind: 'invalid', issues: [{ key: 'customerCode', message: '必填' }] };
|
||||
if (input.idempotencyKey === 'off') return { kind: 'disabled' };
|
||||
if (input.idempotencyKey === 'owner') return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||
return { kind: 'ok', detail, created: true };
|
||||
},
|
||||
instanceDetail: async (id, iid) => (id === W05 && iid === 't-1' ? detail : undefined),
|
||||
instanceFile: async (id, iid, io, index) =>
|
||||
id === W05 && iid === 't-1' && io === 'input' && index === 0 ? { kind: 'file', name: '报 告.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : iid === 't-1' ? { kind: 'no-file' } : { kind: 'no-instance' },
|
||||
setEnabled: async (id, enabled) => (id === W05 ? { ...activity, status: enabled ? 'active' : 'disabled' } : undefined),
|
||||
terminate: async (id, iid) => (id !== W05 || iid === 'nope' ? { kind: 'not-found' } : iid === 't-done' ? { kind: 'terminal' } : { kind: 'ok', detail: { ...detail, state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } } }),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function harness(opts: { anonymous?: boolean } = {}) {
|
||||
const app = new Hono<AppEnv>();
|
||||
const principal: Principal = {
|
||||
staff: { id: owner.id, account: 'chaihao', name: owner.name, positionId: positionIdSchema.parse('pm'), state: 'active', roles: [], kind: 'person' },
|
||||
position: { id: positionIdSchema.parse('pm'), code: 'PM', name: '项目经理', note: '' },
|
||||
roles: [],
|
||||
ends: ['ops'],
|
||||
};
|
||||
if (!opts.anonymous) {
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('principal', principal);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
registerActivityRoutes(app, { activityOps: fakeOps() });
|
||||
const json = (method: string, path: string, body?: unknown) =>
|
||||
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { app, json };
|
||||
}
|
||||
|
||||
describe('活动路由', () => {
|
||||
it('L1 / L2:清单按 status 过滤入参校验;详情 404;视图回 text/html', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('GET', '/api/gov/activities')).json()).toMatchObject({ activities: [{ id: W05, instanceCount: 1 }] });
|
||||
expect((await h.json('GET', '/api/gov/activities?status=paused')).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope')).status).toBe(404);
|
||||
const view = await h.json('GET', `/api/gov/activities/${W05}/view`);
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.headers.get('content-type')).toContain('text/html');
|
||||
expect(view.headers.get('cache-control')).toBe('no-store');
|
||||
expect(await view.text()).toContain('一览');
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/view')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('实例清单:查询默认值与上限;启动参数表 404', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances`)).json()).toMatchObject({ page: 1, size: 8 });
|
||||
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances?page=2&size=20&state=running`)).json()).toMatchObject({ page: 2, size: 20 });
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?size=99`)).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?startedFrom=8-1`)).status).toBe(400);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/instances')).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/launch-params`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/launch-params')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('启动:400 入参 / 412 禁用 / 409 在途 / 404 / 401 没身份 / 200 详情', async () => {
|
||||
const h = harness();
|
||||
const body = { idempotencyKey: 'k', ownerId: 's-1', params: { customerCode: 'CU-1' } };
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ownerId: 's-1' })).status).toBe(400);
|
||||
const bad = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'bad' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(await bad.json()).toMatchObject({ code: 'validation-failed', detail: [{ key: 'customerCode' }] });
|
||||
const owner = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'owner' });
|
||||
expect(owner.status).toBe(400);
|
||||
expect(await owner.json()).toMatchObject({ detail: [{ key: 'ownerId' }] });
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'off' })).status).toBe(412);
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'busy' })).status).toBe(409);
|
||||
expect((await h.json('POST', '/api/gov/activities/nope/instances', body)).status).toBe(404);
|
||||
const ok = await h.json('POST', `/api/gov/activities/${W05}/instances`, body);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.json()).toMatchObject({ id: 't-1', no: 'INS-7' });
|
||||
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances`, body)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('L3:详情 404;文件默认 JSON+base64、raw=1 直出字节带 RFC 5987 文件名;序号不合法 400', async () => {
|
||||
const h = harness();
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1`)).status).toBe(200);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope`)).status).toBe(404);
|
||||
const json = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0`);
|
||||
expect(await json.json()).toEqual({ name: '报 告.pdf', contentType: 'application/pdf', dataBase64: Buffer.from([1, 2, 3]).toString('base64') });
|
||||
const raw = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0?raw=1`);
|
||||
expect(raw.status).toBe(200);
|
||||
expect(raw.headers.get('content-type')).toBe('application/pdf');
|
||||
expect(raw.headers.get('content-disposition')).toBe(`attachment; filename*=UTF-8''${encodeURIComponent('报 告.pdf')}`);
|
||||
expect(new Uint8Array(await raw.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/output/0`)).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope/files/input/0`)).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/both/0`)).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/x`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('禁用 / 激活回活动详情;终止 412 已终态、404、401 没身份', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('POST', `/api/gov/activities/${W05}/disable`)).json()).toMatchObject({ status: 'disabled' });
|
||||
expect(await (await h.json('POST', `/api/gov/activities/${W05}/activate`)).json()).toMatchObject({ status: 'active' });
|
||||
expect((await h.json('POST', '/api/gov/activities/nope/disable')).status).toBe(404);
|
||||
const done = await h.json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`);
|
||||
expect(done.status).toBe(200);
|
||||
expect(await done.json()).toMatchObject({ state: 'failed', reason: { kind: 'terminated' } });
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/t-done/terminate`)).status).toBe(412);
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/nope/terminate`)).status).toBe(404);
|
||||
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { taskIdSchema } from '@contracts';
|
||||
import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } };
|
||||
const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' };
|
||||
|
||||
describe('analyze 网关', () => {
|
||||
it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: (c: Record<string, unknown>) => {
|
||||
seen.push(c);
|
||||
return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
||||
},
|
||||
} as never);
|
||||
const out = await gateway.run(call);
|
||||
expect(out.object).toEqual({ items: [{ p: 'A' }] });
|
||||
expect(seen[0]?.['system']).toBe('逐条判四态');
|
||||
expect(String(seen[0]?.['prompt'])).toContain('"p": "A"');
|
||||
expect(seen[0]?.['reasoning']).toBe('off');
|
||||
});
|
||||
|
||||
it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => {
|
||||
let n = 0;
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n === 1
|
||||
? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
|
||||
: { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
|
||||
);
|
||||
},
|
||||
} as never);
|
||||
const out = await gateway.run(call);
|
||||
expect(n).toBe(2);
|
||||
expect(out.object).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => {
|
||||
let n = 0;
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
||||
},
|
||||
} as never);
|
||||
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
/*
|
||||
* **两次调用的用量都要交回去记账**(2026-08-29 终审 C1)。
|
||||
* `createModelGateway` 自己不做 `addConsumption` —— 不给 `onUsage`,配方里的 analyze
|
||||
* 就是一条问模型不花钱的路,活动预算对它形同虚设。重问那一次照样烧真钱,一并记。
|
||||
*/
|
||||
it('用量交回 onUsage:第一次成的记一次,重问了记两次', async () => {
|
||||
const billed: Array<{ taskId: string; tokens: number }> = [];
|
||||
const once = createAnalyzeGateway(
|
||||
{
|
||||
generate: () =>
|
||||
Promise.resolve({ text: '', object: { items: [] }, usage: { inputTokens: 30, outputTokens: 12, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
|
||||
} as never,
|
||||
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
|
||||
);
|
||||
await once.run(call);
|
||||
expect(billed).toEqual([{ taskId: TASK, tokens: 42 }]);
|
||||
|
||||
billed.length = 0;
|
||||
let n = 0;
|
||||
const twice = createAnalyzeGateway(
|
||||
{
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n === 1
|
||||
? { text: 'not json', usage: { inputTokens: 5, outputTokens: 5, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
|
||||
: { text: '', object: { items: [] }, usage: { inputTokens: 7, outputTokens: 3, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
|
||||
);
|
||||
},
|
||||
} as never,
|
||||
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
|
||||
);
|
||||
await twice.run(call);
|
||||
expect(billed.map((b) => b.tokens)).toEqual([10, 10]);
|
||||
});
|
||||
|
||||
it('两次都不成形也照样记两笔 —— 失败的调用不是免费的', async () => {
|
||||
const billed: number[] = [];
|
||||
const gateway = createAnalyzeGateway(
|
||||
{
|
||||
generate: () =>
|
||||
Promise.resolve({ text: 'nope', usage: { inputTokens: 4, outputTokens: 6, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
|
||||
} as never,
|
||||
(_taskId, tokens) => { billed.push(tokens); return Promise.resolve(); },
|
||||
);
|
||||
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
|
||||
expect(billed).toEqual([10, 10]);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { archiveAssetFiles, keyText } from '../asset-archive.ts';
|
||||
import { archiveAssetFiles, assetRowsOf, keyText } from '../asset-archive.ts';
|
||||
|
||||
const TASK = 't-1' as TaskId;
|
||||
|
||||
@@ -54,6 +54,11 @@ describe('keyText —— 主键拼法必须稳定', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 本单碰过的行 —— `ledger.taskDrafts()` 那一份的形状 */
|
||||
function touch(...ids: string[]) {
|
||||
return ids.map((content_id) => ({ domain: 'content', object: 'asset', key: { content_id } }));
|
||||
}
|
||||
|
||||
describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
it('一行一份,存下来的是真字节(存几份就写几笔)', async () => {
|
||||
const db = fakeDb();
|
||||
@@ -62,6 +67,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
touched: touch('CT-1', 'CT-2'),
|
||||
rowsOfAddress: async () => [
|
||||
{ content_id: 'CT-1', title: '白皮书', body_md: '# 白皮书\n\n正文', file_format: 'docx' },
|
||||
{ content_id: 'CT-2', title: '对照表', body_md: '| a | b |\n| - | - |\n| 1 | 2 |', file_format: 'xlsx' },
|
||||
@@ -72,6 +78,56 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
expect(db.writes.join()).toContain('白皮书');
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-25 柴浩实测:「工作成果怎么有 4 个资料,这单只会产出一个」——
|
||||
* 资料落在**主数据**上,整表里躺着历次跑出来的资产,前三份都是别单的成果。
|
||||
*/
|
||||
it('**别单放行过的行不算这一单产出的** —— 整表四行,本单只碰过一行,就只存一份', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
touched: touch('CT-2026-0004'),
|
||||
rowsOfAddress: async () => [
|
||||
{ content_id: 'CT-2026-0001', title: '白皮书', body_md: '别单的正文', file_format: 'docx' },
|
||||
{ content_id: 'CT-2026-0002', title: '白皮书 2026', body_md: '别单的正文', file_format: 'docx' },
|
||||
{ content_id: 'CT-2026-0003', title: '产品介绍书', body_md: '别单的正文', file_format: 'docx' },
|
||||
{ content_id: 'CT-2026-0004', title: '白皮书 E04 定制版', body_md: '这一单写的正文', file_format: 'docx' },
|
||||
],
|
||||
});
|
||||
expect(saved).toBe(1);
|
||||
expect(db.writes.join()).toContain('CT-2026-0004');
|
||||
expect(db.writes.join()).not.toContain('CT-2026-0001');
|
||||
});
|
||||
|
||||
it('这一单一行也没碰过:表里再多行也存 0 份', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
touched: [],
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
});
|
||||
|
||||
it('别的表碰过不算这张表碰过 —— 地址得对上', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
db,
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
touched: [{ domain: 'content_brand', object: 'requirement', key: { content_id: 'CT-1' } }],
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
});
|
||||
|
||||
it('**没正文的行不存** —— 一份空壳存进去,人点开只会看见一张白纸', async () => {
|
||||
const db = fakeDb();
|
||||
const saved = await archiveAssetFiles({
|
||||
@@ -79,6 +135,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
touched: touch('CT-1'),
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
@@ -92,6 +149,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
touched: touch(''),
|
||||
rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
@@ -104,6 +162,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [{ ...ASSET, file: undefined }],
|
||||
touched: touch('CT-1'),
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||
});
|
||||
expect(saved).toBe(0);
|
||||
@@ -116,6 +175,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
taskId: TASK,
|
||||
ids,
|
||||
declarations: [ASSET],
|
||||
touched: touch('CT-1'),
|
||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }],
|
||||
});
|
||||
expect(saved).toBe(1);
|
||||
@@ -125,7 +185,32 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||
it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => {
|
||||
const db = fakeDb();
|
||||
expect(
|
||||
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], rowsOfAddress: async () => [] }),
|
||||
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], touched: [], rowsOfAddress: async () => [] }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetRowsOf —— 存档与工作成果同一段', () => {
|
||||
it('值取合并行那一份:这轮只改了标题,正文仍按落库后的样子造', () => {
|
||||
const picked = assetRowsOf(
|
||||
ASSET,
|
||||
[{ content_id: 'CT-1', title: '改过的标题', body_md: '早先写下的正文', file_format: 'docx' }],
|
||||
touch('CT-1'),
|
||||
);
|
||||
expect(picked).toEqual([
|
||||
{
|
||||
address: 'content.asset',
|
||||
key: { content_id: 'CT-1' },
|
||||
title: '改过的标题',
|
||||
format: 'docx',
|
||||
markdown: '早先写下的正文',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('标题空着:退主键,不摆一份没名字的资料', () => {
|
||||
const picked = assetRowsOf(ASSET, [{ content_id: 'CT-1', title: ' ', body_md: '正文' }], touch('CT-1'));
|
||||
expect(picked[0]?.title).toBe('CT-1');
|
||||
expect(picked[0]?.format).toBe('md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/** 小 Hono + 假的 org 出口:中间件的四个出口(放行 / 401 / 403 / 票据)逐一走一遍 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import type { Position, Principal, Surface } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import { AccountDisabledError, TokenRejectedError } from '@modules/org';
|
||||
import { bearerOf, createAuthMiddleware, forbiddenBody, requiredEnds } from '../auth-middleware.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { buildRouteRegistry } from '../route-registry.ts';
|
||||
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, kind: 'person' },
|
||||
position,
|
||||
roles,
|
||||
ends,
|
||||
});
|
||||
const ADMIN = principal(['design', 'ops', 'workbench'], ['admin']);
|
||||
const USER = principal(['workbench'], ['user']);
|
||||
const NOBODY = principal([], ['auditor']);
|
||||
|
||||
const tickets = createTicketIssuer(Buffer.alloc(32, 3));
|
||||
const now = new Date('2026-08-25T09:00:00Z');
|
||||
|
||||
function makeApp(): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use(
|
||||
'*',
|
||||
createAuthMiddleware({
|
||||
registry: buildRouteRegistry([
|
||||
{ method: 'GET', path: '/api/health', access: 'public' },
|
||||
{ method: 'GET', path: '/api/me', access: 'any' },
|
||||
{ method: 'GET', path: '/api/desk/tasks', access: 'workbench' },
|
||||
{ method: 'GET', path: '/api/gov/tasks', access: 'ops' },
|
||||
{ method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' },
|
||||
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
|
||||
]),
|
||||
org: {
|
||||
authenticate: async (token) => {
|
||||
if (token === 'admin') return ADMIN;
|
||||
if (token === 'user') return USER;
|
||||
if (token === 'nobody') return NOBODY;
|
||||
if (token === 'disabled') throw new AccountDisabledError('alice');
|
||||
throw new TokenRejectedError('签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例');
|
||||
},
|
||||
principalOf: async (id) => (id === 'sub-1' ? USER : undefined),
|
||||
},
|
||||
tickets,
|
||||
clock: { now: () => now },
|
||||
}),
|
||||
);
|
||||
app.get('/api/health', (c) => c.json({ ok: true }));
|
||||
app.get('/api/me', (c) => c.json({ account: c.get('principal')?.staff.account ?? null }));
|
||||
app.get('/api/desk/tasks', (c) => c.json({ ok: true }));
|
||||
app.get('/api/gov/tasks', (c) => c.json({ ok: true }));
|
||||
app.post('/api/gov/tasks/:taskId/control', (c) => c.json({ ok: true }));
|
||||
app.get('/api/gov/staff', (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
const app = makeApp();
|
||||
// app.request() 的类型签名是 Response | Promise<Response>(Hono 4.12);包一层 Promise.resolve
|
||||
// 只是把类型收窄成调用方好用的 Promise<Response>,不改变行为(await 对两者结果一致)。
|
||||
const get = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||
Promise.resolve(app.request(path, { headers }));
|
||||
const post = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||
Promise.resolve(app.request(path, { method: 'POST', headers }));
|
||||
const head = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||
Promise.resolve(app.request(path, { method: 'HEAD', headers }));
|
||||
type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } };
|
||||
|
||||
describe('鉴权中间件', () => {
|
||||
it('public 不要凭据', async () => {
|
||||
expect((await get('/api/health')).status).toBe(200);
|
||||
});
|
||||
it('没凭据 → 401', async () => {
|
||||
const res = await get('/api/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ code: 'unauthenticated', message: '缺少 Bearer token' });
|
||||
});
|
||||
it('坏 token → 401 带人话', async () => {
|
||||
const res = await get('/api/me', { authorization: 'Bearer garbage' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(((await res.json()) as Body).message).toContain('签名校验失败');
|
||||
});
|
||||
it('any:登录即可,连无端可进的人也行', async () => {
|
||||
const res = await get('/api/me', { authorization: 'Bearer nobody' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ account: 'alice' });
|
||||
});
|
||||
it('端不够 → 403,说清需要哪个端、你能进哪几个', async () => {
|
||||
const res = await get('/api/gov/tasks', { authorization: 'Bearer user' });
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as Body;
|
||||
expect(body.code).toBe('forbidden');
|
||||
expect(body.message).toContain('运营中心');
|
||||
expect(body.message).toContain('工作台');
|
||||
expect(body.detail).toEqual({ need: ['ops'], ends: ['workbench'] });
|
||||
});
|
||||
it('端够了 → 放行;数组归属任一命中即可', async () => {
|
||||
expect((await get('/api/gov/tasks', { authorization: 'Bearer admin' })).status).toBe(200);
|
||||
expect((await get('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200);
|
||||
expect((await get('/api/gov/staff', { authorization: 'Bearer admin' })).status).toBe(200);
|
||||
expect((await get('/api/gov/staff', { authorization: 'Bearer user' })).status).toBe(403);
|
||||
});
|
||||
it('账号停用 → 403(重新登录没用)', async () => {
|
||||
const res = await get('/api/me', { authorization: 'Bearer disabled' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(((await res.json()) as Body).message).toContain('停用');
|
||||
});
|
||||
it('票据:GET 上认,POST 上不认', async () => {
|
||||
const { ticket } = tickets.issue('sub-1', now);
|
||||
expect((await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200);
|
||||
expect((await post(`/api/gov/tasks/t-1/control?ticket=${encodeURIComponent(ticket)}`)).status).toBe(401);
|
||||
});
|
||||
it('过期票据 → 401 指路重取', async () => {
|
||||
const { ticket } = tickets.issue('sub-1', new Date(now.getTime() - 120_000));
|
||||
const res = await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(((await res.json()) as Body).message).toContain('/api/auth/ticket');
|
||||
});
|
||||
it('Bearer 与票据同时在:Bearer 说了算', async () => {
|
||||
const { ticket } = tickets.issue('sub-1', now);
|
||||
expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200);
|
||||
});
|
||||
it('HEAD 按 GET 判:端不够 → 403,与 GET 同款', async () => {
|
||||
// Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 c.req.method 仍是 'HEAD' ——
|
||||
// 不归一的话查表必然 miss、落进「any」兜底,于是「只要登录就能跑任何 GET 处理器」。
|
||||
expect((await get('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403);
|
||||
expect((await head('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403);
|
||||
});
|
||||
it('HEAD 该进的还得进:端够了 → 200', async () => {
|
||||
expect((await head('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200);
|
||||
});
|
||||
it('HEAD 上的票据与 GET 同款(归一之后两条路一致)', async () => {
|
||||
const { ticket } = tickets.issue('sub-1', now);
|
||||
expect((await head(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200);
|
||||
expect((await head('/api/gov/tasks', {})).status).toBe(401);
|
||||
});
|
||||
it('未知路由:没凭据先 401,有凭据再 404', async () => {
|
||||
expect((await get('/api/nope')).status).toBe(401);
|
||||
expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('小件', () => {
|
||||
it('bearerOf 认大小写与空白', () => {
|
||||
expect(bearerOf('bearer abc')).toBe('abc');
|
||||
expect(bearerOf('Bearer abc ')).toBe('abc');
|
||||
expect(bearerOf('Basic abc')).toBeUndefined();
|
||||
expect(bearerOf(undefined)).toBeUndefined();
|
||||
expect(bearerOf('Bearer ')).toBeUndefined();
|
||||
});
|
||||
it('requiredEnds', () => {
|
||||
expect(requiredEnds('public')).toBeUndefined();
|
||||
expect(requiredEnds('any')).toBeUndefined();
|
||||
expect(requiredEnds('ops')).toEqual(['ops']);
|
||||
expect(requiredEnds(['design', 'ops'])).toEqual(['design', 'ops']);
|
||||
});
|
||||
it('forbiddenBody 对无端可进的人如实说', () => {
|
||||
expect(forbiddenBody(['design'], NOBODY).message).toContain('无端可进');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* **确认闸的条件档要在真发卡那条路上转交**(2026-09-01 用户实测:「两个芯片类型不一样,
|
||||
* 页面上方有红色提示,右边派发的卡片还可以点击下一步」)。
|
||||
*
|
||||
* 病:发卡装配那段手写清单逐项点名转交 `gateLedgers`/`variant`,而 `gateWhen`/`gateWhenSay`
|
||||
* 没被点名 —— 配方白纸黑字声明了「类型不一致不许确认」,落库的工单载荷里那两个字段是
|
||||
* undefined(任务 43 法证)。红带是视图画的所以照亮;按钮禁用与服务端拒收读的都是
|
||||
* 存下的 `order.gateWhen`,一起无声失效。**手写字段清单第四次丢值**(fields / blocks /
|
||||
* redoable 之后),而且丢在 `variant` 上次被丢的同一段。
|
||||
*
|
||||
* 这个缝取的是模型也会路过的不可信卡骨架,逐项校验转交是设计 —— 所以治法不是整包 spread,
|
||||
* 是把这两项的取用抽成纯函数、在 variant 的测试位旁边钉死。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cardConditionGate } from '../wiring/engines.ts';
|
||||
|
||||
describe('cardConditionGate —— 条件档从卡骨架到工单载荷', () => {
|
||||
it('声明了就转交,一个字不改', () => {
|
||||
expect(cardConditionGate({ gateWhen: 'parts_profile_distinct_chip_type_id <= 1', gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({
|
||||
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
|
||||
gateWhenSay: '两颗料不是同一个芯片类型',
|
||||
});
|
||||
});
|
||||
|
||||
it('没声明就什么都不带 —— 不发明键', () => {
|
||||
expect(cardConditionGate({})).toEqual({});
|
||||
expect(cardConditionGate({ gateLedgers: ['parts.profile'] })).toEqual({});
|
||||
});
|
||||
|
||||
it('脏值不端下去:非字符串 / 空白串一律丢', () => {
|
||||
expect(cardConditionGate({ gateWhen: 42, gateWhenSay: '话' })).toEqual({});
|
||||
expect(cardConditionGate({ gateWhen: ' ', gateWhenSay: '话' })).toEqual({});
|
||||
expect(cardConditionGate({ gateWhen: null })).toEqual({});
|
||||
});
|
||||
|
||||
it('只有那句话没有条件 = 什么都不带(say 单独存在没有意义,评估端只认 when)', () => {
|
||||
expect(cardConditionGate({ gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({});
|
||||
});
|
||||
|
||||
it('条件在、话没写:条件照转交,拒回用兜底话术', () => {
|
||||
expect(cardConditionGate({ gateWhen: 'a > 1' })).toEqual({ gateWhen: 'a > 1' });
|
||||
expect(cardConditionGate({ gateWhen: 'a > 1', gateWhenSay: '' })).toEqual({ gateWhen: 'a > 1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadConfig } from '../config.ts';
|
||||
|
||||
describe('Keycloak 配置(ADR-012)', () => {
|
||||
it('三样齐了才有 keycloak 段;尾斜杠去掉;issuer / jwks 拼好', () => {
|
||||
const c = loadConfig({ KEYCLOAK_URL: 'https://kc.test/', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'api-gateway' });
|
||||
expect(c.keycloak).toEqual({
|
||||
url: 'https://kc.test',
|
||||
realm: 'corp',
|
||||
clientId: 'api-gateway',
|
||||
issuer: 'https://kc.test/realms/corp',
|
||||
jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs',
|
||||
});
|
||||
});
|
||||
it('缺一样就没有 keycloak 段(main.ts 据此拒绝启动)', () => {
|
||||
expect(loadConfig({ KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp' }).keycloak).toBeUndefined();
|
||||
expect(loadConfig({ KEYCLOAK_URL: ' ', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }).keycloak).toBeUndefined();
|
||||
});
|
||||
it('provisioning 两样都给才带', () => {
|
||||
const base = { KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' };
|
||||
expect(
|
||||
loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning', KEYCLOAK_PROVISIONING_CLIENT_SECRET: 's3' }).keycloak
|
||||
?.provisioning,
|
||||
).toEqual({ clientId: 'provisioning', secret: 's3' });
|
||||
expect(loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning' }).keycloak?.provisioning).toBeUndefined();
|
||||
});
|
||||
it('测试账号逗号分隔、去空白、丢空项', () => {
|
||||
expect(loadConfig({ JCAI_TEST_ACCOUNTS: ' ai, chaihao ,, ' }).testAccounts).toEqual(['ai', 'chaihao']);
|
||||
expect(loadConfig({}).testAccounts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 数据工单确认即入库(2026-08-27 用户定)—— 这段错了不报错:人按下确认、卡记成办结,
|
||||
* 而库里一行没有;要到整单放行才发现,或者永远发现不了(试跑单不落主数据)。
|
||||
*
|
||||
* 这里测的是编排(纯逻辑 + 假台账):落哪几张、落成什么样留什么痕;真库那一侧在
|
||||
* `ledger-store.it.test.ts`(范围放行三条)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId, WorkOrder } from '@contracts';
|
||||
import { workOrderSchema } from '@contracts';
|
||||
import type { ReleaseScope, ReleaseWriteResult } from '@modules/ledger';
|
||||
import { dataOrderLedgers, landDataOrder } from '../data-order-landing.ts';
|
||||
|
||||
const T = 'task-1' as TaskId;
|
||||
|
||||
/** 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转 */
|
||||
const order = (shape: Record<string, unknown>): WorkOrder =>
|
||||
workOrderSchema.parse({
|
||||
id: 'wo-1',
|
||||
at: '2026-08-27T10:00:00Z',
|
||||
title: '数据工单 · 芯片主数据创建(P-08):LM2596S-3.3',
|
||||
basis: [{ k: '触发原因', v: '查无记录' }],
|
||||
ask: '请建齐',
|
||||
fields: [],
|
||||
...shape,
|
||||
});
|
||||
|
||||
const dataOrder = (extra: Record<string, unknown> = {}): WorkOrder =>
|
||||
order({ mode: 'execution', variant: 'data', gateLedgers: ['parts.profile', 'parts.parameter'], ...extra });
|
||||
|
||||
describe('dataOrderLedgers —— 确认之后该入库的表', () => {
|
||||
it('数据工单:与提交闸同源 —— 绑了 blocks 看绑的,否则看 gateLedgers;去重、去脏', () => {
|
||||
expect(dataOrderLedgers(dataOrder())).toEqual(['parts.profile', 'parts.parameter']);
|
||||
expect(dataOrderLedgers(dataOrder({ gateLedgers: [' parts.profile ', 'parts.profile', 'bad address', ''] }))).toEqual(['parts.profile']);
|
||||
const bound = dataOrder({ blocks: [{ t: 'table', title: '表', cols: ['a'], rows: [], ledger: 'parts.document' }] });
|
||||
expect(dataOrderLedgers(bound)).toEqual(['parts.document']);
|
||||
});
|
||||
|
||||
it('普通执行工单 / 验收工单 / 没说自己建哪张表的数据工单 → 空(不替它猜,更不回落到必交付物)', () => {
|
||||
expect(dataOrderLedgers(order({ mode: 'execution', gateLedgers: ['parts.profile'] }))).toEqual([]);
|
||||
expect(dataOrderLedgers(dataOrder({ gateLedgers: undefined }))).toEqual([]);
|
||||
expect(dataOrderLedgers(order({ mode: 'acceptance', criteria: [], actions: [], deliverable: 'x', gateLedgers: ['parts.profile'] }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function harness(reply: ReleaseWriteResult | Error) {
|
||||
const calls: Array<{ taskId: TaskId; scope: ReleaseScope | undefined }> = [];
|
||||
const notes: Array<{ kind: string; title: string; detail: Record<string, unknown> }> = [];
|
||||
let touched = 0;
|
||||
const deps = {
|
||||
ledger: {
|
||||
releaseWrite: async (taskId: TaskId, scope?: ReleaseScope) => {
|
||||
calls.push({ taskId, scope });
|
||||
if (reply instanceof Error) throw reply;
|
||||
return reply;
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
append: async (entry: { kind: string; title: string; detail: Record<string, unknown> }) => {
|
||||
notes.push({ kind: entry.kind, title: entry.title, detail: entry.detail });
|
||||
},
|
||||
},
|
||||
labelOf: async (address: string) => ({ 'parts.profile': '芯片主数据', 'parts.parameter': '标准化参数集' })[address] ?? address,
|
||||
touched: () => {
|
||||
touched += 1;
|
||||
},
|
||||
};
|
||||
return { deps, calls, notes, touched: () => touched };
|
||||
}
|
||||
|
||||
const merged = (rows: Array<[string, string]>): ReleaseWriteResult => ({
|
||||
mergedRows: rows.length,
|
||||
emittedEvents: rows.length,
|
||||
landedEarlierRows: 0,
|
||||
simulatedRows: 0,
|
||||
merged: rows.map(([object, pn]) => ({ domain: 'parts', object, key: { internal_pn: pn }, values: {} })),
|
||||
});
|
||||
|
||||
describe('landDataOrder —— 确认落下之后', () => {
|
||||
it('只并这张卡的那几张表(范围放行),归因带卡号;痕点名哪几张表各几行,并给模型指路;数据域跟着刷', async () => {
|
||||
const h = harness(merged([['profile', 'A'], ['parameter', 'A'], ['parameter', 'A']]));
|
||||
const got = await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(got.landed).toBe(true);
|
||||
expect(h.calls).toEqual([
|
||||
{ taskId: T, scope: { only: [{ domain: 'parts', object: 'profile' }, { domain: 'parts', object: 'parameter' }], cause: 'data-order:wo-1' } },
|
||||
]);
|
||||
expect(h.notes).toHaveLength(1);
|
||||
expect(h.notes[0]?.kind).toBe('ledger-write');
|
||||
expect(h.notes[0]?.title).toContain('芯片主数据 1 行、标准化参数集 2 行已进正式表');
|
||||
expect(h.notes[0]?.title).toContain('别再往这几张表写它们');
|
||||
expect(h.notes[0]?.detail).toMatchObject({
|
||||
workOrderId: 'wo-1',
|
||||
rows: 3,
|
||||
tables: [
|
||||
{ address: 'parts.profile', rows: 1 },
|
||||
{ address: 'parts.parameter', rows: 2 },
|
||||
],
|
||||
});
|
||||
expect(h.touched()).toBe(1);
|
||||
});
|
||||
|
||||
it('试跑单挡下的主数据:痕如实说「只留在活动缓存」,数据域不刷', async () => {
|
||||
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 3, merged: [] });
|
||||
await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(h.notes[0]?.title).toContain('试跑');
|
||||
expect(h.notes[0]?.title).toContain('未落正式表');
|
||||
expect(h.touched()).toBe(0);
|
||||
});
|
||||
|
||||
it('同一张卡确认两次:第二次无事发生,痕也这么说', async () => {
|
||||
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 3, simulatedRows: 0, merged: [] });
|
||||
await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(h.notes[0]?.title).toContain('早已入库');
|
||||
});
|
||||
|
||||
it('不是数据工单就一个字不动(不调台账、不留痕)', async () => {
|
||||
const h = harness(merged([]));
|
||||
const got = await landDataOrder(h.deps, T, order({ mode: 'execution' }));
|
||||
expect(got).toEqual({ landed: false, reason: 'not-data-order' });
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(h.notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('入库失败不抛:人已确认的事实不倒退;原话留痕、行仍在草稿层等放行', async () => {
|
||||
const h = harness(new Error('草稿与正式版并发冲突:1 处字段已被他单改动'));
|
||||
const got = await landDataOrder(h.deps, T, dataOrder());
|
||||
expect(got).toMatchObject({ landed: false, reason: 'failed' });
|
||||
expect(h.notes[0]?.title).toContain('没能入库');
|
||||
expect(h.notes[0]?.title).toContain('并发冲突');
|
||||
expect(h.notes[0]?.title).toContain('放行时再并');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* **决策卡的面孔与人工验收清单,兜底在装配层这一处**(2026-08-29 终审 C2)。
|
||||
*
|
||||
* 病:配方(`auto-runner`)发审核卡时只递了 `variant`,没递 `checklist` ——
|
||||
* 而装配层从前只给 `variant` 兜底。于是那张卡穿着验收工单的衣服、`allowCustom` 也对,
|
||||
* **一条待确认都没有**:回传门的 `checklistMissing` 在 checklist 缺席时恒过,
|
||||
* 「确认通过」当场可按,2026-08-27 用户当面定的那道人工验收闸凭空没了。
|
||||
* 屏上还看不出来 —— 清单区是空的,不是红的。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { checkChatCardReply } from '@modules/human-tool';
|
||||
import { cardIdSchema } from '@contracts';
|
||||
import type { ChatCard, ChatCardReply } from '@contracts';
|
||||
import { decisionCardFace, needsStepLookup } from '../wiring/engines.ts';
|
||||
|
||||
const RELEASE_STEP = {
|
||||
cardVariant: 'review' as const,
|
||||
acceptanceChecklist: ['逐条核对差异明细', '确认推荐等级与依据一致'],
|
||||
};
|
||||
|
||||
describe('决策卡的面孔与清单(装配层兜底)', () => {
|
||||
it('调用方递了清单就用它的 —— 引擎按剧本定好递过来,兜底不抢', () => {
|
||||
const face = decisionCardFace({ variant: 'review', checklist: ['我说了算'] }, RELEASE_STEP);
|
||||
expect(face.checklist).toEqual(['我说了算']);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
});
|
||||
|
||||
it('**配方那一路只递了 variant:清单退回剧本声明的**(C2 的正主)', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
||||
});
|
||||
|
||||
it('面孔也照旧退回剧本(2026-08-27 那条不许回退)', () => {
|
||||
const face = decisionCardFace({}, RELEASE_STEP);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
});
|
||||
|
||||
it('剧本没声明清单:就是没有 —— 不发明一条', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, { cardVariant: 'review' });
|
||||
expect(face.checklist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('空清单当没给:退回剧本(空数组在回传门那儿等于没有闸)', () => {
|
||||
const face = decisionCardFace({ variant: 'review', checklist: [] }, RELEASE_STEP);
|
||||
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
||||
});
|
||||
|
||||
/**
|
||||
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审)。
|
||||
*
|
||||
* `isReleaseStep`(turn-runner.ts)认的形状是:一步声明了 `acceptanceChecklist`(给终局
|
||||
* `AcceptForm` 用)而 `cardVariant` 不是 `review`(T_release 本身就是这种形状)。配方若在
|
||||
* **这一步**发一张普通 `as:'decide'` 卡(没传 variant),从前这里照样把清单兜上去 ——
|
||||
* 前端只在 `variant === 'review'` 时才渲染清单,人看不到勾可打,回传门却要求
|
||||
* `checked ⊇ checklist`:卡按不下去。
|
||||
*/
|
||||
const NON_REVIEW_STEP_WITH_CHECKLIST = { acceptanceChecklist: ['逐条核对差异明细'] };
|
||||
|
||||
it('**非审核面孔的步声明了清单(T_release 那种形状),配方发普通决策卡:清单不兜上去**(I2 的二审正主)', () => {
|
||||
const face = decisionCardFace({}, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||
expect(face.reviewFace).toBe(false);
|
||||
expect(face.checklist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('同一步:调用方明确递了 variant:review,清单才兜上去 —— reviewFace 是唯一的开关', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
expect(face.checklist).toEqual(NON_REVIEW_STEP_WITH_CHECKLIST.acceptanceChecklist);
|
||||
});
|
||||
|
||||
it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => {
|
||||
expect(needsStepLookup({})).toBe(false);
|
||||
expect(needsStepLookup({ itemId: 'T_release' })).toBe(true);
|
||||
expect(needsStepLookup({ itemId: 'T_release', variant: 'review' })).toBe(true);
|
||||
expect(needsStepLookup({ itemId: 'T_release', variant: 'review', checklist: ['a'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **闸真的因此有牙** —— 上面挑出来的清单接到回传门上,「确认通过」才拦得住。
|
||||
* 没有这一条,`decisionCardFace` 的断言只证明「函数回了个数组」。
|
||||
*/
|
||||
describe('清单缺席 = 闸恒过(这就是 C2 的后果)', () => {
|
||||
const CARD_ID = cardIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const card = (checklist?: ReadonlyArray<string>): ChatCard => ({
|
||||
k: 'decision',
|
||||
id: CARD_ID,
|
||||
at: '2026-08-29T00:00:00.000Z',
|
||||
ask: '差异评估结论审核',
|
||||
basis: [],
|
||||
variant: 'review',
|
||||
allowCustom: false,
|
||||
options: [{ value: 'approve', label: '确认通过' }],
|
||||
...(checklist === undefined ? {} : { checklist: [...checklist] }),
|
||||
});
|
||||
const reply: ChatCardReply = { k: 'decision', cardId: CARD_ID, value: 'approve' };
|
||||
|
||||
it('没有清单:一条都没点也通得过 —— 这正是配方发的那张卡从前的样子', () => {
|
||||
expect(checkChatCardReply(card(), reply)).toEqual([]);
|
||||
});
|
||||
|
||||
it('清单兜底进来之后:一条没点就拦', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||
const violations = checkChatCardReply(card(face.checklist), reply);
|
||||
expect(violations.map((v) => v.code)).toEqual(['checklist-incomplete']);
|
||||
});
|
||||
|
||||
it('清单点全了才放行', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||
const violations = checkChatCardReply(card(face.checklist), { ...reply, checked: [...RELEASE_STEP.acceptanceChecklist] });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* **执行工单的面孔在装配层这一处定**(2026-08-31,用户实测:「你怎么给我发成了执行工单,
|
||||
* 我需要的是验收工单」)。
|
||||
*
|
||||
* 病:配方递下来 `variant: 'accept'`(验收工单 —— 件只读、人只确认),而装配层这一处
|
||||
* 是一句写死只认 `'data'` 的三目,别的值**静默丢掉** —— 对话区照旧摆「执行工单」。
|
||||
*
|
||||
* 更值得记的是**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,
|
||||
* 前端那一头断言了「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样绿 ——
|
||||
* 三处都对,而它们中间这一段把值扔了。**面孔要在真发卡那条路上断言。**
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { executionCardVariant } from '../wiring/engines.ts';
|
||||
|
||||
describe('执行工单的两副变体面孔', () => {
|
||||
it('data 数据工单 / accept 验收工单,两个都认', () => {
|
||||
expect(executionCardVariant('data', undefined)).toBe('data');
|
||||
expect(executionCardVariant('accept', undefined)).toBe('accept');
|
||||
});
|
||||
|
||||
it('剧本声明优先于卡骨架 —— 模型抄卡会把 variant 抄丢(2026-08-27 实测)', () => {
|
||||
expect(executionCardVariant(undefined, 'data')).toBe('data');
|
||||
expect(executionCardVariant('data', 'accept')).toBe('accept');
|
||||
});
|
||||
|
||||
it('`review` 是决策卡那一路的面孔,不许串到执行工单上', () => {
|
||||
expect(executionCardVariant('review', undefined)).toBeUndefined();
|
||||
expect(executionCardVariant(undefined, 'review')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('没写 / 认不出的值:普通执行工单,不把脏东西端下去', () => {
|
||||
expect(executionCardVariant(undefined, undefined)).toBeUndefined();
|
||||
expect(executionCardVariant('', undefined)).toBeUndefined();
|
||||
expect(executionCardVariant({ variant: 'accept' }, undefined)).toBeUndefined();
|
||||
expect(executionCardVariant(42, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 【ACT-WORK-05 两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`
|
||||
* (逐参数四态判定)与 `part-substitution-assessment.recommend`(推荐等级定级)曾经在
|
||||
* `../tools/first-party.ts` 里跑,这份文件曾逐条钉死它们的比对语义(硬/软约束、范围
|
||||
* 解析、客户约束边界、四档推荐等级……)。
|
||||
*
|
||||
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定改成
|
||||
* `activities/part-substitution-assessment` 的 `case-model.ts` 里 `S2_assess.auto`
|
||||
* 唯一一次 `analyze`(系统组装入参、模型只答判断),推荐等级改成同一步 `do` 链里的
|
||||
* 投影按 spec §3.5 的 S-FEEL when 链现算 —— 规则只许有一处真源,这两枚工具与它们
|
||||
* 对应的 D01/D02 两张 DMN 已经同批退役(见 `first-party.ts` 里留的墓志铭)。
|
||||
*
|
||||
* 这份文件不删(§12.5「不删测试」的铁律拦的是「删测试让门禁变绿」),但逐条比对
|
||||
* 用例测的是已经不存在的代码,只能跟着它测的东西一起退场,缩成一条退役断言 ——
|
||||
* 拦住的是「哪天有人手滑又把这两枚工具注册回去」。完整的历史用例见 git 历史。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';
|
||||
|
||||
describe('ACT-WORK-05 两枚工具已退役', () => {
|
||||
it('不在注册表里 —— 规则活在 S2 的配方里,不再有第二处真源', () => {
|
||||
const names = FIRST_PARTY_TOOLS.map(([n]) => n);
|
||||
expect(names).not.toContain('part-substitution-assessment.compare-parameters');
|
||||
expect(names).not.toContain('part-substitution-assessment.recommend');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { KeycloakConfig } from '../config.ts';
|
||||
import { checkKeycloakConnection } from '../keycloak-check.ts';
|
||||
|
||||
const kc: KeycloakConfig = {
|
||||
url: 'https://kc.test',
|
||||
realm: 'corp',
|
||||
clientId: 'api-gateway',
|
||||
issuer: 'https://kc.test/realms/corp',
|
||||
jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs',
|
||||
};
|
||||
const reply = (status: number, body: unknown): typeof fetch => async () =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
describe('启动自检(手册第 5 步)', () => {
|
||||
it('issuer 一致 → 回 issuer 与 clientId', async () => {
|
||||
await expect(checkKeycloakConnection(kc, reply(200, { issuer: kc.issuer }))).resolves.toEqual({
|
||||
issuer: kc.issuer,
|
||||
clientId: 'api-gateway',
|
||||
});
|
||||
});
|
||||
it('issuer 不一致 → 抛,话里带期望 / 实际 / KC_HOSTNAME', async () => {
|
||||
await expect(checkKeycloakConnection(kc, reply(200, { issuer: 'http://localhost:8080/realms/corp' }))).rejects.toThrow(
|
||||
/KC_HOSTNAME/,
|
||||
);
|
||||
});
|
||||
it('发现文档拉不到 → 抛带状态码', async () => {
|
||||
await expect(checkKeycloakConnection(kc, reply(503, {}))).rejects.toThrow(/503/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MailPollOutcome } from '@contracts';
|
||||
import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts';
|
||||
|
||||
function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) {
|
||||
const calls: Date[] = [];
|
||||
return {
|
||||
calls,
|
||||
mail: {
|
||||
pollDue: async (now: Date) => {
|
||||
calls.push(now);
|
||||
if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
|
||||
return outcomes;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('pollMailOnce', () => {
|
||||
it('回用例的结果;出错的账号写一行 stderr', async () => {
|
||||
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]);
|
||||
const out = await pollMailOnce({ container: { mail: mail as never } });
|
||||
expect(out).toHaveLength(2);
|
||||
expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上'));
|
||||
write.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startMailPoller', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('按 tick 调;在跑时跳过;stop 后停', async () => {
|
||||
const { mail, calls } = fakeMail([], 1000);
|
||||
const poller = startMailPoller({ container: { mail: mail as never } }, 100);
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
poller.stop();
|
||||
const before = calls.length;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(calls).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞),
|
||||
* 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决:设计端没有消费方,不再无故加宽)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as contracts from '@contracts';
|
||||
import { MAIL_ROUTES } from '@contracts';
|
||||
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
|
||||
|
||||
describe('邮件路由契约', () => {
|
||||
it('15 条全在契约命名空间里,注册表能建起来', () => {
|
||||
const all = routesOf(Object.values(contracts));
|
||||
const registry = buildRouteRegistry(all);
|
||||
expect(MAIL_ROUTES).toHaveLength(15);
|
||||
for (const route of MAIL_ROUTES) {
|
||||
expect(registry.has(route.method, route.path)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('读写都归 ops', () => {
|
||||
for (const route of MAIL_ROUTES) {
|
||||
expect(route.access).toBe('ops');
|
||||
}
|
||||
});
|
||||
|
||||
it('未存草稿探测与已存账号探测是两条不同的路', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
expect(registry.lookup('POST', '/api/gov/mail/accounts/probe')).toBe('ops');
|
||||
expect(registry.lookup('POST', '/api/gov/mail/accounts/acc-1/probe')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/mail/inbound/m-1/attachments/0')).toBe('ops');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。
|
||||
* 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createMailService, createPostalMimeParser } from '@modules/mail';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { registerMailRoutes } from '../routes/mail.ts';
|
||||
import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts';
|
||||
|
||||
function harness() {
|
||||
const clock = fixedClock();
|
||||
const accounts = memoryAccounts(clock);
|
||||
const imap = fakeProvider('imap-smtp');
|
||||
const mail = createMailService({
|
||||
accounts,
|
||||
inbound: memoryInbound(),
|
||||
outbound: memoryOutbound(),
|
||||
attachments: memoryAttachments(),
|
||||
providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') },
|
||||
mime: createPostalMimeParser(),
|
||||
secrets: plainSecrets,
|
||||
clock,
|
||||
ids: seqIds('x'),
|
||||
notify: { changed: () => undefined },
|
||||
});
|
||||
const app = new Hono<AppEnv>();
|
||||
registerMailRoutes(app, { mail });
|
||||
const json = (method: string, path: string, body?: unknown) =>
|
||||
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { app, mail, accounts, imap, json };
|
||||
}
|
||||
const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } };
|
||||
|
||||
describe('账号路由', () => {
|
||||
it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] });
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400);
|
||||
const created = await h.json('POST', '/api/gov/mail/accounts', input);
|
||||
expect(created.status).toBe(200);
|
||||
const view = (await created.json()) as { id: string; secretBound: boolean };
|
||||
expect(view.secretBound).toBe(true);
|
||||
expect(JSON.stringify(view)).not.toContain('pw-1234');
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409);
|
||||
expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404);
|
||||
expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200);
|
||||
expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true });
|
||||
expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => {
|
||||
const h = harness();
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412);
|
||||
const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string };
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200);
|
||||
h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } });
|
||||
const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } });
|
||||
});
|
||||
|
||||
it('立即拉取:200 outcome;停用 412;租约被占 409', async () => {
|
||||
const h = harness();
|
||||
await h.accounts.save(account());
|
||||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
|
||||
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 });
|
||||
await h.accounts.save(account({ enabled: false }));
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412);
|
||||
// 租约是**运行列**:`save()` 只写配置列(整行 upsert 会盖掉另一台正持着的租约),
|
||||
// 所以按真路径占它 —— claimPolling 就是另一台机器那一下
|
||||
await h.accounts.save(account());
|
||||
expect(await h.accounts.claimPolling('acc-1', 60_000)).toBe(true);
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409);
|
||||
});
|
||||
|
||||
it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => {
|
||||
const h = harness();
|
||||
await h.accounts.save(account());
|
||||
const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' });
|
||||
expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '<x-1@we.com>' });
|
||||
h.imap.failSendWith(new Error('535 nope'));
|
||||
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' });
|
||||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('入站 / 出站路由', () => {
|
||||
it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => {
|
||||
const h = harness();
|
||||
await h.accounts.save(account());
|
||||
h.mail.subscribe('spy', async () => undefined);
|
||||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } })));
|
||||
await h.json('POST', '/api/gov/mail/accounts/acc-1/poll');
|
||||
const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> };
|
||||
expect(list.mails).toHaveLength(1);
|
||||
expect(list.mails[0]?.deliveries).toHaveLength(1);
|
||||
const id = list.mails[0]!.id;
|
||||
expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404);
|
||||
const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string };
|
||||
expect(att.name).toBe('a.txt');
|
||||
expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi');
|
||||
expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404);
|
||||
const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string };
|
||||
expect(raw.contentType).toBe('message/rfc822');
|
||||
expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] });
|
||||
expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404);
|
||||
expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400);
|
||||
expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] });
|
||||
});
|
||||
});
|
||||
|
||||
/** 空 body 的 POST:redeliver 的 body 是可省的 */
|
||||
function app_request_redeliver(h: ReturnType<typeof harness>, id: string): Promise<Response> {
|
||||
return Promise.resolve(h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' }));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
|
||||
import type { InboundMail } from '@contracts';
|
||||
import { auditPassed, checkEvolution, staticAudit } from '@modules/toolbox';
|
||||
import type { ToolContract } from '@modules/toolbox';
|
||||
import { threadIdOf } from '@modules/mail';
|
||||
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
|
||||
import { MAIL_TOOL } from '../seed-mvp.ts';
|
||||
|
||||
const detail: MailInboundDetail = {
|
||||
id: 'in-1',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<m1@acme.com>',
|
||||
from: { address: 'buyer@acme.com', name: '张三' },
|
||||
to: [{ address: 'sales@we.com', name: '' }],
|
||||
cc: [{ address: 'ops@we.com', name: 'Ops' }],
|
||||
subject: '询价 RFQ-1',
|
||||
sentAt: '2026-08-25T09:00:00.000Z',
|
||||
receivedAt: '2026-08-25T09:30:00.000Z',
|
||||
textPreview: '你好',
|
||||
hasHtml: true,
|
||||
attachments: [
|
||||
{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' },
|
||||
{ index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' },
|
||||
],
|
||||
deliveries: [],
|
||||
inReplyTo: null,
|
||||
references: ['<root@we.com>', '<q1@we.com>'],
|
||||
text: null,
|
||||
html: '<p>你好</p>',
|
||||
size: 100,
|
||||
rawAvailable: true,
|
||||
};
|
||||
|
||||
function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
|
||||
const calls: MailSendInput[] = [];
|
||||
return {
|
||||
calls,
|
||||
send: async (input) => {
|
||||
calls.push(input);
|
||||
return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('toInboundMail', () => {
|
||||
it('线程取 References 首项(即这一串的根);只带存下来的附件;纯 html 剥成正文', () => {
|
||||
const mail: InboundMail = toInboundMail(detail);
|
||||
// 不是末项 <q1@we.com>:我方出站登记的恒是首封,①级归属是精确等值比对 ——
|
||||
// 取最近一跳的话,跑一个来回(我方再回一封)线程就漂到库里没记过的 Message-ID 上,①级落空退③级
|
||||
expect(mail.threadId).toBe('<root@we.com>');
|
||||
expect(mail.from).toBe('buyer@acme.com');
|
||||
expect(mail.to).toEqual(['sales@we.com']);
|
||||
expect(mail.cc).toEqual(['ops@we.com']);
|
||||
expect(mail.body).toBe('你好');
|
||||
expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]);
|
||||
expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z');
|
||||
});
|
||||
|
||||
it('线程口径只有一处:这里就是 mail 模块的 threadIdOf,不是另抄的一份', () => {
|
||||
// 从前这里内联了一份同义实现,而模块导出的 threadIdOf 取的是**最近一跳** ——
|
||||
// 同一个模块两套相反的语义,下一个人照导出的那个接线就会接反
|
||||
expect(toInboundMail(detail).threadId).toBe(threadIdOf(detail));
|
||||
const onlyHop: MailInboundDetail = { ...detail, references: [], inReplyTo: '<q1@we.com>' };
|
||||
expect(toInboundMail(onlyHop).threadId).toBe(threadIdOf(onlyHop));
|
||||
expect(toInboundMail(onlyHop).threadId).toBe('<q1@we.com>');
|
||||
const none: MailInboundDetail = { ...detail, references: [], inReplyTo: null };
|
||||
expect(toInboundMail(none).threadId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channelSubscriber', () => {
|
||||
it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => {
|
||||
const calls: Array<[string, InboundMail]> = [];
|
||||
const handler = channelSubscriber({
|
||||
ingestMail: async (id, mail) => {
|
||||
calls.push([id, mail]);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' });
|
||||
expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined();
|
||||
expect(calls[0]?.[0]).toBe('ch-1');
|
||||
expect(calls[0]?.[1].messageId).toBe('<m1@acme.com>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('channelTransport', () => {
|
||||
it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => {
|
||||
const mail = fakeSend();
|
||||
const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
|
||||
expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
|
||||
expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } });
|
||||
const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] });
|
||||
expect(fresh.threadId).toBe('<out-1@we.com>');
|
||||
});
|
||||
it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => {
|
||||
await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailSendTool', () => {
|
||||
const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' };
|
||||
it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => {
|
||||
const mail = fakeSend();
|
||||
const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }] } });
|
||||
expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
|
||||
expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } });
|
||||
await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
|
||||
expect(mail.calls[1]?.accountId).toBeUndefined();
|
||||
});
|
||||
it('没给 uploadRef 的附件不静默丢掉:整次调用判 invalid,一封都不发', async () => {
|
||||
// 模块那一层本就是硬的(既没字节也没 ref 就抛 MailSendInvalidError);app 层不许比它松 ——
|
||||
// 悄悄丢掉附件再回 queued:true,等于让界面撒谎
|
||||
const mail = fakeSend();
|
||||
const result = await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error?.code).toBe('invalid');
|
||||
expect(result.error?.retryable).toBe(false);
|
||||
expect(result.error?.message).toContain('no-ref');
|
||||
expect(mail.calls).toHaveLength(0);
|
||||
});
|
||||
it('dryRun 只校验不发', async () => {
|
||||
const mail = fakeSend();
|
||||
expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } });
|
||||
expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false);
|
||||
expect(mail.calls).toHaveLength(0);
|
||||
});
|
||||
it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => {
|
||||
const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
|
||||
expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } });
|
||||
const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail');
|
||||
const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
|
||||
expect(noAccount.error?.code).toBe('no-account');
|
||||
const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } });
|
||||
expect(invalid.error?.code).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mail.send 2.0.0 的演进', () => {
|
||||
/*
|
||||
* **对真契约断言,不对手抄的夹具**。上一版这里的 v1/v2 都是照着 `seed-mvp.ts` 手抄的两份 JSON:
|
||||
* 改真的那一份它不会红(自证),而且抄的 v1 版本号还写成了 `1.0.0` ——
|
||||
* 真前一版是 `1.1.0`(MAIL_TOOL 展开 HISTORY_TOOL,版本跟着它)。
|
||||
* v1 是从 git 里那一版逐字取回来的**历史快照**(只有它必须手写:那份代码已经不在仓里了)。
|
||||
*/
|
||||
const v1: ToolContract = {
|
||||
...MAIL_TOOL,
|
||||
version: '1.1.0',
|
||||
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
|
||||
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } },
|
||||
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
|
||||
connectionKeys: ['endpoint'],
|
||||
};
|
||||
|
||||
it('真契约就是 2.0.0,且过得了静态审', () => {
|
||||
expect(MAIL_TOOL.name).toBe('mail.send');
|
||||
expect(MAIL_TOOL.version).toBe('2.0.0');
|
||||
expect(auditPassed(staticAudit(MAIL_TOOL))).toBe(true);
|
||||
});
|
||||
|
||||
it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => {
|
||||
expect(checkEvolution(v1, MAIL_TOOL)).toEqual([]);
|
||||
// 反证这条断言真的在看东西:真把 v1 的 body 删掉就该报违规
|
||||
const dropped: ToolContract = {
|
||||
...MAIL_TOOL,
|
||||
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'array' }, subject: { type: 'string' } } },
|
||||
};
|
||||
expect(checkEvolution(v1, dropped).map((f) => f.field)).toContain('input.body');
|
||||
});
|
||||
});
|
||||
@@ -80,12 +80,14 @@ beforeAll(async () => {
|
||||
await pool.query(`create schema "${schema}"`);
|
||||
}
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
container = await createContainer(
|
||||
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
|
||||
);
|
||||
await container.org.ensureSeeded();
|
||||
// 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行
|
||||
await seedMvp(container);
|
||||
const { staff } = await container.org.listStaff();
|
||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
||||
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* **打不回的放行卡**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
|
||||
* 因为这里都没有接入 ai」)。
|
||||
*
|
||||
* 判据只有一条:这一步整个由配方跑完、一个字的 `guide` 都没有 —— 那时打回的意见没有模型会去读,
|
||||
* 配方只会原样再提交一次,撞在「打回之后台账没动过就不许再提交」那道闸上,最后落成一张
|
||||
* 「连续撞在同一条判据上」的兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆。**
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { releaseRedoable } from '../wiring/engines.ts';
|
||||
|
||||
describe('放行卡打回得了吗', () => {
|
||||
it('全 auto(有配方、guide 是空串)→ 打不回', () => {
|
||||
expect(releaseRedoable({ auto: { do: [] }, guide: '' })).toBe(false);
|
||||
expect(releaseRedoable({ auto: { do: [] }, guide: ' ' })).toBe(false);
|
||||
});
|
||||
|
||||
it('半 auto(guide 非空)→ 照旧打得回:那儿真有模型会读那句意见', () => {
|
||||
expect(releaseRedoable({ auto: { do: [] }, guide: '按意见修订台账,改完重新提交' })).toBe(true);
|
||||
});
|
||||
|
||||
it('模型驱动(压根没声明配方)→ 照旧打得回', () => {
|
||||
expect(releaseRedoable({ guide: '把这一签下去会发生什么摆清楚' })).toBe(true);
|
||||
});
|
||||
|
||||
it('认不出那一步(没声明人工验收清单的老活动)→ 老行为', () => {
|
||||
expect(releaseRedoable(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 路由覆盖(fail-closed,ADR-012):Hono 里每一条注册了的路由都得在路由注册表里有归属。
|
||||
* 漏一条 = 一个没人管的口子,这里把它变成测试红。要真容器只是为了把路由都挂上,不发请求。
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from '../config.ts';
|
||||
import { createContainer } from '../container.ts';
|
||||
import type { Container } from '../container.ts';
|
||||
import { appRouteRegistry, createServer } from '../server.ts';
|
||||
|
||||
const IT_DB = 'jcai_it_routes';
|
||||
|
||||
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
|
||||
const explicit = process.env['JCAI_TEST_DATABASE_URL'];
|
||||
if (explicit !== undefined) return { url: explicit, stop: async () => undefined };
|
||||
const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
try {
|
||||
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
|
||||
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
|
||||
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
|
||||
await admin.end();
|
||||
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
|
||||
} catch {
|
||||
/* 开发实例连不上,试 Testcontainers */
|
||||
}
|
||||
try {
|
||||
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
|
||||
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
|
||||
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`⚠️ 路由覆盖 it 跳过 —— **这道 fail-closed 的安全保险这一次没有跑**:` +
|
||||
`「写了路由处理器却没有契约声明 access」这一类漏网,本次构建没有检查。\n` +
|
||||
` 连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function migrationsFolder(): string {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const candidate = join(dir, 'db', 'migrations');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error('找不到 db/migrations');
|
||||
}
|
||||
|
||||
const database = await provisionDatabase();
|
||||
let pool: Pool | undefined;
|
||||
let container: Container | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (database === undefined) return;
|
||||
pool = new Pool({ connectionString: database.url, max: 4 });
|
||||
for (const schema of ['sys', 'ledger', 'mastra', 'queue']) {
|
||||
await pool.query(`drop schema if exists "${schema}" cascade`);
|
||||
await pool.query(`create schema "${schema}"`);
|
||||
}
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await container?.close();
|
||||
await pool?.end();
|
||||
await database?.stop();
|
||||
});
|
||||
|
||||
describe.skipIf(database === undefined)('路由覆盖', () => {
|
||||
it('Hono 里每条注册的路由都在归属注册表里', () => {
|
||||
const app = createServer(container!);
|
||||
const registry = appRouteRegistry();
|
||||
const registered = app.routes.filter((r) => r.method !== 'ALL');
|
||||
|
||||
/*
|
||||
* 先钉下界:`missing` 为空有两种可能 —— 真的一条不漏,或者**根本没数到路由**
|
||||
* (createServer 改用子应用组合、Hono 换了 routes 的形状、建 server 中途早退)。
|
||||
* 空集是任何集合的子集,不设下界的话后一种情况会伪装成前一种,
|
||||
* 这道保险就从「会红」变成「永远绿」。
|
||||
*/
|
||||
expect(registered.length, '一条路由都没数到 —— 先查 createServer 与 app.routes 的形状').toBeGreaterThan(50);
|
||||
|
||||
const missing = registered.filter((r) => !registry.has(r.method, r.path)).map((r) => `${r.method} ${r.path}`);
|
||||
expect(missing, '这些路由没有声明归哪个端(契约加 access,或 EXTRA_ROUTES 补入)').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as contracts from '@contracts';
|
||||
import { buildRouteRegistry, isRouteContract, routesOf } from '../route-registry.ts';
|
||||
|
||||
const registry = buildRouteRegistry([
|
||||
{ method: 'GET', path: '/api/health', access: 'public' },
|
||||
{ method: 'GET', path: '/api/me', access: 'any' },
|
||||
{ method: 'GET', path: '/api/gov/tasks', access: 'ops' },
|
||||
{ method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' },
|
||||
{ method: 'GET', path: '/api/gov/testbed/logs', access: 'design' },
|
||||
{ method: 'GET', path: '/api/gov/testbed/logs/:id', access: 'design' },
|
||||
{ method: 'POST', path: '/api/gov/process/validate', access: 'design' },
|
||||
{ method: 'POST', path: '/api/gov/process/:id/:version/state', access: 'design' },
|
||||
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
|
||||
]);
|
||||
|
||||
describe('路由注册表', () => {
|
||||
it('字面路径精确命中', () => {
|
||||
expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/health')).toBe('public');
|
||||
});
|
||||
it('参数段:一段认一段,多一段少一段都不算', () => {
|
||||
expect(registry.lookup('POST', '/api/gov/tasks/t-1/control')).toBe('ops');
|
||||
expect(registry.lookup('POST', '/api/gov/tasks/t-1/x/control')).toBeUndefined();
|
||||
expect(registry.lookup('POST', '/api/gov/process/p1/3/state')).toBe('design');
|
||||
});
|
||||
it('方法不同不混', () => {
|
||||
expect(registry.lookup('POST', '/api/gov/tasks')).toBeUndefined();
|
||||
expect(registry.lookup('GET', '/api/gov/tasks/t-1/control')).toBeUndefined();
|
||||
});
|
||||
it('字面优先于参数段(logs 与 logs/:id 各归各)', () => {
|
||||
expect(registry.lookup('GET', '/api/gov/testbed/logs')).toBe('design');
|
||||
expect(registry.lookup('GET', '/api/gov/testbed/logs/abc')).toBe('design');
|
||||
expect(registry.lookup('GET', '/api/gov/testbed/logs/abc/def')).toBeUndefined();
|
||||
});
|
||||
it('未知路由 → undefined', () => {
|
||||
expect(registry.lookup('GET', '/api/nope')).toBeUndefined();
|
||||
});
|
||||
it('只认五个 HTTP 方法:HEAD 查不到 —— 归一是中间件的职责,不是表的', () => {
|
||||
// 框架把 HEAD 改写成 GET 再分发,而请求行上的方法仍是 HEAD;
|
||||
// 表只按 HTTP_METHODS 装,所以这里如实回 undefined。
|
||||
// 这条钉住的是分工:哪天有人把中间件里那行归一挪走,鉴权就漏了,而这里仍然该是 undefined。
|
||||
expect(registry.lookup('HEAD', '/api/gov/tasks')).toBeUndefined();
|
||||
expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops');
|
||||
});
|
||||
it('数组归属原样回', () => {
|
||||
expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
|
||||
});
|
||||
it('has 认的是注册时的模版,lookup 认的是实际路径', () => {
|
||||
expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true);
|
||||
expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false);
|
||||
expect(registry.has('GET', '/api/health')).toBe(true);
|
||||
expect(registry.size).toBe(9);
|
||||
});
|
||||
it('参数名换一个:lookup 还是能匹配(参数段认任意值),has 就说没有', () => {
|
||||
// 注册的模版是 `:taskId`,lookup('/api/gov/tasks/:otherName/control') 的
|
||||
// `:otherName` 满足参数段的 [^/]+ 正则,lookup 会命中;
|
||||
// 但 has 只认注册时的模版名,两者语义不同
|
||||
expect(registry.lookup('POST', '/api/gov/tasks/:otherName/control')).toBe('ops');
|
||||
expect(registry.has('POST', '/api/gov/tasks/:otherName/control')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('字面优先于参数段 —— 同一个路径两边都能匹配时', () => {
|
||||
const collide = buildRouteRegistry([
|
||||
{ method: 'GET', path: '/api/gov/:zone', access: 'design' },
|
||||
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
|
||||
]);
|
||||
it('字面赢', () => {
|
||||
expect(collide.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
|
||||
});
|
||||
it('没有字面可命中时才落到参数段', () => {
|
||||
expect(collide.lookup('GET', '/api/gov/tools')).toBe('design');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routesOf —— 从契约命名空间挑路由', () => {
|
||||
it('只认长得像路由契约的导出', () => {
|
||||
expect(isRouteContract(contracts.meRoute)).toBe(true);
|
||||
expect(isRouteContract(contracts.staffSchema)).toBe(false);
|
||||
expect(isRouteContract('x')).toBe(false);
|
||||
expect(isRouteContract(null)).toBe(false);
|
||||
});
|
||||
it('扫真契约至少 90 条,且每条都能 has 到自己', () => {
|
||||
const all = routesOf(Object.values(contracts));
|
||||
expect(all.length).toBeGreaterThanOrEqual(90);
|
||||
const reg = buildRouteRegistry(all);
|
||||
for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('建表自检 —— 两条模版能撞就不许建', () => {
|
||||
const reg = (routes: Parameters<typeof buildRouteRegistry>[0]) => () => buildRouteRegistry(routes);
|
||||
it('同方法、同段数、逐位兼容 → 抛错', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
|
||||
{ method: 'GET', path: '/api/:y/b', access: 'design' },
|
||||
]),
|
||||
).toThrow(/同一个具体路径/);
|
||||
});
|
||||
it('方法不同不算撞', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
|
||||
{ method: 'POST', path: '/api/:y/b', access: 'design' },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('段数不同不算撞', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
|
||||
{ method: 'GET', path: '/api/:y/b/c', access: 'design' },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('字面段对不上不算撞', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
|
||||
{ method: 'GET', path: '/api/b/:y', access: 'design' },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('字面与参数并存不算撞 —— 那是有意的优先级', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/gov/:zone', access: 'design' },
|
||||
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('同一条字面路由注册两遍 → 抛(后写的会静默盖掉先写的)', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
|
||||
{ method: 'GET', path: '/api/gov/staff', access: 'design' },
|
||||
]),
|
||||
).toThrow(/注册了两遍/);
|
||||
});
|
||||
it('同路径不同方法照旧不算撞', () => {
|
||||
expect(
|
||||
reg([
|
||||
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
|
||||
{ method: 'POST', path: '/api/gov/staff', access: 'ops' },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('真实 95 条契约路由建得起来(没有暗撞)', () => {
|
||||
expect(() => buildRouteRegistry(routesOf(Object.values(contracts)))).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { taskIdSchema } from '@contracts';
|
||||
import { afterSave } from '../save-wake.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
|
||||
/** 缺省有一张卡在等人 —— 「保存即铺格」的场景恰恰都是这一种 */
|
||||
function spy(pending: ReadonlyArray<unknown> = [{ id: 'card-1' }]) {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
||||
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
|
||||
humanTool: { pendingCards: () => { calls.push('pending'); return Promise.resolve(pending); } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('件上保存之后', () => {
|
||||
it('真落下了行、且有卡在等人:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
|
||||
const { calls, deps } = spy();
|
||||
await afterSave(deps, TASK, 3);
|
||||
expect(calls).toEqual(['repush', 'pending', 'wake']);
|
||||
});
|
||||
|
||||
it('一行都没落下:什么也不做 —— 没变的东西不值得叫醒谁', async () => {
|
||||
const { calls, deps } = spy();
|
||||
await afterSave(deps, TASK, 0);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **没有卡在等人就别叫醒**(2026-08-29 终审 F1):`wake` 对 awaiting-event / awaiting-human
|
||||
* 一律拨成 running,推进器随即推一拍并记一步预算 —— 而现有九个活动包一个 auto 都没有,
|
||||
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
|
||||
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
|
||||
*/
|
||||
/*
|
||||
* **这条用例测的是 `pendingCards` 现读为空的那一种局面 —— 不是验收态**(2026-08-29 复审
|
||||
* A 项二审:这里原话写着「验收态就是这一种」,是错的)。`pendingCards`
|
||||
* (`human-tool-service.ts:374`)把**未办结工单**也算在内,而验收态恰恰有一张开着的
|
||||
* 验收工单 —— 真在验收态时 `pendingCards` 不空,`wake` 照样会发,不落进这条分支。
|
||||
*/
|
||||
it('没有卡在等人(pendingCards 现读为空):件照旧重推,**不叫醒**', async () => {
|
||||
const { calls, deps } = spy([]);
|
||||
await afterSave(deps, TASK, 3);
|
||||
expect(calls).toEqual(['repush', 'pending']);
|
||||
});
|
||||
|
||||
it('叫醒失败不抬手,但**不再静默** —— 它是「保存即铺格」的唯一触发器', async () => {
|
||||
const calls: string[] = [];
|
||||
const said: string[] = [];
|
||||
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
||||
const deps = {
|
||||
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
||||
task: { wake: () => Promise.reject(new Error('库连不上')) },
|
||||
humanTool: { pendingCards: () => Promise.resolve([{ id: 'card-1' }]) },
|
||||
};
|
||||
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
|
||||
write.mockRestore();
|
||||
expect(calls).toEqual(['repush']);
|
||||
expect(said.join('')).toContain('库连不上');
|
||||
});
|
||||
|
||||
it('问不出有没有卡在等人:也不叫醒,照样说一声', async () => {
|
||||
const said: string[] = [];
|
||||
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
views: { repush: () => Promise.resolve(true) },
|
||||
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
|
||||
humanTool: { pendingCards: () => Promise.reject(new Error('库连不上')) },
|
||||
};
|
||||
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
|
||||
write.mockRestore();
|
||||
expect(calls).toEqual([]);
|
||||
expect(said.join('')).toContain('库连不上');
|
||||
});
|
||||
});
|
||||
@@ -6,19 +6,59 @@
|
||||
* 2026-07-31 重分:单据归各活动自己的域,这个文件只留**主数据**,按常规 CRM / ERP 分四域。
|
||||
*
|
||||
* 守的是三条,每条都能机检:
|
||||
* · 只有这四个域,不多不少 —— 多一个就说明有人把单据塞进主数据了;
|
||||
* · 八个域,多一个就说明有人把单据塞进主数据了;
|
||||
* · 每张表都是 `master` 且**不带单标识** —— 带了就是单据归错类(安装静态审也会拦);
|
||||
* · 主键是**业务实体**,不是单。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MASTER_DOMAINS, MASTER_OBJECTS } from '../seed-mvp-data.ts';
|
||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts';
|
||||
|
||||
const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object);
|
||||
const seedRowsOf = (domain: string, object: string) =>
|
||||
MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object);
|
||||
|
||||
describe('主数据四域', () => {
|
||||
it('只有客户 / 项目 / 供应商 / 市场四个域', () => {
|
||||
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market']);
|
||||
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', 'parameter')).toEqual(['internal_pn', 'param_name']);
|
||||
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']);
|
||||
// AMS1117-3.3(LDO)是 W05 重写 Task 1 新播的第三颗 —— 「两颗料类型不一致 → 硬拦」那条路的唯一素材
|
||||
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ', 'AMS1117-3.3']);
|
||||
expect(parts).not.toContain('XL2596S-5.0');
|
||||
});
|
||||
|
||||
it('样例料号的标准化参数集是独立子表 parts.parameter:两颗料各 7 行,主键是 (internal_pn, param_name)', () => {
|
||||
const params = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'parameter');
|
||||
const byPn = (pn: string) => params.filter((r) => r.key['internal_pn'] === pn);
|
||||
expect(byPn('LM2596S-5.0')).toHaveLength(7);
|
||||
expect(byPn('LM2596S-ADJ')).toHaveLength(7);
|
||||
for (const row of params) {
|
||||
expect(Object.keys(row.key).sort()).toEqual(['internal_pn', 'param_name']);
|
||||
expect(row.values['param_value'], JSON.stringify(row.key)).toBeTruthy();
|
||||
// 约束类型已搬到 parts.type_param 字典上(W05 重写 Task 1)—— 这张子表不再带这一列,
|
||||
// 留着旧断言会重新允许「同一个参数在两颗料上各标一次约束」,正是这次改造要消灭的东西。
|
||||
expect(row.values['constraint_type']).toBeUndefined();
|
||||
}
|
||||
// parts.profile 不再声明这一列的值 —— 两行样例主记录里它应为空(物理列留在基线,不再写)
|
||||
const profiles = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile');
|
||||
for (const row of profiles) expect(row.values['normalized_parameter_set']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('四个域各自都有表 —— 空占位也得立得住', () => {
|
||||
for (const d of MASTER_DOMAINS) {
|
||||
expect(MASTER_OBJECTS.filter((o) => o.domain === d).length, `${d} 域一张表都没有`).toBeGreaterThan(0);
|
||||
@@ -65,3 +105,58 @@ describe('主数据四域', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('芯片类型字典(W05 重写)', () => {
|
||||
it('两张新表都物化了,主键形状对', () => {
|
||||
const chipType = tableOf('parts', 'chip_type');
|
||||
expect(chipType?.fields.find((f) => f.name === 'type_id')).toMatchObject({ type: 'integer', primaryKey: true });
|
||||
expect(chipType?.fields.map((f) => f.name)).toEqual(expect.arrayContaining(['type_id', 'type_name', 'description']));
|
||||
|
||||
const typeParam = tableOf('parts', 'type_param');
|
||||
/* 复合主键:一个类型下多个参数名 —— 主键少一半,同一类型的第二个参数就会覆盖第一个 */
|
||||
expect(typeParam?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
|
||||
expect(typeParam?.fields.map((f) => f.name)).toEqual(
|
||||
expect.arrayContaining(['type_id', 'param_name', 'param_desc', 'constraint_type', 'param_unit']),
|
||||
);
|
||||
});
|
||||
|
||||
it('P-08 上有 chip_type_id,类型与字典的主键对得上(integer)', () => {
|
||||
expect(tableOf('parts', 'profile')?.fields.find((f) => f.name === 'chip_type_id')).toMatchObject({ type: 'integer' });
|
||||
});
|
||||
|
||||
it('种子:两个类型,DC-DC 七个核心参数字段、LDO 五个', () => {
|
||||
const types = seedRowsOf('parts', 'chip_type');
|
||||
expect(types.map((r) => r.values['type_name']).sort()).toEqual(['DC-DC 降压稳压器', 'LDO 线性稳压器']);
|
||||
|
||||
const dcdc = types.find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
|
||||
const params = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === dcdc);
|
||||
expect(params.map((r) => r.values['param_name'])).toEqual([
|
||||
'输入电压范围', '输出电压', '输出电流', '开关频率', '静态电流', '工作温度', '使能阈值',
|
||||
]);
|
||||
/* 约束类型搬到字典上了 —— 同一个参数在两颗料上标法不一致这件事从结构上消失 */
|
||||
expect(params.find((r) => r.values['param_name'] === '静态电流')?.values['constraint_type']).toBe('soft');
|
||||
expect(params.find((r) => r.values['param_name'] === '输入电压范围')?.values['constraint_type']).toBe('hard');
|
||||
/* 每个字段都要有说明 —— AI 分析时读的就是它 */
|
||||
expect(params.every((r) => String(r.values['param_desc'] ?? '') !== '')).toBe(true);
|
||||
|
||||
const ldo = types.find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||
expect(seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('三颗既有料都归了 DC-DC 类型,新播一颗 LDO 供「类型不一致」那条用例用', () => {
|
||||
const dcdc = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
|
||||
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||
const parts = seedRowsOf('parts', 'profile');
|
||||
for (const pn of ['LM2596S-5.0', 'LM2596S-ADJ']) {
|
||||
expect(parts.find((r) => r.key['internal_pn'] === pn)?.values['chip_type_id']).toBe(dcdc);
|
||||
}
|
||||
expect(parts.find((r) => r.key['internal_pn'] === 'AMS1117-3.3')?.values['chip_type_id']).toBe(ldo);
|
||||
});
|
||||
|
||||
it('AMS1117-3.3 的参数行按 LDO 的字段清单播齐 —— 它要能被真拿来比', () => {
|
||||
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||
const names = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo).map((r) => r.values['param_name']);
|
||||
const rows = seedRowsOf('parts', 'parameter').filter((r) => r.key['internal_pn'] === 'AMS1117-3.3');
|
||||
expect(rows.map((r) => r.key['param_name']).sort()).toEqual([...names].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,12 +77,14 @@ beforeAll(async () => {
|
||||
await pool.query(`create schema "${schema}"`);
|
||||
}
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
container = await createContainer(
|
||||
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
|
||||
);
|
||||
await container.org.ensureSeeded();
|
||||
await seedMvp(container);
|
||||
const { staff } = await container.org.listStaff();
|
||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
||||
ai = staff.find((s) => s.account === 'AI')!.id;
|
||||
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||
ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TICKET_TTL_MS, createTicketIssuer } from '../ticket.ts';
|
||||
|
||||
const issuer = createTicketIssuer(Buffer.alloc(32, 7));
|
||||
const t0 = new Date('2026-08-25T09:00:00Z');
|
||||
|
||||
describe('60 秒票据', () => {
|
||||
it('签出来能验回 sub;形状 v1.<sub>.<exp>.<mac>;到期时刻 = 签发 + 60s', () => {
|
||||
const { ticket, expiresAt } = issuer.issue('sub-1', t0);
|
||||
expect(ticket.split('.')).toHaveLength(4);
|
||||
expect(ticket.startsWith('v1.')).toBe(true);
|
||||
expect(expiresAt.getTime() - t0.getTime()).toBe(TICKET_TTL_MS);
|
||||
expect(issuer.verify(ticket, new Date(t0.getTime() + 30_000))).toEqual({ staffId: 'sub-1' });
|
||||
});
|
||||
|
||||
it('到期即拒(61 秒后)', () => {
|
||||
const { ticket } = issuer.issue('sub-1', t0);
|
||||
expect(issuer.verify(ticket, new Date(t0.getTime() + TICKET_TTL_MS + 1000))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('改一个字就拒:mac / sub / exp', () => {
|
||||
const { ticket } = issuer.issue('sub-1', t0);
|
||||
const [v, id, exp, mac] = ticket.split('.') as [string, string, string, string];
|
||||
expect(issuer.verify(`${v}.${id}.${exp}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`, t0)).toBeUndefined();
|
||||
expect(issuer.verify(`${v}.${Buffer.from('sub-2').toString('base64url')}.${exp}.${mac}`, t0)).toBeUndefined();
|
||||
expect(issuer.verify(`${v}.${id}.${Number(exp) + 600}.${mac}`, t0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('别的密钥签的不认', () => {
|
||||
const other = createTicketIssuer(Buffer.alloc(32, 8));
|
||||
expect(issuer.verify(other.issue('sub-1', t0).ticket, t0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('垃圾输入不抛只拒', () => {
|
||||
expect(issuer.verify('', t0)).toBeUndefined();
|
||||
expect(issuer.verify('v2.a.b.c', t0)).toBeUndefined();
|
||||
expect(issuer.verify('a.b', t0)).toBeUndefined();
|
||||
expect(issuer.verify('v1...', t0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -88,12 +88,14 @@ beforeAll(async () => {
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
|
||||
// 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
container = await createContainer(
|
||||
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
|
||||
);
|
||||
await container.org.ensureSeeded();
|
||||
await seedMvp(container);
|
||||
|
||||
const { staff } = await container.org.listStaff();
|
||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
||||
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { missingReadings } from '../wiring/engines.ts';
|
||||
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { createOnlyViolation, decisionReadings, missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||
const RECORD = {
|
||||
@@ -171,6 +171,60 @@ describe('件上加行:主键凑得齐才落得下', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **Critical 1(2026-08-30 全分支终审逐行核实的链条)**:一张表**唯一**的主键
|
||||
* 同时是 `auto:true` 又是 `text` 类型(W05 的 `parts.chip_type.type_id` —— 与
|
||||
* `contact_code` 那种「auto 但有前缀主键陪衬」的形状不同,这里没有别的主键列可以
|
||||
* 「凑出半把钥匙」)。这个组合把两条各自成立的保护同时绕开了:
|
||||
* ① `barred` 认的是 `k in cells`(这一列在回传里露没露过面),不是「值是否非空」——
|
||||
* 视图若干脆不给这一列挂 `data-col`,`collect()` 连这一列的空字符串都拿不到,
|
||||
* `barred` 因此永远不会把它算进去;
|
||||
* ② `isSeq` 认的是 declared type 是不是 `integer`/`number` —— 这一列声明成了 `text`
|
||||
* (为了绕开另一条已记 debt 的坑,见 `docs/debt.md`),「序号型主键不走全表一致」
|
||||
* 那条 2026-08-23 的保护对它不成立。
|
||||
* 两条一起缺位,`resolveRowKey` 落到「全表一致」:表里此刻**恰好一行**(活动侧的
|
||||
* 自投影机制保证了这一点)时,新建的那一行会被误判成「就是那唯一一行」,把它的名字
|
||||
* /说明**静默覆盖**到既有那一行头上。
|
||||
*
|
||||
* 这两条用例用**真实、未改动的 `resolveRowKey`**逐字重放两种 `cells` 形状 ——
|
||||
* 「视图漏挂 data-col」(旧版 `part-substitution-assessment/src/views/report.ts`
|
||||
* 的形状)与「视图给了一个禁用、恒空的 data-col」(修复后的形状),证明因果链条本身:
|
||||
* `resolveRowKey` 这个函数一个字没改,活动侧只是换了一种能让平台既有保护生效的
|
||||
* 写法。平台侧的根治(`resolveRowKey` 该把声明了 `auto` 的列直接排除在「全表一致」
|
||||
* 之外,不必等 `k in cells` 这道旁门)仍记在 `docs/debt.md`,下一个建「单一主键又是
|
||||
* auto 列」的活动仍会踩上第一条用例这个坑。
|
||||
*/
|
||||
describe('Critical 1:唯一主键又是 auto 列(无前缀陪衬)——视图漏挂 data-col 会让新建行被误判成改既有行', () => {
|
||||
const KEYS = ['type_id'];
|
||||
const TYPES = { type_id: 'text' };
|
||||
// 活动侧的自投影机制保证此刻 parts.chip_type 恰好一行 —— 种子里的既有类型
|
||||
const ONE_EXISTING = [{ key: { type_id: '1' } }];
|
||||
|
||||
it('**病灶重放**:视图漏挂 data-col → type_id 压根不在 cells 里 → barred 落空 → 「全表一致」把新类型误判成既有那一行的号', () => {
|
||||
const out = resolveRowKey({
|
||||
keys: KEYS,
|
||||
cells: { type_name: '新类型', description: '这是什么' }, // 没有 type_id 这个键 —— 与旧版 ro('type_id') 的产出一致
|
||||
drafts: ONE_EXISTING,
|
||||
types: TYPES,
|
||||
autos: ['type_id'],
|
||||
});
|
||||
// 这正是 Critical 1 的病灶:落下的键是既有那一行的号,`writeEditedRows` 接下来会当成「改行」处理,
|
||||
// 把新类型的 type_name/description 写进 type_id='1' 那一行,静默覆盖种子字典。
|
||||
expect(out).toEqual({ type_id: '1' });
|
||||
});
|
||||
|
||||
it('**修法重放**:视图挂了 data-col、值恒空(禁用输入格)→ type_id 在 cells 里但是空串 → barred 命中,交回 undefined 让 mintable/autoKeys 续新号', () => {
|
||||
const out = resolveRowKey({
|
||||
keys: KEYS,
|
||||
cells: { type_id: '', type_name: '新类型', description: '这是什么' }, // 与修复后 <input data-col="type_id" disabled> 的产出一致
|
||||
drafts: ONE_EXISTING,
|
||||
types: TYPES,
|
||||
autos: ['type_id'],
|
||||
});
|
||||
expect(out).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交闸:必填没填就交不了', () => {
|
||||
const gate = (rows: Array<Record<string, unknown>>) =>
|
||||
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
||||
@@ -284,6 +338,73 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
|
||||
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
|
||||
});
|
||||
|
||||
it('**卡自己声明了 gateLedgers → 看它,不回落到交付物**(2026-08-27 W05 实测:数据工单要补的是 parts.* 主数据,不在必交付物名单里,闸盯着一堆无关的表、一格不缺,空手交卷一路放行)', () => {
|
||||
expect(
|
||||
gateAddressesOf(undefined, [{ ledgers: ['sub_eval.input_gap', 'substitution.relation'] }], ['parts.profile', 'parts.parameter']),
|
||||
).toEqual(['parts.profile', 'parts.parameter']);
|
||||
});
|
||||
|
||||
it('**卡自报的表一行都没有 = 没完成**(2026-08-27 W05 e2e:模型压拍没写骨架就发了数据工单,件上空空如也,而「缺必填」只数已有行 → 零行零缺、闸照开)', () => {
|
||||
const watched = [
|
||||
{ domain: 'parts', object: 'profile', label: '芯片主数据' },
|
||||
{ domain: 'parts', object: 'parameter', label: '标准化参数集' },
|
||||
];
|
||||
const declared = ['parts.profile', 'parts.parameter'];
|
||||
expect(emptyGate({}, watched, declared)?.ready).toBe(false);
|
||||
expect(emptyGate({}, watched, declared)?.why).toContain('芯片主数据、标准化参数集');
|
||||
// 两张都有行 → 不归这一段管,交给「缺不缺必填」那一段
|
||||
expect(emptyGate({ 'mine:parts.profile': [{}], 'mine:parts.parameter': [{}] }, watched, declared)).toBeUndefined();
|
||||
// 一张有一张没有 → 只点没有的那一张
|
||||
expect(emptyGate({ 'mine:parts.profile': [{}] }, watched, declared)?.why).toContain('标准化参数集');
|
||||
});
|
||||
|
||||
it('卡没自报(回落到必交付物那条老路)→ 零行不拦人:那条路上零行是常态', () => {
|
||||
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
* **W05 `input-supplement` 回归网**(2026-08-29 评审 Critical:确认钮永久按不下去)。
|
||||
*
|
||||
* 那张数据工单卡的 `gateLedgers` 一度带着 `parts.chip_type`,而件上没有任何
|
||||
* `data-edit="parts.chip_type"`(选类型走的是 `data-options` 下拉现读,不写这张表)——
|
||||
* `mine:parts.chip_type` 因而永远是空数组,`emptyGate` 永远判「这一单还一行都没有」,
|
||||
* 人把另外三张表填得再全,提交钮也按不下去。这条钉死两件事:
|
||||
* ①**复现旧配置确实会永久卡死**(不是猜的);②**修完(去掉 chip_type)之后闸真的开得了**——
|
||||
* 同一份 `mine`(chip_type 全程零行,profile/type_param/parameter 都已填好)在新
|
||||
* `gateLedgers` 下必须放行。ledger 的 domain/object/label 抄自
|
||||
* `activities/part-substitution-assessment/src/manifest.ts` 的声明原文。
|
||||
*/
|
||||
it('W05 input-supplement:gateLedgers 若带 parts.chip_type 会永久卡死(它没有编辑手段可写)——去掉之后,三张真被编辑的表填齐即放行', () => {
|
||||
const watched = [
|
||||
{ domain: 'parts', object: 'chip_type', label: '芯片类型字典' },
|
||||
{ domain: 'parts', object: 'type_param', label: '芯片类型核心参数字段' },
|
||||
{ domain: 'parts', object: 'profile', label: '芯片主数据(P-08)' },
|
||||
{ domain: 'parts', object: 'parameter', label: '标准化参数集(P-08 子表)' },
|
||||
];
|
||||
// parts.chip_type 从未被这张件写过 —— 无论闸怎么配,它在 mine 里恒是空数组
|
||||
const filledExceptChipType = {
|
||||
'mine:parts.type_param': [{ type_id: '1', param_name: '输入电压范围' }],
|
||||
'mine:parts.profile': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
|
||||
'mine:parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }],
|
||||
};
|
||||
// 旧配置(已删除,这里复现只为证明它确实会卡死):chip_type 也在闸的名单里
|
||||
const buggyDeclared = ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'];
|
||||
const stuck = emptyGate(filledExceptChipType, watched, buggyDeclared);
|
||||
expect(stuck?.ready).toBe(false);
|
||||
expect(stuck?.why).toContain('芯片类型字典');
|
||||
expect(stuck?.why).toContain('这张卡就是来建它的');
|
||||
|
||||
// 修完(case-model.ts 现在的真实值):chip_type 不列入闸的名单
|
||||
const fixedDeclared = ['parts.type_param', 'parts.profile', 'parts.parameter'];
|
||||
expect(emptyGate(filledExceptChipType, watched, fixedDeclared)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
|
||||
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
|
||||
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
|
||||
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], [' parts.profile '])).toEqual(['parts.profile']);
|
||||
});
|
||||
|
||||
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
|
||||
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
|
||||
expect(gateAddressesOf([], [{}])).toEqual([]);
|
||||
@@ -520,3 +641,68 @@ describe('落不下的行要点名 —— 不许悄悄丢', () => {
|
||||
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 只建不改的表(契约 `createOnly`)—— 2026-08-27 W05:数据工单里模型多铺了一行不该建的料号,
|
||||
* 件上多出一行要人填的记录、红带点名它缺品牌。这一项守「在库的料被重建」这一形。
|
||||
*/
|
||||
describe('createOnly:键已在正式表 → 拒并指路', () => {
|
||||
const target = { label: '芯片主数据(P-08)', keys: ['internal_pn'] };
|
||||
const formal = [{ internal_pn: 'LM2596S-ADJ', brand_name: 'TI' }, { internal_pn: 'LM2596S-5.0' }];
|
||||
|
||||
it('在库的料号写下来 → 一句带指路的拒回(点名 drop)', () => {
|
||||
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal);
|
||||
expect(why).toContain('已经有「internal_pn=LM2596S-ADJ」');
|
||||
expect(why).toContain('drop');
|
||||
});
|
||||
|
||||
it('本单经数据工单确认已入库的那一行 → 拒回换一句话:建好了别再写,不点名 drop(撤了读面少一行)', () => {
|
||||
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal, [{ internal_pn: 'LM2596S-ADJ' }]);
|
||||
expect(why).toContain('已入库');
|
||||
expect(why).not.toContain('drop');
|
||||
// 另一颗在库的料号与本单无关,照旧是原来那句
|
||||
expect(createOnlyViolation(target, { internal_pn: 'LM2596S-5.0' }, formal, [{ internal_pn: 'LM2596S-ADJ' }])).toContain('drop');
|
||||
});
|
||||
|
||||
it('库里没有的料号 → 放行;复合主键按全部键列比', () => {
|
||||
expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined();
|
||||
const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] };
|
||||
const rows = [{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }];
|
||||
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, rows)).toBeDefined();
|
||||
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, rows)).toBeUndefined();
|
||||
expect(createOnlyViolation(sub, { internal_pn: 'XL2596S-5.0', param_name: '输出电压' }, rows)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** 决策卡的回答是留痕里的事实(2026-08-27:审核哨兵不再读模型自写的旗) */
|
||||
describe('decisionReadings:按 card-reply 数', () => {
|
||||
it('最后一次选的值 + 按值计数;custom: 一律归 custom', () => {
|
||||
const trail = [
|
||||
{ kind: 'card-sent', detail: {} },
|
||||
{ kind: 'card-reply', detail: { value: 'custom:重做一次' } },
|
||||
{ kind: 'card-reply', detail: { value: 'custom:再改一下' } },
|
||||
{ kind: 'card-reply', detail: { value: 'approve' } },
|
||||
];
|
||||
expect(decisionReadings(trail)).toEqual({ decision_last: 'approve', decision_approve_total: 1, decision_reject_total: 0, decision_custom_total: 2 });
|
||||
});
|
||||
it('一张都没答 → last 为空串、各计数铺 0(哨兵里 decision_last = "approve" 恒假,门关着)', () => {
|
||||
expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionGateOf:确认闸的条件档(2026-08-31,「类型不一致还能确认」逼出)', () => {
|
||||
it('条件为假 → 不受理,拒回用发卡人那句话;为真 / 没声明 → 不拦', async () => {
|
||||
const { conditionGateOf } = await import('../routes/desk.ts');
|
||||
const readings = { parts_profile_distinct_chip_type_id: 2 };
|
||||
const blocked = conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '两颗料不是同一个芯片类型 —— 先改对再确认。', readings);
|
||||
expect(blocked?.ready).toBe(false);
|
||||
expect(blocked?.why).toContain('不是同一个芯片类型');
|
||||
expect(conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '话', { parts_profile_distinct_chip_type_id: 1 })).toBeUndefined();
|
||||
expect(conditionGateOf(undefined, undefined, readings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('表达式炸了不成墙(装包静态审管坏表达式;运行时评估不出的条件不该锁死一单)', async () => {
|
||||
const { conditionGateOf } = await import('../routes/desk.ts');
|
||||
expect(conditionGateOf('((((', '话', {})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 实例五态 —— 对 task 运行态 / 终态的**如实映射**(`docs/API文档-活动模块.md` §0)。
|
||||
*
|
||||
* pending 建了单、推进器还没领到(`used.steps = 0`;`task.start` 不同步跑第一拍)
|
||||
* running 在途:running / awaiting-human / awaiting-event / suspended / landing-blocked
|
||||
* completed 终态 released
|
||||
* rejected 验收被负责人打回、模型还没重新提交的那一段 —— 本引擎里驳回是环内回转不是终态
|
||||
* (`loop_state.pending` 为 null 或 `verdict='reject'`,且最近一条 `裁决:` 不是 pass);
|
||||
* 模型重新提交、新验收卡发出后 pending 再被立起,就回到 running
|
||||
* failed 终态 blocked / stalled / exhausted(kind=failed),以及 terminated(kind=terminated)
|
||||
*
|
||||
* 纯函数;读数由调用方从 `sys.task` / `sys.task_memory` / `sys.loop_state` 取来。
|
||||
*/
|
||||
import type { InstanceReason, InstanceState, TerminalState } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
|
||||
/** 最近一次人的裁决(`release-verdict` 里标题以 `裁决:` 开头的那条;落地那一路也记同 kind,不算) */
|
||||
export type VerdictFact = { readonly pass: boolean; readonly comment: string; readonly byName: string | undefined };
|
||||
/** 在途验收(`loop_state.pending`):卡发出即有;verdict 到达后填上;重做被消费后整个清空 */
|
||||
export type PendingFact = { readonly verdict: 'pass' | 'reject' | undefined; readonly comment: string | undefined };
|
||||
|
||||
export type StateFacts = {
|
||||
readonly terminal: TerminalState | undefined;
|
||||
readonly reason: string | undefined;
|
||||
readonly runtime: string;
|
||||
readonly usedSteps: number;
|
||||
readonly lastVerdict: VerdictFact | undefined;
|
||||
readonly pending: PendingFact | undefined;
|
||||
};
|
||||
|
||||
export type StateVerdict = { readonly state: InstanceState; readonly reason: InstanceReason | null };
|
||||
|
||||
export const VERDICT_PREFIX = '裁决:';
|
||||
|
||||
function rejectedReason(comment: string, byName: string | undefined): InstanceReason {
|
||||
if (comment === '') return { kind: 'rejected', text: '验收驳回:负责人未写明具体意见,已退回重做' };
|
||||
return { kind: 'rejected', text: `验收驳回:${comment}${byName === undefined ? '' : ` —— 负责人 ${byName}`}` };
|
||||
}
|
||||
|
||||
const BUDGET_ITEM_LABEL: Readonly<Record<string, string>> = { steps: '步数', tokens: 'token', activeMs: '活跃时长', humanCalls: '调人次数' };
|
||||
|
||||
/**
|
||||
* `exhausted` 那一路 `sys.task.reason` 存的是预算执行器裁决的 JSON(`{"kind":"exhausted","breaches":[{item,used,cap}]}`)——
|
||||
* 机器话;摆给人的一句要说「哪一项、用了多少、上限多少」。认不出的形状原样交出去(不编)。
|
||||
*/
|
||||
export function failedReasonText(terminal: TerminalState, reason: string | undefined): string {
|
||||
if (reason === undefined || reason === '') return TERMINAL_STATE_LABEL[terminal];
|
||||
if (terminal !== 'exhausted') return reason;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(reason);
|
||||
const breaches = typeof parsed === 'object' && parsed !== null ? (parsed as { breaches?: unknown }).breaches : undefined;
|
||||
if (!Array.isArray(breaches) || breaches.length === 0) return reason;
|
||||
const parts = breaches.map((b) => {
|
||||
const one = (typeof b === 'object' && b !== null ? b : {}) as { item?: unknown; used?: unknown; cap?: unknown };
|
||||
const item = typeof one.item === 'string' ? one.item : '';
|
||||
const scale = item === 'activeMs' ? 60_000 : 1;
|
||||
const fmt = (v: unknown) => (typeof v === 'number' ? String(Math.round(v / scale)) : '?');
|
||||
return `${BUDGET_ITEM_LABEL[item] ?? item} ${fmt(one.used)}/${fmt(one.cap)}${item === 'activeMs' ? ' 分钟' : ''}`;
|
||||
});
|
||||
return `预算耗尽:${parts.join('、')}`;
|
||||
} catch {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function instanceStateOf(f: StateFacts): StateVerdict {
|
||||
if (f.terminal === 'released') return { state: 'completed', reason: null };
|
||||
if (f.terminal === 'terminated') return { state: 'failed', reason: { kind: 'terminated', text: f.reason ?? '已手动终止' } };
|
||||
if (f.terminal !== undefined) {
|
||||
return { state: 'failed', reason: { kind: 'failed', text: failedReasonText(f.terminal, f.reason) } };
|
||||
}
|
||||
if (f.pending?.verdict === 'reject') {
|
||||
return { state: 'rejected', reason: rejectedReason((f.pending.comment ?? f.lastVerdict?.comment ?? '').trim(), f.lastVerdict?.byName) };
|
||||
}
|
||||
if (f.pending === undefined && f.lastVerdict !== undefined && !f.lastVerdict.pass) {
|
||||
return { state: 'rejected', reason: rejectedReason(f.lastVerdict.comment.trim(), f.lastVerdict.byName) };
|
||||
}
|
||||
if (f.runtime === 'running' && f.usedSteps === 0) return { state: 'pending', reason: null };
|
||||
return { state: 'running', reason: null };
|
||||
}
|
||||
|
||||
/** 从任务记忆里摘最近一次人的裁决;`nameOf` 把 `detail.by`(staffId)换成名字 */
|
||||
export function verdictFactOf(
|
||||
entries: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
nameOf: (staffId: string) => string | undefined = () => undefined,
|
||||
): VerdictFact | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||
const entry = entries[i];
|
||||
if (entry === undefined || entry.kind !== 'release-verdict' || !entry.title.startsWith(VERDICT_PREFIX)) continue;
|
||||
const by = entry.detail?.['by'];
|
||||
const comment = entry.detail?.['comment'];
|
||||
return {
|
||||
pass: entry.title === `${VERDICT_PREFIX}pass`,
|
||||
comment: typeof comment === 'string' ? comment : '',
|
||||
byName: typeof by === 'string' ? nameOf(by) : undefined,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* L3 的「动作过程记录」—— 一个计划项一步,按案例模型的文档序排。
|
||||
*
|
||||
* 三处拼起来(没有哪一处单独够用):
|
||||
* · `loop_state.parsed.items`(CMMN 计划项:名字、谁办 —— `manualActivation` 的项是触达人的那几步);
|
||||
* · `loop_state.runtime.items[].state`(办到哪了:Completed / Active / Failed / …,**不带时间**);
|
||||
* · 任务记忆里引擎写的三种痕(`开始办:<名>` / `完成:<名>` / `自动收步:<id> …`)给出时刻与用时。
|
||||
* 阶段(stage)/ 里程碑 / 监听器不是「一步」,不摆。
|
||||
*/
|
||||
import type { InstanceState, InstanceStep } from '@contracts';
|
||||
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
|
||||
|
||||
export const STEP_ITEM_TYPES: ReadonlySet<string> = new Set(['humanTask', 'decisionTask', 'processTask', 'caseTask']);
|
||||
export const STEP_START = '开始办:';
|
||||
export const STEP_DONE = '完成:';
|
||||
export const STEP_AUTO = '自动收步:';
|
||||
|
||||
export type HistoryLine = { readonly kind: string; readonly title: string; readonly at: string };
|
||||
|
||||
export type StepFacts = {
|
||||
readonly items: readonly PlanItem[];
|
||||
readonly runtimeItems: readonly ItemRuntime[];
|
||||
/** 剧本 `steps[].intent`(item id → 一句话) */
|
||||
readonly intents: ReadonlyMap<string, string>;
|
||||
readonly history: readonly HistoryLine[];
|
||||
readonly state: InstanceState;
|
||||
readonly ownerName: string;
|
||||
readonly llm: string;
|
||||
};
|
||||
|
||||
type Res = InstanceStep['res'];
|
||||
|
||||
const IN_PROGRESS: ReadonlySet<string> = new Set(['Active', 'Enabled', 'Failed', 'Suspended']);
|
||||
|
||||
function baseRes(state: string | undefined): Res {
|
||||
if (state === 'Completed') return 'done';
|
||||
if (state === 'Active' || state === 'Enabled') return 'current';
|
||||
if (state === 'Failed') return 'fail';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** 同一项可能重复实例化(repetitionRule),看最新那一实例 */
|
||||
function latestRuntime(items: readonly ItemRuntime[], id: string): ItemRuntime | undefined {
|
||||
let best: ItemRuntime | undefined;
|
||||
for (const it of items) if (it.id === id && (best === undefined || it.instance > best.instance)) best = it;
|
||||
return best;
|
||||
}
|
||||
|
||||
function millis(iso: string): number | undefined {
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : undefined;
|
||||
}
|
||||
|
||||
export function stepsOf(f: StepFacts): InstanceStep[] {
|
||||
const rows = f.items
|
||||
.filter((item) => STEP_ITEM_TYPES.has(item.type))
|
||||
.map((item) => {
|
||||
const rt = latestRuntime(f.runtimeItems, item.id);
|
||||
return { item, rt, res: baseRes(rt?.state) as Res };
|
||||
});
|
||||
|
||||
/*
|
||||
* 失败 / 驳回的那一行:正在办的那一步记 fail(过程面「当前步记已终止 / 驳回」)。
|
||||
* 没有正在办的(终止前一步没开、或验收打回时项都已收)→ 失败取第一个没到的,驳回取最后一个已完成的。
|
||||
*/
|
||||
if (f.state === 'failed' || f.state === 'rejected') {
|
||||
const live = rows.findIndex((r) => r.rt !== undefined && IN_PROGRESS.has(r.rt.state));
|
||||
if (live >= 0) rows[live]!.res = 'fail';
|
||||
else if (f.state === 'failed') {
|
||||
const next = rows.findIndex((r) => r.res === 'pending');
|
||||
if (next >= 0) rows[next]!.res = 'fail';
|
||||
} else {
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
if (rows[i]!.res === 'done') {
|
||||
rows[i]!.res = 'fail';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map(({ item, res }, i) => {
|
||||
const start = f.history.find((h) => h.title === `${STEP_START}${item.name}`);
|
||||
const ends = f.history.filter((h) => h.title === `${STEP_DONE}${item.name}` || h.title.startsWith(`${STEP_AUTO}${item.id} `));
|
||||
const end = ends[ends.length - 1];
|
||||
const startMs = start === undefined ? undefined : millis(start.at);
|
||||
const endMs = end === undefined ? undefined : millis(end.at);
|
||||
const at = start?.at ?? (res === 'done' ? (end?.at ?? null) : null);
|
||||
const durationMs = startMs !== undefined && endMs !== undefined && endMs >= startMs && res !== 'current' ? endMs - startMs : null;
|
||||
return {
|
||||
seq: i + 1,
|
||||
name: item.name,
|
||||
by: item.manualActivation ? 'human' : 'agent',
|
||||
byName: item.manualActivation ? f.ownerName : f.llm,
|
||||
at,
|
||||
durationMs,
|
||||
res,
|
||||
note: f.intents.get(item.id) ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 让某一步的结果**跟随**前面那一步(2026-08-28 用户定:详情接口交给前端之前,第 9 步的 res 跟第 8 步走)。
|
||||
*
|
||||
* 为什么会需要:放行那一步(T_release,人工激活)没有自己的收步事件 —— 人签字后 `submit` 直接把整单落终态,
|
||||
* 计划项本身从没被 finish 过,于是一单已 `released` 的详情里第 9 步永远是 `pending`,而它前面拟落账那一步早就 `done`。
|
||||
* 改 `res` 与 `at`(2026-08-28 用户定,`at` 也跟);用时不编(那一步没有自己的起止痕)。两步有一步不在(别的活动步数不同)就什么都不动。
|
||||
*/
|
||||
export function followRes(steps: readonly InstanceStep[], rule: { readonly seq: number; readonly from: number }): InstanceStep[] {
|
||||
const source = steps.find((s) => s.seq === rule.from);
|
||||
if (source === undefined) return [...steps];
|
||||
return steps.map((s) => (s.seq === rule.seq ? { ...s, res: source.res, at: source.at } : s));
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 启动参数表 —— 从活动契约的 `input.data`(JSON Schema)派生,一活动一份;前端只按它渲染,不写死字段。
|
||||
*
|
||||
* 清单里怎么写(都是 JSON Schema 的标准词或本仓已有的非标词,`jsonSchemaSchema` 是 `record(unknown)`,扩展键能过):
|
||||
* · `title` → label;`description` → desc;`examples[0]` → placeholder;`default` → default;`required[]` → required
|
||||
* · `enum` / `enumNames` → select 的候选(E04 / I01 已在用)
|
||||
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
|
||||
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
|
||||
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
|
||||
* · `x-options.group`:候选的**分组键**(2026-08-31 用户定)—— 只写列名(`'chip_type_id'`)= 有键没名;
|
||||
* 整份写 `{ column, ledger, value, label }` 再从字典表换人读名(`parts.chip_type.type_id → type_name`)
|
||||
* · `x-link`:联动组名 —— 同名的几格按候选的分组键互相收窄(组内先填的那格是锚,其余按锚的键收窄)。
|
||||
* **服务端不据此立墙**:combo 本就收候选之外的值(新增料号),类型一致归活动自己的收步判据管
|
||||
* · `x-distinct`:互斥组名 —— 同名的几格**不能填同一个值**。这条**是墙**(与 `x-link` 正相反):
|
||||
* 前端不把别的格占了的候选列出来,自己写进来的由这里拒。理由见契约 `distinctGroup` 的注释:
|
||||
* W05 两格同一颗料会把单子做死,而唯一能改的时机就是起单这一刻
|
||||
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
|
||||
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
|
||||
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
|
||||
*
|
||||
* 校验与归一(`validateLaunchParams`)是服务端那一道:前端禁提交只是礼貌,拦住的得是这里。
|
||||
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
|
||||
*/
|
||||
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
|
||||
import { LAUNCH_PARAM_TYPES, launchDistinctKey, launchDistinctMessage } from '@contracts';
|
||||
|
||||
/**
|
||||
* 候选的**分组键**(2026-08-31 用户定的联动):`column` 是候选行上取哪一列当键(如 `chip_type_id`),
|
||||
* `dict` 是把这枚键换成人读名的字典表(如 `parts.chip_type.type_id → type_name`);
|
||||
* 字典没声明 / 写残 → 只有键没有名(界面退回摆键本身,不炸)。
|
||||
*/
|
||||
export type GroupSource = { readonly column: string; readonly dict: { readonly ledger: string; readonly value: string; readonly label: string } | undefined };
|
||||
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string; readonly group: GroupSource | undefined };
|
||||
|
||||
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
|
||||
export type FieldSpec = {
|
||||
readonly key: string;
|
||||
readonly required: boolean;
|
||||
readonly title: string | undefined;
|
||||
readonly description: string | undefined;
|
||||
readonly type: string | undefined;
|
||||
readonly itemType: string | undefined;
|
||||
readonly format: string | undefined;
|
||||
readonly control: string | undefined;
|
||||
readonly enumValues: readonly unknown[] | undefined;
|
||||
readonly enumNames: readonly unknown[] | undefined;
|
||||
readonly example: unknown;
|
||||
readonly defaultValue: unknown;
|
||||
readonly optionsSource: OptionsSource | undefined;
|
||||
/** 联动组名(`x-link`);同名的几格按候选的分组键互相收窄 —— 摆法在前端,服务端不据此立墙 */
|
||||
readonly linkGroup: string | undefined;
|
||||
/** 互斥组名(`x-distinct`);同名的几格不能填同一个值 —— 这一条服务端真拦 */
|
||||
readonly distinctGroup: string | undefined;
|
||||
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
|
||||
readonly order: number | undefined;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/** `x-options.group`:只写列名(字符串)= 有键没名;整份声明 = 键 + 换名字典。写残当没声明 —— 候选照出,只是不联动 */
|
||||
function groupSourceOf(value: unknown): GroupSource | undefined {
|
||||
const asColumn = asString(value);
|
||||
if (asColumn !== undefined) return asColumn === '' ? undefined : { column: asColumn, dict: undefined };
|
||||
const raw = asRecord(value);
|
||||
if (raw === undefined) return undefined;
|
||||
const column = asString(raw['column']);
|
||||
if (column === undefined || column === '') return undefined;
|
||||
const ledger = asString(raw['ledger']);
|
||||
const valueCol = asString(raw['value']);
|
||||
const labelCol = asString(raw['label']);
|
||||
const dict =
|
||||
ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === ''
|
||||
? undefined
|
||||
: { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
|
||||
return { column, dict };
|
||||
}
|
||||
|
||||
function optionsSourceOf(value: unknown): OptionsSource | undefined {
|
||||
const raw = asRecord(value);
|
||||
if (raw === undefined) return undefined;
|
||||
const ledger = asString(raw['ledger']);
|
||||
const valueCol = asString(raw['value']);
|
||||
const labelCol = asString(raw['label']);
|
||||
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
|
||||
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol, group: groupSourceOf(raw['group']) };
|
||||
}
|
||||
|
||||
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
|
||||
export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): FieldSpec[] {
|
||||
const props = asRecord(inputSchema['properties']) ?? {};
|
||||
const requiredRaw = inputSchema['required'];
|
||||
const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k): k is string => typeof k === 'string') : [];
|
||||
const specs = Object.entries(props).map(([key, raw]): FieldSpec => {
|
||||
const spec = asRecord(raw) ?? {};
|
||||
const items = asRecord(spec['items']);
|
||||
const enumRaw = spec['enum'];
|
||||
const namesRaw = spec['enumNames'];
|
||||
const examples = spec['examples'];
|
||||
return {
|
||||
key,
|
||||
required: required.includes(key),
|
||||
title: asString(spec['title']),
|
||||
description: asString(spec['description']),
|
||||
type: asString(spec['type']),
|
||||
itemType: items === undefined ? undefined : asString(items['type']),
|
||||
format: asString(spec['format']),
|
||||
control: asString(spec['x-control']),
|
||||
enumValues: Array.isArray(enumRaw) ? enumRaw : undefined,
|
||||
enumNames: Array.isArray(namesRaw) ? namesRaw : undefined,
|
||||
example: Array.isArray(examples) ? examples[0] : undefined,
|
||||
defaultValue: spec['default'],
|
||||
optionsSource: optionsSourceOf(spec['x-options']),
|
||||
linkGroup: asString(spec['x-link']) === '' ? undefined : asString(spec['x-link']),
|
||||
distinctGroup: asString(spec['x-distinct']) === '' ? undefined : asString(spec['x-distinct']),
|
||||
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
|
||||
};
|
||||
});
|
||||
// 稳定排序:声明了 x-order 的按数字,没声明的殿后并保持原序
|
||||
return specs
|
||||
.map((spec, i) => ({ spec, i }))
|
||||
.sort((a, b) => (a.spec.order ?? Number.POSITIVE_INFINITY) - (b.spec.order ?? Number.POSITIVE_INFINITY) || a.i - b.i)
|
||||
.map((x) => x.spec);
|
||||
}
|
||||
|
||||
function isLaunchType(value: string | undefined): value is LaunchParamType {
|
||||
return value !== undefined && (LAUNCH_PARAM_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一格该长成什么控件;`undefined` = 本期不上表单(文件 / 对象 / 非字符串数组)。
|
||||
* 数组一律 list(`x-control` 对数组不生效 —— 一串值只有一种摆法);标量按 `x-control` > enum > 文本。
|
||||
*/
|
||||
export function controlOf(spec: FieldSpec): LaunchParamType | undefined {
|
||||
if (spec.format === 'file') return undefined;
|
||||
if (spec.type === 'array') {
|
||||
if (spec.itemType !== undefined && spec.itemType !== 'string') return undefined;
|
||||
// 数组字段也可以摆成单选(`x-control: combo | select | text`):表单上只选一颗,提交时后端包成一元数组
|
||||
return isLaunchType(spec.control) && spec.control !== 'list' ? spec.control : 'list';
|
||||
}
|
||||
if (spec.type === 'object') return undefined;
|
||||
if (isLaunchType(spec.control) && spec.control !== 'list') return spec.control;
|
||||
if (spec.enumValues !== undefined && spec.enumValues.length > 0) return 'select';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** 枚举 → 候选:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
|
||||
export function enumOptionsOf(spec: FieldSpec): LaunchOption[] | undefined {
|
||||
if (spec.enumValues === undefined || spec.enumValues.length === 0) return undefined;
|
||||
return spec.enumValues.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
|
||||
}
|
||||
|
||||
function placeholderOf(spec: FieldSpec, type: LaunchParamType): string | undefined {
|
||||
const example = spec.example;
|
||||
if (example === undefined || example === null) return undefined;
|
||||
// 数组字段(list,或摆成单选的数组)的示例是一串:占位摆第一颗
|
||||
if (type === 'list' || spec.type === 'array') {
|
||||
const first = Array.isArray(example) ? example[0] : example;
|
||||
return typeof first === 'string' ? first : undefined;
|
||||
}
|
||||
return typeof example === 'string' ? example : JSON.stringify(example);
|
||||
}
|
||||
|
||||
function defaultOf(spec: FieldSpec, type: LaunchParamType): string | string[] | undefined {
|
||||
const value = spec.defaultValue;
|
||||
if (type === 'list') {
|
||||
return Array.isArray(value) && value.every((v): v is string => typeof v === 'string') ? value : undefined;
|
||||
}
|
||||
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined;
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格规格 → 一条参数。`resolved` 是从台账读回来的候选(`x-options`);没有就退回枚举。
|
||||
* select 没有任何候选就降成 text —— 「只能选」却无可选,等于把这一格锁死。
|
||||
*/
|
||||
export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[] | undefined): LaunchParam | undefined {
|
||||
let type = controlOf(spec);
|
||||
if (type === undefined) return undefined;
|
||||
const options = resolved === undefined ? enumOptionsOf(spec) : [...resolved];
|
||||
if (type === 'select' && (options === undefined || options.length === 0)) type = 'text';
|
||||
const withOptions = (type === 'select' || type === 'combo') && options !== undefined && options.length > 0;
|
||||
const placeholder = placeholderOf(spec, type);
|
||||
const fallback = defaultOf(spec, type);
|
||||
return {
|
||||
key: spec.key,
|
||||
label: spec.title ?? spec.key,
|
||||
type,
|
||||
...(spec.required ? { required: true } : {}),
|
||||
...(spec.description === undefined ? {} : { desc: spec.description }),
|
||||
...(placeholder === undefined ? {} : { placeholder }),
|
||||
...(withOptions ? { options } : {}),
|
||||
...(fallback === undefined ? {} : { default: fallback }),
|
||||
/* 联动只在真有候选时才谈得上 —— 一个候选都没有的 combo 摆了组名,前端也没有可收窄的东西 */
|
||||
...(withOptions && spec.linkGroup !== undefined ? { linkGroup: spec.linkGroup } : {}),
|
||||
/* 互斥**不以有候选为前提**:两格都自己写、写成同一个,照样要拦 —— 墙管的是值,不是候选 */
|
||||
...(spec.distinctGroup === undefined ? {} : { distinctGroup: spec.distinctGroup }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 整份参数表;候选读取由调用方注入(读不到就当没有候选,**不炸**) */
|
||||
export async function launchParamsOf(
|
||||
inputSchema: Readonly<Record<string, unknown>>,
|
||||
resolve: (source: OptionsSource) => Promise<readonly LaunchOption[] | undefined>,
|
||||
): Promise<LaunchParam[]> {
|
||||
const out: LaunchParam[] = [];
|
||||
for (const spec of fieldSpecsOf(inputSchema)) {
|
||||
const resolved = spec.optionsSource === undefined ? undefined : await resolve(spec.optionsSource);
|
||||
const param = launchParamOf(spec, resolved);
|
||||
if (param !== undefined) out.push(param);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type LaunchIssue = { readonly key: string; readonly message: string };
|
||||
/** 线上的形状:list 是字符串数组,其余是字符串 */
|
||||
export type LaunchWire = Readonly<Record<string, string | readonly string[]>>;
|
||||
export type LaunchValidation =
|
||||
| { readonly ok: true; readonly data: Record<string, unknown> }
|
||||
| { readonly ok: false; readonly issues: readonly LaunchIssue[] };
|
||||
|
||||
function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unknown } | { error: string } {
|
||||
const type = spec?.type;
|
||||
if (type === 'integer' || type === 'number') {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || (type === 'integer' && !Number.isInteger(n))) return { error: `要填${type === 'integer' ? '整数' : '数字'}` };
|
||||
return { value: n };
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
if (value === 'true' || value === 'false') return { value: value === 'true' };
|
||||
return { error: '只能是 true / false' };
|
||||
}
|
||||
return { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一互斥组(`x-distinct`)里撞了同一个值的那几格 —— 点名的是**后填的那格**(表单次序在后的),
|
||||
* 话里指向它撞上了谁。只认标量:数组格不参与(见契约 `distinctGroup` 的注释)。
|
||||
* 「算不算同一个」由 `launchDistinctKey` 说了算 —— 前端用的是同一把尺。
|
||||
*/
|
||||
function distinctIssuesOf(params: readonly LaunchParam[], data: Readonly<Record<string, unknown>>): LaunchIssue[] {
|
||||
const issues: LaunchIssue[] = [];
|
||||
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
|
||||
for (const param of params) {
|
||||
const group = param.distinctGroup;
|
||||
const value = data[param.key];
|
||||
if (group === undefined || value === undefined || value === null || typeof value === 'object') continue;
|
||||
const key = launchDistinctKey(String(value));
|
||||
if (key === '') continue;
|
||||
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
|
||||
seenPerGroup.set(group, seen);
|
||||
const first = seen.get(key);
|
||||
if (first === undefined) seen.set(key, param);
|
||||
else issues.push({ key: param.key, message: launchDistinctMessage(first.label) });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
|
||||
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
|
||||
* **互斥组(`x-distinct`)是这里唯一的跨格校验**:同组两格填了同一个值就拒 —— 前端不列出别人占了的
|
||||
* 候选只是礼貌,自己写一个同样的值、或压根不经这个表单起单(脚本 / API),拦住的得是这里。
|
||||
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
|
||||
*/
|
||||
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
|
||||
const issues: LaunchIssue[] = [];
|
||||
const data: Record<string, unknown> = {};
|
||||
const known = new Set(params.map((p) => p.key));
|
||||
const specOf = new Map(specs.map((s) => [s.key, s]));
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!known.has(key)) issues.push({ key, message: '不是这个活动的启动参数' });
|
||||
}
|
||||
for (const param of params) {
|
||||
const raw = input[param.key];
|
||||
const required = param.required === true;
|
||||
const spec = specOf.get(param.key);
|
||||
/*
|
||||
* 清单里是数组的字段(list,或摆成单选的 combo / select):线上 string 与 string[] 都收,
|
||||
* 归一成 string[] 交活动 —— 前端单选传一个字符串,后端包成一元数组(2026-08-27 用户定)。
|
||||
*/
|
||||
if (param.type === 'list' || spec?.type === 'array') {
|
||||
const entries = (typeof raw === 'string' ? [raw] : (raw ?? [])).map((v) => v.trim()).filter((v) => v !== '');
|
||||
if (entries.length === 0) {
|
||||
if (required) issues.push({ key: param.key, message: param.type === 'list' ? '必填,至少一项' : '必填' });
|
||||
continue;
|
||||
}
|
||||
if (param.type === 'select' && param.options !== undefined) {
|
||||
const off = entries.find((v) => !param.options?.some((o) => o.value === v));
|
||||
if (off !== undefined) {
|
||||
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
data[param.key] = entries;
|
||||
continue;
|
||||
}
|
||||
if (raw !== undefined && typeof raw !== 'string') {
|
||||
issues.push({ key: param.key, message: '要传字符串' });
|
||||
continue;
|
||||
}
|
||||
const value = (raw ?? '').trim();
|
||||
if (value === '') {
|
||||
if (required) issues.push({ key: param.key, message: '必填' });
|
||||
continue;
|
||||
}
|
||||
if (param.type === 'select' && param.options !== undefined && !param.options.some((o) => o.value === value)) {
|
||||
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
|
||||
continue;
|
||||
}
|
||||
const coerced = coerceScalar(value, spec);
|
||||
if ('error' in coerced) {
|
||||
issues.push({ key: param.key, message: coerced.error });
|
||||
continue;
|
||||
}
|
||||
data[param.key] = coerced.value;
|
||||
}
|
||||
issues.push(...distinctIssuesOf(params, data));
|
||||
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
/**
|
||||
* 运营中心 · 活动模块的用例编排(app 层;`docs/API文档-活动模块.md`)。
|
||||
*
|
||||
* 读是跨模块的投影:活动 = 注册表卡片 + 清单 + 运营侧档案 + 实例计数;
|
||||
* 实例 = `sys.task` 一行 + 任务记忆里的裁决与引擎痕 + loop 运行态 + 输入 / 产出文件。
|
||||
* 写一律走既有 port:`task.start`(`origin:'ops'`)、`task.control('terminate')`、`registry.setEnabled`。
|
||||
*
|
||||
* 启动的幂等:`sys.activity_launch` 按键占位(一句 SQL 判 + 占),同键重放回第一次那一单;
|
||||
* 键占了、单还没建成的那 60 秒里再来 = 409(不是第二单,也不是无限等)。
|
||||
*/
|
||||
import type {
|
||||
LedgerQuery,
|
||||
ViewData,
|
||||
ActivityDetail,
|
||||
ActivityHue,
|
||||
ActivityStatus,
|
||||
ActivitySummary,
|
||||
InstanceDetail,
|
||||
InstanceFile,
|
||||
InstanceList,
|
||||
InstanceSource,
|
||||
InstanceState,
|
||||
InstanceSummary,
|
||||
LaunchInput,
|
||||
LaunchOption,
|
||||
LaunchParams,
|
||||
StaffId,
|
||||
StaffRef,
|
||||
TaskId,
|
||||
TerminalState,
|
||||
} from '@contracts';
|
||||
import { ACTIVITY_HUES, TERMINAL_STATES, staffIdSchema, taskIdSchema } from '@contracts';
|
||||
import type { ActivityCard } from '@modules/activity-registry';
|
||||
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
|
||||
import { StaffNotFoundError } from '@modules/org';
|
||||
import { renderView } from '@modules/interaction';
|
||||
import type { StartTaskInput } from '@modules/task';
|
||||
import { MIME_OF } from '../file-compose.ts';
|
||||
import type { FileFormat } from '../file-compose.ts';
|
||||
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
||||
import type { FieldSpec, GroupSource, LaunchIssue, OptionsSource } from './launch-params.ts';
|
||||
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
||||
import type { StateVerdict } from './instance-state.ts';
|
||||
import { followRes, stepsOf } from './instance-steps.ts';
|
||||
import { applyInstanceFilters, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from './summaries.ts';
|
||||
import type { InstanceQuery } from './summaries.ts';
|
||||
import { LEDGER_TEMPLATE, injectHeightScript } from './view-html.ts';
|
||||
import { resolveViewData, viewScopeOf } from './view-scope.ts';
|
||||
import type { ViewRead } from './view-scope.ts';
|
||||
import { caseStamp } from '../case-stamp.ts';
|
||||
import type { ActivityOpsStore, TaskRow } from './store.ts';
|
||||
|
||||
/** 键占了、单还没建成:这么久之后允许重占(进程崩在 claim 与 start 之间不该把这枚键锁死) */
|
||||
export const LAUNCH_CLAIM_STALE_MS = 60_000;
|
||||
/** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */
|
||||
export const OPTIONS_CAP = 500;
|
||||
/** 详情时间线交给前端之前:第 9 步(放行)的结果跟第 8 步(拟落账)走 —— 见 `followRes` 的注释(2026-08-28 用户定) */
|
||||
export const STEP_FOLLOW_RULE = { seq: 9, from: 8 } as const;
|
||||
|
||||
/** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */
|
||||
export type InstanceFileStore = {
|
||||
readUpload(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
|
||||
listArchived(taskId: TaskId): Promise<ReadonlyArray<{ address: string; key: Record<string, string>; format: FileFormat; name: string; size: number }>>;
|
||||
readArchived(taskId: TaskId, address: string, key: Readonly<Record<string, string>>): Promise<{ bytes: Uint8Array; name: string; format: FileFormat } | undefined>;
|
||||
};
|
||||
|
||||
/*
|
||||
* 依赖只声明**用到的那几面**(不是整个 `ActivityRegistryService` / `TaskService` …):
|
||||
* 真服务结构上都装得进来(server.ts 装配处由 tsc 核),测试只需拼几个字面量,不用造整本清单。
|
||||
*/
|
||||
/** 清单里运营面要看的几处(台账声明只为沙箱看板铺 tally 读数) */
|
||||
export type ManifestFace = {
|
||||
readonly identity: { readonly name: string };
|
||||
readonly contract: { readonly input: { readonly data: Readonly<Record<string, unknown>> } };
|
||||
readonly declarations: {
|
||||
readonly models: readonly string[];
|
||||
readonly ledger: ReadonlyArray<{ readonly domain: string; readonly object: string; readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }> }>;
|
||||
};
|
||||
readonly assets: { readonly viewTemplates: ReadonlyArray<{ readonly name: string; readonly html: string; readonly data?: ViewData | undefined }> };
|
||||
};
|
||||
export type RegistryFace = {
|
||||
list(): Promise<ActivityCard[]>;
|
||||
get(activityId: string): Promise<ActivityCard | undefined>;
|
||||
manifestOf(activityId: string, packageVersion?: string): Promise<ManifestFace>;
|
||||
setEnabled(activityId: string, enabled: boolean): Promise<void>;
|
||||
};
|
||||
export type TaskFace = {
|
||||
start(input: StartTaskInput): Promise<{ readonly id: TaskId }>;
|
||||
control(taskId: TaskId, semantic: 'terminate', by: StaffId, reason?: string): Promise<unknown>;
|
||||
};
|
||||
export type OrgFace = {
|
||||
listStaff(): Promise<{ staff: ReadonlyArray<{ readonly id: string; readonly name: string }> }>;
|
||||
getStaff(staffId: StaffId): Promise<{ readonly name: string; readonly state: string }>;
|
||||
};
|
||||
export type HistoryEntry = { readonly kind: string; readonly title: string; readonly at: string; readonly detail?: Record<string, unknown> | undefined };
|
||||
export type MemoryFace = { history(taskId: TaskId): Promise<ReadonlyArray<HistoryEntry>> };
|
||||
export type LedgerFace = {
|
||||
describe(address: { domain: string; object: string }): Promise<{ readonly columns: ReadonlyArray<{ readonly name: string }> } | undefined>;
|
||||
query(query: LedgerQuery): Promise<{ readonly rows: ReadonlyArray<Readonly<Record<string, unknown>>> }>;
|
||||
};
|
||||
export type LoopFace = {
|
||||
readonly model: { readonly steps?: ReadonlyArray<{ readonly item: string; readonly intent: string }> | undefined };
|
||||
readonly parsed: { readonly items: readonly PlanItem[] };
|
||||
readonly runtime: { readonly items: readonly ItemRuntime[] };
|
||||
readonly pending?: { readonly verdict?: 'pass' | 'reject' | undefined; readonly comment?: string | undefined } | undefined;
|
||||
};
|
||||
export type LoopStateFace = { load(taskId: TaskId): Promise<LoopFace | undefined> };
|
||||
|
||||
export type ActivityOpsDeps = {
|
||||
readonly registry: RegistryFace;
|
||||
readonly task: TaskFace;
|
||||
readonly org: OrgFace;
|
||||
readonly memory: MemoryFace;
|
||||
readonly ledger: LedgerFace;
|
||||
readonly loopState: LoopStateFace;
|
||||
readonly store: ActivityOpsStore;
|
||||
readonly files: InstanceFileStore;
|
||||
readonly clock: { now(): Date };
|
||||
/** 读不到候选这类**不该炸**的事,说一声就好 */
|
||||
readonly warn?: (line: string) => void;
|
||||
};
|
||||
|
||||
export type Actor = { readonly id: StaffId; readonly name: string };
|
||||
|
||||
export type LaunchOutcome =
|
||||
| { readonly kind: 'ok'; readonly detail: InstanceDetail; readonly created: boolean }
|
||||
| { readonly kind: 'not-found' }
|
||||
| { readonly kind: 'disabled' }
|
||||
| { readonly kind: 'invalid'; readonly issues: readonly LaunchIssue[] }
|
||||
| { readonly kind: 'owner-invalid'; readonly message: string }
|
||||
| { readonly kind: 'busy'; readonly message: string };
|
||||
|
||||
export type TerminateOutcome = { readonly kind: 'ok'; readonly detail: InstanceDetail } | { readonly kind: 'not-found' } | { readonly kind: 'terminal' };
|
||||
|
||||
export type FileOutcome =
|
||||
| { readonly kind: 'file'; readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }
|
||||
| { readonly kind: 'no-instance' }
|
||||
| { readonly kind: 'no-file' };
|
||||
|
||||
export type ViewOutcome = { readonly kind: 'html'; readonly html: string } | { readonly kind: 'no-activity' } | { readonly kind: 'no-view' };
|
||||
|
||||
export type ActivityOps = {
|
||||
listActivities(status?: ActivityStatus): Promise<ActivitySummary[]>;
|
||||
getActivity(activityId: string): Promise<ActivityDetail | undefined>;
|
||||
viewHtml(activityId: string): Promise<ViewOutcome>;
|
||||
listInstances(activityId: string, query: InstanceQuery & { page: number; size: number }): Promise<InstanceList | undefined>;
|
||||
launchParams(activityId: string): Promise<LaunchParams | undefined>;
|
||||
launch(activityId: string, input: LaunchInput, by: Actor): Promise<LaunchOutcome>;
|
||||
instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined>;
|
||||
instanceFile(activityId: string, instanceId: string, io: 'input' | 'output', index: number): Promise<FileOutcome>;
|
||||
setEnabled(activityId: string, enabled: boolean): Promise<ActivityDetail | undefined>;
|
||||
terminate(activityId: string, instanceId: string, by: Actor): Promise<TerminateOutcome>;
|
||||
};
|
||||
|
||||
const NAME_FALLBACK = '员工';
|
||||
|
||||
function isTerminal(value: string | null): value is TerminalState {
|
||||
return value !== null && (TERMINAL_STATES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function hueOf(raw: string | null): ActivityHue | undefined {
|
||||
return raw !== null && (ACTIVITY_HUES as readonly string[]).includes(raw) ? (raw as ActivityHue) : undefined;
|
||||
}
|
||||
|
||||
/** 当前生效的包版本:非 uninstalled 的最新一版(store 按 installedAt 升序给) */
|
||||
function versionOf(card: ActivityCard): string {
|
||||
const live = card.packageVersions.filter((p) => p.state !== 'uninstalled');
|
||||
const pick = (live.length > 0 ? live : card.packageVersions).at(-1);
|
||||
return pick?.version ?? '';
|
||||
}
|
||||
|
||||
function createdAtOf(card: ActivityCard): string {
|
||||
let earliest: string | undefined;
|
||||
for (const p of card.packageVersions) if (earliest === undefined || p.installedAt < earliest) earliest = p.installedAt;
|
||||
return earliest ?? '';
|
||||
}
|
||||
|
||||
function llmOf(manifest: ManifestFace): string {
|
||||
return manifest.declarations.models[0] ?? '—';
|
||||
}
|
||||
|
||||
function channelLabel(channel: string): string {
|
||||
if (channel === 'mail') return '邮件';
|
||||
if (channel === 'im') return '即时消息';
|
||||
return channel === '' ? '通道' : channel;
|
||||
}
|
||||
|
||||
export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
||||
const warn = deps.warn ?? ((line: string) => process.stderr.write(`${line}\n`));
|
||||
|
||||
async function namesOf(): Promise<Map<string, string>> {
|
||||
const { staff } = await deps.org.listStaff();
|
||||
return new Map(staff.map((s) => [s.id, s.name]));
|
||||
}
|
||||
|
||||
function staffRef(id: string, names: ReadonlyMap<string, string>): StaffRef {
|
||||
return { id: staffIdSchema.parse(id), name: names.get(id) ?? NAME_FALLBACK };
|
||||
}
|
||||
|
||||
function summaryOf(
|
||||
card: ActivityCard,
|
||||
manifest: ManifestFace,
|
||||
profile: { ownerId: string | null; description: string; ico: string | null; hue: string | null } | undefined,
|
||||
instanceCount: number,
|
||||
names: ReadonlyMap<string, string>,
|
||||
): ActivitySummary {
|
||||
const hue = hueOf(profile?.hue ?? null);
|
||||
return {
|
||||
id: card.activityId,
|
||||
name: card.name,
|
||||
biz: card.category,
|
||||
status: card.enabled ? 'active' : 'disabled',
|
||||
version: versionOf(card),
|
||||
owner: profile?.ownerId === null || profile?.ownerId === undefined ? null : staffRef(profile.ownerId, names),
|
||||
llm: llmOf(manifest),
|
||||
instanceCount,
|
||||
createdAt: createdAtOf(card),
|
||||
...(profile?.ico === null || profile?.ico === undefined || profile.ico === '' ? {} : { ico: profile.ico }),
|
||||
...(hue === undefined ? {} : { hue }),
|
||||
};
|
||||
}
|
||||
|
||||
async function detailOf(card: ActivityCard): Promise<ActivityDetail> {
|
||||
const [manifest, profiles, names] = await Promise.all([deps.registry.manifestOf(card.activityId), deps.store.profilesOf(), namesOf()]);
|
||||
const profile = profiles.get(card.activityId);
|
||||
const { instanceCount: _drop, ...rest } = summaryOf(card, manifest, profile, 0, names);
|
||||
void _drop;
|
||||
const hasView = manifest.assets.viewTemplates.some((v) => v.name === LEDGER_TEMPLATE);
|
||||
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组键的人读名字典(`x-options.group.dict`)—— 键 → 名。
|
||||
* 表没物化 / 列不在 / 查不动 → 空表(候选仍带键、只是没有名;界面退回摆键本身,不炸)。
|
||||
*/
|
||||
async function groupLabels(dict: NonNullable<GroupSource['dict']>): Promise<ReadonlyMap<string, string>> {
|
||||
const [domain, object] = dict.ledger.split('.', 2);
|
||||
if (domain === undefined || object === undefined || domain === '' || object === '') return new Map();
|
||||
const address = { domain, object };
|
||||
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||
if (described === undefined || !described.columns.some((c) => c.name === dict.value) || !described.columns.some((c) => c.name === dict.label)) {
|
||||
warn(`[activity-ops] 分组人读名读不到:${dict.ledger} 未物化或没有列 ${dict.value} / ${dict.label}`);
|
||||
return new Map();
|
||||
}
|
||||
const page = await deps.ledger.query({ address, where: {}, orderBy: [], limit: OPTIONS_CAP, offset: 0 }).catch((error: unknown) => {
|
||||
warn(`[activity-ops] 分组人读名查询失败:${dict.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
const out = new Map<string, string>();
|
||||
for (const row of page?.rows ?? []) {
|
||||
const key = row[dict.value];
|
||||
const name = row[dict.label];
|
||||
if (key === undefined || key === null || String(key) === '') continue;
|
||||
if (name === undefined || name === null || String(name) === '') continue;
|
||||
out.set(String(key), String(name));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
||||
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
||||
const [domain, object] = source.ledger.split('.', 2);
|
||||
if (domain === undefined || object === undefined || domain === '' || object === '') return undefined;
|
||||
const address = { domain, object };
|
||||
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||
if (described === undefined) {
|
||||
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 尚未物化`);
|
||||
return undefined;
|
||||
}
|
||||
const columns = new Set(described.columns.map((c) => c.name));
|
||||
if (!columns.has(source.value) || !columns.has(source.label)) {
|
||||
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 没有列 ${source.value} / ${source.label}`);
|
||||
return undefined;
|
||||
}
|
||||
const page = await deps.ledger
|
||||
.query({ address, where: {}, orderBy: [{ field: source.label, dir: 'asc' }], limit: OPTIONS_CAP, offset: 0 })
|
||||
.catch((error: unknown) => {
|
||||
warn(`[activity-ops] 启动参数候选查询失败:${source.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
});
|
||||
if (page === undefined) return undefined;
|
||||
/* 分组键(联动用):声明了、且候选表真有这一列才取 —— 列不在就当没声明(候选照出,只是不联动) */
|
||||
const group = source.group !== undefined && columns.has(source.group.column) ? source.group : undefined;
|
||||
if (source.group !== undefined && group === undefined) warn(`[activity-ops] 分组键读不到:${source.ledger} 没有列 ${source.group.column}`);
|
||||
const names = group?.dict === undefined ? new Map<string, string>() : await groupLabels(group.dict);
|
||||
return page.rows
|
||||
.map((row) => {
|
||||
const value = row[source.value];
|
||||
const label = row[source.label];
|
||||
const v = value === undefined || value === null ? '' : String(value);
|
||||
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
||||
if (group === undefined) return { value: v, label: l };
|
||||
const raw = row[group.column];
|
||||
/* 那一列没值 = 「未标类型」:空串是它自己的一档,不是「没有分组键这回事」 */
|
||||
const key = raw === undefined || raw === null ? '' : String(raw);
|
||||
const name = names.get(key);
|
||||
return { value: v, label: l, group: key, ...(name === undefined ? {} : { groupLabel: name }) };
|
||||
})
|
||||
.filter((o) => o.value !== '');
|
||||
}
|
||||
|
||||
type InstanceCtx = { readonly names: ReadonlyMap<string, string>; readonly specs: readonly FieldSpec[] };
|
||||
|
||||
/** 一行 task → 状态裁定;非终态才需要翻记忆与 loop 运行态(判驳回) */
|
||||
async function verdictOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<{ verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||
const terminal = isTerminal(row.terminal) ? row.terminal : undefined;
|
||||
if (terminal !== undefined) {
|
||||
return { verdict: instanceStateOf({ terminal, reason: row.reason ?? undefined, runtime: row.runtime, usedSteps: row.usedSteps, lastVerdict: undefined, pending: undefined }), history: undefined };
|
||||
}
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
const [history, loop] = await Promise.all([deps.memory.history(taskId), deps.loopState.load(taskId)]);
|
||||
const pending = loop?.pending;
|
||||
return {
|
||||
verdict: instanceStateOf({
|
||||
terminal: undefined,
|
||||
reason: row.reason ?? undefined,
|
||||
runtime: row.runtime,
|
||||
usedSteps: row.usedSteps,
|
||||
lastVerdict: verdictFactOf(history, (id) => names.get(id)),
|
||||
pending: pending === undefined ? undefined : { verdict: pending.verdict, comment: pending.comment },
|
||||
}),
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
async function inputTextOf(row: TaskRow, specs: readonly FieldSpec[]): Promise<string> {
|
||||
const fromData = inputSummaryOf(row.data, specs);
|
||||
if (fromData !== '') return fromData;
|
||||
const comm = await deps.store.inboundCommOf(row.id);
|
||||
return inputSummaryOf({}, [], comm?.subject !== undefined && comm.subject !== '' ? comm.subject : row.title);
|
||||
}
|
||||
|
||||
async function outputTextOf(row: TaskRow, state: InstanceState, history: ReadonlyArray<HistoryEntry> | undefined): Promise<string | null> {
|
||||
if (state !== 'completed') return null;
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
const archived = await deps.files.listArchived(taskId);
|
||||
const entries = history ?? (await deps.memory.history(taskId));
|
||||
const fired = [...entries]
|
||||
.reverse()
|
||||
.find((e) => e.kind === 'release-verdict' && Array.isArray(e.detail?.['ports']))
|
||||
?.detail?.['ports'];
|
||||
const ports = Array.isArray(fired) ? fired.filter((p): p is string => typeof p === 'string') : [];
|
||||
return outputSummaryOf(archived.map((f) => f.name), ports);
|
||||
}
|
||||
|
||||
async function summarizeRow(row: TaskRow, ctx: InstanceCtx): Promise<{ summary: InstanceSummary; verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||
const { verdict, history } = await verdictOf(row, ctx.names);
|
||||
return {
|
||||
summary: {
|
||||
id: row.id,
|
||||
no: row.no,
|
||||
state: verdict.state,
|
||||
owner: staffRef(row.ownerId, ctx.names),
|
||||
input: await inputTextOf(row, ctx.specs),
|
||||
output: null,
|
||||
startedAt: row.startedAt,
|
||||
},
|
||||
verdict,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
async function sourceOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<InstanceSource> {
|
||||
if (row.workflowId !== null) {
|
||||
const wf = await deps.store.workflowOf(row.workflowId);
|
||||
return { kind: 'upstream', label: wf === undefined || wf.subject === '' ? '上游工作流' : `上游 · ${wf.subject}` };
|
||||
}
|
||||
const claim = await deps.store.launchOf(row.id);
|
||||
if (claim !== undefined || row.origin === 'ops') {
|
||||
const who = claim === undefined ? '运营端' : (names.get(claim.launchedBy) ?? NAME_FALLBACK);
|
||||
return { kind: 'manual', label: `手动启动 · ${who}` };
|
||||
}
|
||||
const comm = await deps.store.inboundCommOf(row.id);
|
||||
if (comm !== undefined) {
|
||||
const kind = comm.channel === '' ? 'mail' : comm.channel;
|
||||
return { kind, label: `${channelLabel(comm.channel)} · ${comm.party !== '' ? comm.party : comm.subject}` };
|
||||
}
|
||||
if (row.origin === 'testbed') return { kind: 'testbed', label: '测试台试跑' };
|
||||
return { kind: 'system', label: '系统起单' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 沙箱看板:最近一次**已放行**实例的落账快照(2026-08-27 用户定;在办的单不摆,行全从正式表圈)。
|
||||
* 案号 = 声明的前缀 + 开案时刻(与模型铸主键那串同源);逐表读经 `describe → query → catch`,一张表读不了
|
||||
* 不拖垮整屏(说一声);案号查无 → `board:state = missing`,没放行过的活动 → `none`,模版自己亮横幅。
|
||||
*/
|
||||
async function renderBoard(activityId: string, manifest: ManifestFace, html: string, spec: ViewData): Promise<string> {
|
||||
const declared = manifest.declarations.ledger;
|
||||
const instance = await deps.store.latestReleased(activityId);
|
||||
if (instance === undefined) return renderView(html, viewScopeOf({ spec, sets: {}, declared, state: 'none' }));
|
||||
const caseRef = `${spec.case.prefix}${caseStamp(instance.startedAt)}`;
|
||||
const owner = await deps.org.getStaff(staffIdSchema.parse(instance.ownerId)).then((s) => s.name, () => '');
|
||||
const read: ViewRead = async (address, where, orderBy) => {
|
||||
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||
if (described === undefined) return undefined;
|
||||
const page = await deps.ledger.query({ address, where, orderBy: [...orderBy], limit: OPTIONS_CAP, offset: 0 }).catch(() => undefined);
|
||||
return page?.rows;
|
||||
};
|
||||
const resolved = await resolveViewData(spec, { caseRef, read });
|
||||
for (const name of new Set(resolved.skipped)) warn(`[activity-ops] 沙箱看板读不到 ${name}(${activityId} · ${caseRef}):表未物化或查询失败,这一块按空摆`);
|
||||
const scope = viewScopeOf({
|
||||
spec,
|
||||
sets: resolved.sets,
|
||||
declared,
|
||||
instance: { no: instance.no, startedAt: instance.startedAt, finishedAt: instance.finishedAt, owner },
|
||||
caseRef,
|
||||
state: resolved.caseFound ? 'ready' : 'missing',
|
||||
});
|
||||
return renderView(html, scope);
|
||||
}
|
||||
|
||||
async function activityOf(activityId: string): Promise<{ card: ActivityCard; manifest: ManifestFace } | undefined> {
|
||||
const card = await deps.registry.get(activityId);
|
||||
if (card === undefined) return undefined;
|
||||
return { card, manifest: await deps.registry.manifestOf(activityId) };
|
||||
}
|
||||
|
||||
async function detailRow(row: TaskRow, manifest: ManifestFace, names: ReadonlyMap<string, string>): Promise<InstanceDetail> {
|
||||
const specs = fieldSpecsOf(manifest.contract.input.data);
|
||||
const { summary, verdict, history } = await summarizeRow(row, { names, specs });
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
const [entries, loop, source, archived] = await Promise.all([
|
||||
history === undefined ? deps.memory.history(taskId) : Promise.resolve(history),
|
||||
deps.loopState.load(taskId),
|
||||
sourceOf(row, names),
|
||||
deps.files.listArchived(taskId),
|
||||
]);
|
||||
const llm = llmOf(manifest);
|
||||
const steps = followRes(
|
||||
stepsOf({
|
||||
items: loop?.parsed.items ?? [],
|
||||
runtimeItems: loop?.runtime.items ?? [],
|
||||
intents: new Map((loop?.model.steps ?? []).map((s) => [s.item, s.intent])),
|
||||
history: entries.map((e) => ({ kind: e.kind, title: e.title, at: e.at })),
|
||||
state: verdict.state,
|
||||
ownerName: summary.owner.name,
|
||||
llm,
|
||||
}),
|
||||
STEP_FOLLOW_RULE,
|
||||
);
|
||||
const startedMs = Date.parse(row.startedAt);
|
||||
const finishedMs = row.finishedAt === null ? undefined : Date.parse(row.finishedAt);
|
||||
const durationMs = finishedMs !== undefined && Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs ? finishedMs - startedMs : null;
|
||||
const inputFiles: InstanceFile[] = inputFilesOf(row.data, specs).map((f) => ({ index: f.index, name: f.name, contentType: f.contentType, size: f.size }));
|
||||
const outputFiles: InstanceFile[] = archived.map((f, index) => ({ index, name: f.name, contentType: MIME_OF[f.format], size: f.size }));
|
||||
// 详情里 `output` 是文件清单(文档 §6),清单上那一句摘要在详情里不再出现
|
||||
const { input: content, output: _outputText, ...rest } = summary;
|
||||
void _outputText;
|
||||
return {
|
||||
...rest,
|
||||
activityId: row.activityId,
|
||||
llm,
|
||||
endedAt: row.finishedAt,
|
||||
durationMs,
|
||||
reason: verdict.reason,
|
||||
input: { content, source, submittedAt: row.startedAt, files: inputFiles },
|
||||
output: { files: outputFiles },
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
async function instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined> {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return undefined;
|
||||
const row = await deps.store.getTask(activityId, instanceId);
|
||||
if (row === undefined) return undefined;
|
||||
const names = await namesOf();
|
||||
return detailRow(row, found.manifest, names);
|
||||
}
|
||||
|
||||
return {
|
||||
async listActivities(status) {
|
||||
const cards = await deps.registry.list();
|
||||
const [profiles, counts, names] = await Promise.all([deps.store.profilesOf(), deps.store.countByActivity(), namesOf()]);
|
||||
const out: ActivitySummary[] = [];
|
||||
for (const card of cards) {
|
||||
const manifest = await deps.registry.manifestOf(card.activityId);
|
||||
const summary = summaryOf(card, manifest, profiles.get(card.activityId), counts.get(card.activityId) ?? 0, names);
|
||||
if (status === undefined || summary.status === status) out.push(summary);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getActivity(activityId) {
|
||||
const card = await deps.registry.get(activityId);
|
||||
return card === undefined ? undefined : detailOf(card);
|
||||
},
|
||||
|
||||
async viewHtml(activityId) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'no-activity' };
|
||||
const template = found.manifest.assets.viewTemplates.find((v) => v.name === LEDGER_TEMPLATE);
|
||||
if (template === undefined) return { kind: 'no-view' };
|
||||
const html = template.data === undefined ? template.html : await renderBoard(activityId, found.manifest, template.html, template.data);
|
||||
return { kind: 'html', html: injectHeightScript(html) };
|
||||
},
|
||||
|
||||
async listInstances(activityId, query) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return undefined;
|
||||
const specs = fieldSpecsOf(found.manifest.contract.input.data);
|
||||
const names = await namesOf();
|
||||
const rows = await deps.store.listTasks(activityId);
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
const summarized = await Promise.all(rows.map((row) => summarizeRow(row, { names, specs })));
|
||||
const all = summarized.map((s) => s.summary);
|
||||
const stats = statsOf(all);
|
||||
const filtered = applyInstanceFilters(all, query);
|
||||
const page = pageOf(filtered, query.page, query.size);
|
||||
const items = await Promise.all(
|
||||
page.map(async (summary) => {
|
||||
const row = byId.get(summary.id);
|
||||
const hit = summarized.find((s) => s.summary.id === summary.id);
|
||||
const output = row === undefined || hit === undefined ? null : await outputTextOf(row, summary.state, hit.history);
|
||||
return { ...summary, output };
|
||||
}),
|
||||
);
|
||||
return { stats, total: filtered.length, page: query.page, size: query.size, items };
|
||||
},
|
||||
|
||||
async launchParams(activityId) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return undefined;
|
||||
const params = await launchParamsOf(found.manifest.contract.input.data, resolveOptions);
|
||||
return { activityId, version: versionOf(found.card), params };
|
||||
},
|
||||
|
||||
async launch(activityId, input, by) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'not-found' };
|
||||
if (!found.card.enabled) return { kind: 'disabled' };
|
||||
try {
|
||||
const owner = await deps.org.getStaff(input.ownerId);
|
||||
if (owner.state !== 'active') return { kind: 'owner-invalid', message: `责任人 ${owner.name} 已停用,换一位在职的人` };
|
||||
} catch (error) {
|
||||
if (error instanceof StaffNotFoundError) return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||
throw error;
|
||||
}
|
||||
const schema = found.manifest.contract.input.data;
|
||||
const params = await launchParamsOf(schema, resolveOptions);
|
||||
const checked = validateLaunchParams(params, input.params, fieldSpecsOf(schema));
|
||||
if (!checked.ok) return { kind: 'invalid', issues: checked.issues };
|
||||
|
||||
const claim = await deps.store.claimLaunch({ key: input.idempotencyKey, activityId, ownerId: input.ownerId, launchedBy: by.id });
|
||||
if (claim !== 'claimed') {
|
||||
if (claim.activityId !== activityId) return { kind: 'busy', message: '这枚幂等键已用在别的活动上 —— 换一枚' };
|
||||
if (claim.taskId !== null) {
|
||||
const detail = await instanceDetail(activityId, claim.taskId);
|
||||
return detail === undefined ? { kind: 'busy', message: '同一枚幂等键起的那一单已不在,换一枚键再启动' } : { kind: 'ok', detail, created: false };
|
||||
}
|
||||
const age = deps.clock.now().getTime() - Date.parse(claim.claimedAt);
|
||||
if (!(age >= LAUNCH_CLAIM_STALE_MS)) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||
const won = await deps.store.reclaimLaunch(input.idempotencyKey, LAUNCH_CLAIM_STALE_MS, input.ownerId, by.id);
|
||||
if (!won) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||
}
|
||||
|
||||
const task = await deps.task.start({ activityId, ownerId: input.ownerId, title: found.manifest.identity.name, origin: 'ops', data: checked.data });
|
||||
await deps.store.bindLaunch(input.idempotencyKey, task.id);
|
||||
const detail = await instanceDetail(activityId, task.id);
|
||||
return detail === undefined ? { kind: 'busy', message: '实例已建、暂时读不到,稍后刷新' } : { kind: 'ok', detail, created: true };
|
||||
},
|
||||
|
||||
instanceDetail,
|
||||
|
||||
async instanceFile(activityId, instanceId, io, index) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'no-instance' };
|
||||
const row = await deps.store.getTask(activityId, instanceId);
|
||||
if (row === undefined) return { kind: 'no-instance' };
|
||||
const taskId = taskIdSchema.parse(row.id);
|
||||
if (io === 'input') {
|
||||
const file = inputFilesOf(row.data, fieldSpecsOf(found.manifest.contract.input.data))[index];
|
||||
if (file === undefined) return { kind: 'no-file' };
|
||||
const stored = await deps.files.readUpload(file.ref);
|
||||
return stored === undefined ? { kind: 'no-file' } : { kind: 'file', name: stored.name, contentType: stored.contentType, bytes: stored.bytes };
|
||||
}
|
||||
const archived = (await deps.files.listArchived(taskId))[index];
|
||||
if (archived === undefined) return { kind: 'no-file' };
|
||||
const read = await deps.files.readArchived(taskId, archived.address, archived.key);
|
||||
return read === undefined ? { kind: 'no-file' } : { kind: 'file', name: read.name, contentType: MIME_OF[read.format], bytes: read.bytes };
|
||||
},
|
||||
|
||||
async setEnabled(activityId, enabled) {
|
||||
const card = await deps.registry.get(activityId);
|
||||
if (card === undefined) return undefined;
|
||||
if (card.enabled !== enabled) await deps.registry.setEnabled(activityId, enabled);
|
||||
const fresh = await deps.registry.get(activityId);
|
||||
return fresh === undefined ? undefined : detailOf(fresh);
|
||||
},
|
||||
|
||||
async terminate(activityId, instanceId, by) {
|
||||
const found = await activityOf(activityId);
|
||||
if (found === undefined) return { kind: 'not-found' };
|
||||
const row = await deps.store.getTask(activityId, instanceId);
|
||||
if (row === undefined) return { kind: 'not-found' };
|
||||
if (row.terminal !== null) return { kind: 'terminal' };
|
||||
await deps.task.control(taskIdSchema.parse(row.id), 'terminate', by.id, `由 ${by.name} 手动终止`);
|
||||
const detail = await instanceDetail(activityId, instanceId);
|
||||
return detail === undefined ? { kind: 'not-found' } : { kind: 'ok', detail };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 运营中心 · 活动模块的 app 层存取 —— 对 `sys.task` / `sys.activity_launch` / `sys.activity_profile` /
|
||||
* `sys.workflow` / `sys.task_comm` 的 raw SQL 读(先例 `asset-archive.ts` / `upload-store.ts` / `workers/task-claim.ts`)。
|
||||
*
|
||||
* 为什么不在 task 模块加 `listByActivity`:清单按活动取、带序号与计数,是**运营面的读投影**,
|
||||
* 不是任务运行时的能力;app 是唯一能同时认识多个模块的层(编码规范 §5)。写一律仍走 port。
|
||||
* `seq` / `activity_launch` / `activity_profile` 都是手写迁移(0034 / 0035)建的,不在 Drizzle schema 里。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
type Db = { execute(query: unknown): Promise<unknown> };
|
||||
|
||||
function rowsOf<T>(result: unknown): T[] {
|
||||
if (Array.isArray(result)) return result as T[];
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 时刻一律回 ISO 8601 UTC(契约 §0)。pg 的 timestamptz 在 raw `db.execute` 这一路可能回**文本**
|
||||
* (`2026-08-27 16:55:14.383+08`,实测),Date 与文本两种都归一;认不出的原样交出去(不编)。
|
||||
*/
|
||||
function iso(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const text = String(value);
|
||||
const ms = Date.parse(text);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : text;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** `sys.task` 的一行,运营面要的那几列 */
|
||||
export type TaskRow = {
|
||||
readonly id: string;
|
||||
/** 人读实例号 `INS-<seq>` */
|
||||
readonly no: string;
|
||||
readonly activityId: string;
|
||||
readonly workflowId: string | null;
|
||||
readonly origin: string | null;
|
||||
readonly ownerId: string;
|
||||
readonly title: string;
|
||||
readonly runtime: string;
|
||||
readonly terminal: string | null;
|
||||
readonly reason: string | null;
|
||||
readonly envelope: Record<string, unknown>;
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly usedSteps: number;
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt: string | null;
|
||||
};
|
||||
|
||||
export type LaunchClaim = {
|
||||
readonly key: string;
|
||||
readonly activityId: string;
|
||||
readonly ownerId: string;
|
||||
readonly launchedBy: string;
|
||||
readonly taskId: string | null;
|
||||
readonly claimedAt: string;
|
||||
};
|
||||
|
||||
export type ActivityProfile = {
|
||||
readonly activityId: string;
|
||||
readonly ownerId: string | null;
|
||||
readonly description: string;
|
||||
readonly ico: string | null;
|
||||
readonly hue: string | null;
|
||||
};
|
||||
|
||||
export type ActivityOpsStore = {
|
||||
countByActivity(): Promise<ReadonlyMap<string, number>>;
|
||||
listTasks(activityId: string): Promise<TaskRow[]>;
|
||||
getTask(activityId: string, taskId: string): Promise<TaskRow | undefined>;
|
||||
/** 最近一次已放行的实例(沙箱看板摆它):按放行时刻倒序,没放行过回 undefined */
|
||||
latestReleased(activityId: string): Promise<TaskRow | undefined>;
|
||||
/** 占键:插得进去回 `claimed`;键已在就回那一行(判与占一句 SQL,两台后端安全) */
|
||||
claimLaunch(input: { key: string; activityId: string; ownerId: string; launchedBy: string }): Promise<'claimed' | LaunchClaim>;
|
||||
/** 键在、单没建成(task_id 空)且已过 `staleMs` → 重占;抢到回 true */
|
||||
reclaimLaunch(key: string, staleMs: number, ownerId: string, launchedBy: string): Promise<boolean>;
|
||||
bindLaunch(key: string, taskId: string): Promise<void>;
|
||||
launchOf(taskId: string): Promise<LaunchClaim | undefined>;
|
||||
profilesOf(): Promise<ReadonlyMap<string, ActivityProfile>>;
|
||||
upsertProfile(profile: ActivityProfile): Promise<void>;
|
||||
workflowOf(workflowId: string): Promise<{ subject: string; source: Record<string, unknown> } | undefined>;
|
||||
inboundCommOf(taskId: string): Promise<{ channel: string; party: string; subject: string } | undefined>;
|
||||
};
|
||||
|
||||
type RawTask = {
|
||||
id: string;
|
||||
seq: unknown;
|
||||
activity_id: string;
|
||||
workflow_id: string | null;
|
||||
origin: string | null;
|
||||
owner_id: string;
|
||||
title: string;
|
||||
runtime: string;
|
||||
terminal: string | null;
|
||||
reason: string | null;
|
||||
envelope: unknown;
|
||||
input_data: unknown;
|
||||
used_steps: unknown;
|
||||
started_at: unknown;
|
||||
finished_at: unknown;
|
||||
};
|
||||
|
||||
function taskOf(r: RawTask): TaskRow {
|
||||
return {
|
||||
id: r.id,
|
||||
no: `INS-${String(r.seq ?? '')}`,
|
||||
activityId: r.activity_id,
|
||||
workflowId: r.workflow_id,
|
||||
origin: r.origin,
|
||||
ownerId: r.owner_id,
|
||||
title: r.title,
|
||||
runtime: r.runtime,
|
||||
terminal: r.terminal,
|
||||
reason: r.reason,
|
||||
envelope: record(r.envelope),
|
||||
data: record(r.input_data),
|
||||
usedSteps: Number(r.used_steps ?? 0),
|
||||
startedAt: iso(r.started_at),
|
||||
finishedAt: r.finished_at === null || r.finished_at === undefined ? null : iso(r.finished_at),
|
||||
};
|
||||
}
|
||||
|
||||
type RawClaim = { key: string; activity_id: string; owner_id: string; launched_by: string; task_id: string | null; claimed_at: unknown };
|
||||
|
||||
function claimOf(r: RawClaim): LaunchClaim {
|
||||
return { key: r.key, activityId: r.activity_id, ownerId: r.owner_id, launchedBy: r.launched_by, taskId: r.task_id, claimedAt: iso(r.claimed_at) };
|
||||
}
|
||||
|
||||
const TASK_COLUMNS = sql`id, seq, activity_id, workflow_id, origin, owner_id, title, runtime, terminal, reason, envelope, input_data, used_steps, started_at, finished_at`;
|
||||
|
||||
export function createActivityOpsStore(db: Db): ActivityOpsStore {
|
||||
return {
|
||||
async countByActivity() {
|
||||
const rows = rowsOf<{ activity_id: string; n: unknown }>(
|
||||
await db.execute(sql`select activity_id, count(*)::int as n from sys.task group by activity_id`),
|
||||
);
|
||||
return new Map(rows.map((r) => [r.activity_id, Number(r.n ?? 0)]));
|
||||
},
|
||||
|
||||
async listTasks(activityId) {
|
||||
const rows = rowsOf<RawTask>(
|
||||
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} order by started_at desc, seq desc`),
|
||||
);
|
||||
return rows.map(taskOf);
|
||||
},
|
||||
|
||||
async getTask(activityId, taskId) {
|
||||
const rows = rowsOf<RawTask>(
|
||||
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} and id = ${taskId} limit 1`),
|
||||
);
|
||||
const one = rows[0];
|
||||
return one === undefined ? undefined : taskOf(one);
|
||||
},
|
||||
|
||||
async latestReleased(activityId) {
|
||||
const rows = rowsOf<RawTask>(
|
||||
await db.execute(sql`
|
||||
select ${TASK_COLUMNS} from sys.task
|
||||
where activity_id = ${activityId} and terminal = 'released'
|
||||
order by finished_at desc nulls last, started_at desc limit 1`),
|
||||
);
|
||||
const one = rows[0];
|
||||
return one === undefined ? undefined : taskOf(one);
|
||||
},
|
||||
|
||||
async claimLaunch({ key, activityId, ownerId, launchedBy }) {
|
||||
const inserted = rowsOf<{ key: string }>(
|
||||
await db.execute(sql`
|
||||
insert into sys.activity_launch (key, activity_id, owner_id, launched_by)
|
||||
values (${key}, ${activityId}, ${ownerId}, ${launchedBy})
|
||||
on conflict (key) do nothing
|
||||
returning key`),
|
||||
);
|
||||
if (inserted.length > 0) return 'claimed';
|
||||
const existing = rowsOf<RawClaim>(
|
||||
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where key = ${key} limit 1`),
|
||||
)[0];
|
||||
// 插不进去又读不到:另一台刚回滚 —— 当作它还在创建,让调用方按 busy 处置
|
||||
return existing === undefined ? { key, activityId, ownerId, launchedBy, taskId: null, claimedAt: new Date(0).toISOString() } : claimOf(existing);
|
||||
},
|
||||
|
||||
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||
const rows = rowsOf<{ key: string }>(
|
||||
await db.execute(sql`
|
||||
update sys.activity_launch
|
||||
set claimed_at = now(), owner_id = ${ownerId}, launched_by = ${launchedBy}
|
||||
where key = ${key} and task_id is null and claimed_at < now() - (${staleMs} || ' milliseconds')::interval
|
||||
returning key`),
|
||||
);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async bindLaunch(key, taskId) {
|
||||
await db.execute(sql`update sys.activity_launch set task_id = ${taskId} where key = ${key}`);
|
||||
},
|
||||
|
||||
async launchOf(taskId) {
|
||||
const one = rowsOf<RawClaim>(
|
||||
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where task_id = ${taskId} limit 1`),
|
||||
)[0];
|
||||
return one === undefined ? undefined : claimOf(one);
|
||||
},
|
||||
|
||||
async profilesOf() {
|
||||
const rows = rowsOf<{ activity_id: string; owner_id: string | null; description: string; ico: string | null; hue: string | null }>(
|
||||
await db.execute(sql`select activity_id, owner_id, description, ico, hue from sys.activity_profile`),
|
||||
);
|
||||
return new Map(
|
||||
rows.map((r) => [r.activity_id, { activityId: r.activity_id, ownerId: r.owner_id, description: r.description ?? '', ico: r.ico, hue: r.hue }]),
|
||||
);
|
||||
},
|
||||
|
||||
async upsertProfile(p) {
|
||||
await db.execute(sql`
|
||||
insert into sys.activity_profile (activity_id, owner_id, description, ico, hue, updated_at)
|
||||
values (${p.activityId}, ${p.ownerId}, ${p.description}, ${p.ico}, ${p.hue}, now())
|
||||
on conflict (activity_id) do update
|
||||
set owner_id = excluded.owner_id, description = excluded.description, ico = excluded.ico, hue = excluded.hue, updated_at = now()`);
|
||||
},
|
||||
|
||||
async workflowOf(workflowId) {
|
||||
const one = rowsOf<{ subject: string; source: unknown }>(await db.execute(sql`select subject, source from sys.workflow where id = ${workflowId} limit 1`))[0];
|
||||
return one === undefined ? undefined : { subject: one.subject ?? '', source: record(one.source) };
|
||||
},
|
||||
|
||||
async inboundCommOf(taskId) {
|
||||
const one = rowsOf<{ channel: string; party: string; subject: string }>(
|
||||
await db.execute(sql`select channel, party, subject from sys.task_comm where task_id = ${taskId} and direction = 'in' order by at limit 1`),
|
||||
)[0];
|
||||
return one === undefined ? undefined : { channel: one.channel ?? '', party: one.party ?? '', subject: one.subject ?? '' };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 清单上的一句话与检索 —— 输入摘要 / 输入文件 / 产出摘要 / 过滤 / 统计 / 分页,全是纯函数。
|
||||
*/
|
||||
import type { InstanceState, InstanceStats, InstanceSummary } from '@contracts';
|
||||
import { INSTANCE_STATES } from '@contracts';
|
||||
import type { FieldSpec } from './launch-params.ts';
|
||||
|
||||
export const SUMMARY_MAX = 80;
|
||||
|
||||
/** 压空白、截到一行能摆下的长度;截了才加省略号(加了等于说「后面还有」) */
|
||||
export function clip(text: string, max = SUMMARY_MAX): string {
|
||||
const one = text.replace(/\s+/g, ' ').trim();
|
||||
return one.length > max ? `${one.slice(0, max - 1)}…` : one;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入一句话:清单标了 `x-control: textarea` 的那格最像「这单是什么」(客户需求 / 本轮说明),
|
||||
* 没有就取最长的那个顶层字符串;都没有退回来件主题;再没有就空串。文件字段不算。
|
||||
*/
|
||||
export function inputSummaryOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[], fallback?: string): string {
|
||||
const fileKeys = new Set(specs.filter((s) => s.format === 'file').map((s) => s.key));
|
||||
for (const spec of specs) {
|
||||
if (spec.control !== 'textarea') continue;
|
||||
const value = data[spec.key];
|
||||
if (typeof value === 'string' && value.trim() !== '') return clip(value);
|
||||
}
|
||||
let longest = '';
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (fileKeys.has(key) || typeof value !== 'string') continue;
|
||||
if (value.trim().length > longest.trim().length) longest = value;
|
||||
}
|
||||
if (longest.trim() !== '') return clip(longest);
|
||||
return fallback === undefined ? '' : clip(fallback);
|
||||
}
|
||||
|
||||
export type InputFileRef = { readonly index: number; readonly name: string; readonly contentType: string; readonly size: number; readonly ref: string };
|
||||
|
||||
/** `format: 'file'` 的字段里那些 `{name, contentType, size, ref}`;`index` 跨字段连续编号 */
|
||||
export function inputFilesOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[]): InputFileRef[] {
|
||||
const out: InputFileRef[] = [];
|
||||
for (const spec of specs) {
|
||||
if (spec.format !== 'file') continue;
|
||||
const raw = data[spec.key];
|
||||
const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
for (const one of list) {
|
||||
if (typeof one !== 'object' || one === null) continue;
|
||||
const file = one as Record<string, unknown>;
|
||||
const ref = file['ref'];
|
||||
if (typeof ref !== 'string' || ref === '') continue;
|
||||
const size = file['size'];
|
||||
out.push({
|
||||
index: out.length,
|
||||
name: typeof file['name'] === 'string' ? file['name'] : '未命名',
|
||||
contentType: typeof file['contentType'] === 'string' ? file['contentType'] : 'application/octet-stream',
|
||||
size: typeof size === 'number' && Number.isFinite(size) ? size : 0,
|
||||
ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 产出一句话:有存档文件摆文件名;没有就摆发了哪几口;都没有 = null(前端摆 `-`) */
|
||||
export function outputSummaryOf(archivedNames: readonly string[], firedPorts: readonly string[]): string | null {
|
||||
if (archivedNames.length > 0) return clip(archivedNames.join('、'));
|
||||
if (firedPorts.length > 0) return clip(`已发口:${firedPorts.join('、')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export type InstanceQuery = {
|
||||
readonly id?: string | undefined;
|
||||
readonly ownerId?: string | undefined;
|
||||
readonly state?: InstanceState | undefined;
|
||||
readonly startedFrom?: string | undefined;
|
||||
readonly startedTo?: string | undefined;
|
||||
};
|
||||
|
||||
function dayBound(day: string, end: boolean): number | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
||||
if (m === null) return undefined;
|
||||
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
// 服务进程的本地时区:单时区部署;「含两端整日」
|
||||
return end ? new Date(y, mo - 1, d, 23, 59, 59, 999).getTime() : new Date(y, mo - 1, d, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
/** 四条件 AND;`id` 模糊对实例号与任务 id 的子串(不分大小写) */
|
||||
export function applyInstanceFilters(items: readonly InstanceSummary[], q: InstanceQuery): InstanceSummary[] {
|
||||
const needle = q.id?.trim().toLowerCase() ?? '';
|
||||
const from = q.startedFrom === undefined ? undefined : dayBound(q.startedFrom, false);
|
||||
const to = q.startedTo === undefined ? undefined : dayBound(q.startedTo, true);
|
||||
return items.filter((it) => {
|
||||
if (needle !== '' && !it.no.toLowerCase().includes(needle) && !it.id.toLowerCase().includes(needle)) return false;
|
||||
if (q.ownerId !== undefined && q.ownerId !== '' && it.owner.id !== q.ownerId) return false;
|
||||
if (q.state !== undefined && it.state !== q.state) return false;
|
||||
if (from !== undefined || to !== undefined) {
|
||||
const t = Date.parse(it.startedAt);
|
||||
if (!Number.isFinite(t)) return false;
|
||||
if (from !== undefined && t < from) return false;
|
||||
if (to !== undefined && t > to) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function statsOf(items: readonly InstanceSummary[]): InstanceStats {
|
||||
const byState = Object.fromEntries(INSTANCE_STATES.map((s) => [s, 0])) as Record<InstanceState, number>;
|
||||
for (const it of items) byState[it.state] += 1;
|
||||
return { total: items.length, byState };
|
||||
}
|
||||
|
||||
export function pageOf<T>(items: readonly T[], page: number, size: number): T[] {
|
||||
const start = (Math.max(1, page) - 1) * Math.max(1, size);
|
||||
return items.slice(start, start + Math.max(1, size));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* L2 沙箱视图 —— 活动包里名为 `activity-ledger` 的视图模版(关键台账数据看板:最近一次已放行实例的落账快照,
|
||||
* 行由基座按清单 `viewTemplates[].data` 的声明从正式表圈出,见 `view-scope.ts`)。
|
||||
*
|
||||
* 模版本身**无脚本**(安装静态审 `templateIsSafe` 拦着);而 iframe 是 `sandbox="allow-scripts"`
|
||||
* 无同源,父页量不到它的高 —— 报高的脚本由服务端在**出口那一刻**注入,协议只有一条消息:
|
||||
* `postMessage({ type: 'jcai:view-height', height }, '*')`(load + ResizeObserver + 1s 兜底)。
|
||||
*/
|
||||
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||
|
||||
export const LEDGER_TEMPLATE = 'activity-ledger';
|
||||
|
||||
export function heightScript(): string {
|
||||
return (
|
||||
`<script>(function(){var T=${JSON.stringify(ACTIVITY_VIEW_HEIGHT_MESSAGE)};` +
|
||||
'function h(){var b=document.body;return Math.max(document.documentElement.scrollHeight,b?b.scrollHeight:0)}' +
|
||||
"function post(){try{window.parent.postMessage({type:T,height:h()},'*')}catch(e){}}" +
|
||||
"window.addEventListener('load',post);" +
|
||||
"if(typeof ResizeObserver!=='undefined'){new ResizeObserver(post).observe(document.documentElement)}" +
|
||||
'setInterval(post,1000);post()})();</script>'
|
||||
);
|
||||
}
|
||||
|
||||
/** 插在 `</body>` 之前(没有就追加在末尾);模版原文一个字不改 */
|
||||
export function injectHeightScript(html: string): string {
|
||||
const script = heightScript();
|
||||
const at = html.toLowerCase().lastIndexOf('</body>');
|
||||
return at < 0 ? `${html}\n${script}` : `${html.slice(0, at)}${script}${html.slice(at)}`;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 沙箱看板的取数与作用域(零 IO;读表经注入)—— 执行清单 `viewTemplates[].data` 那份声明。
|
||||
*
|
||||
* 分工:模版归活动、「哪几行是这一单的」归活动声明(案号前缀、哪张表按哪一列圈),这里只做两件机械的事:
|
||||
* ① `resolveViewData`:按声明逐条求集合 —— `by.column ∈ values(by.from)`,`from` 只能指 `case`
|
||||
* (案号本身)或前面已求出的 `<as>.<列>`;每个值单独查一次(正式表里历次运行的行并排躺着,
|
||||
* `where` 只有等值),结果按 `from` 值的顺序拼接,再按 `not` 排除;
|
||||
* ② `viewScopeOf`:把行集摊成渲染器要的扁平作用域 —— `<as>` 行数组、`<as>.<列>` 首行标量、
|
||||
* `<as 去符号>_rows` 行数、`<as 去符号>_<列>_<值>` tally 计数(先铺 0,与引擎读数同一套命名习惯)、
|
||||
* `case:*` 实例事实、`board:state` 三态(模版拿它亮横幅 / 藏区块)。
|
||||
*
|
||||
* 表未物化 / 查询失败 → 那个集合为空并记在 `skipped`(看板不因一张表 500);案号那一行查无 → `caseFound=false`
|
||||
* (多半是起单时关了「落真库」,主数据只留在活动缓存),看板如实说「没落账」而不是摆一屏空表装没事。
|
||||
*/
|
||||
import type { ViewData, ViewDataRows } from '@contracts';
|
||||
import { VIEW_DATA_CASE_REF } from '@contracts';
|
||||
|
||||
export type ViewRow = Readonly<Record<string, unknown>>;
|
||||
export type ViewAddress = { readonly domain: string; readonly object: string };
|
||||
export type ViewOrder = ReadonlyArray<{ readonly field: string; readonly dir: 'asc' | 'desc' }>;
|
||||
/** 读一张正式表(等值过滤);表读不了回 undefined —— 由调用方决定怎么说 */
|
||||
export type ViewRead = (address: ViewAddress, where: Readonly<Record<string, string>>, orderBy: ViewOrder) => Promise<readonly ViewRow[] | undefined>;
|
||||
|
||||
export type ViewSets = Readonly<Record<string, readonly ViewRow[]>>;
|
||||
export type ResolvedViewData = { readonly sets: ViewSets; readonly caseFound: boolean; readonly skipped: readonly string[] };
|
||||
|
||||
export type BoardState = 'ready' | 'missing' | 'none';
|
||||
export const BOARD_STATE_KEY = 'board:state';
|
||||
|
||||
export type DeclaredLedger = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }>;
|
||||
};
|
||||
|
||||
export type ViewInstance = {
|
||||
readonly no: string;
|
||||
readonly startedAt: string;
|
||||
readonly finishedAt: string | null;
|
||||
readonly owner: string;
|
||||
};
|
||||
|
||||
export function addressOf(ledger: string): ViewAddress | undefined {
|
||||
const at = ledger.indexOf('.');
|
||||
if (at <= 0 || at === ledger.length - 1) return undefined;
|
||||
return { domain: ledger.slice(0, at), object: ledger.slice(at + 1) };
|
||||
}
|
||||
|
||||
/** `<as>.<列>` 从最后一个点拆(列名是标识符,不含点;`as` 本身可以带点) */
|
||||
function refOf(from: string): { as: string; column: string } | undefined {
|
||||
const at = from.lastIndexOf('.');
|
||||
if (at <= 0 || at === from.length - 1) return undefined;
|
||||
return { as: from.slice(0, at), column: from.slice(at + 1) };
|
||||
}
|
||||
|
||||
/** 一列的值集:去空、转字符串、去重、保序 */
|
||||
function valuesOf(rows: readonly ViewRow[], column: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const row of rows) {
|
||||
const raw = row[column];
|
||||
if (raw === undefined || raw === null || raw === '') continue;
|
||||
const text = String(raw);
|
||||
if (seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceValues(from: string, caseRef: string, sets: Record<string, readonly ViewRow[]>): string[] | undefined {
|
||||
if (from === VIEW_DATA_CASE_REF) return [caseRef];
|
||||
const ref = refOf(from);
|
||||
if (ref === undefined) return undefined;
|
||||
const rows = sets[ref.as];
|
||||
return rows === undefined ? undefined : valuesOf(rows, ref.column);
|
||||
}
|
||||
|
||||
export async function resolveViewData(spec: ViewData, io: { readonly caseRef: string; readonly read: ViewRead }): Promise<ResolvedViewData> {
|
||||
const sets: Record<string, readonly ViewRow[]> = {};
|
||||
const skipped: string[] = [];
|
||||
const caseAddress = addressOf(spec.case.ledger);
|
||||
const caseRows = caseAddress === undefined ? undefined : await io.read(caseAddress, { [spec.case.key]: io.caseRef }, []);
|
||||
const caseFound = (caseRows?.length ?? 0) > 0;
|
||||
if (caseRows === undefined) skipped.push(spec.case.ledger);
|
||||
for (const entry of spec.rows) {
|
||||
sets[entry.as] = await resolveEntry(entry, io.caseRef, sets, io.read, skipped);
|
||||
}
|
||||
return { sets, caseFound, skipped };
|
||||
}
|
||||
|
||||
async function resolveEntry(entry: ViewDataRows, caseRef: string, sets: Record<string, readonly ViewRow[]>, read: ViewRead, skipped: string[]): Promise<readonly ViewRow[]> {
|
||||
const address = addressOf(entry.ledger);
|
||||
const values = sourceValues(entry.by.from, caseRef, sets);
|
||||
if (address === undefined || values === undefined) {
|
||||
skipped.push(entry.as);
|
||||
return [];
|
||||
}
|
||||
if (values.length === 0) return [];
|
||||
const orderBy = entry.orderBy ?? [];
|
||||
const out: ViewRow[] = [];
|
||||
for (const value of values) {
|
||||
const rows = await read(address, { [entry.by.column]: value }, orderBy);
|
||||
if (rows === undefined) {
|
||||
skipped.push(entry.as);
|
||||
return [];
|
||||
}
|
||||
out.push(...rows);
|
||||
}
|
||||
if (entry.not === undefined) return out;
|
||||
const banned = new Set(sourceValues(entry.not.from, caseRef, sets) ?? []);
|
||||
const notColumn = entry.not.column;
|
||||
return out.filter((row) => {
|
||||
const raw = row[notColumn];
|
||||
return raw === undefined || raw === null || !banned.has(String(raw));
|
||||
});
|
||||
}
|
||||
|
||||
/** 与引擎读数同一套习惯:非字母数字一律下划线(`sub_eval.verdict` → `sub_eval_verdict`) */
|
||||
export function slugOf(as: string): string {
|
||||
return as.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||
}
|
||||
|
||||
/** 屏上摆的时刻:本地、到分、带年(看板隔着日子看,「08-27 21:51」不够) */
|
||||
export function localMinute(iso: string | null): string {
|
||||
if (iso === null) return '';
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return '';
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function viewScopeOf(input: {
|
||||
readonly spec: ViewData | undefined;
|
||||
readonly sets: ViewSets;
|
||||
readonly declared: ReadonlyArray<DeclaredLedger>;
|
||||
readonly instance?: ViewInstance | undefined;
|
||||
readonly caseRef?: string | undefined;
|
||||
readonly state: BoardState;
|
||||
}): Record<string, unknown> {
|
||||
const scope: Record<string, unknown> = {
|
||||
[BOARD_STATE_KEY]: input.state,
|
||||
'case:instanceNo': input.instance?.no ?? '',
|
||||
'case:openedAt': input.instance === undefined ? '' : localMinute(input.instance.startedAt),
|
||||
'case:releasedAt': input.instance === undefined ? '' : localMinute(input.instance.finishedAt),
|
||||
'case:owner': input.instance?.owner ?? '',
|
||||
'case:ref': input.caseRef ?? '',
|
||||
};
|
||||
for (const entry of input.spec?.rows ?? []) {
|
||||
const rows = input.sets[entry.as] ?? [];
|
||||
const slug = slugOf(entry.as);
|
||||
scope[entry.as] = rows;
|
||||
scope[`${slug}_rows`] = rows.length;
|
||||
const first = rows[0];
|
||||
if (first !== undefined) for (const [column, value] of Object.entries(first)) scope[`${entry.as}.${column}`] = value;
|
||||
const address = addressOf(entry.ledger);
|
||||
const declaration = address === undefined ? undefined : input.declared.find((d) => d.domain === address.domain && d.object === address.object);
|
||||
for (const field of declaration?.fields ?? []) {
|
||||
if (field.tally === undefined) continue;
|
||||
for (const value of field.tally) scope[`${slug}_${field.name}_${slugOf(value)}`] = 0;
|
||||
for (const row of rows) {
|
||||
const raw = row[field.name];
|
||||
if (raw === undefined || raw === null || raw === '') continue;
|
||||
const key = `${slug}_${field.name}_${slugOf(String(raw))}`;
|
||||
scope[key] = Number(scope[key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
* 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。
|
||||
*
|
||||
* 一单一行一份,重复放行覆盖(不留两份说法不一的档)。
|
||||
*
|
||||
* **「这一单产出的」认的是本单碰过的行**,不是这张表里现有的所有行 ——
|
||||
* 见 `assetRowsOf` 的病根记档。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { TaskId } from '@contracts';
|
||||
@@ -35,6 +38,71 @@ function rowsOf<T>(result: unknown): T[] {
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
|
||||
/** 一张台账表的声明 —— 带 `file` 段的才产资料;这份声明同时是授权面 */
|
||||
export type AssetDeclaration = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
|
||||
readonly file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
|
||||
};
|
||||
|
||||
/** 本单碰过的一行(`ledger.taskDrafts()` 那一份的形状) */
|
||||
export type TouchedKey = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly key: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** 这一单产出的一份资料 —— 还没造成字节的那一步 */
|
||||
export type AssetRow = {
|
||||
readonly address: string;
|
||||
readonly key: Record<string, string>;
|
||||
readonly title: string;
|
||||
readonly format: FileFormat;
|
||||
readonly markdown: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 这一单**产出的**资料行 —— 存档与工作成果两处同一段,不各写一份。
|
||||
*
|
||||
* 「产出的」认的是**本单碰过的行**(草稿键),不是这张表里现有的所有行。
|
||||
* 病根记档(2026-08-25 柴浩实测:「工作成果怎么有 4 个资料,这单只会产出一个」):
|
||||
* 两处从前都拿 `taskView` 的整页,而那是「正式版全表 + 本单草稿」的合并视图 ——
|
||||
* 资料常落在**主数据**上(品牌内容库 `content.asset`,历次跑出来的资产都躺在里面),
|
||||
* 于是一单产 1 份、工作成果里摆 4 份:前三份是别单的成果,冒充这一单交出去的东西。
|
||||
* 台账契约本就把这两件事分得很清楚(`taskDrafts` 的注释:「引擎求值要区分
|
||||
* 『这一单的事实』与『别人放行后的事实』」),视图模版也一律绑 `mine:` ——
|
||||
* 只有资料这两处忘了。
|
||||
*
|
||||
* 值仍取合并视图那一份(正式现值 + 本单草稿叠加):改过一格的行要按
|
||||
* 「落库后的样子」造文件,只拿草稿会缺掉这轮没碰过的那几列(与 `touched:` 同一道理)。
|
||||
*/
|
||||
export function assetRowsOf(
|
||||
declared: AssetDeclaration,
|
||||
rows: ReadonlyArray<Record<string, unknown>>,
|
||||
touched: ReadonlyArray<TouchedKey>,
|
||||
): AssetRow[] {
|
||||
const spec = declared.file;
|
||||
if (spec === undefined) return []; // 没声明 `file` 段的表不产资料 —— 存档与列表都认这道授权面
|
||||
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
|
||||
if (keys.length === 0) return [];
|
||||
const mine = touched.filter((t) => t.domain === declared.domain && t.object === declared.object);
|
||||
const address = `${declared.domain}.${declared.object}`;
|
||||
const out: AssetRow[] = [];
|
||||
for (const row of rows) {
|
||||
const key: Record<string, string> = {};
|
||||
for (const k of keys) key[k] = String(row[k] ?? '');
|
||||
if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回
|
||||
if (!mine.some((t) => keys.every((k) => String(t.key[k] ?? '') === key[k]))) continue; // 别单的成果不算这一单的
|
||||
const markdown = String(row[spec.bodyColumn] ?? '').trim();
|
||||
if (markdown === '') continue; // 没正文的行不是资料,别摆一份空壳
|
||||
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
|
||||
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
|
||||
out.push({ address, key, title, format: isFileFormat(rawFormat) ? rawFormat : 'md', markdown });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主键 → 一个**稳定的**字符串(按列名排序后拼)。
|
||||
* 稳定是要害:存的时候与取的时候拼法不一样,那一行就永远对不上。
|
||||
@@ -111,7 +179,7 @@ export async function readArchived(
|
||||
* 把这一单产出的资料**全部存一份**。放行落地之前调 —— 那时草稿还在、人的修订也已落回,
|
||||
* 存下来的正是他签字那一刻的样子。
|
||||
*
|
||||
* 返回存了几份;一份都没有(活动没声明 `file` 段、或一行都没写)时返回 0,不报错 ——
|
||||
* 返回存了几份;一份都没有(活动没声明 `file` 段、或这一单一行也没写)时返回 0,不报错 ——
|
||||
* 大多数活动本来就不产资料文件。
|
||||
*/
|
||||
export async function archiveAssetFiles(input: {
|
||||
@@ -119,40 +187,25 @@ export async function archiveAssetFiles(input: {
|
||||
readonly taskId: TaskId;
|
||||
readonly ids: { next(): string };
|
||||
/** 活动清单声明的台账对象(带 `file` 段的才算) */
|
||||
readonly declarations: ReadonlyArray<{
|
||||
domain: string;
|
||||
object: string;
|
||||
fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
|
||||
file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
|
||||
}>;
|
||||
/** 本单在该地址写下的行(正式版 + 本单草稿) */
|
||||
readonly declarations: ReadonlyArray<AssetDeclaration>;
|
||||
/**
|
||||
* **本单碰过的行** —— `ledger.taskDrafts()` 那一份。
|
||||
* 「哪几行是这一单产出的」只有这一个答案:表里现有的行不作数(见 `assetRowsOf`)。
|
||||
*/
|
||||
readonly touched: ReadonlyArray<TouchedKey>;
|
||||
/** 该地址的合并行(正式现值 + 本单草稿)—— 值按「落库后的样子」造文件 */
|
||||
readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise<Array<Record<string, unknown>>>;
|
||||
}): Promise<number> {
|
||||
let saved = 0;
|
||||
for (const declared of input.declarations) {
|
||||
const spec = declared.file;
|
||||
if (spec === undefined) continue;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
|
||||
if (keys.length === 0) continue;
|
||||
|
||||
if (declared.file === undefined) continue; // 整张表不产资料,连取行都免了
|
||||
const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object });
|
||||
for (const row of rows) {
|
||||
const markdown = String(row[spec.bodyColumn] ?? '').trim();
|
||||
if (markdown === '') continue; // 没正文的行不是资料,别存一份空壳
|
||||
const key: Record<string, string> = {};
|
||||
for (const k of keys) key[k] = String(row[k] ?? '');
|
||||
if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回
|
||||
|
||||
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
|
||||
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
|
||||
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
|
||||
const composed = composeFile({ title, markdown, format });
|
||||
const address = `${declared.domain}.${declared.object}`;
|
||||
|
||||
for (const asset of assetRowsOf(declared, rows, input.touched)) {
|
||||
const composed = composeFile({ title: asset.title, markdown: asset.markdown, format: asset.format });
|
||||
await input.db.execute(sql`
|
||||
insert into sys.task_asset_file (id, task_id, address, row_key, title, format, name, bytes, size)
|
||||
values (${input.ids.next()}, ${input.taskId}, ${address}, ${JSON.stringify(key)}::jsonb,
|
||||
${title}, ${format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
|
||||
values (${input.ids.next()}, ${input.taskId}, ${asset.address}, ${JSON.stringify(asset.key)}::jsonb,
|
||||
${asset.title}, ${asset.format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
|
||||
on conflict (task_id, address, row_key) do update set
|
||||
title = excluded.title, format = excluded.format, name = excluded.name,
|
||||
bytes = excluded.bytes, size = excluded.size, created_at = now()`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user