Archived
Compare commits
27
Commits
d133d6c2a8
...
3171d8a864
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3171d8a864 | ||
|
|
8556432dba | ||
|
|
bd86dd6088 | ||
|
|
6b7bf82df3 | ||
|
|
d67f744f59 | ||
|
|
6a69acfc69 | ||
|
|
6f7513aa58 | ||
|
|
182d3c39b5 | ||
|
|
df18882322 | ||
|
|
e235207e4a | ||
|
|
eb80876554 | ||
|
|
7ea08fb16a | ||
|
|
c121349dea | ||
|
|
c6b02dfd39 | ||
|
|
02ef572470 | ||
|
|
00133c2ca0 | ||
|
|
c3ab236f70 | ||
|
|
148d16a1b7 | ||
|
|
e1b04f01aa | ||
|
|
7d743635eb | ||
|
|
ec3de1f19e | ||
|
|
0629a7f054 | ||
|
|
f9dbdc8c9c | ||
|
|
f7fe8e3836 | ||
|
|
13183b9287 | ||
|
|
f52f0168df | ||
|
|
63034f5ab2 |
@@ -21,3 +21,6 @@ coverage/
|
||||
|
||||
# SDD 工作区(ledger / brief / 评审包)—— 记录归 git 历史,过程件不入库
|
||||
.superpowers/
|
||||
|
||||
# 稳定版快照与运行态(./stable.sh 写的)
|
||||
.stable/
|
||||
|
||||
@@ -1324,6 +1324,69 @@
|
||||
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)。
|
||||
|
||||
- **第三个活动 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` 仍是正路)。
|
||||
|
||||
## 文档权威地图
|
||||
|
||||
@@ -1337,6 +1400,8 @@
|
||||
| | `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` | **ACT-WORK-05 差异评估的走查账**:六轮逐轮结论 + A–K 十一条发现总表(现象 / 病根 / 治法 / 层) |
|
||||
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 的十张台账 / 110 列 / 人填列 27 —— 建活动时照它对字段 |
|
||||
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
||||
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
||||
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
|
||||
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与授权面对齐,结构照 E04 / I01。
|
||||
*
|
||||
* 七组:清单本身合规 / 台账声明 / 案例模型与清单的对齐 / 报告模版:编辑面与只读面各就各位 /
|
||||
* 徽标别名与值域相关的坑(2026-08-27 实测账,回归网)/ 可编辑件的主键与必填列
|
||||
* (2026-08-27 复审,回归网)/ 活动入口。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { createMockHost } from '@sdk';
|
||||
import { manifest, D, RULE_VERSION } from '../src/manifest.ts';
|
||||
import activity from '../src/index.ts';
|
||||
|
||||
const ITEMS = ['S0_bind', 'S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S2_normalize', 'S3_assess', 'S4_review', 'S5_prepare_landing', 'T_release'];
|
||||
const NO_DONE_WHEN = new Set(['S0d_p08_master', 'S0d_p10_document', 'S1_input_confirm', 'S4_review', 'T_release']);
|
||||
const VIEWS = ['p08-master-create', 'p10-document-create', 'eval-input-list', 'difference-review', 'landing-confirm'];
|
||||
const byAddress = (domain: string, object: string) => manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object);
|
||||
const viewHtml = (name: string) => manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||
|
||||
describe('清单本身合规', () => {
|
||||
it('过 @contracts 的 schema', () => {
|
||||
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
|
||||
});
|
||||
|
||||
it('没有静态监听(等外部回音一律落在执行工单上,listen 在本仓不可用)', () => {
|
||||
expect(manifest.declarations.listeners).toEqual([]);
|
||||
});
|
||||
|
||||
it('L1/L2 判据都给了谓词;L4 三条不给(裁量项);L5 恰一条', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate, c.key).toBeDefined();
|
||||
if (c.level === 'L4') expect(c.predicate, c.key).toBeUndefined();
|
||||
}
|
||||
expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4')).toHaveLength(3);
|
||||
expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('机检谓词只读基座读数与记账旗:不出现活动自报的汇总列名', () => {
|
||||
const forbidden = ['relation_total', 'candidate_total', 'recommended_total', 'not_recommended_total'];
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
for (const name of forbidden) expect(c.predicate ?? '', `${c.key} 读了自报数 ${name}`).not.toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('三件必交付物都有台账地址;人环预算严格大于「基线 8 + 两轮审核循环 4」', () => {
|
||||
expect(manifest.contract.requiredDeliverables).toHaveLength(3);
|
||||
for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined();
|
||||
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(12);
|
||||
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(12);
|
||||
});
|
||||
|
||||
it('起单只必填 4 项(客户 / 需求 / 原 PN / 候选数组);datasheet 是选填文件', () => {
|
||||
expect(manifest.contract.input.data['required']).toEqual(['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
|
||||
const properties = manifest.contract.input.data['properties'] as Record<string, { type?: string; format?: string }>;
|
||||
expect(properties['candidateInternalPns']?.type).toBe('array');
|
||||
expect(properties['datasheets']?.format).toBe('file');
|
||||
});
|
||||
|
||||
it('五条样例:第一条满载走数据工单那条路,名字 ≤14 字,形状各不相同', () => {
|
||||
expect(manifest.samples).toHaveLength(5);
|
||||
for (const s of manifest.samples) expect(s.name.length, s.name).toBeLessThanOrEqual(14);
|
||||
expect(new Set(manifest.samples.map((s) => JSON.stringify(s.input))).size).toBe(5);
|
||||
const first = manifest.samples[0]?.input as { candidateInternalPns?: string[] };
|
||||
expect(first.candidateInternalPns).toContain('XL2596S-5.0');
|
||||
const newCustomer = manifest.samples.find((s) => (s.input as { customerName?: string }).customerName !== undefined);
|
||||
expect(newCustomer).toBeDefined();
|
||||
});
|
||||
|
||||
it('五张视图模版都声明了,剧本每一步引用的 view 都在其中', () => {
|
||||
expect(new Set(manifest.assets.viewTemplates.map((v) => v.name))).toEqual(new Set(VIEWS));
|
||||
for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
|
||||
if (step.view !== undefined) expect(VIEWS, step.view).toContain(step.view);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('台账声明:授权面与《统一方案》口径', () => {
|
||||
it('P-08 主键 internal_pn,标准化参数集拆成独立子表 parts.parameter(2026-08-27 结构改:与目标件一致)', () => {
|
||||
const p08 = byAddress('parts', 'profile');
|
||||
expect(p08?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn']);
|
||||
expect(p08?.fields.find((f) => f.name === 'normalized_parameter_set')).toBeUndefined();
|
||||
const param = byAddress('parts', 'parameter');
|
||||
expect(param?.kind).toBe('master');
|
||||
expect(param?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn', 'param_name']);
|
||||
expect(param?.fields.find((f) => f.name === 'internal_pn')?.refersTo).toBe('parts.profile.internal_pn');
|
||||
/*
|
||||
* 参数名自指引用:只能用台账里已经在用的那几个 —— 2026-08-27 实测三次,
|
||||
* 模型给新料写英文 key,剧本写过两遍 + 一条乘法对账都拦不住「只建一颗」那一路。
|
||||
*/
|
||||
const pn = param?.fields.find((f) => f.name === 'param_name');
|
||||
expect(pn?.refersTo).toBe('parts.parameter.param_name');
|
||||
expect(pn?.distinct).toBe(true);
|
||||
expect(param?.fields.find((f) => f.name === 'param_value')?.required).toBe(true);
|
||||
expect(param?.fields.find((f) => f.name === 'constraint_type')?.tally).toEqual(['hard', 'soft']);
|
||||
expect(param?.fields.find((f) => f.name === 'constraint_type')?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('P-11 主记录的原 PN / 候选 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门)', () => {
|
||||
const rel = byAddress('substitution', 'relation');
|
||||
for (const name of ['original_internal_pn', 'candidate_internal_pn']) {
|
||||
expect(rel?.fields.find((f) => f.name === name)?.refersTo, name).toBe('parts.profile.internal_pn');
|
||||
}
|
||||
});
|
||||
|
||||
it('客户批准状态**不在声明里**:本活动没有它的写权(只读边界)', () => {
|
||||
const rel = byAddress('substitution', 'relation');
|
||||
expect(rel?.fields.map((f) => f.name)).not.toContain('customer_approval_status');
|
||||
});
|
||||
|
||||
it('UNKNOWN 必有去向:unknown_disposition 钉在 difference_status = unknown 的行上;四态是 ASCII tally', () => {
|
||||
const item = byAddress('substitution', 'difference_item');
|
||||
expect(item?.fields.find((f) => f.name === 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' });
|
||||
expect(item?.fields.find((f) => f.name === 'difference_status')?.tally).toEqual(['match', 'mismatch', 'unknown', 'conditional']);
|
||||
expect(item?.fields.find((f) => f.name === 'substitution_relation_id')?.distinct).toBe(true);
|
||||
});
|
||||
|
||||
it('P-01 回写五项钉在 current_stage = gate03 那一刻(放行前不拦,放行门 L2 数得出来)', () => {
|
||||
const p01 = byAddress('demand', 'request');
|
||||
for (const name of ['risk_level', 'verification_requirement', 'output_ref', 'next_action', 'current_activity']) {
|
||||
expect(p01?.fields.find((f) => f.name === name)?.requiredWhen, name).toEqual({ column: 'current_stage', value: 'gate03' });
|
||||
}
|
||||
});
|
||||
|
||||
it('自持单据都带 caseKey eval_ref 且在主键里;放弃候选必须写原因', () => {
|
||||
for (const object of ['pn_check', 'input_gap', 'verdict']) {
|
||||
const t = byAddress(D, object);
|
||||
expect(t?.kind, object).toBe('record');
|
||||
expect(t?.caseKey, object).toBe('eval_ref');
|
||||
expect(t?.fields.find((f) => f.name === 'eval_ref')?.primaryKey, object).toBe(true);
|
||||
}
|
||||
expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' });
|
||||
});
|
||||
|
||||
it('判「在库」必须填得出引用依据,基座拿它去主数据里查(2026-08-27 实测:原 PN 不在库而模型把两颗都写成 found,硬阻塞那条路整个被跳过)', () => {
|
||||
const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref');
|
||||
expect(ref?.refersTo).toBe('parts.profile.internal_pn');
|
||||
expect(ref?.requiredWhen).toEqual({ column: 'exists_status', value: 'found' });
|
||||
expect(ref?.primaryKey ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it('规则版本常量随包冻结', () => {
|
||||
expect(RULE_VERSION).toBe('R-2026.07');
|
||||
expect(manifest.assets.businessModel.dmn.tables.map((t) => t.id)).toEqual(['d01-difference-status', 'd02-recommendation']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('案例模型与清单的对齐', () => {
|
||||
it('九个计划项,剧本 item 与 CMMN humanTask id 一一对应', () => {
|
||||
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
expect(cmmnIds).toEqual(ITEMS);
|
||||
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item)).toEqual(ITEMS);
|
||||
});
|
||||
|
||||
it('五处不给 doneWhen(两张数据工单 / 输入确认 / 审核 / 放行),其余四处都给', () => {
|
||||
for (const s of manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>) {
|
||||
if (NO_DONE_WHEN.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined();
|
||||
else expect(s.doneWhen, s.item).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('两张数据工单件各自绑自己的 view(P-08 与 P-10 不共用同一份画面)', () => {
|
||||
const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>;
|
||||
expect(steps.find((s) => s.item === 'S0d_p08_master')?.view).toBe('p08-master-create');
|
||||
expect(steps.find((s) => s.item === 'S0d_p10_document')?.view).toBe('p10-document-create');
|
||||
});
|
||||
|
||||
it('那五项在 CMMN 里都标了人工激活', () => {
|
||||
for (const id of NO_DONE_WHEN) {
|
||||
const block = new RegExp(`<humanTask id="${id}"[\\s\\S]*?</humanTask>`).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? '';
|
||||
expect(block, id).toContain('manualActivationRule');
|
||||
}
|
||||
});
|
||||
|
||||
it('除放行外每一项恰好属于一个阶段;5 段 + 放行 = x/6,与文档 0.5/1/2/3/4/5 六步对齐', () => {
|
||||
const phases = manifest.assets.businessModel.phases ?? [];
|
||||
expect(phases).toHaveLength(5);
|
||||
const phaseItems = phases.flatMap((p) => p.items);
|
||||
expect([...phaseItems].sort()).toEqual([...ITEMS.filter((i) => i !== 'T_release')].sort());
|
||||
expect(new Set(phaseItems).size).toBe(phaseItems.length);
|
||||
});
|
||||
|
||||
it('说明书点名的计划项都真实存在', () => {
|
||||
const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d[a-z]?_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? '');
|
||||
for (const name of mentioned) expect(ITEMS, `说明书提到但不存在的计划项: ${name}`).toContain(name);
|
||||
});
|
||||
|
||||
it('两种决策卡骨架都在说明书里:候选放弃/创建(create/abandon)与审核(approve/reject/custom:)', () => {
|
||||
const n = manifest.assets.businessModel.narrative;
|
||||
expect(n).toContain('"kind":"decide","itemId":"S0d_p08_master"');
|
||||
expect(n).toContain('"value":"create"');
|
||||
expect(n).toContain('"value":"abandon"');
|
||||
expect(n).toContain('"kind":"decide","itemId":"S4_review"');
|
||||
expect(n).toContain('"approve"');
|
||||
expect(n).toContain('"reject"');
|
||||
expect(n).toContain('"custom:"');
|
||||
});
|
||||
|
||||
it('T_release 剧本里 judge criterion 的原文逐字对得上 acceptanceCriteria 的 label', () => {
|
||||
const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find((s) => s.item === 'T_release');
|
||||
const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
expect(quoted).toHaveLength(3);
|
||||
const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label));
|
||||
for (const q of quoted) expect(labels.has(q), q).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('报告模版:编辑面与只读面各就各位', () => {
|
||||
it('P-08 数据工单件的表 1 + 表 2(标准化参数子表)都可加行可编辑:data-edit / data-edit-add 与主键 data-col 都在', () => {
|
||||
const p08 = viewHtml('p08-master-create');
|
||||
expect(p08).toContain('data-edit="parts.profile"');
|
||||
expect(p08).toContain('data-edit-add="parts.profile"');
|
||||
expect(p08).toContain('data-col="internal_pn"');
|
||||
expect(p08).toContain('data-edit="parts.parameter"');
|
||||
expect(p08).toContain('data-edit-add="parts.parameter"');
|
||||
expect(p08).toContain('data-col="param_name"');
|
||||
expect(p08).toContain('data-col="param_value"');
|
||||
expect(p08).toContain('data-col="constraint_type"');
|
||||
const p10 = viewHtml('p10-document-create');
|
||||
expect(p10).toContain('data-edit="parts.document"');
|
||||
expect(p10).toContain('data-col="doc_seq"');
|
||||
});
|
||||
|
||||
it('评估输入清单:缺口表可编辑(supplement),绑定区只读', () => {
|
||||
const html = viewHtml('eval-input-list');
|
||||
expect(html).toContain(`data-edit="${D}.input_gap"`);
|
||||
expect(html).toContain('data-col="supplement"');
|
||||
expect(html).toContain(`data-rows="mine:${D}.pn_check"`);
|
||||
});
|
||||
|
||||
it('差异审核与落账确认只读:没有任何 data-edit / data-col', () => {
|
||||
for (const name of ['difference-review', 'landing-confirm']) {
|
||||
expect(viewHtml(name), name).not.toContain('data-edit=');
|
||||
expect(viewHtml(name), name).not.toContain('data-col=');
|
||||
}
|
||||
});
|
||||
|
||||
it('每个可编辑格子的 data-col 都在对应台账对象的声明里(p08-master-create 一屏两张表,两张台账对象的声明并起来看)', () => {
|
||||
const cases: Array<[string, Array<[string, string]>]> = [
|
||||
['p08-master-create', [['parts', 'profile'], ['parts', 'parameter']]],
|
||||
['p10-document-create', [['parts', 'document']]],
|
||||
['eval-input-list', [[D, 'input_gap']]],
|
||||
];
|
||||
for (const [view, objects] of cases) {
|
||||
const cols = new Set([...viewHtml(view).matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? ''));
|
||||
const declared = new Set(objects.flatMap(([domain, object]) => byAddress(domain, object)?.fields.map((f) => f.name) ?? []));
|
||||
for (const c of cols) expect(declared.has(c), `${view}: ${c}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('枚举列只用 data-class-of 投影,不摆 ASCII 原值', () => {
|
||||
expect(viewHtml('difference-review')).toContain('data-class-of="difference_status"');
|
||||
expect(viewHtml('difference-review')).toContain('data-class-of="recommendation_level"');
|
||||
expect(viewHtml('difference-review')).not.toContain('data-slot="difference_status"');
|
||||
const landing = viewHtml('landing-confirm');
|
||||
for (const col of ['request_status', 'current_stage', 'analysis_status', 'recommendation_level']) {
|
||||
expect(landing, col).toContain(`data-class-of="${col}"`);
|
||||
expect(landing, col).not.toContain(`data-slot="${col}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('宿主拒推含脚本的模版', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
|
||||
it('五张模版的外壳都是搬自目标件的 page/card,不再含 E04/I01 那套视觉底(.proto-root)的痕迹', () => {
|
||||
for (const view of VIEWS) {
|
||||
const html = viewHtml(view);
|
||||
expect(html, view).toContain('class="card-head"');
|
||||
expect(html, view).toContain('class="card-foot"');
|
||||
expect(html, view).not.toContain('proto-root');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-27 实测账,两条都真截图 + SQL 复核过:
|
||||
* ① **带下划线的枚举值一律显示成「待定」**——平台有两套互相打架的消毒规则:
|
||||
* `data-class-of` 投影出的 class 走 `view-render.ts` 的 `classToken`(只留 `[a-z0-9-]`,
|
||||
* 吃掉下划线,`pin_to_pin` → `v-pintopin`);基座读数名走 `engines.ts` 的 `slug`
|
||||
* (只留 `[a-z0-9_]`,吃掉连字符)。手写 `.v-pin_to_pin` 那种选择器永远匹配不上 ——
|
||||
* 这条测试把 `manifest.declarations.ledger` 里每一个「模版真的摆了徽标」的 tally 列过一遍,
|
||||
* 用与 `classToken` 一字不差的 `vtok` 算出选择器,断言对应的 CSS 规则确实存在。
|
||||
* ② **缺口表补充内容每一行都摆红「必填」,而闸只认 3 行**——`requiredWhen` 是按行不按列,
|
||||
* 静态 `class="req"` 把整列都标了必填。
|
||||
*/
|
||||
describe('徽标别名与值域相关的坑(2026-08-27 实测账,回归网)', () => {
|
||||
/** 必须与 `apps/api/src/modules/interaction/internal/domain/view-render.ts` 的 `classToken` 一字不差 */
|
||||
const vtok = (v: string): string => v.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
|
||||
|
||||
/** 每张模版真的摆了徽标的那几列(带下划线的 tally 值最容易漏,漏了就是「待定」那次事故) */
|
||||
const BADGED: ReadonlyArray<{ view: string; domain: string; object: string; column: string }> = [
|
||||
{ view: 'p08-master-create', domain: 'parts', object: 'profile', column: 'lifecycle_status' },
|
||||
{ view: 'p08-master-create', domain: 'parts', object: 'parameter', column: 'constraint_type' },
|
||||
{ view: 'p10-document-create', domain: 'parts', object: 'document', column: 'document_type' },
|
||||
{ view: 'p10-document-create', domain: 'parts', object: 'document', column: 'document_status' },
|
||||
{ view: 'eval-input-list', domain: D, object: 'pn_check', column: 'exists_status' },
|
||||
{ view: 'eval-input-list', domain: D, object: 'pn_check', column: 'role' },
|
||||
{ view: 'eval-input-list', domain: D, object: 'input_gap', column: 'status' },
|
||||
{ view: 'difference-review', domain: 'substitution', object: 'relation', column: 'substitution_type' },
|
||||
{ view: 'difference-review', domain: 'substitution', object: 'relation', column: 'recommendation_level' },
|
||||
{ view: 'difference-review', domain: 'substitution', object: 'relation', column: 'risk_level' },
|
||||
{ view: 'difference-review', domain: 'substitution', object: 'difference_item', column: 'constraint_type' },
|
||||
{ view: 'difference-review', domain: 'substitution', object: 'difference_item', column: 'difference_status' },
|
||||
{ view: 'difference-review', domain: 'substitution', object: 'difference_item', column: 'risk_level' },
|
||||
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'substitution_type' },
|
||||
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'recommendation_level' },
|
||||
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'analysis_status' },
|
||||
{ view: 'landing-confirm', domain: 'substitution', object: 'relation', column: 'risk_level' },
|
||||
{ view: 'landing-confirm', domain: 'demand', object: 'request', column: 'request_status' },
|
||||
];
|
||||
|
||||
it('每张模版摆了徽标的每个 tally 列,声明里的每个值都有对应的 v-<token> 规则(选择器是从值算出来的,不是手写的)', () => {
|
||||
for (const { view, domain, object, column } of BADGED) {
|
||||
const tally = byAddress(domain, object)?.fields.find((f) => f.name === column)?.tally ?? [];
|
||||
expect(tally.length, `${domain}.${object}.${column} 没有声明 tally`).toBeGreaterThan(0);
|
||||
const html = viewHtml(view);
|
||||
for (const value of tally) {
|
||||
expect(html, `${view}: ${domain}.${object}.${column}=${value} → 应有 .v-${vtok(value)}::after`).toContain(`.v-${vtok(value)}::after`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('缺口表补充内容按行投影必填,不是整列静态 req(requiredWhen 是按行的,不是按列的)', () => {
|
||||
const html = viewHtml('eval-input-list');
|
||||
expect(html).not.toContain('class="req" data-h="补充内容"');
|
||||
expect(html).toContain('data-class-of="status"');
|
||||
expect(html).toContain('.sup.v-gap');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-27 定向复审逮住的两条 Critical(都是这一轮版面重做新引入的回归,不是原有的坑):
|
||||
* ① `sub_eval.input_gap` 的「键」(`item_key`)那一格丢了 `data-col` —— 一单 11 行,`item_key`
|
||||
* 逐行不同,人保存**任何一行**时服务端 `resolveRowKey` 凑不出主键(它既不在件上、
|
||||
* 又不是「全表一致」的那种案号型列),整格落不下去;
|
||||
* ② `sub_eval.input_gap.status` 声明 `required: true`,件上又有「+ 加一行」——
|
||||
* 降成只读徽标后,人新加的行永远没有格子能把这一列填上,`_missing` 恒为正、
|
||||
* 提交闸永远拦着,这是「墙不配路」的死路形态。
|
||||
* 两条各配一张回归网,防的是同一类事故再出现在别的字段上。
|
||||
*/
|
||||
describe('可编辑件的主键与必填列(2026-08-27 复审:主键丢钥匙 / 必填填不了,两条 Critical 回归)', () => {
|
||||
/** 三张真能编辑的件与它们各自绑定的台账对象 */
|
||||
const EDITABLE: ReadonlyArray<{ view: string; domain: string; object: string }> = [
|
||||
{ view: 'p08-master-create', domain: 'parts', object: 'profile' },
|
||||
{ view: 'p08-master-create', domain: 'parts', object: 'parameter' },
|
||||
{ view: 'p10-document-create', domain: 'parts', object: 'document' },
|
||||
{ view: 'eval-input-list', domain: D, object: 'input_gap' },
|
||||
];
|
||||
|
||||
it('每一个主键列都有 data-col —— 它不是编辑面,是行的钥匙,丢了就整行保存不下去', () => {
|
||||
for (const { view, domain, object } of EDITABLE) {
|
||||
const table = byAddress(domain, object);
|
||||
/*
|
||||
* `caseKey` 声明的那一列(如 `eval_ref`)是例外:它是「一单一号,全表当然一致」的
|
||||
* 案号型主键(与 I01 的 `round_ref` 同款),件上压根不摆,服务端 `resolveRowKey`
|
||||
* 按「全表一致」自动补上(`apps/api/src/app/routes/desk.ts` 注释里点名的那条路)——
|
||||
* 摆出来给人填反而是错的(那会让人以为能按行改案号)。真正逐行不同、必须挂
|
||||
* `data-col` 的,是主键里除 caseKey 之外的那几列(如 `item_key`)。
|
||||
*/
|
||||
const pk = (table?.fields.filter((f) => f.primaryKey).map((f) => f.name) ?? []).filter((name) => name !== table?.caseKey);
|
||||
expect(pk.length, `${domain}.${object} 没有非 caseKey 的主键列`).toBeGreaterThan(0);
|
||||
const html = viewHtml(view);
|
||||
for (const col of pk) {
|
||||
expect(html, `${view}: 主键列 ${domain}.${object}.${col} 缺 data-col`).toContain(`data-col="${col}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('可编辑件上,每一个 required:true 的列都有真能填的控件(data-col)—— 必填但填不了是墙不配路', () => {
|
||||
for (const { view, domain, object } of EDITABLE) {
|
||||
const table = byAddress(domain, object);
|
||||
const required = table?.fields.filter((f) => f.required === true).map((f) => f.name) ?? [];
|
||||
expect(required.length, `${domain}.${object} 没有声明 required 列`).toBeGreaterThan(0);
|
||||
const html = viewHtml(view);
|
||||
for (const col of required) {
|
||||
expect(html, `${view}: 必填列 ${domain}.${object}.${col} 没有可填的控件`).toContain(`data-col="${col}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('活动入口', () => {
|
||||
it('没有 run —— 行为全在清单里', () => {
|
||||
expect(activity.run).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const CONTRACT = {
|
||||
activityId: 'part-substitution-assessment',
|
||||
version: '1.0.0',
|
||||
data: {},
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: ['assessment-released'], selective: [] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 步骤剧本与呈现面 —— 这段错了不报错,只是活动开始转圈 / 界面开始撒谎(与 E04 / I01 同一套惯例)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { ASSESSMENT_PHASES, ASSESSMENT_STEPS, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
|
||||
import { manifest, D } from '../src/manifest.ts';
|
||||
|
||||
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = ASSESSMENT_STEPS.find((s) => s.item === item)?.doneWhen;
|
||||
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
|
||||
return found;
|
||||
}
|
||||
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** 开局局面:入参 + 所有基座读数归零 */
|
||||
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
|
||||
...input,
|
||||
demand_request_rows: 0,
|
||||
demand_request_missing: 0,
|
||||
[`${D}_verdict_rows`]: 0,
|
||||
[`${D}_pn_check_rows`]: 0,
|
||||
[`${D}_pn_check_missing`]: 0,
|
||||
[`${D}_pn_check_exists_status_absent`]: 0,
|
||||
[`${D}_pn_check_exists_status_abandoned`]: 0,
|
||||
[`${D}_input_gap_rows`]: 0,
|
||||
[`${D}_input_gap_missing`]: 0,
|
||||
input_confirmed_total: 0,
|
||||
review_approved_total: 0,
|
||||
landing_prepared_total: 0,
|
||||
substitution_relation_rows: 0,
|
||||
substitution_relation_missing: 0,
|
||||
substitution_relation_analysis_status_fae_confirmed: 0,
|
||||
substitution_difference_item_rows: 0,
|
||||
substitution_difference_item_missing: 0,
|
||||
substitution_difference_item_distinct_substitution_relation_id: 0,
|
||||
substitution_difference_item_difference_status_match: 0,
|
||||
substitution_difference_item_difference_status_mismatch: 0,
|
||||
substitution_difference_item_difference_status_unknown: 0,
|
||||
substitution_difference_item_difference_status_conditional: 0,
|
||||
tool_part_substitution_assessment_compare_parameters_calls: 0,
|
||||
tool_part_substitution_assessment_recommend_calls: 0,
|
||||
});
|
||||
|
||||
describe('剧本与案例模型对得上', () => {
|
||||
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
|
||||
for (const step of ASSESSMENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
for (const id of ITEM_IDS) expect(ASSESSMENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本与这份阶段', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(ASSESSMENT_STEPS);
|
||||
expect(manifest.assets.businessModel.phases).toBe(ASSESSMENT_PHASES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本声明的读写面 ⊆ 清单声明', () => {
|
||||
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
|
||||
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
|
||||
it('steps[].writes / reads(台账那几个)全落在声明过的对象上', () => {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
for (const address of step.writes) expect(declaredAddresses.has(address), `${step.item} 要写 ${address}`).toBe(true);
|
||||
for (const address of step.reads.filter((r) => r.includes('.') && !r.startsWith('case:'))) {
|
||||
expect(declaredAddresses.has(address), `${step.item} 要读 ${address}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('steps[].tools 全部是声明过的工具;只有 S3 调工具', () => {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
|
||||
if (step.item !== 'S3_assess') expect(step.tools, step.item).toEqual([]);
|
||||
}
|
||||
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.tools).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('customer_approval_status 在任何剧本里都不许写(只读边界)', () => {
|
||||
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(/customer_approval_status\s*=/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen —— 开局不许真,干完必须真', () => {
|
||||
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
|
||||
for (const sample of manifest.samples) {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
expect(safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>)), `样例「${sample.name}」下 ${step.item} 开局就为真`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('S0:两颗候选 + 原 PN = 3 行核对齐、需求与结论都写了 → 收得掉;少写一颗 → 收不掉', () => {
|
||||
const base = { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'], demand_request_rows: 1, demand_request_missing: 0, [`${D}_verdict_rows`]: 1, [`${D}_pn_check_missing`]: 0 };
|
||||
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 3 })).toBe(true);
|
||||
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('S2:未放弃的候选各一条关系且每条都有明细 → 收得掉;放弃一颗后关系数随之减一', () => {
|
||||
const two = { candidateInternalPns: ['A', 'B'], [`${D}_pn_check_exists_status_abandoned`]: 0, substitution_difference_item_rows: 14 };
|
||||
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 2 })).toBe(true);
|
||||
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(false);
|
||||
expect(safeEval(doneOf('S2_normalize'), { ...two, [`${D}_pn_check_exists_status_abandoned`]: 1, substitution_relation_rows: 1, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('S3:四态之和 = 明细行数、必填齐、两枚工具都调过 → 收得掉;工具没调过 → 收不掉', () => {
|
||||
const ok = {
|
||||
substitution_difference_item_missing: 0,
|
||||
substitution_relation_missing: 0,
|
||||
substitution_difference_item_rows: 7,
|
||||
substitution_difference_item_difference_status_match: 4,
|
||||
substitution_difference_item_difference_status_mismatch: 1,
|
||||
substitution_difference_item_difference_status_unknown: 1,
|
||||
substitution_difference_item_difference_status_conditional: 1,
|
||||
tool_part_substitution_assessment_compare_parameters_calls: 1,
|
||||
tool_part_substitution_assessment_recommend_calls: 1,
|
||||
};
|
||||
expect(safeEval(doneOf('S3_assess'), ok)).toBe(true);
|
||||
expect(safeEval(doneOf('S3_assess'), { ...ok, tool_part_substitution_assessment_recommend_calls: 0 })).toBe(false);
|
||||
expect(safeEval(doneOf('S3_assess'), { ...ok, substitution_difference_item_difference_status_match: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it('S5:旗 + 全部关系 FAE 确认 + P-01 回写齐 → 收得掉;缺一头都收不掉', () => {
|
||||
const ok = { landing_prepared_total: 1, substitution_relation_analysis_status_fae_confirmed: 2, substitution_relation_rows: 2, substitution_relation_missing: 0, demand_request_missing: 0 };
|
||||
expect(safeEval(doneOf('S5_prepare_landing'), ok)).toBe(true);
|
||||
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, substitution_relation_analysis_status_fae_confirmed: 1 })).toBe(false);
|
||||
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, demand_request_missing: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('说明书与剧本的骨架', () => {
|
||||
it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => {
|
||||
expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"');
|
||||
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_p08_master"');
|
||||
expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)');
|
||||
expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)');
|
||||
expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认');
|
||||
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S4_review"');
|
||||
});
|
||||
|
||||
it('剧本里不引反例原话;S3 固定三拍且写明「工具后同拍作废」', () => {
|
||||
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
|
||||
expect(s3).toContain('第一拍');
|
||||
expect(s3).toContain('第二拍');
|
||||
expect(s3).toContain('第三拍');
|
||||
expect(s3).toContain('工具调用后同拍其余动作作废');
|
||||
});
|
||||
|
||||
it('S3 第三拍写盘前先自检两枚工具的调用次数(2026-08-27 实测:漏调 recommend 会让 doneWhen 恒假、模型死循环重写同样的行)', () => {
|
||||
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
|
||||
const thirdPass = s3.slice(s3.indexOf('第三拍'));
|
||||
expect(thirdPass).toContain('tool_part_substitution_assessment_recommend_calls');
|
||||
expect(thirdPass).toContain('tool_part_substitution_assessment_compare_parameters_calls');
|
||||
expect(thirdPass).toMatch(/自检|先.*找这两个数|必须\s*>\s*0/);
|
||||
// 第二拍那一段要点出「推荐等级只能由工具算,不调它收不了步」,别让模型自己拍推荐等级
|
||||
const secondPass = s3.slice(s3.indexOf('第二拍'), s3.indexOf('第三拍'));
|
||||
expect(secondPass).toContain('推荐等级只能由它算');
|
||||
expect(secondPass).toMatch(/收不了步|收不掉/);
|
||||
});
|
||||
|
||||
it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本按候选总数判断该不该问人(=1 必建、≥2 才问、原 PN 硬阻塞)', () => {
|
||||
const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||
expect(s0).toContain('当下可为');
|
||||
expect(s0).toContain('不要对计划项用');
|
||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(s0d).toContain('硬阻塞');
|
||||
expect(s0d).toContain('候选总数 = 1');
|
||||
});
|
||||
|
||||
it('S0d_p08_master 剧本按候选总数(不是「不在库的颗数」)判断要不要问人 —— 候选总数 ≥2 时逐颗不在库的都要问(2026-08-27 实测逼出的措辞修正)', () => {
|
||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(s0d).toContain('候选总数');
|
||||
expect(s0d).toContain('不在库');
|
||||
expect(s0d).toMatch(/候选总数\s*≥\s*2/);
|
||||
// 反面提示:「不在库的只有一颗」不该被读成跳过决策卡的理由
|
||||
expect(s0d).toContain('不是跳过决策卡的理由');
|
||||
});
|
||||
|
||||
it('s_bound 哨兵真的检查了「有料号不在库」这件事(都在库时门不该开;退回旧写法会红)', () => {
|
||||
expect(CMMN_XML).toContain('sub_eval_pn_check_exists_status_absent > 0');
|
||||
const sBoundBlock = /<sentry id="s_bound">[\s\S]*?<\/sentry>/.exec(CMMN_XML)?.[0] ?? '';
|
||||
expect(sBoundBlock).toContain('caseFileItemRef="sub_eval_pn_check_exists_status_absent"');
|
||||
expect(sBoundBlock).toContain('sub_eval_pn_check_exists_status_absent > 0');
|
||||
});
|
||||
|
||||
it('说明书与全部剧本里不会教模型对计划项调用 disable(允许出现在「不要 disable」这类禁止句里)', () => {
|
||||
// 只逮「disable + 计划项 id」这种祈使形(教模型真的去调用它);
|
||||
// 「不要…用 `disable`」「Disabled 只能从 Enabled 来」这类禁止句/状态名不含「disable + id」,不会被误伤。
|
||||
const disableCallPattern = /disable\s+(S\d[a-zA-Z_]*|T_release)/;
|
||||
expect(NARRATIVE).not.toMatch(disableCallPattern);
|
||||
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(disableCallPattern);
|
||||
});
|
||||
|
||||
it('S0d 拆成两项各自绑自己的 view(P-10 那份件不会永远推不出来),两项都没有 doneWhen', () => {
|
||||
const p08 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master');
|
||||
const p10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document');
|
||||
expect(p08?.view).toBe('p08-master-create');
|
||||
expect(p10?.view).toBe('p10-document-create');
|
||||
expect(p08?.doneWhen).toBeUndefined();
|
||||
expect(p10?.doneWhen).toBeUndefined();
|
||||
});
|
||||
|
||||
it('候选放弃到只剩最后一颗时不许再放弃(至少留一颗,防止全放弃死锁)', () => {
|
||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(s0d).toContain('至少留一颗候选');
|
||||
});
|
||||
|
||||
it('P-08 交回之后先 finish 再发 P-10 卡,顺序写死进两段 guide(2026-08-27 实测:漏收步会让工作区画面停在 P-08 那两张表,P-10 卡发出后人却看不见资料表、提交闸拦死)', () => {
|
||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(s0d).toContain('finish S0d_p08_master');
|
||||
expect(s0d).toMatch(/顺序不能反/);
|
||||
expect(s0d).toContain('p08-master-create');
|
||||
const s0d10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document')?.guide ?? '';
|
||||
expect(s0d10).toContain('finish S0d_p08_master');
|
||||
expect(s0d10).toMatch(/若它还开着|先.*finish/);
|
||||
});
|
||||
|
||||
it('「发卡前清单必须先有行」写在说明书里,不只在剧本里(2026-08-27 实测:模型把 P-10 收步与 S1 发卡压成一拍,那一拍 S1 剧本没摊开,卡发出去时 input_gap 一行都没有,人对着空表无从下手)', () => {
|
||||
expect(NARRATIVE).toContain('sub_eval.input_gap` 必须先有行');
|
||||
expect(NARRATIVE).toMatch(/压拍/);
|
||||
expect(NARRATIVE).toMatch(/顺序不能反/);
|
||||
});
|
||||
|
||||
it('存在性核对的剧本教的是「填得出依据才算在库」,不是「你看着办」(结构治,配 found_pn_ref 那道墙)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||
expect(g).toContain('found_pn_ref');
|
||||
expect(g).toMatch(/查无此值当场拒/);
|
||||
expect(g).toMatch(/名单里没有就是没有/);
|
||||
expect(g).toMatch(/把这一行改成 absent/);
|
||||
});
|
||||
|
||||
it('数据工单的名单只认 absent,「建哪几颗」与「要不要问人」分开写(2026-08-27 实测:原 PN 缺库那单,模型把在库的候选派成了数据工单,缺的那颗没人建)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/问题一 · 建哪几颗/);
|
||||
expect(g).toMatch(/问题二 · 建之前要不要先问人/);
|
||||
expect(g).toMatch(/exists_status = "found" 的一行都不建/);
|
||||
});
|
||||
|
||||
it('数据录入卡自带 basis,不让基座兜上内部家具(2026-08-27 实测:没声明 basis 的那张卡,参考信息栏摆的是「这一步 / 当下可为 / 目标缺口」加九个计划项全名)', () => {
|
||||
const i = NARRATIVE.indexOf('"kind":"entry"');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const block = NARRATIVE.slice(i, i + 900);
|
||||
expect(block).toContain('"basis"');
|
||||
expect(block).toContain('触发原因');
|
||||
});
|
||||
|
||||
it('三张执行工单各自声明 gateLedgers —— 零输入卡不声明,闸就盯着与它无关的表(2026-08-27 e2e 实测:两张数据工单空手交卷都放行了)', () => {
|
||||
for (const [item, ledger] of [
|
||||
['芯片主数据创建', '"gateLedgers":["parts.profile","parts.parameter"]'],
|
||||
['原厂技术资料创建', '"gateLedgers":["parts.document"]'],
|
||||
['评估输入与资料补齐确认', '"gateLedgers":["sub_eval.input_gap"]'],
|
||||
] as const) {
|
||||
expect(NARRATIVE, item).toContain(ledger);
|
||||
}
|
||||
});
|
||||
|
||||
it('数据工单也要「先写骨架再发卡」写进说明书,且哨兵不认零行主数据(2026-08-27 e2e:模型压拍没写骨架就发卡,人打开是空表,零行同时骗过闸与哨兵,一路走到 S2 才被引用检查拦下)', () => {
|
||||
// 两张数据工单各要一条(P-08 的 profile/parameter、P-10 的 document)
|
||||
expect(NARRATIVE.match(/必须先有骨架行/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
expect(NARRATIVE).toContain('parts.document` 必须先有骨架行');
|
||||
expect(NARRATIVE).toMatch(/压拍/);
|
||||
// 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了)
|
||||
expect(CMMN_XML).toContain('parts_profile_rows >= sub_eval_pn_check_exists_status_created');
|
||||
});
|
||||
|
||||
it('评估输入那张卡的**前提**写在说明书的卡骨架上(2026-08-27 e2e:两颗缺库,模型只问只建了第一颗,CN 还 absent 就把 S1 的卡发了出去 —— 发卡的授权面是调人预算,绕得过哨兵)', () => {
|
||||
expect(NARRATIVE).toContain('一颗 absent 都不剩');
|
||||
expect(NARRATIVE).toMatch(/有几颗 absent 就要办几颗/);
|
||||
});
|
||||
|
||||
it('发数据录入卡那一拍不许顺手写需求行(客户还没落草稿层,引用检查必拒)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||
expect(g).toMatch(/那一拍只发卡/);
|
||||
expect(g).toContain('引用检查');
|
||||
});
|
||||
|
||||
it('absent 没清零之前不许收掉数据工单那一项(2026-08-27 e2e:建完第一颗就 finish,再想给第二颗发卡撞上「这一步已经办完收掉了」,连拒四次)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/还有没有 absent 的料号/);
|
||||
expect(g).toMatch(/这一项千万别 finish/);
|
||||
expect(g).toMatch(/收掉了就回不去了/);
|
||||
});
|
||||
|
||||
it('参数名一律中文、与库里既有行同名(2026-08-27 e2e:模型写成 input_voltage_range,而原 PN 是「输入电压范围」—— 主键的一半对不上,27 条里 14 条判成 UNKNOWN)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/参数名一律用中文/);
|
||||
// 措辞写过两遍都没拦住 → 上结构:把「同一套名字」变成基座算得出的数
|
||||
expect(CMMN_XML).toContain('parts_parameter_rows = parts_parameter_distinct_param_name * sub_eval_pn_check_exists_status_created');
|
||||
expect(g).toContain('input_voltage_range');
|
||||
expect(g).toMatch(/不是同一行/);
|
||||
});
|
||||
|
||||
it('第二颗缺库的候选也要先问(每一颗都有被放弃的权利)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/这一颗也要先发一张决策卡问人/);
|
||||
expect(g).toMatch(/第一颗问过了不代表第二颗也问过/);
|
||||
});
|
||||
|
||||
it('created 认的是「卡交回来了」,不是「表里有行」—— 写骨架 ≠ 建好了(2026-08-27 e2e:模型铺完骨架就标 created、直接 finish,撞「从没给它发过卡」连拒五拍)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/写骨架 ≠ 建好了/);
|
||||
expect(g).toMatch(/写骨架 → \*\*发卡\*\* → 这一拍结束 → 人交回/);
|
||||
expect(g).not.toMatch(/只有它在 \\`parts\.profile\\` 里真有一行/);
|
||||
});
|
||||
|
||||
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
|
||||
expect(ASSESSMENT_PHASES).toHaveLength(5);
|
||||
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
|
||||
const all = ASSESSMENT_PHASES.flatMap((p) => p.items);
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "@activities/part-substitution-assessment", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
|
||||
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||
*
|
||||
* 业务出处:`docs/SS02-ACT-WORK-05-html/ACT-WORK-05-活动流程与工单说明.md`(步骤 0.5 + 5 步,
|
||||
* 1 数据(条件)+ 1 执行 + 2 验收)与《SS02-SS03-SS04_Demo台账字段细化与统一方案》(字段口径)。
|
||||
*
|
||||
* 文档六步收成 9 个案例模型项(0.5 那一步因 view 各不相同拆成两项 —— 引擎里剧本声明的
|
||||
* view 恒压过模型当拍报的 say.view,一个计划项只能绑一份画面,P-08 与 P-10 各自要一份可编辑的
|
||||
* 工作区件,不能共用同一个 view):
|
||||
* 启动期 → S0_bind(AI:铸案号、建 P-01 V1、锁规则版本、逐个 PN 查库、写存在性核对表)
|
||||
* 0.5a 等待数据(建主数据)→ S0d_p08_master(人:数据工单 P-08,件上表 1 主记录 + 表 2 标准化
|
||||
* 参数子表 `parts.parameter`;候选总数 ≥2 时缺库先决策卡;
|
||||
* 都在库时哨兵不开,这一项与下一项都不会进「当下可为」)
|
||||
* 0.5b 等待数据(建资料)→ S0d_p10_document(人:数据工单 P-10;都在库时同样不会进「当下可为」)
|
||||
* 1 建立评估上下文 → S1_input_confirm(人:执行工单 01,评估输入清单,件上补缺口)
|
||||
* 2 读取并标准化 → S2_normalize(AI:P-11 主记录骨架 + 差异明细对照行)
|
||||
* 3 逐项差异评估 → S3_assess(AI + 两枚工具:四态 / 风险 / 证据 / 影响 / 推荐等级 / 验证任务)
|
||||
* 4 FAE/RI 审核 → S4_review(人:验收工单 01 = 决策卡循环,驳回则 AI 改台账再发)
|
||||
* 5 提交评审放行 → S5_prepare_landing(AI:analysis_status 推 fae_confirmed、回写 P-01)+ T_release(人:验收工单 02,终局放行)
|
||||
*
|
||||
* 五处不给 doneWhen:S0d_p08_master / S0d_p10_document(触达人)、S1(交人补缺口)、
|
||||
* S4(拍板且可循环)、T_release(签下去)。
|
||||
*/
|
||||
import { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
|
||||
|
||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||
<case id="part-substitution-assessment">
|
||||
<!--
|
||||
案卷项的 id 就是任务视图里的键名。入参顶层字段(customerCode / customerName / requestDescription /
|
||||
originalInternalPn / candidateInternalPns / datasheets)不进这份 caseFileModel —— 谓词里只用
|
||||
count(candidateInternalPns)。其余全是基座读数(行数 / 缺必填 / 按值计数 / 工具调用数)与
|
||||
sub_eval.verdict 里的三面记账旗(每面旗都配着一半基座数)。
|
||||
-->
|
||||
<caseFileModel>
|
||||
<caseFileItem id="demand_request_rows" name="本单写下的需求主记录行数(基座现数)"/>
|
||||
<caseFileItem id="demand_request_missing" name="需求主记录缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="sub_eval_pn_check_rows" name="存在性核对行数(基座现数)"/>
|
||||
<caseFileItem id="sub_eval_pn_check_missing" name="存在性核对缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="sub_eval_pn_check_exists_status_absent" name="仍不在库的料号数(基座现数)"/>
|
||||
<caseFileItem id="sub_eval_pn_check_exists_status_abandoned" name="已放弃的候选数(基座现数)"/>
|
||||
<caseFileItem id="sub_eval_verdict_rows" name="结论行数(基座现数)"/>
|
||||
<caseFileItem id="parts_profile_rows" name="本单建的芯片主数据行数(基座现数)"/>
|
||||
<caseFileItem id="sub_eval_pn_check_exists_status_created" name="经数据工单建好的料号数(基座现数)"/>
|
||||
<caseFileItem id="parts_profile_missing" name="本单建的芯片主数据缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="parts_parameter_missing" name="本单建的标准化参数集缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="parts_document_missing" name="本单建的资料记录缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="parts_document_rows" name="本单建的资料记录行数(基座现数)"/>
|
||||
<caseFileItem id="parts_parameter_rows" name="本单建的参数行数(基座现数)"/>
|
||||
<caseFileItem id="parts_parameter_distinct_param_name" name="本单用到的不同参数名个数(基座现数不同值)"/>
|
||||
<caseFileItem id="input_confirmed_total" name="评估输入已确认(人环凭据)"/>
|
||||
<caseFileItem id="sub_eval_input_gap_rows" name="评估输入清单行数(基座现数)"/>
|
||||
<caseFileItem id="sub_eval_input_gap_missing" name="评估输入清单缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="substitution_relation_rows" name="替代关系行数(基座现数)"/>
|
||||
<caseFileItem id="substitution_relation_missing" name="替代关系缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="substitution_relation_analysis_status_fae_confirmed" name="分析状态已 FAE 确认的关系数(基座现数)"/>
|
||||
<caseFileItem id="substitution_difference_item_rows" name="差异明细行数(基座现数)"/>
|
||||
<caseFileItem id="substitution_difference_item_missing" name="差异明细缺必填的行数(基座现数)"/>
|
||||
<caseFileItem id="substitution_difference_item_distinct_substitution_relation_id" name="有明细的关系数(基座现数不同值)"/>
|
||||
<caseFileItem id="substitution_difference_item_difference_status_match" name="MATCH 条数(基座现数)"/>
|
||||
<caseFileItem id="substitution_difference_item_difference_status_mismatch" name="MISMATCH 条数(基座现数)"/>
|
||||
<caseFileItem id="substitution_difference_item_difference_status_unknown" name="UNKNOWN 条数(基座现数)"/>
|
||||
<caseFileItem id="substitution_difference_item_difference_status_conditional" name="CONDITIONAL 条数(基座现数)"/>
|
||||
<caseFileItem id="tool_part_substitution_assessment_compare_parameters_calls" name="比对工具成功调用次数(基座现数)"/>
|
||||
<caseFileItem id="tool_part_substitution_assessment_recommend_calls" name="定级工具成功调用次数(基座现数)"/>
|
||||
<caseFileItem id="review_approved_total" name="差异结论已审核通过(仅通过时置一)"/>
|
||||
<caseFileItem id="landing_prepared_total" name="拟落账已备好(S5 写完时置一)"/>
|
||||
</caseFileModel>
|
||||
<casePlanModel id="plan" name="差异评估" autoComplete="true">
|
||||
<!-- ① 已绑定且真有料号不在库:需求主记录与存在性核对都写下了、至少一颗还 absent —— 数据工单那一项的入场前提 -->
|
||||
<sentry id="s_bound">
|
||||
<onPart id="op_b" caseFileItemRef="sub_eval_pn_check_exists_status_absent"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_b"><condition>demand_request_rows > 0 and sub_eval_pn_check_rows > 0 and sub_eval_pn_check_exists_status_absent > 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!-- ②′ 芯片主数据已建齐(表 1 主记录 + 表 2 参数子表都不缺必填)—— 补资料那一步的入场前提(基座现数) -->
|
||||
<sentry id="s_p08_created">
|
||||
<onPart id="op_p8" caseFileItemRef="parts_profile_rows"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_p8"><condition>parts_profile_rows > 0 and parts_profile_missing = 0 and parts_parameter_missing = 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!--
|
||||
② 料号全部落定:原 PN + 每个候选各一行核对,没有一颗还「不在库」,放弃的少于候选数,
|
||||
本单建的主数据 / 参数子表 / 资料不缺必填 —— 评估输入确认那一步的入场前提。全是基座现数。
|
||||
-->
|
||||
<sentry id="s_pns_resolved">
|
||||
<onPart id="op_pr" caseFileItemRef="sub_eval_pn_check_exists_status_absent"><standardEvent>update</standardEvent></onPart>
|
||||
<!--
|
||||
**标了 created 的每一颗都要有一行主数据**(2026-08-27 e2e 两轮各现一形):
|
||||
「没写过 ≠ 写了 0 行」(2026-08-01 A1 立的那一课,在这里以新形状复现):
|
||||
\`_missing\` 只数**已有行**缺不缺格子 —— 一行都没写时它是 0,门就开了。
|
||||
实测:模型压拍没写骨架就把两张数据工单发了出去、人也「交」了,
|
||||
\`parts_profile_missing = 0\` 成立,这一步照过,而库里那颗料根本不存在。
|
||||
第一刀写成「created ≥ 1 就得有行」还是太松:三候选那单里模型把**第二颗**也标成 created
|
||||
却只建了第一颗,一行足以让门开着,直到 S2 写关系时才被引用检查拦下。
|
||||
改成**逐颗对账**:\`parts_profile_rows >= created\` —— 标了几颗建好,就得有几行。
|
||||
-->
|
||||
<ifPart id="if_pr"><condition>sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 and parts_profile_rows >= sub_eval_pn_check_exists_status_created and parts_document_rows >= sub_eval_pn_check_exists_status_created and parts_parameter_rows = parts_parameter_distinct_param_name * sub_eval_pn_check_exists_status_created</condition></ifPart>
|
||||
</sentry>
|
||||
<!-- ③ 评估输入已确认(旗 + 清单有行且必填齐)—— 标准化那一步的入场前提 -->
|
||||
<sentry id="s_input_confirmed">
|
||||
<onPart id="op_ic" caseFileItemRef="input_confirmed_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_ic"><condition>input_confirmed_total > 0 and sub_eval_input_gap_rows > 0 and sub_eval_input_gap_missing = 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!-- ④ 对照表已铺好:每个未放弃的候选一条关系、每条关系都有明细 —— 逐项评估的入场前提 -->
|
||||
<sentry id="s_normalized">
|
||||
<onPart id="op_n" caseFileItemRef="substitution_difference_item_rows"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_n"><condition>substitution_relation_rows > 0 and substitution_relation_rows = count(candidateInternalPns) - sub_eval_pn_check_exists_status_abandoned and substitution_difference_item_rows > 0 and substitution_difference_item_distinct_substitution_relation_id = substitution_relation_rows</condition></ifPart>
|
||||
</sentry>
|
||||
<!-- ⑤ 逐项评估已完成:明细与关系必填齐、四态之和 = 明细行数、两枚工具都真调过 —— 审核的入场前提 -->
|
||||
<sentry id="s_assessed">
|
||||
<onPart id="op_a" caseFileItemRef="substitution_difference_item_missing"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_a"><condition>substitution_difference_item_missing = 0 and substitution_relation_missing = 0 and substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows and tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!-- ⑥ 审核已通过 —— 拟落账的入场前提 -->
|
||||
<sentry id="s_reviewed">
|
||||
<onPart id="op_r" caseFileItemRef="review_approved_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_r"><condition>review_approved_total > 0 and substitution_difference_item_missing = 0</condition></ifPart>
|
||||
</sentry>
|
||||
<!-- ⑦ 拟落账已备好:每条关系都到 FAE 确认、P-01 回写五项齐 —— 放行门的入场前提 -->
|
||||
<sentry id="s_landing_ready">
|
||||
<onPart id="op_l" caseFileItemRef="landing_prepared_total"><standardEvent>update</standardEvent></onPart>
|
||||
<ifPart id="if_l"><condition>landing_prepared_total > 0 and substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and substitution_relation_missing = 0 and demand_request_missing = 0</condition></ifPart>
|
||||
</sentry>
|
||||
|
||||
<!-- 启动期:铸案号、建需求 V1、锁规则版本、逐个料号查库(能算的不问人) -->
|
||||
<humanTask id="S0_bind" name="建立评估上下文:绑定四项、新建需求、存在性核对"/>
|
||||
|
||||
<!-- 0.5a 等待数据(建主数据):P-08 数据工单;触达人,不给 doneWhen;都在库时 s_bound 不开,不进「当下可为」 -->
|
||||
<humanTask id="S0d_p08_master" name="数据工单:补建芯片主数据(P-08)">
|
||||
<entryCriterion id="ec_s0d8" sentryRef="s_bound"/>
|
||||
<itemControl><manualActivationRule id="m_s0d8"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 0.5b 等待数据(建资料):P-10 数据工单;触达人,不给 doneWhen;都在库时 s_p08_created 不开,不进「当下可为」 -->
|
||||
<humanTask id="S0d_p10_document" name="数据工单:补建原厂技术资料(P-10)">
|
||||
<entryCriterion id="ec_s0d10" sentryRef="s_p08_created"/>
|
||||
<itemControl><manualActivationRule id="m_s0d10"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 1 建立评估上下文:交 PW 在件上补缺口(执行工单 01)—— 不给 doneWhen -->
|
||||
<humanTask id="S1_input_confirm" name="评估输入与资料补齐确认">
|
||||
<entryCriterion id="ec_s1" sentryRef="s_pns_resolved"/>
|
||||
<itemControl><manualActivationRule id="m_s1"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 2 读取并标准化资料(纯 AI) -->
|
||||
<humanTask id="S2_normalize" name="标准化参数对照表:P-11 主记录骨架 + 差异明细对照行">
|
||||
<entryCriterion id="ec_s2" sentryRef="s_input_confirmed"/>
|
||||
</humanTask>
|
||||
|
||||
<!-- 3 逐项差异评估(AI + 两枚工具) -->
|
||||
<humanTask id="S3_assess" name="逐项差异评估:四态、风险、证据、推荐等级、验证任务">
|
||||
<entryCriterion id="ec_s3" sentryRef="s_normalized"/>
|
||||
</humanTask>
|
||||
|
||||
<!-- 4 FAE/RI 审核:决策卡循环,通过或驳回 + AI 改台账重发 —— 不给 doneWhen -->
|
||||
<humanTask id="S4_review" name="差异评估结论审核:通过或驳回(驳回则 AI 修订重提交)">
|
||||
<entryCriterion id="ec_s4" sentryRef="s_assessed"/>
|
||||
<itemControl><manualActivationRule id="m_s4"/></itemControl>
|
||||
</humanTask>
|
||||
|
||||
<!-- 5(前半)拟落账:分析状态推到 FAE 确认、回写 P-01(不触达世界,给 doneWhen) -->
|
||||
<humanTask id="S5_prepare_landing" name="拟落账:P-11 分析状态 → FAE 确认,回写 P-01">
|
||||
<entryCriterion id="ec_s5" sentryRef="s_reviewed"/>
|
||||
</humanTask>
|
||||
|
||||
<!-- 5(后半)提交 ACT-GATE-03:终局唯放行,必须人工激活 -->
|
||||
<humanTask id="T_release" name="评审放行与落账确认(ACT-GATE-03)">
|
||||
<entryCriterion id="ec_rel" sentryRef="s_landing_ready"/>
|
||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||
</humanTask>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
/**
|
||||
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本)。
|
||||
* 卡骨架住在这里而不是剧本里(模型会压拍,压过之后只有说明书还在 —— A1/A6/I01 同一课)。
|
||||
*/
|
||||
export const NARRATIVE = `§0 开案:**判断候选料号在技术上能不能替代原料号,逐参数给出可复核的差异结论,审核后落账**。
|
||||
入参:
|
||||
· \`customerCode\` —— 客户编码(L-02);\`customerName\` 只在客户不在库时给。
|
||||
· \`requestDescription\` —— 客户需求原话,据它新建 P-01 需求主记录(版本 V1)。
|
||||
· \`originalInternalPn\` —— 原内部料号;\`candidateInternalPns\` —— 候选内部料号数组(≥1,逐个评估、各一条 P-11)。
|
||||
· \`datasheets\` —— 选填的真文件,基座已读成文本(每件 \`{name, text?, note}\`);有就拿它在数据工单里预填,没有就人全填。
|
||||
|
||||
**本活动只回答一件事:技术上哪种料能替**。不做数量 / 价格 / 交期 / 采购判断;客户批准状态由审批流维护,你没有那一列的写权。
|
||||
|
||||
**四项绑定后不可改**(客户 / 需求 / 原 PN / 候选数组):绑错只能退回启动人重新启动。
|
||||
|
||||
**能算的不问人也不自己判**:四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)与推荐等级
|
||||
(推荐 / 条件推荐 / 备选 / 不推荐)全由两枚工具算 —— \`${TOOL_COMPARE}\`、\`${TOOL_RECOMMEND}\`,
|
||||
一批一次。你负责对照表、证据引用、影响说明、UNKNOWN 的去向。**规则版本 \`${RULE_VERSION}\` 随每条结论落账**。
|
||||
|
||||
**UNKNOWN 是一等公民**:查不到的参数值写「未披露」,不许估、不许留空串冒充;每个 UNKNOWN 必须写去向
|
||||
(挂验证任务号或补来源动作)。硬约束 MISMATCH 不许改判,要改只能补证据后重比。
|
||||
|
||||
**存在性检查与数据工单**:启动时逐个料号在 \`parts.profile\` 里查(原 PN + 每个候选)。查无记录 = 「完全没有」,
|
||||
要人从零建整条主数据(表 1 主记录 + 表 2 标准化参数子表 \`parts.parameter\`)—— 发**数据工单**(一张单:P-08 一张、P-10 一张);
|
||||
PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该不该问人,按候选总数判断,不是按不在库的颗数判断**:
|
||||
原 PN 缺 = 硬阻塞必须建、不问;候选总数 = 1 时那一颗必须建、不问;候选总数 ≥ 2 时,每一颗不在库的候选都要先问人放弃还是创建。
|
||||
**库里都有的话这两项的入场前提本就不成立**(此单不走这条),不会出现在你的「当下可为」里,别发空单。
|
||||
|
||||
**六步走**:绑定与存在性核对 → (等待数据)→ 人补评估输入 → 标准化对照表 → 逐项评估 → 人审结论(可循环)→ 拟落账 → 人放行。
|
||||
|
||||
【本单会用到的卡,全部照下面的骨架发,别自己另编】
|
||||
|
||||
① 新客户建档(只在 \`customerCode\` 在 \`customer.profile\` 里查无时,S0_bind 里发;编码用入参给的那个,人核名):
|
||||
{"kind":"entry","itemId":"S0_bind","ask":"客户 <customerCode> 不在客户档案里,请核对建档信息后确认",
|
||||
"ledger":"customer.profile","key":{"customer_code":"<customerCode>"},
|
||||
"fields":[
|
||||
{"key":"name","label":"客户名称","value":"<customerName,没有就整格不写 value>","required":true},
|
||||
{"key":"industry","label":"所属行业"},
|
||||
{"key":"region","label":"区域"}
|
||||
],
|
||||
"basis":[{"k":"触发原因","v":"按客户编码 <customerCode> 查客户档案,查无记录"},
|
||||
{"k":"这一单要评估的","v":"原 PN <originalInternalPn> → <N> 个候选"},
|
||||
{"k":"确认之后","v":"这家客户落进本单草稿层,评估照常往下走"}]}
|
||||
**\`basis\` 这三行必须给**:不给的话基座会兜上「这一步 / 当下可为 / 目标缺口」那几行内部家具 ——
|
||||
把九个计划项的全名摆到人脸上,他一个字也用不上(2026-08-27 实测)。
|
||||
人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。
|
||||
|
||||
② 候选不在库、且候选总数 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选总数 = 1 时不问、直接建):
|
||||
{"kind":"decide","itemId":"S0d_p08_master",
|
||||
"ask":"候选 <internal_pn> 在芯片主数据里查无记录。是从零建它的主数据(派数据工单),还是本轮放弃这颗候选?",
|
||||
"options":[{"value":"create","label":"创建主数据(派数据工单)","recommended":true},
|
||||
{"value":"abandon","label":"放弃这颗候选"}],"allowCustom":true}
|
||||
\`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因;
|
||||
点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。
|
||||
|
||||
③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上)——
|
||||
**发这张卡之前,\`parts.profile\` 与 \`parts.parameter\` 必须先有骨架行**(名单里每颗料各一行 + 它的参数各一行)。
|
||||
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时这一步的剧本还没摊开),写骨架与发卡都得在同一串里,顺序不能反 ——
|
||||
件上一行都没有的话,人打开是一张空表、什么也补不了,而这一单的主数据从头到尾不会存在
|
||||
(实测走到 S2 才被引用检查拦下,那时两张数据工单都已经「办完」了)
|
||||
{"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{
|
||||
"title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>",
|
||||
"ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。",
|
||||
"steps":["表 1 每一行:品牌、规格描述、封装、生命周期状态为必填,缺一格确认不了",
|
||||
"表 2 一行一个参数:所属料号、参数名、值、单位、约束(硬/软)必填,说明是只读注释;原厂未披露的值写「未披露」,它会进入 UNKNOWN 并挂验证任务",
|
||||
"有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写",
|
||||
"改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"],
|
||||
"standard":"每颗料号表 1 一行,必填列全齐;表 2 至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项各一行。",
|
||||
"gateLedgers":["parts.profile","parts.parameter"],
|
||||
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"},
|
||||
{"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}}
|
||||
**\`gateLedgers\` 必须给**:这是零输入卡,基座不知道这一张要人补哪几张表 ——
|
||||
不给它就回落到必交付物那几张(与这张卡无关),于是件上红带点着「缺品牌名称」而提交钮照样绿,
|
||||
空手交卷一路放行(2026-08-27 实测)。给了之后,闸与红带、放行门读的是同一个数。
|
||||
|
||||
④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发)——
|
||||
**与③同一条:发这张卡之前 \`parts.document\` 必须先有骨架行**(名单里每颗料至少一行,
|
||||
key 是 {internal_pn, doc_seq:"01"});一行都没有的话人打开是空表、什么也补不了,
|
||||
提交闸会如实拦住(「这一单还一行都没有」),这一单就卡在这儿了(2026-08-27 实测)。
|
||||
{"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{
|
||||
"title":"数据工单 · 原厂技术资料创建(P-10):<料号列表>",
|
||||
"ask":"请把这些料号的 datasheet / 承诺函等资料记录补进原厂技术资料库(一份资料一行,可加行)。",
|
||||
"steps":["资料类型、标题、版本、文件链接、资料状态为必填;证据定位写页码或章节,差异评估的证据引用会指向它",
|
||||
"同一料号多份资料就多加几行,序号 01/02… 自己续",
|
||||
"改完保存,回这张卡确认"],
|
||||
"standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
|
||||
"gateLedgers":["parts.document"],
|
||||
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}
|
||||
|
||||
⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入)——
|
||||
**发它的前提:所有料号都已落定** —— \`${D}.pn_check\` 里**一颗 absent 都不剩**(每颗要么 found、要么经数据工单 created、
|
||||
要么人点了放弃 abandoned),且建过的那几颗在 \`parts.profile\` 里真有行。还有 absent 就**回去把它们办完**
|
||||
(候选总数 ≥2 的逐颗发决策卡问、原 PN 与独苗候选直接建),别先发这张卡:
|
||||
硬发出去人也补不成 —— 后面写替代关系时会被引用检查逐颗拦下,而那时数据工单都已经「办完」了(2026-08-27 实测两轮)。
|
||||
**有几颗 absent 就要办几颗**,办完第一颗别忘了还有第二颗。
|
||||
**发这张卡之前,\`${D}.input_gap\` 必须先有行**:清单为空的卡就是让人对着一张空表签字,他补不了任何东西。
|
||||
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时 S1 的剧本还没摊开),写清单与发卡都得在同一串里,顺序不能反:
|
||||
已绑定四项(customer / request / original_pn / candidate_N,status="ok")、待补两项(application_scenario / bom_position,status="gap")、
|
||||
默认三项(substitution_type / rule_version / owner,status="default")、每颗候选的「未披露」参数各一行(status="gap")、原 PN 资料版本(status="ok");
|
||||
逐列口径见这一步的剧本。
|
||||
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
|
||||
"gateLedgers":["${D}.input_gap"],
|
||||
"title":"评估输入与资料补齐确认:<原 PN> → <N> 个候选",
|
||||
"ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。",
|
||||
"steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)",
|
||||
"原厂未披露的参数(如使能阈值)补不到就写「挂验证任务」或「留 UNKNOWN」—— 补不了也是合法答案,不拦流程",
|
||||
"替代类型 / 规则版本 / 负责人期限三行是系统默认,可改可不改",
|
||||
"加一行 = 补充 AI 没列出的资料项;删一行 = 该项不适用;已绑定的四项不可改",
|
||||
"改完保存,回这张卡提交"],
|
||||
"standard":"每一条「待补」行的补充内容都有;补不了的写明理由。",
|
||||
"basis":[{"k":"已绑定","v":"客户 <customerCode> · 需求 <request_id> V1 · 原 PN <原 PN> · 候选 <N> 颗"},
|
||||
{"k":"缺口","v":"<N> 项待补 / 待确认"}]}}
|
||||
|
||||
⑥ 验收工单 01 · 差异评估结论审核(S4_review;决策卡循环,不是终局验收)—— 发之前先在轨迹里找有没有已答过、还没处理的 \`[card-reply]\`:
|
||||
· \`value\` 是 \`"approve"\` → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项,别再发卡;
|
||||
· \`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头 → 都算驳回:\`"reject"\` 没写意见,**不许再发卡追问**;\`"custom:"\` 的 \`note\` 就是他的意见,
|
||||
按意见改 \`substitution.difference_item\` / \`substitution.relation\`(同 PK 覆盖写,不开新行),意见要挂验证的写一行 \`validation.record\`,
|
||||
然后**换一种问法**再发一张(措辞不换会被当成同一张卡静默吞掉);
|
||||
· 没有 → 才发第一张:
|
||||
{"kind":"decide","itemId":"S4_review","allowCustom":true,
|
||||
"ask":"差异评估结论已算出:<N> 个候选、共 <M> 条参数比对(MATCH a / MISMATCH b / UNKNOWN c / CONDITIONAL d),推荐等级 <逐候选一句>。请审核。",
|
||||
"options":[{"value":"approve","label":"通过","recommended":true},
|
||||
{"value":"reject","label":"驳回(不写具体意见)"}]}
|
||||
三个 \`value\` 只有 \`"approve"\` / \`"reject"\` / \`"custom:"\` 开头三种,别造第四种。
|
||||
|
||||
【一路上要说话】每一拍带一句 say,说业务的话:查了几颗料、缺几颗、对照了几项、四态各几条、推荐等级各是什么。到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。用件上那三个词(必填 / 建议 / 选填)说缺口,只说事实。
|
||||
|
||||
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告(有 doneWhen 的项引擎自己收)。
|
||||
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍;tool 之后同拍其余动作作废。
|
||||
**常路 9–12 拍收口**(候选缺库那条路多两张数据工单、多两拍)。
|
||||
|
||||
§Z 结案:FAE/RI 在验收单上签字,系统把草稿合并进 P-08 / P-10 / P-01 / P-11 / P-06 正式版并发射 \`assessment-released\` 口。`;
|
||||
|
||||
/**
|
||||
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
|
||||
* 五处不给 doneWhen:S0d_p08_master / S0d_p10_document(发数据工单触达人)、S1(交人补缺口)、
|
||||
* S4(拍板且循环)、T_release(签下去)。
|
||||
*/
|
||||
export const ASSESSMENT_STEPS = [
|
||||
{
|
||||
item: 'S0_bind',
|
||||
intent: '铸案号、建需求 V1、锁规则版本、逐个料号查库、写存在性核对表',
|
||||
writes: ['demand.request', `${D}.pn_check`, `${D}.verdict`, 'customer.profile'],
|
||||
reads: ['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets', 'case:openedAt', 'customer.profile', 'parts.profile'],
|
||||
view: 'eval-input-list',
|
||||
tools: [],
|
||||
/*
|
||||
* 「该有几行」也进判据:原 PN + 每个候选各一行核对(count(candidateInternalPns) + 1);
|
||||
* 少一行 = 一颗料从这一单里凭空消失。count() 对未绑定名回 1 —— candidateInternalPns 是必填入参,恒绑定。
|
||||
*/
|
||||
doneWhen:
|
||||
`demand_request_rows > 0 and demand_request_missing = 0 and ${D}_verdict_rows > 0 and ` +
|
||||
`${D}_pn_check_rows = count(candidateInternalPns) + 1 and ${D}_pn_check_missing = 0`,
|
||||
guide: `第一件事:**把这一单的上下文立起来**,一拍连做(客户不在库时多一张录入卡、多一拍)。
|
||||
|
||||
【先铸案号】\`eval_ref = 'W05-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`(\`case:openedAt\` 形如 "2026-08-26 21:03:15",去掉分隔符即得);
|
||||
\`request_id = 'REQ-' + 同一串数字\`;\`input_snapshot_ref = 'SNAP-' + eval_ref\`。**不许从正式表里别单的案号里挑**,整单只有这一个 eval_ref。
|
||||
|
||||
【客户在不在库】在 \`customer.profile\` 里找 \`customerCode\`:在 → 直接用;不在 → 照说明书①发数据录入卡(名字用 \`customerName\`),
|
||||
发完这一拍结束;人确认后这家客户就在草稿层里了,下一拍接着做。**客户不在库也不许自己 write customer.profile**。
|
||||
**那一拍只发卡,别顺手写 \`demand.request\`** —— 需求行的客户编码有引用检查(客户还没落草稿层,写下去当场被拒);
|
||||
等人确认完,下一拍再写需求行与核对表,一次写齐。
|
||||
|
||||
【写 \`demand.request\` 一行(V1)】request_id、customer_code(= customerCode)、requirement_version = "V1"、request_type = "技术需求"、
|
||||
request_description(入参原话,不改写)、input_snapshot_ref、original_internal_pn、request_status = "pending"、current_activity = "ACT-WORK-05"、
|
||||
current_stage = "assessing"。application_scenario / bom_position **这一步不写**(启动时未收,S1 由人补)。
|
||||
|
||||
【逐个料号查库,写 \`${D}.pn_check\`】\`parts.profile\` 整表在你的作用域里,按 \`internal_pn\` 逐个核对:原 PN 一行(role = "original"),
|
||||
每个候选一行(role = "candidate");在 → exists_status = "found" **且 \`found_pn_ref\` 填你在 \`parts.profile\` 里真找到的那一行的 internal_pn**
|
||||
(与本行 internal_pn 一模一样);不在 → exists_status = "absent"、\`found_pn_ref\` 留空(**等数据工单确认后再改成 created**)。
|
||||
一颗都不能少:数组里点了几颗候选,就写几行 + 原 PN 一行。note 选填(如「PCN 转 NRND」)。
|
||||
|
||||
**「在库」这句话基座要拿去查**:\`found_pn_ref\` 声明了引用 \`parts.profile.internal_pn\`,写下去那一刻基座就去主数据里查,
|
||||
**查无此值当场拒**。被拒不是让你换个号去顶 —— 那说明这颗料真的不在库,把这一行改成 absent(依据留空),数据工单那一步的门自己会开。
|
||||
判 found 却填不出依据也走不动:这一步的收步条件里有「核对表不缺必填」。**别凭料号长得像就判在库,名单里没有就是没有。**
|
||||
|
||||
【写 \`${D}.verdict\` 一行】eval_ref、request_id、rule_version = "${RULE_VERSION}"、original_internal_pn、candidate_total = 候选数组长度。
|
||||
|
||||
写完这一项就结束。有 absent 的 → 下一项是 S0d_p08_master(数据工单),照常往下办。
|
||||
**都在库的话你什么都不用做** —— 数据工单那两项的入场前提本就不成立,它们不会出现在你的「当下可为」里,
|
||||
下一拍你会直接看到「评估输入与资料补齐确认」那一步。**任何时候都不要对计划项用 \`disable\`**:
|
||||
本引擎里 \`Disabled\` 只能从 \`Enabled\` 来,而普通计划项一直停在 \`Available\`,disable 一定被拒。`,
|
||||
},
|
||||
{
|
||||
item: 'S0d_p08_master',
|
||||
intent: '料号不在库:派 P-08 数据工单让人从零建芯片主数据 + 标准化参数子表;候选总数 ≥2 才逐颗问放弃还是创建',
|
||||
writes: ['parts.profile', 'parts.parameter', 'parts.document', `${D}.pn_check`],
|
||||
reads: [`${D}.pn_check`, 'candidateInternalPns', 'originalInternalPn', 'datasheets', 'parts.profile', 'parts.parameter', 'parts.document'],
|
||||
view: 'p08-master-create',
|
||||
tools: [],
|
||||
/* 触达人,不给 doneWhen;人工激活:发卡就算启动 */
|
||||
guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走 —— 都在库时入场前提本就不成立,这一项与下一项 S0d_p10_document 都不会进你的「当下可为」。**先分岔,再动作**:
|
||||
|
||||
【先数两个数,再分岔】(先看轨迹:这一项里已经发过哪些卡、人答了什么,【当前这一步】下面那行引擎事实说得清楚)
|
||||
· **候选总数**:入参 \`candidateInternalPns\` 的长度(\`${D}.pn_check\` 里 role = "candidate" 的行数也是它,不随本单进展变化);
|
||||
· **不在库的候选数**:\`${D}.pn_check\` 里 role = "candidate" 且 exists_status = "absent" 的行数(这个数会随着人放弃/创建而变小)。
|
||||
**这里要分开答两个问题,别混成一个**:
|
||||
|
||||
**问题一 · 建哪几颗?** 名单 = \`${D}.pn_check\` 里 **exists_status = "absent" 的每一行**,不管它是原 PN 还是候选。
|
||||
**exists_status = "found" 的一行都不建** —— 那几颗的主数据本来就在库里,给它们派数据工单等于让人把已有的东西再填一遍,
|
||||
而真正缺的那颗还是没人建、单子照样卡死。名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。
|
||||
|
||||
**问题二 · 建之前要不要先问人?** 只有 **absent 的候选**才谈得上问,**看的是候选总数,不是不在库的颗数**
|
||||
——**「不在库的只有一颗」不是跳过决策卡的理由**:
|
||||
· 原 PN absent → **硬阻塞**,不问人,直接进名单(它是评估基准,没有它整单无从谈起);
|
||||
· **候选总数 = 1** → 那一颗候选**如果是 absent** 就不许放弃、不问人,直接进名单(候选是 found 的话它压根不在名单里,没有可问的);
|
||||
· **候选总数 ≥ 2** → **每一颗 absent 的候选都要先发一张决策卡问人**(一次一张,按候选数组顺序;哪怕这一单不在库的候选只有一颗,
|
||||
只要候选总数 ≥ 2 就要问)照说明书②;人选 "abandon"/"custom:" 的,把那一行 \`${D}.pn_check\` 改成
|
||||
exists_status = "abandoned" + abandon_reason(同 PK 覆盖写),这颗不再评估;选 "create" 的进「建」的名单。
|
||||
**至少留一颗候选**:放弃到只剩最后一颗还是 absent 的候选时,那一颗按「候选总数 = 1」办 —— 不再发决策卡问,直接进「建」的名单
|
||||
(候选全被放弃,存在性核对永远凑不齐、单子会死锁,这一条就是防它)。
|
||||
**别把同一颗候选问两遍**:决策卡答过就按答案办。
|
||||
|
||||
【「建」的名单定了 → 写骨架行,发 P-08 数据工单(一张单,件上两张表)】
|
||||
1. 给名单里每颗料写一行 \`parts.profile\` 骨架(表 1):key {internal_pn},patch 只带你确有依据的列 —— 有 \`datasheets[].text\` 就从里面读
|
||||
brand_name / specification_description / package / lifecycle_status(active/nrnd/eol/discontinued/risk 之一)/ datasheet_ref + datasheet_version;
|
||||
没有 datasheet 就只写 internal_pn 这一列(其余留给人填,**别编**)。
|
||||
2. 同一拍给每颗料写标准化参数集骨架(表 2,\`parts.parameter\`,一行一参数):
|
||||
**参数名一律用中文,与库里既有料号的那几行同名**(输入电压范围 / 输出电压 / 输出电流 / 开关频率 /
|
||||
静态电流 / 工作温度 / 使能阈值…)—— \`param_name\` 是主键的一半,写成 \`input_voltage_range\`
|
||||
与写成「输入电压范围」**不是同一行**:原 PN 那边是中文、候选这边是英文,比对时两套名字对不上,
|
||||
逐条判成 UNKNOWN,结论就废了(2026-08-27 实测:27 条里 14 条 UNKNOWN)。
|
||||
封装、工艺、生命周期这些**不是参数**,它们在表 1,别往表 2 里塞。
|
||||
**建几颗料就用同一套名字**:门上有一条对账 —— 参数行数 = 不同参数名数 × 建过的料数,
|
||||
只有每颗料的名字集合一模一样时才成立;混着写(这颗中文那颗英文、或同一颗写两套)这一步就过不去。有 \`datasheets[].text\` 就从里面读出典型参数
|
||||
(如输入电压范围 / 输出电压 / 输出电流 / 开关频率 / 静态电流 / 工作温度 / 使能阈值等,按品类而定)各写一行,
|
||||
key {internal_pn, param_name},patch 带 param_value / param_unit / constraint_type(hard/soft)/ param_note(一句话说清这项为什么要卡,
|
||||
如「须覆盖客户输入上限」);原厂没写的值写「未披露」(它会进入 UNKNOWN 并挂验证任务)。没有 datasheet 就只写
|
||||
internal_pn + param_name 骨架(把你判断这颗料该评估的参数名列出来,**param_value 留空给人填,别编**)。
|
||||
3. 同一拍**给名单里每一颗料各写一行** \`parts.document\` 骨架(两颗就两行,别只写一行 ——
|
||||
门上有一条对账:建好几颗就得有几行资料记录;少一行,那颗料的资料工单人打开是空的):key {internal_pn, doc_seq: "01"},patch {document_type: "datasheet", document_status: "pending",
|
||||
brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。
|
||||
4. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。
|
||||
|
||||
**\`created\` 认的是「那张卡交回来了」,不是「表里有行」** —— **写骨架 ≠ 建好了**:
|
||||
骨架是**你**铺的(只有料号和参数名),值是**人**在件上填的。骨架一铺就把 pn_check 改成 created,
|
||||
等于替人宣布他已经办完了:接着你会以为数据工单收工、去 finish 这一项,而卡根本没发出去 ——
|
||||
撞上「这一单里从没给它发过卡」,连撞五拍熔断(2026-08-27 实测,整单白跑)。
|
||||
**顺序只有一个**:写骨架 → **发卡** → 这一拍结束 → 人交回 → 这时才把那几颗改成 created。
|
||||
门上还有一条逐颗对账(建好几颗就得有几行主数据、几行资料),标了却没建,这一步照样过不去。
|
||||
|
||||
【P-08 那张确认回来了】**先看名单里还有没有 absent 的料号**(\`${D}.pn_check\` 现读):
|
||||
· **还有** → **这一项千万别 finish**。先回到上面【问题二】那一档:候选总数 ≥2 的话,
|
||||
**这一颗也要先发一张决策卡问人**(照说明书②,人选 create 才建 —— 每一颗 absent 的候选都有被放弃的权利,
|
||||
第一颗问过了不代表第二颗也问过);人选 create 之后再把它的骨架写好(profile + parameter,有 datasheet 就预填),
|
||||
发一张 P-08 卡(标题带那一颗的料号)—— 同一个计划项可以先后发好几张卡,一颗一张也行。
|
||||
原 PN 与「候选总数 = 1」那两档不问,直接建。
|
||||
**收掉了就回不去了**:这一项一旦 Completed,再想给它挂第二张卡会被拒「这一步已经办完收掉了」,
|
||||
而那颗料从此没人建,后面写替代关系时被引用检查逐颗拦下(2026-08-27 实测,一单连拒四次)。
|
||||
· **一颗都不剩了** → 才走下面这两步。
|
||||
|
||||
这一拍的动作串**恰好两个,顺序不能反**:
|
||||
① 先 \`finish S0d_p08_master\` —— **必须先把这一项收掉**。不收掉,引擎挑工作区画面时按
|
||||
「当下还没干完的第一步」取 view,S0d_p08_master 只要还 Active,画面就一直停在
|
||||
\`p08-master-create\`(P-08 那两张表);下一拍就算发出了 P-10 的卡,人的工作区也看不见
|
||||
P-10 的资料表,一格都填不了,提交闸必然拦死。
|
||||
② 再照说明书④发 P-10 数据工单 —— 下一项是 S0d_p10_document,同一批料号接着补资料记录。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`,
|
||||
},
|
||||
{
|
||||
item: 'S0d_p10_document',
|
||||
intent: '料号不在库:派 P-10 数据工单让人从零建原厂技术资料',
|
||||
writes: ['parts.document', `${D}.pn_check`],
|
||||
reads: [`${D}.pn_check`, 'parts.profile', 'parts.document'],
|
||||
view: 'p10-document-create',
|
||||
tools: [],
|
||||
/* 触达人,不给 doneWhen;人工激活:发卡就算启动 */
|
||||
guide: `这一项只在 \`${D}.pn_check\` 里有 absent 行时走(要走到这一步,S0d_p08_master 必然已经收完);都在库时入场前提本就不成立,
|
||||
这一项与 S0d_p08_master 都不会进你的「当下可为」。**进到这一项时,S0d_p08_master 应当已经收掉了;若它还开着(Active),
|
||||
先 \`finish S0d_p08_master\` 再发这张卡** —— 它没收掉,画面会一直停在 P-08 那两张表,人拿到 P-10 的卡也看不见资料表。
|
||||
|
||||
【发 P-10 数据工单】「建」的名单就是 \`${D}.pn_check\` 里仍是 exists_status = "absent" 的那几行(候选总数 ≥2 时被放弃的那些,
|
||||
S0d_p08_master 那一步已经把它们改成 "abandoned",不在这份名单里)。同一拍照说明书④发那张 P-10 数据工单(title 里列全料号,只发一次),
|
||||
发完这一拍结束(挂起)。
|
||||
|
||||
【P-10 那张确认回来了】把名单里每颗料的 \`${D}.pn_check\` 改成 exists_status = "created"(同 PK 覆盖写),然后 finish 这一项。
|
||||
这时 S1_input_confirm 的入场前提(全部料号落定、本单建的主数据不缺必填)自己就成立了 —— 缺必填由提交闸兜着,卡回来时一定是齐的。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发;答复里没有值(空 values)也算答过 —— 人在件上填的东西已经落草稿层。`,
|
||||
},
|
||||
{
|
||||
item: 'S1_input_confirm',
|
||||
intent: '评估输入清单:绑定四项只读核对,缺口行交 PW 在件上补',
|
||||
writes: [`${D}.input_gap`, `${D}.verdict`, 'demand.request'],
|
||||
reads: [`${D}.input_gap`, `${D}.pn_check`, 'demand.request', 'parts.profile', 'parts.parameter', 'parts.document', 'candidateInternalPns', 'originalInternalPn'],
|
||||
view: 'eval-input-list',
|
||||
tools: [],
|
||||
/* 交人补缺口,不给 doneWhen;人工激活:发卡就算启动 */
|
||||
guide: `料号全部落定了,**该 PW 补评估输入了** —— 这是本活动唯一的执行工单,零输入。**先分岔**:【当前这一步】那行引擎事实说
|
||||
「从没给它发过卡」→ 走【发卡】;说「人已经被问过了」→ 走【收口】。
|
||||
|
||||
【发卡】先把评估输入清单写下来(\`${D}.input_gap\`,一行一项,item_key 用英文短键,input_item 用人读名),一拍连做:
|
||||
· 已绑定四项(status = "ok",detail 写绑定值,origin = "ai"):item_key = customer / request / original_pn / candidates
|
||||
(候选多颗就 candidate_1、candidate_2… 一颗一行,detail 写料号与在库状态);
|
||||
· 待补两项(status = "gap",detail 写「启动时未收」):application_scenario「应用场景」(source_ledger = "P-01")、bom_position「BOM 位」(source_ledger = "P-09");
|
||||
· 默认可改三项(status = "default",detail 写默认值):substitution_type「替代类型」= "Pin-to-Pin"、rule_version「规则版本」= "${RULE_VERSION}"、owner「负责人 / 期限」= "按客户线默认 · 1 个工作日";
|
||||
· 待确认(status = "gap"):逐颗候选看它名下 \`parts.parameter\` 的那几行,param_value 是「未披露」的各一行(item_key = unknown_<料号>_<序号>,
|
||||
input_item 如「XL2596S-5.0 使能阈值」,source_ledger = "P-08",detail 写「原厂未披露(有而不全)」);
|
||||
· 齐备项(status = "ok"):原 PN 资料版本(source_ledger = "P-10",detail 写 datasheet 版本)。
|
||||
写完同一拍照说明书⑤发执行工单,发完这一拍结束(挂起)。
|
||||
|
||||
【收口】人交回之后只做三件事,然后 finish:
|
||||
① 读 \`${D}.input_gap\` 现值:把 application_scenario / bom_position 两行的 supplement 抄进 \`demand.request\`(同 PK 覆盖写这两列;
|
||||
supplement 写「补不了」「UNKNOWN」之类的,这两列不写);人加的行(origin = "human-added")同等作数,写进 technical_constraints 一句汇总。
|
||||
② 写 \`${D}.verdict.input_confirmed_total = 1\`。
|
||||
③ finish 这一项。**别在这一拍做对照表** —— 那是下一步的活。
|
||||
|
||||
【这张卡一单只发一次】人交回来之后不许再发;缺必填有提交闸兜着。`,
|
||||
},
|
||||
{
|
||||
item: 'S2_normalize',
|
||||
intent: '读取并标准化:每个未放弃的候选一条 P-11 主记录骨架 + 逐参数对照行',
|
||||
writes: ['substitution.relation', 'substitution.difference_item'],
|
||||
reads: ['parts.profile', 'parts.parameter', 'parts.document', 'demand.request', `${D}.pn_check`, `${D}.input_gap`, `${D}.verdict`, 'candidateInternalPns', 'originalInternalPn'],
|
||||
view: 'difference-review',
|
||||
tools: [],
|
||||
doneWhen:
|
||||
`substitution_relation_rows > 0 and substitution_relation_rows = count(candidateInternalPns) - ${D}_pn_check_exists_status_abandoned and ` +
|
||||
'substitution_difference_item_rows > 0 and substitution_difference_item_distinct_substitution_relation_id = substitution_relation_rows',
|
||||
guide: `输入齐了,**把多来源资料整成可比对的对照表**,一拍连做(先写关系,再写明细 —— 明细引用关系 id,顺序反了会被拒)。
|
||||
|
||||
【每个未放弃的候选一条 \`substitution.relation\` 骨架】(\`${D}.pn_check\` 里 exists_status 是 found / created 的候选;abandoned 的不写)
|
||||
substitution_relation_id = "REL-<案号数字串>-01/02…"(按候选数组顺序)、eval_ref、request_id、original_internal_pn、candidate_internal_pn、
|
||||
substitution_type = "pin_to_pin"(人在评估输入清单里改了替代类型就照改:功能替代 → functional、参数替代 → parametric)、
|
||||
analysis_status = "ai_draft"、rule_version = "${RULE_VERSION}"、bom_position(照 demand.request 现值抄,没有不写)。
|
||||
recommendation_level / recommendation_reason / difference_summary / risk_level **这一步一格都不写**(S3 的活)。
|
||||
|
||||
【每条关系逐参数写 \`substitution.difference_item\` 对照行】骨架按**原 PN 在 \`parts.parameter\` 里的那几行**(它是独立子表,一行一参数,
|
||||
按 internal_pn 归属):原 PN 有的每个参数一行 —— difference_item_id = "<关系id>-P01/P02…"、parameter_name(取原 PN 那一行的 param_name)、
|
||||
original_value(取原 PN 那一行的 param_value)、candidate_value(候选在 \`parts.parameter\` 里同 param_name 那一行的 param_value;
|
||||
候选没有这个参数 → 写「未披露」)、parameter_unit(以原 PN 那一行的 param_unit 为准;候选单位不同先换算,
|
||||
换算不了原值照写、evidence_ref 里注明)、constraint_type(取原 PN 那一行的 constraint_type;没标的按口径:封装 / 工艺 / 电压 / 电流 / 温度 / 阈值 hard,其余 soft)、
|
||||
rule_version = "${RULE_VERSION}"、evidence_ref(值从哪来:「P-08 · P-10 <版本>」;本单数据工单建的料号写「人工录入 · 数据工单 · P-08」)。
|
||||
另加两行**封装**与**生命周期**(constraint_type = hard / soft,值分别取 \`package\` 与 \`lifecycle_status\`)。
|
||||
difference_status / compare_result / risk_level / impact_statement / unknown_disposition **这一步不写**。
|
||||
|
||||
**缺参数不得伪造**:候选那一侧查不到就写「未披露」。写完引擎自己收这一项(每条关系都有明细行才算)。`,
|
||||
},
|
||||
{
|
||||
item: 'S3_assess',
|
||||
intent: '逐项差异评估:四态与推荐等级由工具算,证据 / 影响 / UNKNOWN 去向由你写',
|
||||
writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
|
||||
reads: ['substitution.relation', 'substitution.difference_item', 'demand.request', `${D}.input_gap`, `${D}.verdict`],
|
||||
view: 'difference-review',
|
||||
tools: [TOOL_COMPARE, TOOL_RECOMMEND],
|
||||
doneWhen:
|
||||
'substitution_difference_item_missing = 0 and substitution_relation_missing = 0 and ' +
|
||||
'substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + ' +
|
||||
'substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows and ' +
|
||||
'tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0',
|
||||
guide: `对照表铺好了,**固定三拍**:比对 → 定级 → 写盘。**工具调用后同拍其余动作作废**,别把工具和写台账串在一起;两枚工具各只调一次。
|
||||
|
||||
【第一拍:只调 \`${TOOL_COMPARE}\`,别的什么都不写】把**全部**关系一次打包:
|
||||
{"candidates":[{"candidateInternalPn":"<候选>","pairs":[{"parameterName":"输入电压范围","originalValue":"4.5~40","candidateValue":"4.5~32",
|
||||
"unit":"V","constraintType":"hard","requirement":">= 40","evidenceRef":"P-08 · P-10 Rev H"}, …]}, …]}
|
||||
· pairs 逐条照 \`substitution.difference_item\` 抄(parameterName / originalValue / candidateValue / unit / constraintType / evidenceRef);
|
||||
· **客户在应用场景里给了边界的参数才写 requirement**(如「输入上限 40V」→ 输入电压范围那一对写 ">= 40";「工业温域」→ 工作温度写 "-40~85");
|
||||
· 每一对都带 evidenceRef —— 硬约束没有证据引用工具不判 MATCH,那正是要的。
|
||||
调完这一拍结束。
|
||||
|
||||
【第二拍:只调 \`${TOOL_RECOMMEND}\`,别的什么都不做】把第一拍出参里每个候选的 counts 原样打包传进去:{"candidates":[{"candidateInternalPn":"…","counts":{…}}, …]}。调完这一拍结束。
|
||||
**推荐等级只能由它算**——你自己填一个进 \`substitution.relation.recommendation_level\`,数据面看着像对的,机制上却是你拍的;
|
||||
而且这一步的收步判据数着这枚工具的调用次数,不调它这一步永远收不掉。
|
||||
|
||||
【第三拍:写盘前先自检,再一拍连做写盘,一次都不再调工具】
|
||||
动手写之前,先在局面的基座读数里找这两个数:\`tool_part_substitution_assessment_compare_parameters_calls\` 与
|
||||
\`tool_part_substitution_assessment_recommend_calls\`。**两个都必须 > 0 才能写盘**。若 \`recommend\` 那个还是 0,
|
||||
说明第二拍没调成——**回去调它**(入参就是第一拍出参里每个候选的 counts),不要往下写、更不要自己拍推荐等级。
|
||||
这一步的收步判据里就有这两个数,**不调够,写多少行都收不了步**。
|
||||
① 逐条 \`substitution.difference_item\` 同 PK 覆盖写:difference_status / compare_result / risk_level 照第一拍出参**一字不改**;
|
||||
impact_statement(MATCH 写「无」;其余说清对应用 / 验证 / 报价的影响,如「输入上限 32V < 客户 40V,不能上板」);
|
||||
difference_status = unknown 的行**必须**写 unknown_disposition(「挂验证任务 VAL-<案号数字串>-01:实测使能阈值」或「向原厂补来源」);confidence(0~1)。
|
||||
② 每个 UNKNOWN 写一行 \`validation.record\`:validation_id = "VAL-<案号数字串>-01/02…"、request_id、validation_internal_pn(候选)、
|
||||
validation_type = "pin_to_pin"、validation_status = "not_started"、next_validation_action(要测什么、什么条件)、test_condition。
|
||||
③ 逐条 \`substitution.relation\` 同 PK 覆盖写:recommendation_level / risk_level 照第二拍出参、recommendation_reason(工具 reason 扩写成人话,
|
||||
引具体参数)、difference_summary(「7 项比对:4 MATCH / 1 MISMATCH / 1 UNKNOWN / 1 CONDITIONAL」)、analysis_report_ref = "RPT-<案号数字串>"、
|
||||
confidence_score(取该候选各行 confidence 的最小值);recommendation_level = conditional 的行**必须**写 verification_requirement(列出 VAL 号)、validation_id。
|
||||
④ \`${D}.verdict\` 同 PK 覆盖写:relation_total、recommended_total / conditional_total / alternate_total / not_recommended_total(数关系表)。
|
||||
写完引擎自己收这一项;下一项是审核(S4),**这一拍不要发卡**。
|
||||
|
||||
**不许把工具的结论改成别的**:不推荐就是不推荐;要改只能补证据后重比。`,
|
||||
},
|
||||
{
|
||||
item: 'S4_review',
|
||||
intent: '差异评估结论审核:决策卡循环,通过或驳回 + AI 按意见改台账重发',
|
||||
writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
|
||||
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', `${D}.verdict`],
|
||||
view: 'difference-review',
|
||||
tools: [],
|
||||
/* 要人拍板,且这一项内部可能循环 —— 不给 doneWhen */
|
||||
guide: `结论算出来了,**该 FAE/RI 审核了** —— 这是验收工单 01,走决策卡(不是终局验收)。**先分岔,再动作**:
|
||||
轨迹里找这一项已答过、还没处理的 [card-reply]:
|
||||
· 没有 → 照说明书⑥发第一张决策卡(ask 里把候选数、比对条数、四态各几条、逐候选推荐等级说全),发完这一拍结束。
|
||||
· "approve" → 写 \`${D}.verdict.review_approved_total = 1\`,finish 这一项。别再发卡。
|
||||
· "reject" / "custom:…" → **驳回**。按意见改台账(同 PK 覆盖写,不开新行、不改行数):
|
||||
· 意见点名某个参数 → 改那一行 \`substitution.difference_item\` 的 impact_statement / unknown_disposition / evidence_ref;要挂验证的补一行 \`validation.record\`;
|
||||
· 意见说推荐理由站不住 → 改 \`substitution.relation.recommendation_reason\`(等级不许改 —— 那是工具算的;要改先回 S3 补证据重比,本活动不回头,写清楚为什么维持);
|
||||
· 没写具体意见("reject")→ 自己挑最站不住的一处改实(证据最含糊的那条 evidence_ref、去向最空的那条 unknown_disposition),**至少改对一处**;
|
||||
然后**换一种问法**再发一张决策卡(把改了什么写进 ask),发完结束。
|
||||
**基座对着字面比对意见与草稿**:观察里点名了哪一行,就改那一行,不挪到别的行头上。`,
|
||||
},
|
||||
{
|
||||
item: 'S5_prepare_landing',
|
||||
intent: '拟落账:P-11 分析状态推到 FAE 确认,回写 P-01 五项',
|
||||
writes: ['substitution.relation', 'demand.request', `${D}.verdict`],
|
||||
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
|
||||
view: 'landing-confirm',
|
||||
tools: [],
|
||||
doneWhen:
|
||||
'landing_prepared_total > 0 and substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and ' +
|
||||
'substitution_relation_missing = 0 and demand_request_missing = 0',
|
||||
guide: `审核通过了,**把拟落账的内容备好**,一拍连做:
|
||||
① 每条 \`substitution.relation\` 同 PK 覆盖写 analysis_status = "fae_confirmed"(只推分析层;客户批准状态你没有写权,别碰)。
|
||||
② \`demand.request\` 同 PK 覆盖写五项 + 状态:risk_level(取各候选 risk_level 里最高的一档)、verification_requirement(把所有 VAL 号与要测什么列全;
|
||||
没有 UNKNOWN 写「无需验证」)、output_ref = "RPT-<案号数字串>"、next_action = "提交 ACT-GATE-03 评审放行"、current_activity = "ACT-WORK-05"、
|
||||
current_stage = "gate03"、request_status = "responded"。
|
||||
③ \`${D}.verdict\` 同 PK 覆盖写:landing_prepared_total = 1、summary 一句人话(几个候选各什么等级、挂了几个验证任务)。
|
||||
写完引擎自己收这一项,下一项是放行。**这一拍不要 submit**。`,
|
||||
},
|
||||
{
|
||||
item: 'T_release',
|
||||
intent: '拟落账内容已备好,提交 ACT-GATE-03 交 FAE/RI 放行;被打回就改台账再交',
|
||||
/* 被打回重做时要改的就是这几张 —— 不声明就写不进去 */
|
||||
writes: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
|
||||
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'demand.request', `${D}.verdict`],
|
||||
view: 'landing-confirm',
|
||||
tools: [],
|
||||
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
|
||||
guide: `落账确认那一屏摆着拟写 P-11 主记录与拟回写 P-01。**先看轨迹里有没有「验收被打回重做」**:
|
||||
有 → 这一拍不是再提交一遍。照意见改**台账本身**(同 PK 覆盖写,不开新行):
|
||||
· 他说推荐等级与明细不符 → 核对 \`substitution.relation.recommendation_level\` 与该关系明细的四态计数,不符的把 recommendation_reason 改实、把漏挂的验证要求补上;
|
||||
· 他说 P-01 回写不全 → 补 \`demand.request\` 那五项;
|
||||
· 他说证据不可信 → 改那几行 \`substitution.difference_item.evidence_ref\`(指到具体资料版本与页码);
|
||||
· 没写具体意见 → 自己挑最站不住的一处改实,至少改对一处;
|
||||
改完这一拍再 judge + submit。**基座那道闸:打回之后台账一个字没改就不许再提交。**
|
||||
|
||||
没被打回(或已改过)→ 先裁三条裁量判据,然后 submit,同一拍连做:
|
||||
· judge criterion="证据链可信:每条结论都能追溯到 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入)" —— 逐条看 evidence_ref 指不指得到具体记录;
|
||||
· judge criterion="硬约束项未被自动判 MATCH:封装 / 工艺 / 电气边界等硬约束的 MATCH 都有证据支持" —— 看 hard 且 match 的行 evidence_ref 是否非空且具体;
|
||||
· judge criterion="推荐等级与差异明细一致:存在 UNKNOWN / CONDITIONAL 的候选没有给「推荐」" —— 逐候选核对;
|
||||
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄。
|
||||
这一单没有分支去向,只发一口 \`assessment-released\`,不必你选出口。终局唯放行:你不能自己宣布完成。`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
|
||||
* 放行那一格由基座统一加,人看到的是 **x/6** —— 与文档的步骤 0.5 / 1 / 2 / 3 / 4 / 5 六步逐格对齐:
|
||||
* 0.5 启动与存在性检查(等待数据)· 1 建立评估上下文 · 2 读取并标准化 · 3 逐项差异评估 · 4 FAE 审核与补证(含拟落账)· 5 提交评审放行(基座那格)
|
||||
*/
|
||||
export const ASSESSMENT_PHASES = [
|
||||
{ name: '启动与存在性检查', items: ['S0_bind', 'S0d_p08_master', 'S0d_p10_document'] },
|
||||
{ name: '建立评估上下文', items: ['S1_input_confirm'] },
|
||||
{ name: '读取并标准化资料', items: ['S2_normalize'] },
|
||||
{ name: '逐项差异评估', items: ['S3_assess'] },
|
||||
{ name: 'FAE 审核与补证', items: ['S4_review', 'S5_prepare_landing'] },
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 清单与案例模型共用的常量 —— 单独成文件,免得 manifest ↔ case-model 循环 import 撞 TDZ。 */
|
||||
export const ACTIVITY_ID_TEXT = 'part-substitution-assessment';
|
||||
/** 活动自持单据的域名 */
|
||||
export const D = 'sub_eval';
|
||||
/** GOV 差异规则版本 —— 随包冻结,随每条结论落账(rule_version) */
|
||||
export const RULE_VERSION = 'R-2026.07';
|
||||
export const TOOL_COMPARE = 'part-substitution-assessment.compare-parameters';
|
||||
export const TOOL_RECOMMEND = 'part-substitution-assessment.recommend';
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
|
||||
* 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 五张 HTML 报告模版。
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
|
||||
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
|
||||
export default defineActivity({ name: 'part-substitution-assessment' });
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* 活动包清单 —— ACT-WORK-05 差异评估(案例活动)。
|
||||
*
|
||||
* 业务出处:`docs/SS02-ACT-WORK-05-html/`(活动流程与工单说明 / 一键启动入参说明 /
|
||||
* 《SS02-SS03-SS04_Demo台账字段细化与统一方案》/ 四份目标件 / 全流程分支图)。
|
||||
* 骨架照 E04 / I01 已验证的样子:双级入参 → 活动自持单据 + 共享主数据 → decide 循环做中间审核 →
|
||||
* 终局放行。三处与前两个活动刻意不同,写在这里免得下一个读者当成疏漏:
|
||||
* ① **主数据是七张(P-08 主 + P-08 参数子表 / P-10 / P-01 / P-11 主+子 / P-06)**,物理表由平台基线
|
||||
* (`apps/api/src/app/seed-mvp-data.ts`)物化,这里只**追加**本活动要用的列与标记
|
||||
* (required / tally / refersTo / requiredWhen 是活动的授权面;声明合并是纯 add-column)。
|
||||
* **P-08 的标准化参数集是独立子表 `parts.parameter`**(2026-08-27 结构改:用户核对实跑
|
||||
* 页面后指出必须与目标件结构一致——目标件《数据工单-01》本就是两张表,塞进一个文本列
|
||||
* 违反《统一方案》§六「重复明细用子表承载,不在单一文本字段堆叠」)。
|
||||
* ② **数据工单**(PN 不在库时让人从零建整条主数据)= 零输入执行工单 + 工作区的件
|
||||
* (`mine:parts.profile` / `mine:parts.parameter` / `mine:parts.document` 可加行),
|
||||
* 确认落草稿层、放行并入正式表(守铁律②;文档里「确认即入库」在本平台落成「无审核环节、
|
||||
* 放行并入」)。一张台账表一张单(P-08 主记录 + 参数子表算同一张数据工单 —— 目标件是
|
||||
* 同一份工单卡里的两张表)。
|
||||
* ③ 逐项四态与推荐等级**由两枚纯工具算**(规则同时以 D01/D02 两张 DMN 随包冻结当文档),
|
||||
* 模型只负责对照表、证据与影响说明 —— 硬约束 MISMATCH 不可能被措辞改判。
|
||||
*/
|
||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||
import { activityIdSchema } from '@contracts';
|
||||
import { ASSESSMENT_SKILLS } from './skills.ts';
|
||||
import { CMMN_XML, NARRATIVE, ASSESSMENT_PHASES, ASSESSMENT_STEPS } from './case-model.ts';
|
||||
import { P08_MASTER_CREATE, P10_DOCUMENT_CREATE, EVAL_INPUT_LIST, DIFFERENCE_REVIEW, LANDING_CONFIRM } from './views/report.ts';
|
||||
import { ACTIVITY_ID_TEXT, D, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
|
||||
|
||||
export { D, RULE_VERSION, TOOL_COMPARE, TOOL_RECOMMEND } from './constants.ts';
|
||||
const ACTIVITY_ID = activityIdSchema.parse(ACTIVITY_ID_TEXT);
|
||||
|
||||
/**
|
||||
* 两张判定表 —— **文档形态**(求值器在 `apps/api/src/app/tools/first-party.ts`,与这里同一份规则):
|
||||
* D01 逐参数四态、D02 推荐等级。都是 FIRST:规则从上往下第一条命中即出结论,顺序就是优先级。
|
||||
*/
|
||||
export const D01_DMN: DmnModel['tables'][number] = {
|
||||
id: 'd01-difference-status',
|
||||
label: '差异四态 D01(R-2026.07)',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'compare_result', expression: 'compare_result' },
|
||||
{ name: 'constraint_type', expression: 'constraint_type' },
|
||||
{ name: 'evidence_present', expression: 'evidence_present' },
|
||||
],
|
||||
outputs: [{ name: 'difference_status' }, { name: 'risk_level' }],
|
||||
rules: [
|
||||
{ id: 'd01-u', when: ['"unknown"', '-', '-'], then: ['unknown', 'medium'], annotation: '一侧未披露 → UNKNOWN,挂验证任务' },
|
||||
{ id: 'd01-m0', when: ['"equal","covers"', '"hard"', 'false'], then: ['conditional', 'medium'], annotation: '硬约束缺证据不判 MATCH' },
|
||||
{ id: 'd01-m', when: ['"equal","covers"', '-', '-'], then: ['match', 'low'], annotation: '一致或候选范围盖住原范围' },
|
||||
{ id: 'd01-c', when: ['"configurable","partial"', '-', '-'], then: ['conditional', 'medium'], annotation: '可调配成 / 部分重叠 → 条件' },
|
||||
{ id: 'd01-x', when: ['"narrower","differs"', '"hard"', '-'], then: ['mismatch', 'high'], annotation: '硬约束不一致' },
|
||||
{ id: 'd01-s', when: ['"narrower","differs"', '"soft"', '-'], then: ['conditional', 'medium'], annotation: '软约束不一致 → 评估影响' },
|
||||
],
|
||||
};
|
||||
|
||||
export const D02_DMN: DmnModel['tables'][number] = {
|
||||
id: 'd02-recommendation',
|
||||
label: '推荐等级 D02(R-2026.07)',
|
||||
hitPolicy: 'FIRST',
|
||||
inputs: [
|
||||
{ name: 'hard_mismatch_total', expression: 'hard_mismatch_total' },
|
||||
{ name: 'unknown_total', expression: 'unknown_total' },
|
||||
{ name: 'configurable_total', expression: 'configurable_total' },
|
||||
{ name: 'conditional_total', expression: 'conditional_total' },
|
||||
],
|
||||
outputs: [{ name: 'recommendation_level' }],
|
||||
rules: [
|
||||
{ id: 'd02-n', when: ['> 0', '-', '-', '-'], then: ['not_recommended'], annotation: '硬约束失败直接不推荐' },
|
||||
{ id: 'd02-u', when: ['0', '> 0', '-', '-'], then: ['conditional'], annotation: '有 UNKNOWN → 等验证闭环' },
|
||||
{ id: 'd02-a', when: ['0', '0', '> 0', '-'], then: ['alternate'], annotation: '只靠改外围配得成 → 备选' },
|
||||
{ id: 'd02-c', when: ['0', '0', '0', '> 0'], then: ['conditional'], annotation: '其余条件项 → 条件推荐' },
|
||||
{ id: 'd02-r', when: ['0', '0', '0', '0'], then: ['recommended'], annotation: '全 MATCH → 推荐' },
|
||||
],
|
||||
};
|
||||
|
||||
export const ASSESSMENT_DMN: DmnModel = {
|
||||
drd: [
|
||||
{ decision: 'd01-difference-status', requires: [], inputs: ['compare_result', 'constraint_type', 'evidence_present'] },
|
||||
{ decision: 'd02-recommendation', requires: [], inputs: ['hard_mismatch_total', 'unknown_total', 'configurable_total', 'conditional_total'] },
|
||||
],
|
||||
tables: [D01_DMN, D02_DMN],
|
||||
};
|
||||
|
||||
/** 字段声明的短写(与 E04 同一套写法:显式 primaryKey / masked,其余按需) */
|
||||
type F = { name: string; type: 'text' | 'number' | 'integer' | 'boolean' | 'date' | 'timestamp' | 'json'; label: string; primaryKey?: boolean; required?: boolean; expect?: boolean; tally?: string[]; auto?: boolean; distinct?: boolean; refersTo?: string; requiredWhen?: { column: string; value: string } };
|
||||
const col = (f: F) => ({ primaryKey: false, masked: false, ...f });
|
||||
|
||||
export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
packageVersion: '1.3.6',
|
||||
category: '需求分析与方案匹配',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-part-substitution-assessment',
|
||||
},
|
||||
|
||||
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||
contract: {
|
||||
activityId: ACTIVITY_ID,
|
||||
version: '1.0.0',
|
||||
input: {
|
||||
/*
|
||||
* 一键启动只收 4 项(文档《一键启动入参说明》极简原则):客户 / 客户需求 / 原 PN / 候选 PN 数组。
|
||||
* 应用场景、BOM 位、替代类型、规则版本、负责人期限**不是入参** —— 启动后在执行工单 01 里补。
|
||||
* datasheet 选填文件:传了 AI 在数据工单件上预填,没传人全填(文档 0c:同为合法路径)。
|
||||
* `customerName` 只在客户不在库(分支 0a)时用:S0 发数据录入卡建档,名字先摆上去让人核。
|
||||
*/
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
|
||||
properties: {
|
||||
customerCode: { type: 'string', title: '客户编码(L-02 客户主台账)', examples: ['CU-0142'] },
|
||||
customerName: { type: 'string', title: '客户名称(仅客户不在库时填,启动后先建档)', examples: ['深圳恒星电源有限公司'] },
|
||||
requestDescription: {
|
||||
type: 'string',
|
||||
title: '客户需求(原始描述 —— 据此新建 P-01 需求主记录 · 版本 V1)',
|
||||
examples: ['工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料'],
|
||||
},
|
||||
originalInternalPn: { type: 'string', title: '原内部料号(P-08)', examples: ['LM2596S-5.0'] },
|
||||
candidateInternalPns: {
|
||||
type: 'array',
|
||||
title: '候选内部料号(数组,≥1;不在库的启动后经数据工单创建)',
|
||||
items: { type: 'string' },
|
||||
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
|
||||
},
|
||||
datasheets: {
|
||||
type: 'array',
|
||||
format: 'file',
|
||||
title: '候选 datasheet(选填;传了 AI 在数据工单里预填,没传人全填)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: '文件名' },
|
||||
contentType: { type: 'string', title: '类型' },
|
||||
size: { type: 'integer', title: '字节数' },
|
||||
ref: { type: 'string', title: '存档号' },
|
||||
text: { type: 'string', title: '读出来的文本' },
|
||||
note: { type: 'string', title: '读取说明' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
messageHint: 'FAE 可在此说明客户的应用边界、验证条件或本轮评估的特殊要求',
|
||||
},
|
||||
output: {
|
||||
/* 单一必发口:本活动终点是 ACT-GATE-03 评审放行;下游 ACT-WORK-08 尚未实现,按「契约先立」声明 */
|
||||
mandatory: [
|
||||
{
|
||||
name: 'assessment-released',
|
||||
description: '差异评估结论已落账(P-11 替代关系主记录 + 差异明细、P-01 回写)—— 供 ACT-WORK-08 证据归集消费;当前无消费者,载荷 = 已落地事实',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['evalRef', 'requestId', 'relationTotal'],
|
||||
properties: {
|
||||
evalRef: { type: 'string', title: '评估案号' },
|
||||
requestId: { type: 'string', title: '需求编号(P-01)' },
|
||||
relationTotal: { type: 'integer', title: '落账的替代关系条数' },
|
||||
recommendedTotal: { type: 'integer', title: '推荐' },
|
||||
conditionalTotal: { type: 'integer', title: '条件推荐' },
|
||||
alternateTotal: { type: 'integer', title: '备选' },
|
||||
notRecommendedTotal: { type: 'integer', title: '不推荐' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
requiredDeliverables: [
|
||||
{ key: 'eval-input', label: '评估输入清单', ledger: `${D}.input_gap`, ledgers: [`${D}.input_gap`, `${D}.pn_check`, `${D}.verdict`], editableAtRelease: [] },
|
||||
{ key: 'difference-items', label: '差异评估结论(P-11 差异明细)', ledger: 'substitution.difference_item', ledgers: ['substitution.difference_item', 'substitution.relation'], editableAtRelease: [] },
|
||||
{ key: 'landing', label: '拟落账内容(P-11 主记录 + P-01 回写)', ledger: 'substitution.relation', ledgers: ['substitution.relation', 'demand.request'], editableAtRelease: [] },
|
||||
],
|
||||
/*
|
||||
* 验收判据 —— L1/L2 只读基座读数;L4 三条是文档验收工单 01/02 里留给人的裁量;
|
||||
* 「推荐等级与明细一致」「硬约束不判 MATCH」机器已由工具守住,这里仍留 L4 让签字的人过目。
|
||||
*/
|
||||
acceptanceCriteria: [
|
||||
{
|
||||
key: 'input-confirmed',
|
||||
label: '评估输入已经 PW 补齐确认:执行工单 01 已交回,缺口行的补充内容都有,补不了的留了 UNKNOWN',
|
||||
level: 'L1',
|
||||
predicate: `input_confirmed_total > 0 and ${D}_input_gap_rows > 0 and ${D}_input_gap_missing = 0`,
|
||||
},
|
||||
{
|
||||
key: 'review-approved',
|
||||
label: '差异评估结论已经 FAE/RI 审核通过:验收工单 01 已过,不处在驳回待改状态',
|
||||
level: 'L1',
|
||||
predicate: 'review_approved_total > 0 and substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0',
|
||||
},
|
||||
{
|
||||
key: 'every-candidate-assessed',
|
||||
label: '每个未放弃的候选都有一条替代关系记录(N 候选 = N 条 P-11,并行独立落账)',
|
||||
level: 'L2',
|
||||
predicate: `substitution_relation_rows > 0 and substitution_relation_rows + ${D}_pn_check_exists_status_abandoned + 1 = ${D}_pn_check_rows`,
|
||||
},
|
||||
{
|
||||
key: 'every-item-has-status',
|
||||
label: '关键参数差异均有明确状态:每条明细都是 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 之一',
|
||||
level: 'L2',
|
||||
predicate:
|
||||
'substitution_difference_item_rows > 0 and substitution_difference_item_difference_status_match + substitution_difference_item_difference_status_mismatch + ' +
|
||||
'substitution_difference_item_difference_status_unknown + substitution_difference_item_difference_status_conditional = substitution_difference_item_rows',
|
||||
},
|
||||
{
|
||||
key: 'unknown-has-disposition',
|
||||
label: 'UNKNOWN 项均有去向:每个 UNKNOWN 都挂了验证任务或补来源动作,影响说明与证据引用逐条齐全',
|
||||
level: 'L2',
|
||||
predicate: 'substitution_difference_item_missing = 0',
|
||||
},
|
||||
{
|
||||
key: 'judged-by-rule',
|
||||
label: '四态与推荐等级由规则算出:逐参数比对工具与推荐定级工具都真调过',
|
||||
level: 'L2',
|
||||
predicate: `tool_part_substitution_assessment_compare_parameters_calls > 0 and tool_part_substitution_assessment_recommend_calls > 0`,
|
||||
},
|
||||
{
|
||||
key: 'analysis-status-confirmed',
|
||||
label: '分析状态与客户批准状态分离:每条 P-11 只写分析层 analysis_status = FAE 确认,规则版本已锁定随结论落账',
|
||||
level: 'L2',
|
||||
predicate: 'substitution_relation_analysis_status_fae_confirmed = substitution_relation_rows and substitution_relation_missing = 0',
|
||||
},
|
||||
{
|
||||
key: 'p01-writeback-complete',
|
||||
label: '回写 P-01 字段完整:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全',
|
||||
level: 'L2',
|
||||
predicate: 'demand_request_rows > 0 and demand_request_missing = 0',
|
||||
},
|
||||
{ key: 'evidence-credible', label: '证据链可信:每条结论都能追溯到 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(人工录入)', level: 'L4' },
|
||||
{ key: 'hard-not-auto-match', label: '硬约束项未被自动判 MATCH:封装 / 工艺 / 电气边界等硬约束的 MATCH 都有证据支持', level: 'L4' },
|
||||
{ key: 'recommendation-consistent', label: '推荐等级与差异明细一致:存在 UNKNOWN / CONDITIONAL 的候选没有给「推荐」', level: 'L4' },
|
||||
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||
{ key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-01 回写,结论供下游消费', level: 'L5' },
|
||||
],
|
||||
revisableAtRelease: [],
|
||||
prohibitions: [
|
||||
{ key: 'no-fabricated-parameter', statement: 'UNKNOWN 是一等公民:查不到的参数值必须显式写「未披露」并记去向,不得伪造、不得用估计值或空串冒充' },
|
||||
{ key: 'no-override-hard-mismatch', statement: '硬约束 MISMATCH 不得被任何措辞改判 MATCH;要改只能补证据后重比' },
|
||||
{ key: 'no-customer-approval-write', statement: '客户批准状态由独立审批流维护,本活动不写 customer_approval_status(没有这一列的写权)' },
|
||||
{ key: 'no-commercial-judgement', statement: '本活动只答技术可替代性:不做数量、价格、交期、采购判断' },
|
||||
{ key: 'no-rebind', statement: '启动绑定的 4 项(客户 / 需求 / 原 PN / 候选数组)工单内不可改;绑错只能退回触发方重新启动' },
|
||||
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
|
||||
],
|
||||
authorization: {
|
||||
discretion: [],
|
||||
preauthorizedActions: [],
|
||||
/*
|
||||
* 基线:数据工单 ≤2 + 候选放弃/创建决策 ≤2 + 新客户录入卡 1 + 执行单 01 + 审核决策 1 + 放行 1 = 8;
|
||||
* 审核循环预留两轮(+4)= 12。cap 必须严格大于最后一次拍板时的实际次数(E04 实测栽过),留到 16。
|
||||
*/
|
||||
humanCallBudget: 16,
|
||||
},
|
||||
resources: {
|
||||
tools: [{ name: TOOL_COMPARE }, { name: TOOL_RECOMMEND }],
|
||||
skills: ASSESSMENT_SKILLS.map((s) => s.id),
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['FAE/RI'],
|
||||
budget: { steps: 90, tokens: 600_000, activeMinutes: 120, humanCalls: 16 },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 声明 ────────────────────────────────────────────────────── */
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: TOOL_COMPARE }, { name: TOOL_RECOMMEND }],
|
||||
ledger: [
|
||||
/* L-02 客户主台账:只在分支 0a(客户不在库)由数据录入卡建一行 —— 录入卡写的列都得声明 */
|
||||
{
|
||||
domain: 'customer',
|
||||
object: 'profile',
|
||||
kind: 'master',
|
||||
label: '客户档案',
|
||||
fields: [
|
||||
col({ name: 'customer_code', type: 'text', label: '客户编码', primaryKey: true }),
|
||||
col({ name: 'name', type: 'text', label: '客户名称', required: true }),
|
||||
col({ name: 'industry', type: 'text', label: '所属行业', expect: true }),
|
||||
col({ name: 'region', type: 'text', label: '区域', expect: true }),
|
||||
],
|
||||
},
|
||||
/* P-08 芯片主数据:数据工单建整条;标准化参数集已改子表 parts.parameter(2026-08-27,
|
||||
用户看了实跑页面指出「必须与目标件结构一致」——目标件本就是两张表,一张主记录一张
|
||||
参数子表,塞进一个文本列违反《统一方案》§六「重复明细用子表承载」) */
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'profile',
|
||||
kind: 'master',
|
||||
label: '芯片主数据(P-08)',
|
||||
fields: [
|
||||
col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
|
||||
col({ name: 'brand_name', type: 'text', label: '品牌名称', required: true }),
|
||||
col({ name: 'manufacturer_name', type: 'text', label: '制造商名称', expect: true }),
|
||||
col({ name: 'specification_description', type: 'text', label: '规格描述', required: true }),
|
||||
col({ name: 'package', type: 'text', label: '封装', required: true }),
|
||||
col({ name: 'process_technology', type: 'text', label: '工艺', expect: true }),
|
||||
col({ name: 'lifecycle_status', type: 'text', label: '生命周期状态', required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }),
|
||||
col({ name: 'eol_pcn_status', type: 'text', label: 'EOL/PCN 状态', expect: true }),
|
||||
col({ name: 'datasheet_ref', type: 'text', label: 'Datasheet 引用', expect: true }),
|
||||
col({ name: 'datasheet_version', type: 'text', label: 'Datasheet 版本', expect: true }),
|
||||
col({ name: 'application_scenarios', type: 'text', label: '适配应用场景', expect: true }),
|
||||
],
|
||||
},
|
||||
/* P-08 子表:标准化参数集,一行一参数,internal_pn 是引用墙(查无那颗主记录当场拒) */
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'parameter',
|
||||
kind: 'master',
|
||||
label: '标准化参数集(P-08 子表)',
|
||||
fields: [
|
||||
col({ name: 'internal_pn', type: 'text', label: '所属料号', primaryKey: true, refersTo: 'parts.profile.internal_pn' }),
|
||||
/*
|
||||
* `distinct` 是为了让「两颗料是不是用同一套参数名」变成**基座算得出的数**
|
||||
* (`parts_parameter_distinct_param_name`)—— 见 CMMN 的 s_pns_resolved:
|
||||
* 参数行数 = 不同参数名数 × 建过的料数,只有每颗料的名字集合一模一样时才成立。
|
||||
* 2026-08-27 实测:模型给第二颗料写英文 key(`input_voltage_range`),
|
||||
* 而原 PN 在库里是中文「输入电压范围」—— 名字是主键的一半,对不上就没法逐项比。
|
||||
* 措辞写过两遍都没拦住(设计律第六条:措辞治不了的结构治)。
|
||||
*/
|
||||
col({
|
||||
name: 'param_name',
|
||||
type: 'text',
|
||||
label: '参数名',
|
||||
primaryKey: true,
|
||||
distinct: true,
|
||||
/*
|
||||
* **参数名只能用台账里已经在用的那几个**(自指引用)—— 比对是逐参数对着比的,
|
||||
* 候选身上有一个原 PN 没有的维度,本来也无从比起;名字对不上更是直接判成 UNKNOWN。
|
||||
* 2026-08-27 实测三次:模型给新料写英文 key(`input_voltage_range`),
|
||||
* 而库里既有行是中文「输入电压范围」—— 剧本写过两遍、又加了一条乘法对账
|
||||
* (行数 = 不同名字数 × 建过的料数)仍拦不住**只建一颗**的那一路(5 = 5×1 恒成立)。
|
||||
* 自指是唯一在一颗料时也咬得住的:写下去那一刻基座去这一列里查,查无此值当场拒。
|
||||
* 代价(已知约束):真要引入一个全新的参数维度,得先有一颗料用过它 ——
|
||||
* MVP 的品类词汇表是固定的七项,够用;将来要放开,改成引用一张参数字典表。
|
||||
*/
|
||||
refersTo: 'parts.parameter.param_name',
|
||||
}),
|
||||
col({ name: 'param_value', type: 'text', label: '值', required: true }),
|
||||
col({ name: 'param_unit', type: 'text', label: '单位', expect: true }),
|
||||
col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }),
|
||||
col({ name: 'param_note', type: 'text', label: '说明', expect: true }),
|
||||
],
|
||||
},
|
||||
/* P-10 原厂技术资料库:数据工单建;评估时只读做证据回链 */
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'document',
|
||||
kind: 'master',
|
||||
label: '原厂技术资料库(P-10)',
|
||||
fields: [
|
||||
col({ name: 'internal_pn', type: 'text', label: '关联内部料号', primaryKey: true }),
|
||||
col({ name: 'doc_seq', type: 'text', label: '资料序号(同一料号下 01/02…)', primaryKey: true }),
|
||||
col({ name: 'document_type', type: 'text', label: '资料类型', required: true, tally: ['datasheet', 'app_note', 'reference_design', 'white_paper', 'sample_doc', 'commitment_letter', 'other'] }),
|
||||
col({ name: 'brand_name', type: 'text', label: '品牌名称', expect: true }),
|
||||
col({ name: 'document_title', type: 'text', label: '资料标题', required: true }),
|
||||
col({ name: 'document_version', type: 'text', label: '资料版本', required: true }),
|
||||
col({ name: 'document_ref', type: 'text', label: '资料文件链接', required: true }),
|
||||
col({ name: 'citation_locator', type: 'text', label: '证据定位(页码/章节/表格)', expect: true }),
|
||||
col({ name: 'document_status', type: 'text', label: '资料状态', required: true, tally: ['valid', 'expired', 'pending', 'void'] }),
|
||||
],
|
||||
},
|
||||
/* P-01 需求主记录:S0 新建 V1,放行时回写五项(current_stage = gate03 那一刻五项必填) */
|
||||
{
|
||||
domain: 'demand',
|
||||
object: 'request',
|
||||
kind: 'master',
|
||||
label: '客户需求主记录(P-01)',
|
||||
fields: [
|
||||
col({ name: 'request_id', type: 'text', label: '需求编号', primaryKey: true }),
|
||||
col({ name: 'customer_code', type: 'text', label: '客户编码', required: true, refersTo: 'customer.profile.customer_code' }),
|
||||
col({ name: 'requirement_version', type: 'text', label: '需求版本', required: true }),
|
||||
col({ name: 'request_type', type: 'text', label: '需求类型', required: true }),
|
||||
col({ name: 'request_description', type: 'text', label: '需求描述', required: true }),
|
||||
col({ name: 'input_snapshot_ref', type: 'text', label: '输入快照', required: true }),
|
||||
col({ name: 'original_internal_pn', type: 'text', label: '原内部料号', required: true }),
|
||||
col({ name: 'bom_position', type: 'text', label: 'BOM 位', expect: true }),
|
||||
col({ name: 'application_scenario', type: 'text', label: '应用场景', expect: true }),
|
||||
col({ name: 'technical_constraints', type: 'text', label: '技术约束' }),
|
||||
col({ name: 'risk_level', type: 'text', label: '风险等级', tally: ['low', 'medium', 'high', 'critical'], requiredWhen: { column: 'current_stage', value: 'gate03' } }),
|
||||
col({ name: 'verification_requirement', type: 'text', label: '验证/复测/确认要求', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
|
||||
col({ name: 'request_status', type: 'text', label: '需求状态', required: true, tally: ['pending', 'responded', 'won', 'abandoned', 'closed'] }),
|
||||
col({ name: 'current_activity', type: 'text', label: '当前 Activity', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
|
||||
col({ name: 'current_stage', type: 'text', label: '当前阶段' }),
|
||||
col({ name: 'next_action', type: 'text', label: '下一步动作', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
|
||||
col({ name: 'output_ref', type: 'text', label: '标准输出链接', requiredWhen: { column: 'current_stage', value: 'gate03' } }),
|
||||
],
|
||||
},
|
||||
/* P-11 替代关系主记录:一候选一条;两枚 PN 都是引用墙(查无此料当场拒 —— 存在性检查的硬门) */
|
||||
{
|
||||
domain: 'substitution',
|
||||
object: 'relation',
|
||||
kind: 'master',
|
||||
label: '替代关系主记录(P-11)',
|
||||
fields: [
|
||||
col({ name: 'substitution_relation_id', type: 'text', label: '替代关系ID', primaryKey: true }),
|
||||
col({ name: 'eval_ref', type: 'text', label: '评估案号', required: true }),
|
||||
col({ name: 'request_id', type: 'text', label: '关联需求编号', required: true }),
|
||||
col({ name: 'bom_position', type: 'text', label: '关联 BOM 位', expect: true }),
|
||||
col({ name: 'original_internal_pn', type: 'text', label: '原内部料号', required: true, refersTo: 'parts.profile.internal_pn' }),
|
||||
col({ name: 'candidate_internal_pn', type: 'text', label: '候选内部料号', required: true, refersTo: 'parts.profile.internal_pn' }),
|
||||
col({ name: 'substitution_type', type: 'text', label: '替代类型', required: true, tally: ['pin_to_pin', 'functional', 'parametric', 'other'] }),
|
||||
col({ name: 'recommendation_level', type: 'text', label: '推荐等级', required: true, tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }),
|
||||
col({ name: 'recommendation_reason', type: 'text', label: '推荐理由', required: true }),
|
||||
col({ name: 'difference_summary', type: 'text', label: '差异摘要', required: true }),
|
||||
col({ name: 'risk_level', type: 'text', label: '替代风险等级', required: true, tally: ['low', 'medium', 'high', 'critical'] }),
|
||||
col({ name: 'verification_requirement', type: 'text', label: '验证要求', requiredWhen: { column: 'recommendation_level', value: 'conditional' } }),
|
||||
col({ name: 'validation_id', type: 'text', label: '关联验证ID' }),
|
||||
col({ name: 'analysis_status', type: 'text', label: '分析状态', required: true, tally: ['ai_draft', 'fae_confirmed', 'pending_customer', 'customer_approved', 'rejected', 'void'] }),
|
||||
col({ name: 'analysis_report_ref', type: 'text', label: '替代分析报告', expect: true }),
|
||||
col({ name: 'confidence_score', type: 'number', label: '证据可信度', expect: true }),
|
||||
col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
|
||||
],
|
||||
},
|
||||
/* P-11 差异明细子表:一条参数一条记录;relation id 上 distinct 让基座数「几条关系有明细」 */
|
||||
{
|
||||
domain: 'substitution',
|
||||
object: 'difference_item',
|
||||
kind: 'master',
|
||||
label: '差异明细(P-11 子表)',
|
||||
fields: [
|
||||
col({ name: 'substitution_relation_id', type: 'text', label: '替代关系ID', primaryKey: true, distinct: true, refersTo: 'substitution.relation.substitution_relation_id' }),
|
||||
col({ name: 'difference_item_id', type: 'text', label: '差异明细ID', primaryKey: true }),
|
||||
col({ name: 'parameter_name', type: 'text', label: '标准参数名', required: true }),
|
||||
col({ name: 'original_value', type: 'text', label: '原物料参数值', required: true }),
|
||||
col({ name: 'candidate_value', type: 'text', label: '候选物料参数值', required: true }),
|
||||
col({ name: 'parameter_unit', type: 'text', label: '参数单位', expect: true }),
|
||||
col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }),
|
||||
col({ name: 'compare_result', type: 'text', label: '比对结果(工具出参)', expect: true }),
|
||||
col({ name: 'difference_status', type: 'text', label: '差异状态', required: true, tally: ['match', 'mismatch', 'unknown', 'conditional'] }),
|
||||
col({ name: 'impact_statement', type: 'text', label: '影响说明(MATCH 写「无」)', required: true }),
|
||||
col({ name: 'unknown_disposition', type: 'text', label: 'UNKNOWN 去向(验证任务号 / 补来源动作)', requiredWhen: { column: 'difference_status', value: 'unknown' } }),
|
||||
col({ name: 'risk_level', type: 'text', label: '风险等级', required: true, tally: ['low', 'medium', 'high'] }),
|
||||
col({ name: 'evidence_ref', type: 'text', label: '差异证据引用', required: true }),
|
||||
col({ name: 'confidence', type: 'number', label: 'AI 置信度', expect: true }),
|
||||
col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
|
||||
],
|
||||
},
|
||||
/* P-06 验证记录:事实类 —— 本活动只写「验证要求」(状态 not_started),不经数据工单建 */
|
||||
{
|
||||
domain: 'validation',
|
||||
object: 'record',
|
||||
kind: 'master',
|
||||
label: '芯片验证记录(P-06)',
|
||||
fields: [
|
||||
col({ name: 'validation_id', type: 'text', label: '验证ID', primaryKey: true }),
|
||||
col({ name: 'request_id', type: 'text', label: '关联需求编号', required: true }),
|
||||
col({ name: 'validation_internal_pn', type: 'text', label: '验证内部料号', required: true, refersTo: 'parts.profile.internal_pn' }),
|
||||
col({ name: 'validation_type', type: 'text', label: '验证类型', required: true, tally: ['pin_to_pin', 'functional', 'reliability', 'certification', 'retest'] }),
|
||||
col({ name: 'validation_status', type: 'text', label: '验证状态', required: true, tally: ['not_started', 'in_progress', 'passed', 'failed', 'conditional', 'pending_evidence'] }),
|
||||
col({ name: 'test_condition', type: 'text', label: '测试条件', expect: true }),
|
||||
col({ name: 'next_validation_action', type: 'text', label: '后续验证动作', required: true }),
|
||||
col({ name: 'evidence_ref', type: 'text', label: '验证证据引用', expect: true }),
|
||||
],
|
||||
},
|
||||
/* ── 活动自持单据(record,caseKey eval_ref)── */
|
||||
{
|
||||
domain: D,
|
||||
object: 'pn_check',
|
||||
kind: 'record',
|
||||
caseKey: 'eval_ref',
|
||||
label: '料号存在性核对',
|
||||
fields: [
|
||||
col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }),
|
||||
col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
|
||||
col({ name: 'role', type: 'text', label: '角色', required: true, tally: ['original', 'candidate'] }),
|
||||
col({ name: 'exists_status', type: 'text', label: '在库状态', required: true, tally: ['found', 'absent', 'created', 'abandoned'] }),
|
||||
/*
|
||||
* 「在库」不是模型说了算的一句话 —— 判 found 就必须把**在主数据里真找到的那个料号**填在这里,
|
||||
* 基座拿它去 `parts.profile` 查(refersTo),查无此值当场拒。requiredWhen 让这一格只对 found 行必填,
|
||||
* 而 S0_bind 的 doneWhen 里有 `_missing = 0` —— 于是「判了 found 却填不出依据」这一步就收不掉。
|
||||
* 2026-08-27 实测逼出来的:原 PN 根本不在库,模型把两颗都写成 found、交代里还说「都在库」,
|
||||
* 硬阻塞那条路整个被跳过(剧本早写着「整表在你的作用域里逐个核对」—— 措辞治不了的结构治)。
|
||||
*/
|
||||
col({
|
||||
name: 'found_pn_ref',
|
||||
type: 'text',
|
||||
label: '在库核对依据',
|
||||
refersTo: 'parts.profile.internal_pn',
|
||||
requiredWhen: { column: 'exists_status', value: 'found' },
|
||||
}),
|
||||
col({ name: 'abandon_reason', type: 'text', label: '放弃原因', requiredWhen: { column: 'exists_status', value: 'abandoned' } }),
|
||||
col({ name: 'note', type: 'text', label: '备注' }),
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: D,
|
||||
object: 'input_gap',
|
||||
kind: 'record',
|
||||
caseKey: 'eval_ref',
|
||||
label: '评估输入清单',
|
||||
fields: [
|
||||
col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }),
|
||||
col({ name: 'item_key', type: 'text', label: '输入项键', primaryKey: true }),
|
||||
col({ name: 'input_item', type: 'text', label: '输入项', required: true }),
|
||||
col({ name: 'source_ledger', type: 'text', label: '来源台账', expect: true }),
|
||||
col({ name: 'status', type: 'text', label: '状态', required: true, tally: ['gap', 'default', 'ok'] }),
|
||||
col({ name: 'detail', type: 'text', label: '绑定值 / 缺口说明', expect: true }),
|
||||
col({ name: 'supplement', type: 'text', label: '补充内容', requiredWhen: { column: 'status', value: 'gap' } }),
|
||||
col({ name: 'origin', type: 'text', label: '来源(ai/human-added)' }),
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: D,
|
||||
object: 'verdict',
|
||||
kind: 'record',
|
||||
caseKey: 'eval_ref',
|
||||
label: '本次评估结论',
|
||||
fields: [
|
||||
col({ name: 'eval_ref', type: 'text', label: '评估案号', primaryKey: true }),
|
||||
col({ name: 'request_id', type: 'text', label: '需求编号' }),
|
||||
col({ name: 'rule_version', type: 'text', label: '规则版本' }),
|
||||
col({ name: 'original_internal_pn', type: 'text', label: '原内部料号' }),
|
||||
col({ name: 'candidate_total', type: 'integer', label: '候选数' }),
|
||||
col({ name: 'input_confirmed_total', type: 'integer', label: '评估输入已确认' }),
|
||||
col({ name: 'review_approved_total', type: 'integer', label: '差异结论已审核通过' }),
|
||||
col({ name: 'landing_prepared_total', type: 'integer', label: '拟落账已备好' }),
|
||||
col({ name: 'relation_total', type: 'integer', label: '替代关系条数' }),
|
||||
col({ name: 'recommended_total', type: 'integer', label: '推荐' }),
|
||||
col({ name: 'conditional_total', type: 'integer', label: '条件推荐' }),
|
||||
col({ name: 'alternate_total', type: 'integer', label: '备选' }),
|
||||
col({ name: 'not_recommended_total', type: 'integer', label: '不推荐' }),
|
||||
col({ name: 'summary', type: 'text', label: '本次评估结论' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
listeners: [],
|
||||
harness: {
|
||||
memoryWindow: 24,
|
||||
stallAfter: 'PT4H',
|
||||
nudge: [{ after: 'PT2H', action: 'nudge' }],
|
||||
retry: { times: 2, backoff: 'PT30S' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||
assets: {
|
||||
businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: ASSESSMENT_DMN, steps: ASSESSMENT_STEPS, phases: ASSESSMENT_PHASES },
|
||||
skills: ASSESSMENT_SKILLS,
|
||||
/*
|
||||
* 五个局面各一张:
|
||||
* · `p08-master-create` —— S0d 摆,数据工单(P-08),件上可加行、可编辑;
|
||||
* · `p10-document-create` —— S0d 摆,数据工单(P-10),同上;
|
||||
* · `eval-input-list` —— S1 摆,评估输入清单:绑定区只读 + 缺口表可编辑;
|
||||
* · `difference-review` —— S2/S3/S4 摆,差异明细 + 各候选推荐等级,只读;
|
||||
* · `landing-confirm` —— S5/T_release 摆,拟写 P-11 / 拟回写 P-01,只读;放行后即归档件。
|
||||
*/
|
||||
viewTemplates: [
|
||||
{ name: 'p08-master-create', html: P08_MASTER_CREATE },
|
||||
{ name: 'p10-document-create', html: P10_DOCUMENT_CREATE },
|
||||
{ name: 'eval-input-list', html: EVAL_INPUT_LIST },
|
||||
{ name: 'difference-review', html: DIFFERENCE_REVIEW },
|
||||
{ name: 'landing-confirm', html: LANDING_CONFIRM },
|
||||
],
|
||||
cardSchemas: {},
|
||||
distillHooks: [{ on: 'terminal', prompt: '归纳本次差异评估:哪些候选、各自推荐等级与关键依据、UNKNOWN 挂了什么验证任务、审核是否被驳回过(为什么)、数据工单建了哪些主数据。' }],
|
||||
},
|
||||
|
||||
/*
|
||||
* ── 样例集:五条形状真不同(常备案例即验收案例)──
|
||||
* 第一条满载且走「候选缺库 → 数据工单」那条最长的路(测试台落地即预填的是它);样例名 ≤14 字。
|
||||
* 库里只播了 LM2596S-5.0 / LM2596S-ADJ 两颗(见 seed-mvp-data.ts),XL2596S-5.0 / CN-DCDC-32V / LM2596S-3.3 刻意不在库。
|
||||
*/
|
||||
samples: [
|
||||
{
|
||||
name: '候选缺库·派数据工单',
|
||||
input: {
|
||||
customerCode: 'CU-0142',
|
||||
requestDescription: '工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料;应用 12V→5V、工业温域、输入上限 40V',
|
||||
originalInternalPn: 'LM2596S-5.0',
|
||||
candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'],
|
||||
},
|
||||
expect: { ports: ['assessment-released'] },
|
||||
},
|
||||
{
|
||||
name: '都在库·常路',
|
||||
input: {
|
||||
customerCode: 'CU-0142',
|
||||
requestDescription: 'LM2596S-5.0 转 NRND,客户先评估同族可调版 LM2596S-ADJ 能否替代(愿意改外围);应用 12V→5V、工业温域',
|
||||
originalInternalPn: 'LM2596S-5.0',
|
||||
candidateInternalPns: ['LM2596S-ADJ'],
|
||||
},
|
||||
expect: { ports: ['assessment-released'] },
|
||||
},
|
||||
{
|
||||
name: '原PN缺库·硬阻塞',
|
||||
input: {
|
||||
customerCode: 'CU-0142',
|
||||
requestDescription: '客户另一块板在用 LM2596S-3.3(3.3V 固定版),同批 PCN,要求评估 LM2596S-ADJ 替代;应用 12V→3.3V',
|
||||
originalInternalPn: 'LM2596S-3.3',
|
||||
candidateInternalPns: ['LM2596S-ADJ'],
|
||||
},
|
||||
expect: { ports: ['assessment-released'] },
|
||||
},
|
||||
{
|
||||
name: '三候选·含硬约束不推荐',
|
||||
input: {
|
||||
customerCode: 'CU-0142',
|
||||
requestDescription: '工业电源 12V→5V,输入上限 40V,工业温域;三个候选:XL2596S-5.0、LM2596S-ADJ、国产 CN-DCDC-32V(输入上限只有 32V)',
|
||||
originalInternalPn: 'LM2596S-5.0',
|
||||
candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ', 'CN-DCDC-32V'],
|
||||
},
|
||||
expect: { ports: ['assessment-released'] },
|
||||
},
|
||||
{
|
||||
name: '新客户·先建档',
|
||||
input: {
|
||||
customerCode: 'CU-9001',
|
||||
customerName: '深圳恒星电源有限公司',
|
||||
requestDescription: '新客户口述:工业电源 12V→5V 在用 LM2596S-5.0,想评估 LM2596S-ADJ',
|
||||
originalInternalPn: 'LM2596S-5.0',
|
||||
candidateInternalPns: ['LM2596S-ADJ'],
|
||||
},
|
||||
expect: { ports: ['assessment-released'] },
|
||||
},
|
||||
],
|
||||
|
||||
entry: { file: 'index.js', checksum: 'dev-entry-part-substitution-assessment' },
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的技能 —— 载方法,不载权力。
|
||||
* 剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、拿不准时按哪条走」。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
|
||||
export const ASSESSMENT_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('substitution.normalize-parameters'),
|
||||
label: '把多来源资料整成可比对的标准参数对照表',
|
||||
applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。',
|
||||
method: [
|
||||
'参数集是独立子表 `parts.parameter`(P-08 的结构子表),一行一参数,按 `internal_pn` 归属主记录:列是 param_name / param_value / param_unit / constraint_type / param_note。',
|
||||
'对照表按**原 PN 名下的那几行参数名**为骨架:原 PN 有的参数,候选名下没有同名那一行 → 候选值写「未披露」(它会成为 UNKNOWN);',
|
||||
'候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。',
|
||||
'单位以原 PN 那一行的 param_unit 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。',
|
||||
'约束类型以原 PN 那一行的 constraint_type 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。',
|
||||
'**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['标准化参数子表结构', '硬/软约束口径'],
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('substitution.difference-judgement'),
|
||||
label: '逐项四态由工具算,证据与影响由你写',
|
||||
applicability: '对照表齐了,要出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 时。',
|
||||
method: [
|
||||
'四态**不许自己判**:调 `part-substitution-assessment.compare-parameters`,把每个候选的全部参数对一次性传进去(candidates 数组,一次一批)。',
|
||||
'每对参数都带 `evidenceRef`(值从哪份记录来:「P-08」「P-10 Rev H §6.5」「数据工单 <单号> · 确认人 <人>」)——硬约束没有证据引用,工具不判 MATCH。',
|
||||
'客户在应用场景里给了边界(如「输入上限 40V」)就写进那一对的 `requirement`(`>= 40`),候选不满足直接 MISMATCH。',
|
||||
'工具出参照抄进 `substitution.difference_item`:difference_status / compare_result / risk_level 一字不改;',
|
||||
'`impact_statement` 你写:MATCH 写「无」,其余说清对应用 / 验证 / 报价的影响;UNKNOWN 必须写 `unknown_disposition`(挂验证任务号或补来源动作),不许悬空。',
|
||||
'推荐等级同样由工具定(`part-substitution-assessment.recommend`,传每个候选的 counts),`recommendation_reason` 照工具的 reason 扩写,不许把不推荐改成推荐。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['四态口径', '推荐等级四档'],
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('substitution.landing'),
|
||||
label: '落账只写分析层,客户批准不碰',
|
||||
applicability: '审核通过之后拟 P-11 / 回写 P-01 时。',
|
||||
method: [
|
||||
'`analysis_status` 从 `ai_draft` 推到 `fae_confirmed`;`customer_approval_status` 由独立审批流维护 —— 本活动**没有这一列的写权**,写了会被拒。',
|
||||
'P-01 回写五项:risk_level(取各候选里最高的)、verification_requirement(把所有验证任务号列全)、output_ref(替代分析报告号)、next_action(「提交 ACT-GATE-03 评审放行」)、current_stage 写 `gate03`。',
|
||||
'规则版本随结论落账:每条 P-11 主记录与差异明细都写 rule_version(工具出参的 ruleVersion),中途规则更新不影响进行中的评估。',
|
||||
'被打回重做时改的是**台账本身**(理由 / 依据 / 去向),不是把同一份原样再递一遍。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['analysis_status 与 customer_approval_status 分离'],
|
||||
version: '1.0.0',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
|
||||
*
|
||||
* 五个局面各一张,版面语言**逐块搬自** `docs/SS02-ACT-WORK-05-html/` 的四份目标件
|
||||
* (《HTML报告生成规范》§7:有目标件时「搬」,不要「照着做」——样式表整段搬,标记逐块搬,
|
||||
* 只把写死的数据换成绑定):
|
||||
* · `p08-master-create` —— S0d 摆,数据工单(P-08 芯片主数据),搬自「数据工单-01」
|
||||
* (2026-08-27:补齐目标件里原本就有、之前漏搬的【创建什么 · 表 2】标准化参数子表 `parts.parameter`
|
||||
* —— 用户核对实跑页面后指出「页面结构必须与目标件一致」,目标件本就是两张表);
|
||||
* · `p10-document-create` —— S0d 摆,数据工单(P-10 原厂技术资料),**没有自己的目标件**
|
||||
* (规格里数据工单是「一个统一模板按目标台账实例化」),复用「数据工单-01」的样式表与版面骨架;
|
||||
* · `eval-input-list` —— S0/S1 摆,评估输入清单,搬自「执行工单-01」;
|
||||
* · `difference-review` —— S2/S3/S4 摆,差异评估结论审核,搬自「验收工单-01」;
|
||||
* · `landing-confirm` —— S5/T_release 摆,评审放行与落账确认,搬自「验收工单-02」。
|
||||
*
|
||||
* 目标件的样式表逐字节存在 `./target-style.ts`(`P08_CSS`/`EXEC_CSS`/`ACC1_CSS`/`ACC2_CSS`,
|
||||
* 抽取与 diff 验证过程见 task-5-report.md)。**这份文件只做取值绑定,不算不判**;
|
||||
* `BIND_COMMON`/`BIND_P08`/`BIND_EXEC`/`BIND_ACC1`/`BIND_ACC2` 是平台绑定层必须新增的东西
|
||||
* (目标件是写死数据的静态demo,没有「N 行草稿」「编辑态与归档态二选一」这些平台概念):
|
||||
* 缺项红带、编辑闸/增删行按 `html.editable` 收起、序号列换成 CSS 计数器(目标件靠 JS 脚本
|
||||
* 重排数字,沙箱零脚本改用它)、`data-class-of` 的 `v-*` 别名(枚举列不许摆 ASCII 原值 ——
|
||||
* 有色徽标的地方原样复制目标件自己那几条颜色声明当 `v-` 版,目标件本来是纯文字的地方
|
||||
* 就只补 `::after` 文案、不额外配色)。
|
||||
*/
|
||||
import { P08_CSS, EXEC_CSS, ACC1_CSS, ACC2_CSS } from './target-style.ts';
|
||||
import { D } from '../constants.ts';
|
||||
|
||||
/* ====================================================================================
|
||||
* 绑定层公共开关 —— 五张模版共用,与目标件自己的样式表分开存(那份要保持逐字节可比)
|
||||
* ==================================================================================== */
|
||||
const BIND_COMMON = `
|
||||
/* 只在「待处理」这一态露面;归档 / 验收只读态即便 HTML 里还带着 data-edit 也不摆闸和加行钮
|
||||
(目标件是静态 demo,不区分这两态;html.editable 是平台在 iframe 根节点上真开的关) */
|
||||
html:not(.editable) .jc-editgate, html:not(.editable) .jc-addrow { display: none !important; }
|
||||
/* 「加一行」钮跟着紧邻的表进不进编辑态现身(目标件靠 JS 脚本的 classList.toggle('show',on),
|
||||
沙箱零脚本,改用 :has() 认「紧挨着的表是不是 .on」) */
|
||||
.jc-tbl-wrap:has(table.on) + .jc-addrow { display: flex; align-items: center; gap: 8px; }
|
||||
/* 序号列交给 CSS 计数器 —— 目标件的 JS 脚本在增删行后重写 .jc-rn-num / .num 的文本,
|
||||
沙箱零脚本,新增的行 / 删掉的行让计数器自己算对,不必比对哪一行手改过数字。
|
||||
两种列名都要接(.rn 用于 P08/P10/执行工单 —— 目标件里那三份自己就用它;
|
||||
.num 用于两份验收工单 —— 目标件里那两份自己就用它,静态数字,这里换成动态计数)。 */
|
||||
.tbl-wrap table { counter-reset: rn; }
|
||||
.tbl-wrap table td.rn { counter-increment: rn; }
|
||||
.tbl-wrap table td.rn .jc-rn-num::before { content: counter(rn); }
|
||||
.tbl-wrap table td.num { counter-increment: rn; }
|
||||
.tbl-wrap table td.num::before { content: counter(rn); }
|
||||
/* 缺项红带 —— 目标件没有这个概念(它是静态 demo,不会「缺东西」);
|
||||
这是本仓的必填提示机制,与提交闸同源,配色用目标件自己 :root 里那两个 --danger 变量
|
||||
(四份目标件的 :root 对这两个变量给的是同一组值,借用不算改目标件的数值)。 */
|
||||
.jc-gapbar { display: none; }
|
||||
html.editable .jc-gapbar {
|
||||
display: flex; align-items: flex-start; gap: 8px; margin: 0 0 10px; padding: 9px 12px;
|
||||
border-radius: 9px; background: #fff5f5; border: 1px solid #f3b9b5; color: var(--danger); font-size: 12px; line-height: 1.65;
|
||||
}
|
||||
.jc-gapbar b { font-weight: 800; }
|
||||
.jc-gapbar span { color: #9a3b36; }
|
||||
html.editable .jc-gapbar.v-0 { display: none; }
|
||||
`;
|
||||
|
||||
/*
|
||||
* ==================== 徽标别名的生成器 ====================
|
||||
*
|
||||
* **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-]`,
|
||||
* **吃掉下划线**,`pin_to_pin` → `v-pintopin`,封顶 24 字);基座读数名走 `engines.ts` 的
|
||||
* `slug`(只留 `[a-z0-9_]`,吃掉连字符)。两套规则不一样,tally 值一旦带下划线,手写的
|
||||
* `.v-xxx` 选择器就必然是错的。**判据那一侧(读数名)一个字都不碰**——那已经审查通过、
|
||||
* 与 `slug` 一致;这里改的只是展示层:选择器一律用 `vtok()` 从 tally 值**算**出来,不再手写。
|
||||
*/
|
||||
/** 必须与 `classToken` 一字不差:小写、只留 `[a-z0-9-]`、封顶 24 字。 */
|
||||
const vtok = (v: string): string => v.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
|
||||
/** 一族徽标的 CSS:选择器与文案从 `[值, 人读名, 可选颜色声明]` 表算出来,值与文案成对写在一处 */
|
||||
type BadgeSpec = readonly [value: string, label: string, color?: string];
|
||||
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');
|
||||
|
||||
const OK = 'color: var(--ok); background: var(--ok-soft); border-color: #c9ecc2;';
|
||||
const DANGER = 'color: var(--danger); background: var(--danger-soft); border-color: #f3b9b5;';
|
||||
const AMBER = 'color: #8f6a12; background: #fdf3d7; border-color: #ecd9a0;';
|
||||
const MUTED = 'color: var(--ink-3); background: #eef1f6; border-color: var(--line);';
|
||||
|
||||
/* ==================== 数据工单(P08/P10 共用):徽标别名 ==================== */
|
||||
const BIND_P08 = `
|
||||
/* 生命周期 / 资料类型 / 资料状态 三个枚举列共用同一个徽标类 —— 取值域互不重叠(active/nrnd/eol/
|
||||
discontinued/risk 与 datasheet/app_note/… 与 valid/expired/pending/void 三组没有一个词撞车),
|
||||
同一页内不必再分家。配色借的是本设计语言在别的目标件里已经出现过的那几个色(ok/danger/琥珀),
|
||||
不是新配的颜色。 */
|
||||
.stx { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 999px; border: 1px solid transparent; white-space: nowrap; }
|
||||
${badgeCss('.stx', [
|
||||
['active', 'Active', OK],
|
||||
['nrnd', 'NRND', AMBER],
|
||||
['eol', 'EOL', DANGER],
|
||||
['discontinued', '停产', MUTED],
|
||||
['risk', '风险', DANGER],
|
||||
['datasheet', 'Datasheet', 'color: var(--ink-2); background: var(--soft); border-color: var(--line);'],
|
||||
['app_note', '应用笔记', 'color: var(--ink-2); background: var(--soft); border-color: var(--line);'],
|
||||
['reference_design', '参考设计', 'color: var(--ink-2); background: var(--soft); border-color: var(--line);'],
|
||||
['white_paper', '白皮书', 'color: var(--ink-2); background: var(--soft); border-color: var(--line);'],
|
||||
['sample_doc', '样片资料', 'color: var(--ink-2); background: var(--soft); border-color: var(--line);'],
|
||||
['commitment_letter', '承诺函', 'color: var(--ink-2); background: var(--soft); border-color: var(--line);'],
|
||||
['other', '其他', 'color: var(--ink-2); background: var(--soft); border-color: var(--line);'],
|
||||
['valid', '有效', OK],
|
||||
['expired', '过期', DANGER],
|
||||
['pending', '待核实', AMBER],
|
||||
['void', '作废', MUTED],
|
||||
])}
|
||||
/*
|
||||
* 表 2(标准化参数集)的约束类型(硬/软)—— 目标件里这一格是字面类名 .ct.hard / .ct.soft
|
||||
* (P08_CSS 已带这两条颜色声明,.ct 基座形状也在);平台的 data-class-of 走的是
|
||||
* v-<token> 别名机制,不是字面枚举值,这里用 badgeCss 生成 .ct.v-hard / .ct.v-soft,
|
||||
* 颜色原样照抄目标件那两条声明的数值(DANGER/MUTED 与 .ct.hard / .ct.soft 同一份值)。
|
||||
*/
|
||||
${badgeCss('.ct', [
|
||||
['hard', '硬约束', DANGER],
|
||||
['soft', '软约束', MUTED],
|
||||
])}
|
||||
`;
|
||||
|
||||
/* ==================== 执行工单(评估输入清单):徽标别名 ==================== */
|
||||
const BIND_EXEC = `
|
||||
/* 存在性核对的两列(角色 / 在库状态)与评估输入清单的状态列共用目标件自己的 .st 徽标基座 ——
|
||||
取值域互不重叠(found/absent/created/abandoned 与 gap/default/ok 没有一个词撞车)。
|
||||
四档配色**原样照抄目标件自己的 .st.bound / .st.ok / .st.miss / .st.def 那四条声明**,只换成 v- 版。 */
|
||||
${badgeCss('.st', [
|
||||
['found', '在库', OK],
|
||||
['absent', '不在库', 'color: var(--human); background: var(--human-soft); border-color: var(--human-line);'],
|
||||
['created', '本单新建', 'color: var(--brand-deep); background: var(--brand-wash); border-color: var(--brand-soft);'],
|
||||
['abandoned', '已放弃', MUTED],
|
||||
['gap', '待补*', 'color: var(--human); background: var(--human-soft); border-color: var(--human-line);'],
|
||||
['default', '默认·可改', 'color: var(--brand-deep); background: var(--brand-wash); border-color: var(--brand-soft);'],
|
||||
['ok', '齐备', OK],
|
||||
])}
|
||||
/* 角色(原 PN / 候选 PN)是分类标签不是状态,目标件里这类描述文字从不配色,这里只补 ::after 文案 */
|
||||
${badgeCss('.rl', [
|
||||
['original', '原内部料号'],
|
||||
['candidate', '候选内部料号'],
|
||||
])}
|
||||
/*
|
||||
* **补充内容按行投影必填**(2026-08-27 实测:11 行全摆红「必填」胶囊,而闸只认 3 行)——
|
||||
* \`sub_eval.input_gap.supplement\` 的必填是 \`requiredWhen:{status:'gap'}\`,按行不按列;
|
||||
* 静态 \`class="req"\` 会把每一行都标必填。改成 \`<td data-class-of="status">\` 按本行的
|
||||
* status 值投影,只在 v-gap 那一档出红胶囊与行首红条,default / ok 两档不出。
|
||||
*/
|
||||
td.sup.v-gap > .jc-vcell:empty::after {
|
||||
content: "必填"; display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed #f3b9b5; color: var(--danger); background: var(--danger-soft);
|
||||
}
|
||||
tbody tr:has(td.sup.v-gap > .jc-vcell:empty) td { background: #fffbfb; }
|
||||
tbody tr:has(td.sup.v-gap > .jc-vcell:empty) td.rn { color: var(--danger); font-weight: 800; }
|
||||
`;
|
||||
|
||||
/* ==================== 验收工单 01(差异评估结论审核):徽标别名 ==================== */
|
||||
const BIND_ACC1 = `
|
||||
/* 差异四态与推荐等级**都**落在这一屏(本活动数据面的既有约定 —— 各候选推荐等级摘要与
|
||||
逐项差异明细同屏摆),两者取值域都含 "conditional",光认 .st 分不清是哪一列,
|
||||
加 .dst / .rec 两个字段级修饰类各自配色(**原样照抄目标件 .st.ok/.miss/.unknown/.cond
|
||||
四条声明**,只是同一份颜色现在要接两条不同的人读文案)。 */
|
||||
${badgeCss('.st.dst', [
|
||||
['match', 'MATCH', OK],
|
||||
['mismatch', 'MISMATCH', DANGER],
|
||||
['unknown', 'UNKNOWN', AMBER],
|
||||
['conditional', 'CONDITIONAL', 'color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line);'],
|
||||
])}
|
||||
${badgeCss('.st.rec', [
|
||||
['recommended', '推荐', OK],
|
||||
['conditional', '条件推荐', 'color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line);'],
|
||||
['alternate', '备选', AMBER],
|
||||
['not_recommended', '不推荐', DANGER],
|
||||
])}
|
||||
/*
|
||||
* 风险等级(原样照抄目标件 .risk.low/.mid/.hi 三条声明)——加 critical 一档:这一屏「各候选
|
||||
* 推荐等级」摘要读的是 substitution.relation.risk_level(声明了 low/medium/high/critical
|
||||
* 四档),差异明细本身的 risk_level 只有三档,共用同一族选择器,多出的第四档不会撞车。
|
||||
*/
|
||||
${badgeCss('.risk', [
|
||||
['low', '低', 'color: var(--ok);'],
|
||||
['medium', '中', 'color: #8f6a12;'],
|
||||
['high', '高', 'color: var(--danger);'],
|
||||
['critical', '危急', 'color: var(--danger); font-weight: 900;'],
|
||||
])}
|
||||
/*
|
||||
* 约束类型(硬/软)—— 目标件的差异明细表里这一列是纯文字,没有配色(数据工单-01 的 .ct.hard/
|
||||
* .ct.soft 才是这两个值真正的徽标声明,只是它长在另一份目标件里)。这里把那两条颜色声明
|
||||
* 原样搬过来当 v- 版(数值不改,只换了外壳类名,因为这一页的样式表里从未定义过 .ct)。
|
||||
*/
|
||||
.con { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 999px; border: 1px solid transparent; white-space: nowrap; }
|
||||
${badgeCss('.con', [
|
||||
['hard', '硬约束', DANGER],
|
||||
['soft', '软约束', MUTED],
|
||||
])}
|
||||
/* 替代类型 —— 目标件里也是纯文字,不配色,只补文案 */
|
||||
${badgeCss('.enumtxt', [
|
||||
['pin_to_pin', 'Pin-to-Pin'],
|
||||
['functional', '功能替代'],
|
||||
['parametric', '参数替代'],
|
||||
['other', '其他'],
|
||||
])}
|
||||
`;
|
||||
|
||||
/* ==================== 验收工单 02(评审放行与落账确认):徽标别名 ==================== */
|
||||
const BIND_ACC2 = `
|
||||
/* 这一屏四个枚举列(推荐等级 / 分析状态 / 需求状态 / 当前阶段)取值域两两不重叠,共用 .st 即可。
|
||||
推荐等级、分析状态目标件里本就是 .st 徽标(原样照抄 .st.ok/.miss/.unknown/.cond 四条声明);
|
||||
需求状态 / 当前阶段目标件没有这两列,借同一套配色,不新配颜色。 */
|
||||
${badgeCss('.st', [
|
||||
['recommended', '推荐', OK],
|
||||
['conditional', '条件推荐', 'color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line);'],
|
||||
['alternate', '备选', AMBER],
|
||||
['not_recommended', '不推荐', DANGER],
|
||||
['ai_draft', 'AI 草稿', AMBER],
|
||||
['fae_confirmed', 'FAE确认', OK],
|
||||
['pending_customer', '待客户验证', 'color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line);'],
|
||||
['customer_approved', '客户已批准', OK],
|
||||
['rejected', '不批准', DANGER],
|
||||
['void', '作废', MUTED],
|
||||
['pending', '待处理', MUTED],
|
||||
['responded', '已响应', 'color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line);'],
|
||||
['won', '已成交', OK],
|
||||
['abandoned', '已放弃', DANGER],
|
||||
['closed', '已关闭', MUTED],
|
||||
['assessing', '评估中', MUTED],
|
||||
['gate03', '提交 ACT-GATE-03', OK],
|
||||
])}
|
||||
/* 风险等级比验收 01 多一档 critical(P-11 主记录的 risk_level 声明了它,差异明细没有) */
|
||||
${badgeCss('.risk', [
|
||||
['low', '低', 'color: var(--ok);'],
|
||||
['medium', '中', 'color: #8f6a12;'],
|
||||
['high', '高', 'color: var(--danger);'],
|
||||
['critical', '危急', 'color: var(--danger); font-weight: 900;'],
|
||||
])}
|
||||
/* 替代类型 —— 目标件里是纯文字,不配色,只补文案(与验收 01 同一套值域,各自页面各存一份) */
|
||||
${badgeCss('.enumtxt', [
|
||||
['pin_to_pin', 'Pin-to-Pin'],
|
||||
['functional', '功能替代'],
|
||||
['parametric', '参数替代'],
|
||||
['other', '其他'],
|
||||
])}
|
||||
`;
|
||||
|
||||
/** 三层壳照目标件:`<div class="page"><div class="card">`,不再套 `.proto-root`/`.app-shell`/`.jc-stage`
|
||||
* (那是 E04/I01 自己的视觉底约定,不是平台要求 —— ui-kit / view-render 从未引用过这几个类名;
|
||||
* 目标件本身就是直接 `page > card`,搬版面就该原样搬这一层)。
|
||||
* 收件人 / 关联任务两格由引擎注入;目标件的「最迟交回」(`card.deadline`)本仓没有真数据源,不摆;
|
||||
* 工单编号(`card.id`)换成本次评估案号 `eval_ref`。
|
||||
*/
|
||||
const doc = (input: { css: string; alias: string; badge: string; hic?: string; tag: string; kicker: string; title: string; step: string; body: string; footTag: string }): string =>
|
||||
`<style>${input.css}${BIND_COMMON}${input.alias}</style>
|
||||
<div class="page"><div class="card">
|
||||
<header class="card-head">
|
||||
<div class="badge">${input.badge}</div>
|
||||
${input.hic ?? ''}
|
||||
<div class="tit">
|
||||
<div class="kick">${input.kicker}</div>
|
||||
<h1>${input.title}</h1>
|
||||
<div class="sub">收件人:<span data-slot="case:owner"></span> · 关联任务:<span class="mono" data-slot="case:taskId" data-blank></span></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<span class="tag">${input.tag}</span>
|
||||
<span class="step">${input.step}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-bd">${input.body}</div>
|
||||
<footer class="card-foot">
|
||||
<span>${input.kicker} · ${input.title}</span>
|
||||
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">${input.footTag} · <span data-slot="eval_ref" data-blank></span></span>
|
||||
</footer>
|
||||
</div></div>`;
|
||||
|
||||
/* ==================== 内联 SVG 图标 —— 逐字节搬自「数据工单-01」目标件(它们是版面语言的一部分) ==================== */
|
||||
const HIC =
|
||||
'<svg class="hic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="10" cy="5" rx="7" ry="3"/><path d="M3 5v14c0 1.66 3.13 3 7 3 1.13 0 2.21-.09 3.2-.26"/><path d="M3 12c0 1.66 3.13 3 7 3 1.13 0 2.21-.09 3.2-.26"/><circle cx="18" cy="18" r="4.2"/><path d="M18 15.8v4.4M15.8 18h4.4"/></svg>';
|
||||
const ZIC_TASK =
|
||||
'<svg class="zic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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.5" 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>';
|
||||
|
||||
/* ==================== 通用绑定小工具 ==================== */
|
||||
const zone = (label: string, aside: string, icon = ''): string => `<p class="zone">${icon}<b>【${label}】</b>—— ${aside}</p>`;
|
||||
const note = (items: readonly string[]): string => `<div class="note">${items.map((p) => `<p>${p}</p>`).join('')}</div>`;
|
||||
const criteria = (title: string, items: readonly string[]): string =>
|
||||
`<div class="criteria"><div class="c-title">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
|
||||
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>`;
|
||||
const addrow = (address: string, label: string, hint: string): string => `<div class="jc-addrow">
|
||||
<button type="button" data-edit-add="${address}">${label}</button>
|
||||
<em>${hint}</em>
|
||||
</div>`;
|
||||
/** 可改格:只读那一份留空,渲染器自动配孪生 `<input>`(手写 input 会不填值,人一点编辑就是白框) */
|
||||
const cell = (col: string, cls = ''): string => `<span class="jc-vcell${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" data-col="${col}" data-blank></span>`;
|
||||
/** 只读格:即便挂在可改的表里也不许被 `table.on` 藏起来 —— 目标件自己就有这条覆盖(`.jc-vcell.ro`) */
|
||||
const ro = (col: string, cls = ''): string => `<span class="jc-vcell ro${cls === '' ? '' : ` ${cls}`}" data-slot="${col}" 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>`;
|
||||
/** 枚举徽标(可改表里用,旁边挨着 `.jc-cell` 孪生件时要包一层 `.ro` 才不被编辑态藏起来) */
|
||||
const badge = (klass: string, col: string, wrapRo = false): string => {
|
||||
const inner = `<span class="${klass}" data-class-of="${col}"></span>`;
|
||||
return wrapRo ? `<span class="jc-vcell ro">${inner}</span>` : inner;
|
||||
};
|
||||
|
||||
/* ==================== 局面 0 · 数据工单 P-08(搬自「数据工单-01」)==================== */
|
||||
|
||||
const P08_TABLE = `<div class="jc-tbl-wrap"><table id="tbl1">
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th class="req" data-h="内部料号">内部料号 internal_pn<span class="thd">全链路统一基准,不可自由文本</span></th>
|
||||
<th class="req" data-h="品牌名称">品牌 brand_name<span class="thd">与制造商、供应商分开</span></th>
|
||||
<th data-h="制造商名称">制造商 manufacturer_name<span class="thd">与品牌不一致时分开记录</span></th>
|
||||
<th class="req" data-h="规格描述">规格描述 specification_description<span class="thd">按原厂资料口径</span></th>
|
||||
<th class="req" data-h="封装">封装 package<span class="thd">影响硬约束比对</span></th>
|
||||
<th data-h="工艺">工艺 process_technology<span class="thd">硬约束比较项</span></th>
|
||||
<th class="req" data-h="生命周期状态">生命周期 lifecycle_status<span class="thd">Active/NRND/EOL/停产/风险</span></th>
|
||||
<th data-h="EOL/PCN状态">EOL/PCN eol_pcn_status<span class="thd">通知/影响/生效时间</span></th>
|
||||
<th data-h="Datasheet引用">Datasheet 引用 datasheet_ref</th>
|
||||
<th data-h="Datasheet版本">Datasheet 版本 datasheet_version<span class="thd">与引用成对</span></th>
|
||||
<th data-h="适配应用场景">适配场景 application_scenarios<span class="thd">判断实际适用性</span></th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:parts.profile" data-edit="parts.profile"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.profile">×</button></td>
|
||||
<td>${cell('internal_pn', 'mono')}</td>
|
||||
<td>${cell('brand_name')}</td>
|
||||
<td>${cell('manufacturer_name')}</td>
|
||||
<td>${cell('specification_description')}</td>
|
||||
<td>${cell('package', 'mono')}</td>
|
||||
<td>${cell('process_technology')}</td>
|
||||
<td>${badge('stx', 'lifecycle_status', true)}${select('lifecycle_status', [['active', 'Active'], ['nrnd', 'NRND'], ['eol', 'EOL'], ['discontinued', '停产'], ['risk', '风险']], true)}</td>
|
||||
<td>${cell('eol_pcn_status')}</td>
|
||||
<td>${cell('datasheet_ref', 'mono')}</td>
|
||||
<td>${cell('datasheet_version')}</td>
|
||||
<td>${cell('application_scenarios')}</td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
/*
|
||||
* 表 2 · 标准化参数集(P-08 子表 `parts.parameter`)—— 粒度与表 1 不同(一个参数一行,
|
||||
* 行数不固定),不能塞进表 1 的一格(《统一方案》§六:重复明细用子表承载),
|
||||
* 逐块搬自目标件《数据工单-01》的【创建什么 · 表 2】,`id="tbl2"` 沿用目标件自己的
|
||||
* 列宽声明(P08_CSS 里 `#tbl2 th:nth-child(n)` 那几条,不必在这里重复)。
|
||||
* 「说明」列是系统只读注释(目标件原话:「说明列为系统注释,无需填写」),不给 data-col。
|
||||
*/
|
||||
const P08_PARAMS_TABLE = `<div class="jc-tbl-wrap"><table id="tbl2">
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th class="req" data-h="所属料号">所属料号 internal_pn<span class="thd">归属表 1 哪条记录</span></th>
|
||||
<th class="req" data-h="参数名">参数名</th>
|
||||
<th class="req" data-h="值">值</th>
|
||||
<th data-h="单位">单位</th>
|
||||
<th class="req" data-h="约束类型">约束(硬/软)</th>
|
||||
<th data-h="说明">说明</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:parts.parameter" data-edit="parts.parameter"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.parameter">×</button></td>
|
||||
<td>${cell('internal_pn', 'mono')}</td>
|
||||
<td>${cell('param_name')}</td>
|
||||
<td>${cell('param_value', 'mono')}</td>
|
||||
<td>${cell('param_unit')}</td>
|
||||
<td>${badge('ct', 'constraint_type', true)}${select('constraint_type', [['hard', '硬约束'], ['soft', '软约束']], true)}</td>
|
||||
<td class="desc">${ro('param_note')}</td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
export const P08_MASTER_CREATE = doc({
|
||||
css: P08_CSS,
|
||||
alias: BIND_P08,
|
||||
badge: '数据',
|
||||
hic: HIC,
|
||||
tag: '数据工单',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估 · 数据工单 1/2',
|
||||
title: '芯片主数据创建(P-08)',
|
||||
step: '步骤 0.5 · 等待数据',
|
||||
footTag: '工单类型:数据',
|
||||
body:
|
||||
zone('做什么', '批量创建 P-08 主数据记录', ZIC_TASK) +
|
||||
note([
|
||||
'<span class="need">模板定位</span>:本单是「数据工单」统一模板的实例——<b>一单一类(可多条)</b>:目标台账 = <b>芯片主数据(P-08)</b>;<b>字段即表头、每行一条新记录</b>;标准化参数集是它的结构子表(表 2,一行一参数,按所属料号归属)。',
|
||||
'<span class="need">触发原因</span>:系统按内部料号查芯片主数据<b>查无记录</b>——是「完全没有」,不是字段缺失(那归执行工单)。',
|
||||
'<span class="done">AI 预填</span>:上传了 datasheet 的料号 AI 已按资料预填,标着值的格子请核对;没上传的料号全部手填,同为合法路径。',
|
||||
'<span class="need">纪律</span>:必填列全齐才允许确认;确认后这批记录进本单草稿,放行时并入正式库并留痕(确认人 = 数据责任人);只建主数据,验证记录等事实类不在此建。',
|
||||
]) +
|
||||
zone('创建什么 · 表 1', '主数据记录(每行一条,*为必填列)', ZIC_TABLE) +
|
||||
gapbar('parts_profile', '必填') +
|
||||
editGate('parts.profile', `<b>待建 <span data-slot="parts_profile_rows"></span> 条记录</b>,点「编辑」核对预填、补齐空缺;「+ 加一行」= 再建一条`) +
|
||||
P08_TABLE +
|
||||
addrow('parts.profile', '+ 加一行(再建一条记录)', '新行的内部料号必须写(它是这一行的钥匙);其余必填列全部填写后才允许确认;删一行 = 该记录不建') +
|
||||
`<p class="zone tbl-gap">${ZIC_TABLE}<b>【创建什么 · 表 2】</b>—— 标准化参数集(每行一个参数,按所属料号归属,*必填)</p>` +
|
||||
gapbar('parts_parameter', '必填') +
|
||||
editGate('parts.parameter', `<b>已铺 <span data-slot="parts_parameter_rows"></span> 行参数</b>(有 datasheet 的已按资料预填),点「编辑」核对修正;「+ 加一行」补充未列出的参数`) +
|
||||
P08_PARAMS_TABLE +
|
||||
addrow('parts.parameter', '+ 加一行(补充参数)', '所属料号 / 参数名 / 值 / 单位 / 约束类型齐全;「未披露」请直接写未披露,将进入 UNKNOWN 并挂验证任务;「说明」列为系统注释,无需填写') +
|
||||
flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'),
|
||||
});
|
||||
|
||||
/* ==================== 局面 0′ · 数据工单 P-10(复用「数据工单-01」的样式与骨架,没有独立目标件)==================== */
|
||||
|
||||
const P10_TABLE = `<div class="jc-tbl-wrap"><table>
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th class="req" data-h="关联料号">关联料号 internal_pn<span class="thd">对应 P-08 那条记录</span></th>
|
||||
<th class="req" data-h="资料序号">资料序号 doc_seq<span class="thd">同一料号下 01/02… 自己续</span></th>
|
||||
<th class="req" data-h="资料类型">资料类型 document_type</th>
|
||||
<th data-h="品牌名称">品牌 brand_name</th>
|
||||
<th class="req" data-h="资料标题">资料标题 document_title</th>
|
||||
<th class="req" data-h="资料版本">资料版本 document_version</th>
|
||||
<th class="req" data-h="文件链接">文件链接 document_ref</th>
|
||||
<th data-h="证据定位">证据定位 citation_locator<span class="thd">页码/章节/表格</span></th>
|
||||
<th class="req" data-h="资料状态">资料状态 document_status</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:parts.document" data-edit="parts.document"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="parts.document">×</button></td>
|
||||
<td>${cell('internal_pn', 'mono')}</td>
|
||||
<td>${cell('doc_seq', 'mono')}</td>
|
||||
<td>${badge('stx', 'document_type', true)}${select('document_type', [['datasheet', 'Datasheet'], ['app_note', '应用笔记'], ['reference_design', '参考设计'], ['white_paper', '白皮书'], ['sample_doc', '样片资料'], ['commitment_letter', '承诺函'], ['other', '其他']], true)}</td>
|
||||
<td>${cell('brand_name')}</td>
|
||||
<td>${cell('document_title')}</td>
|
||||
<td>${cell('document_version')}</td>
|
||||
<td>${cell('document_ref', 'mono')}</td>
|
||||
<td>${cell('citation_locator')}</td>
|
||||
<td>${badge('stx', 'document_status', true)}${select('document_status', [['valid', '有效'], ['expired', '过期'], ['pending', '待核实'], ['void', '作废']], true)}</td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
export const P10_DOCUMENT_CREATE = doc({
|
||||
css: P08_CSS,
|
||||
alias: BIND_P08,
|
||||
badge: '数据',
|
||||
hic: HIC,
|
||||
tag: '数据工单',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估 · 数据工单 2/2',
|
||||
title: '原厂技术资料创建(P-10)',
|
||||
step: '步骤 0.5 · 等待数据',
|
||||
footTag: '工单类型:数据',
|
||||
body:
|
||||
zone('做什么', '为本单新建的料号登记原厂资料(P-10)', ZIC_TASK) +
|
||||
note([
|
||||
'<span class="need">模板定位</span>:与 P-08 同一套「数据工单」统一模板——目标台账 = <b>原厂技术资料库(P-10)</b>;一份资料一行,同一料号多份资料序号 01/02… 自己续。',
|
||||
'<span class="need">触发原因</span>:按内部料号查原厂技术资料库<b>查无记录</b>(P-08 那张先确认过,这一批料号才会走到这里)。',
|
||||
'<span class="need">为什么要它</span>:差异明细的证据引用要指到具体资料的版本与页码——没有这一行,结论就追溯不到来源。',
|
||||
]) +
|
||||
zone('创建什么', '资料记录(每行一条,*为必填列)', ZIC_TABLE) +
|
||||
gapbar('parts_document', '必填') +
|
||||
editGate('parts.document', `<b>待建 <span data-slot="parts_document_rows"></span> 条资料记录</b>,点「编辑」补齐`) +
|
||||
P10_TABLE +
|
||||
addrow('parts.document', '+ 加一行(再登记一份资料)', '关联料号与序号是这一行的钥匙,新行都要写;必填列全齐才允许确认;删一行 = 该资料不建') +
|
||||
flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。'),
|
||||
});
|
||||
|
||||
/* ==================== 局面 1 · 评估输入清单(搬自「执行工单-01」)==================== */
|
||||
|
||||
const PN_CHECK_TABLE = `<div class="jc-tbl-wrap"><table>
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th data-h="料号">料号</th>
|
||||
<th data-h="角色">角色</th>
|
||||
<th data-h="在库状态">在库状态</th>
|
||||
<th data-h="备注/放弃原因">备注 / 放弃原因</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:${D}.pn_check"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||
<td>${ro('internal_pn', 'mono')}</td>
|
||||
<td><span class="jc-vcell ro"><span class="rl" data-class-of="role"></span></span></td>
|
||||
<td>${badge('st', 'exists_status', true)}</td>
|
||||
<td>${ro('note')} ${ro('abandon_reason')}</td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
const GAP_TABLE = `<div class="jc-tbl-wrap"><table id="tbl">
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th style="width:120px" data-h="键">键</th>
|
||||
<th data-h="输入项">输入项</th>
|
||||
<th data-h="来源">来源</th>
|
||||
<th data-h="状态">状态</th>
|
||||
<th data-h="绑定值/缺口说明">绑定值 / 缺口说明</th>
|
||||
<th data-h="补充内容">补充内容</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:${D}.input_gap" data-edit="${D}.input_gap"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行" data-edit-del="${D}.input_gap">×</button></td>
|
||||
<td>${cell('item_key', 'mono')}</td>
|
||||
<td>${cell('input_item')}</td>
|
||||
<td>${cell('source_ledger', 'mono')}</td>
|
||||
<td>${select('status', [['gap', '待补'], ['default', '默认·可改'], ['ok', '齐备']], true)}${badge('st', 'status')}</td>
|
||||
<td>${cell('detail')}</td>
|
||||
<td class="sup" data-class-of="status">${cell('supplement')}</td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
export const EVAL_INPUT_LIST = doc({
|
||||
css: EXEC_CSS,
|
||||
alias: BIND_EXEC,
|
||||
badge: '执行',
|
||||
tag: '执行工单',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1',
|
||||
title: '评估输入与资料补齐确认',
|
||||
step: '步骤 1/5 · 建立评估上下文',
|
||||
footTag: '工单类型:执行',
|
||||
body:
|
||||
zone('做什么', '启动 4 项已绑定,其余信息在工单里补齐') +
|
||||
note([
|
||||
'<span class="done">AI 已完成</span>:系统已按启动传入的 <b>4 项</b>完成绑定——客户、客户需求(新建 P-01 · 版本 V1)、原 PN、候选 PN(数组,逐个评估)——并生成输入快照。',
|
||||
'<span class="need">需要你补齐</span>:启动时未收的信息(应用场景、BOM 位)<b>直接在下表补</b>;默认值(替代类型 / 规则版本 / 负责人期限)可确认可改。本单缺口只覆盖「<b>有而不全</b>」;「完全没有」(料号不在库)已由数据工单创建。未补齐的缺口项将按 <b>UNKNOWN</b> 进入差异评估。',
|
||||
]) +
|
||||
zone('料号存在性核对', '只读参考 · 与下表「补充内容」互不冲突') +
|
||||
PN_CHECK_TABLE +
|
||||
zone('交回什么', '评估输入清单(已绑定项照实核对,缺口项补齐,*为必填)') +
|
||||
gapbar(`${D}_input_gap`, '补充内容') +
|
||||
editGate(`${D}.input_gap`, `<b>还有 <span data-slot="${D}_input_gap_missing"></span> 项待补 / 待确认</b>,点「编辑」在「补充内容」列填入 —— 已绑定那几行的「输入项/来源/绑定值」抄的是启动入参,发现记录本身有误请退回触发方重新启动,不要在这里改`) +
|
||||
GAP_TABLE +
|
||||
addrow(`${D}.input_gap`, '+ 加一行(补充资料项)', '新行的「键」必须写(它是这一行的钥匙,写一个英文短名,如 thermal_note);「输入项」与「补充内容」也是必填;删一行 = 该输入项不适用;补不了的写「留 UNKNOWN」或「挂验证任务」') +
|
||||
flow(
|
||||
'<b>审批 / 提交:</b>本工单只承载内容;「提交」请在对话区的【执行卡】中进行。<b>提交后去向:</b>进入步骤 2 · AI 读取与标准化,仍缺失的项将按 UNKNOWN 进入差异评估。<b>注意:</b>启动参数如有误,需退回触发方修订后重新触发,本单不可改。',
|
||||
),
|
||||
});
|
||||
|
||||
/* ==================== 局面 2 · 差异评估结论审核(搬自「验收工单-01」)==================== */
|
||||
|
||||
const RELATION_SUMMARY_TABLE = `<div class="tbl-wrap"><table>
|
||||
<thead><tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th data-h="候选料号">候选料号</th>
|
||||
<th data-h="替代类型">替代类型</th>
|
||||
<th data-h="推荐等级">推荐等级</th>
|
||||
<th data-h="风险等级">风险等级</th>
|
||||
<th data-h="推荐理由">推荐理由</th>
|
||||
<th data-h="差异摘要">差异摘要</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:substitution.relation"><tr>
|
||||
<td class="num"></td>
|
||||
<td class="mono" data-slot="candidate_internal_pn" data-blank></td>
|
||||
<td><span class="enumtxt" data-class-of="substitution_type"></span></td>
|
||||
<td>${badge('st rec', 'recommendation_level')}</td>
|
||||
<td>${badge('risk', 'risk_level')}</td>
|
||||
<td class="reason" data-slot="recommendation_reason" data-blank></td>
|
||||
<td class="reason" data-slot="difference_summary" data-blank></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
const DIFFERENCE_ITEM_TABLE = `<div class="tbl-wrap"><table>
|
||||
<thead><tr><th style="width:40px">#</th><th data-h="参数项 parameter_name">参数项 parameter_name</th><th data-h="原PN值 original_value">原 PN 值 original_value</th><th data-h="候选PN值 candidate_value">候选 PN 值 candidate_value</th><th data-h="单位 parameter_unit">单位 parameter_unit</th><th data-h="约束类型 constraint_type">约束类型 constraint_type</th><th data-h="结论 difference_status">结论 difference_status</th><th data-h="风险等级">风险等级</th><th data-h="证据引用 evidence_ref">证据引用 evidence_ref</th><th data-h="AI置信度">AI 置信度</th></tr></thead>
|
||||
<tbody data-rows="mine:substitution.difference_item"><tr>
|
||||
<td class="num"></td>
|
||||
<td class="cust" data-slot="parameter_name" data-blank></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><span class="con" data-class-of="constraint_type"></span></td>
|
||||
<td>${badge('st dst', 'difference_status')}</td>
|
||||
<td>${badge('risk', 'risk_level')}</td>
|
||||
<td class="mono" data-slot="evidence_ref" data-blank></td>
|
||||
<td class="mono" data-slot="confidence" data-blank></td>
|
||||
</tr></tbody>
|
||||
</table></div>`;
|
||||
|
||||
export const DIFFERENCE_REVIEW = doc({
|
||||
css: ACC1_CSS,
|
||||
alias: BIND_ACC1,
|
||||
badge: '验收',
|
||||
tag: '验收工单',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2',
|
||||
title: '差异评估结论审核',
|
||||
step: '步骤 3~4/5 · 逐项差异评估 + FAE/RI 审核',
|
||||
footTag: '工单类型:验收',
|
||||
body:
|
||||
zone('AI 结论说明', '结论是怎么算出来的') +
|
||||
note([
|
||||
'<b>AI 已完成的计算</b>:按 GOV 评估规则(锁定版本 <b class="mono" data-slot="rule_version" data-blank></b>),对原 PN <b class="mono" data-slot="original_internal_pn" data-blank></b> 与每个候选逐项比对,产出四态结论——<b>MATCH / MISMATCH / UNKNOWN / CONDITIONAL</b>,并给出风险等级、证据引用与置信度。差异明细 = <b>P-11 差异明细子表</b>(一条参数差异一条记录);当前为 AI 草稿,<b>FAE 审核通过前不落账</b>。评估参数可能含本次数据工单创建的主数据(确认人背书;未附 datasheet 的参数项会显式标注「人工录入」)。',
|
||||
]) +
|
||||
criteria('判断这份差异评估结论是否可信,请逐项核对', [
|
||||
'<b>关键参数差异均有明确状态</b>:逐项结论无漏项,每个参数都有四态之一的判定;',
|
||||
'<b>UNKNOWN 项均有去向</b>:每个 UNKNOWN 都挂了验证任务或补来源动作,不允许悬空;',
|
||||
'<b>证据链完整</b>:每项结论可追溯至 P-08 / P-10 / P-06 的具体记录,或本单数据工单的创建记录(标注:人工录入);',
|
||||
'<b>硬约束项未被自动判 MATCH</b>:封装 / 工艺等硬约束未经证据支持不得判 MATCH。',
|
||||
]) +
|
||||
zone('各候选推荐等级', '只读展示(P-11 主记录草稿,一个候选一条)') +
|
||||
RELATION_SUMMARY_TABLE +
|
||||
zone('AI 产出的差异明细', '只读展示(P-11 差异明细子表草稿 · 一条参数一条记录 difference_item_id)') +
|
||||
DIFFERENCE_ITEM_TABLE +
|
||||
flow(
|
||||
'<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。审核意见与补充条件写入 P-06(验证要求 verification_requirement);<b>通过后去向:</b>进入步骤 5 · 评审放行与落账确认。',
|
||||
),
|
||||
});
|
||||
|
||||
/* ==================== 局面 3 · 评审放行与落账确认(搬自「验收工单-02」)==================== */
|
||||
|
||||
/** 字段:值 两列表 —— 逐字节搬自目标件「拟写 P-11」「拟回写 P-01」的版式(一行一个字段),
|
||||
* 用 `data-rows` 包一整张小表当行模版,一个候选 / 一条需求各重复出一份,天然支持多候选。 */
|
||||
const P11_FIELD_TABLE = `<div class="tbl-wrap"><table>
|
||||
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
||||
<tbody data-rows="mine:substitution.relation">
|
||||
<tr><td class="num"></td><td class="cust">替代类型 substitution_type</td><td><span class="enumtxt" data-class-of="substitution_type"></span></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">原内部料号 original_internal_pn</td><td class="mono" data-slot="original_internal_pn" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">候选内部料号 candidate_internal_pn</td><td class="mono" data-slot="candidate_internal_pn" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">推荐等级 recommendation_level</td><td>${badge('st', 'recommendation_level')} <span class="reason">(枚举:推荐 / 条件推荐 / 备选 / 不推荐,四态之一)</span></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">推荐理由 recommendation_reason</td><td data-slot="recommendation_reason" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">差异摘要 difference_summary</td><td data-slot="difference_summary" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">替代分析报告 analysis_report_ref</td><td class="mono" data-slot="analysis_report_ref" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">分析状态 analysis_status</td><td>${badge('st', 'analysis_status')} <span class="reason">(枚举:AI草稿 / FAE确认 / 待客户验证 / 客户已批准 / 不批准 / 作废)</span></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">客户批准状态 customer_approval_status</td><td>未提交 <span class="reason">(由审批流程维护,本活动不产生)</span></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">风险等级 risk_level</td><td>${badge('risk', 'risk_level')}</td></tr>
|
||||
<tr><td class="num"></td><td class="cust">验证要求 verification_requirement</td><td class="mono" data-slot="verification_requirement" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">证据可信度 confidence_score</td><td class="mono" data-slot="confidence_score" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">差异规则版本 rule_version</td><td class="mono" data-slot="rule_version" data-blank></td></tr>
|
||||
</tbody>
|
||||
</table></div>`;
|
||||
|
||||
const P01_FIELD_TABLE = `<div class="tbl-wrap"><table>
|
||||
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
||||
<tbody data-rows="mine:demand.request">
|
||||
<tr><td class="num"></td><td class="cust">风险等级 risk_level</td><td>${badge('risk', 'risk_level')}</td></tr>
|
||||
<tr><td class="num"></td><td class="cust">验证要求 verification_requirement</td><td class="mono" data-slot="verification_requirement" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">输出链接 output_ref</td><td class="mono" data-slot="output_ref" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">下一步动作 next_action</td><td data-slot="next_action" data-blank></td></tr>
|
||||
<tr><td class="num"></td><td class="cust">当前阶段 current_stage</td><td>${badge('st', 'current_stage')}</td></tr>
|
||||
<tr><td class="num"></td><td class="cust">需求状态 request_status</td><td>${badge('st', 'request_status')}</td></tr>
|
||||
</tbody>
|
||||
</table></div>`;
|
||||
|
||||
export const LANDING_CONFIRM = doc({
|
||||
css: ACC2_CSS,
|
||||
alias: BIND_ACC2,
|
||||
badge: '验收',
|
||||
tag: '验收工单',
|
||||
kicker: 'SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2',
|
||||
title: '评审放行与落账确认',
|
||||
step: '步骤 5/5 · 提交 ACT-GATE-03',
|
||||
footTag: '工单类型:验收',
|
||||
body:
|
||||
zone('AI 结论说明', '本单确认什么') +
|
||||
note([
|
||||
'<b>本活动以 ACT-GATE-03 评审放行为终点。</b>本单确认拟落账内容:评估结论层写 <b>P-11 替代关系主记录</b>(替代类型 / 原内部料号 / 候选内部料号 / 推荐等级 / 推荐理由 / 差异摘要 / 替代分析报告 / 分析状态 / 风险等级 / 验证要求 / 证据可信度 / 差异规则版本),需求状态回写 <b>P-01</b>(风险等级 / 验证要求 / 输出链接 / 下一步动作 / 当前阶段)。<b>客户批准不属于本活动</b>——客户批准状态由审批流程维护,本单只写分析层(分析状态 = FAE 确认),customer_approval_status 保持「未提交」,不由差异评估产生。',
|
||||
]) +
|
||||
criteria('放行与落账前,请逐项核对', [
|
||||
'<b>推荐等级与差异明细一致</b>:差异明细中存在 UNKNOWN / CONDITIONAL 时,推荐等级不得给「推荐」;',
|
||||
'<b>分析状态与客户批准状态分离</b>:本单只写分析层(分析状态 = FAE 确认),客户批准状态保持「未提交」;',
|
||||
'<b>GOV 规则版本已锁定</b>:规则版本随结论一并落账;',
|
||||
'<b>回写 P-01 字段完整</b>:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全。',
|
||||
]) +
|
||||
zone('拟写 P-11 · 替代关系主记录', '只读展示') +
|
||||
P11_FIELD_TABLE +
|
||||
zone('拟回写 P-01 · 需求状态', '只读展示') +
|
||||
P01_FIELD_TABLE +
|
||||
flow(
|
||||
'<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。<b>通过后去向:</b>在对话区卡提交 ACT-GATE-03 评审放行;放行后结论供 ACT-WORK-08 证据归集消费。',
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的四份目标件样式表 —— **机械抽取,一个数值都不许改**
|
||||
* (《HTML报告生成规范》§7:有目标件时「搬」,不要「照着做」;样式表整段搬,标记逐块搬)。
|
||||
*
|
||||
* 来源(`docs/SS02-ACT-WORK-05-html/`):
|
||||
* · `P08_CSS` ← `ACT-WORK-05-数据工单-01-芯片主数据创建.html` 的两段 `<style>`
|
||||
* (该文件头部连续放了两个 `<style>` 元素:第一段是基座壳,第二段标着
|
||||
* 「视觉补充段 v3」——`.hic`/`.zic`/`.thd`/`#tbl1`/`#tbl2` 等数据工单专属样式。
|
||||
* 两段按文档原有顺序首尾相接存成一个常量;等价于浏览器解析两个 `<style>` 标签,
|
||||
* 级联结果与原文件逐字节一致,已用 diff 验证,见 task-5-report.md);
|
||||
* · `EXEC_CSS` ← `ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html` 的 `<style>`;
|
||||
* · `ACC1_CSS` ← `ACT-WORK-05-验收工单-01-差异评估结论审核.html` 的 `<style>`;
|
||||
* · `ACC2_CSS` ← `ACT-WORK-05-验收工单-02-评审放行与落账确认.html` 的 `<style>`。
|
||||
*
|
||||
* `p10-document-create`(P-10 原厂技术资料创建)**没有自己的目标件**——规格里数据工单是
|
||||
* 「一个统一模板按目标台账实例化」,复用 `P08_CSS`(同一套壳,只是表头列换成 P-10 的字段)。
|
||||
*
|
||||
* 抽取用 `sed -n` 按行号截取 `<style>`/`</style>` 之间的原文,再拿抽出来的内容包回
|
||||
* `<style>…</style>` 与源文件同一行区间做 `diff`,四份全部「零差异」——过程写在
|
||||
* task-5-report.md,这里不重复贴证据。
|
||||
*
|
||||
* 这份文件只装**目标件自己的样式**,不装平台绑定用的开关(三态占位、缺项红带、
|
||||
* `data-class-of` 的 `v-*` 别名这类)——那些是本活动自己加出来的东西,活在
|
||||
* `report.ts` 的 `BIND_CSS_*` 常量里,两处不要混。
|
||||
*/
|
||||
|
||||
export const P08_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;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--data);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(51,112,255,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0 0 6px;font-size:13px;color:var(--ink-2)}
|
||||
.note p:last-child{margin-bottom:0}
|
||||
.note b{color:var(--ink)}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--data);font-weight:700}
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;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:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
|
||||
.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(--brand-wash);border-color:var(--data-line);color:#1e40af}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
||||
table.on td>.jc-vcell{display:none}
|
||||
table.on td>.jc-cell{display:block}
|
||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
table.on td.rn .jc-rn-num{display:none}
|
||||
table.on td.rn .jc-rowdel{display:block}
|
||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:数据工单专用标识(仅追加,不改动基件样式) */
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.pre{font-style:normal;font-size:10.5px;font-weight:800;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:0 7px;margin-left:6px;white-space:nowrap}
|
||||
table.on td>.pre{display:none}
|
||||
.ct{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.ct.hard{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
||||
.ct.soft{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
.tbl-gap{margin-top:18px}
|
||||
/* ===== 视觉补充段 v3(通用批量模板:字段即表头、每行一条;不改基件样式与 JS 钩子) ===== */
|
||||
.hic{flex:none;width:26px;height:26px;color:var(--data)}
|
||||
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
|
||||
/* 表头内字段说明(第二行小字) */
|
||||
.thd{display:block;font-size:10px;font-weight:600;color:var(--ink-4);letter-spacing:0;margin-top:1px;line-height:1.4}
|
||||
/* 表 1:字段即表头,内容多列,允许横向滚动 */
|
||||
#tbl1{min-width:1240px}
|
||||
#tbl1 .jc-vcell{display:inline}
|
||||
/* 表 2 列宽 */
|
||||
#tbl2 th:nth-child(2){width:150px}
|
||||
#tbl2 th:nth-child(3){width:18%}
|
||||
#tbl2 th:nth-child(4){width:14%}
|
||||
#tbl2 th:nth-child(5){width:8%;text-align:center;white-space:nowrap}
|
||||
#tbl2 th:nth-child(6){width:13%;text-align:center;white-space:nowrap}
|
||||
#tbl2 td:nth-child(5),#tbl2 td:nth-child(6){text-align:center;white-space:nowrap}
|
||||
/* 说明列(只读注释,编辑态保持可见) */
|
||||
.desc{color:var(--ink-3);font-size:12px;line-height:1.5}
|
||||
table.on td>.jc-vcell.ro{display:block}
|
||||
.tbl-wrap{overflow-x:auto}
|
||||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
||||
`;
|
||||
|
||||
export const EXEC_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;
|
||||
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--human);font-weight:700}
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;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:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--human);border-color:var(--human)}
|
||||
.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(--human-soft);border-color:var(--human-line);color:#7c4a12}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
||||
table.on td>.jc-vcell{display:none}
|
||||
table.on td>.jc-cell{display:block}
|
||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
table.on td.rn .jc-rn-num{display:none}
|
||||
table.on td.rn .jc-rowdel{display:block}
|
||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:评估输入清单状态标识(仅追加,不改动基件样式) */
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.st{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.st.bound{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#c9ecc2}
|
||||
.st.miss{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
.st.def{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||
tr.ro td{background:var(--soft)}
|
||||
table.on td>.jc-vcell.ro{display:block}
|
||||
`;
|
||||
|
||||
export const ACC1_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;
|
||||
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
||||
.criteria li{margin:4px 0}
|
||||
.criteria b{color:var(--ink)}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
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)}
|
||||
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
||||
.cust{font-weight:700}
|
||||
.prio{font-family:var(--mono);font-weight:800}
|
||||
.prio.hi{color:var(--danger)}
|
||||
.prio.mid{color:var(--ink-2)}
|
||||
.reason{font-size:12.5px;color:var(--ink-2)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:四态结论 pill 与风险等级(仅追加,不改动基件样式) */
|
||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);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:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
||||
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||
.risk{font-weight:800;font-size:12.5px}
|
||||
.risk.low{color:var(--ok)}
|
||||
.risk.mid{color:#8f6a12}
|
||||
.risk.hi{color:var(--danger)}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
`;
|
||||
|
||||
export const ACC2_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;
|
||||
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
||||
.criteria li{margin:4px 0}
|
||||
.criteria b{color:var(--ink)}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
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)}
|
||||
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
||||
.cust{font-weight:700}
|
||||
.prio{font-family:var(--mono);font-weight:800}
|
||||
.prio.hi{color:var(--danger)}
|
||||
.prio.mid{color:var(--ink-2)}
|
||||
.reason{font-size:12.5px;color:var(--ink-2)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:推荐等级 pill 与状态强调(仅追加,不改动基件样式) */
|
||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);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:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
||||
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||
.risk{font-weight:800;font-size:12.5px}
|
||||
.risk.mid{color:#8f6a12}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.tbl-gap{margin-top:18px}
|
||||
`;
|
||||
+72
-2
@@ -5,8 +5,27 @@
|
||||
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
|
||||
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
|
||||
*/
|
||||
import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts';
|
||||
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||
import type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts';
|
||||
export type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||
/**
|
||||
* 目录写面的两个入参 —— 住在 contract.ts 而不是 port.ts:它们是**服务出口的形状**
|
||||
* (路由照它填),port 只是恰好也要用。反过来放会让 port ↔ contract 成环。
|
||||
*/
|
||||
export type CreateDirectoryUser = {
|
||||
readonly username: string;
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
/** 临时口令;不给就建一个还登不进来的壳 */
|
||||
readonly password?: string;
|
||||
/** 一并授的 realm 角色 */
|
||||
readonly roles?: readonly string[];
|
||||
};
|
||||
export type DirectoryUserPatch = {
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
};
|
||||
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
||||
export type StaffWithPosition = Staff & {
|
||||
readonly position: Position;
|
||||
@@ -47,6 +66,24 @@ export declare class AccountDisabledError extends Error {
|
||||
export declare class AccessLockoutError extends Error {
|
||||
constructor(message?: string);
|
||||
}
|
||||
/**
|
||||
* 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。
|
||||
* **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。
|
||||
*/
|
||||
export declare class DirectoryUnavailableError extends Error {
|
||||
/** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */
|
||||
readonly misconfigured: boolean;
|
||||
constructor(message: string, misconfigured: boolean);
|
||||
}
|
||||
/**
|
||||
* 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`),
|
||||
* 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。
|
||||
* 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。
|
||||
*/
|
||||
export declare class ServiceAccountProtectedError extends Error {
|
||||
readonly account: string;
|
||||
constructor(account: string);
|
||||
}
|
||||
export type OrgService = {
|
||||
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
|
||||
authenticate(token: string): Promise<Principal>;
|
||||
@@ -75,6 +112,39 @@ export type OrgService = {
|
||||
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;
|
||||
|
||||
+7
-3
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
|
||||
export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
|
||||
export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts';
|
||||
export type { CreateDirectoryUser, DirectoryUserPatch, Identity, InFlightWork, OrgGroup, OrgRole, OrgService, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
|
||||
export { AccessLockoutError, AccountDisabledError, DirectoryUnavailableError, PositionNotFoundError, ServiceAccountProtectedError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
|
||||
export type { Clock, DirectoryGroup, DirectoryRole, DirectoryUser, IdGenerator, IdentityPatch, InFlightWorkProbe, KeycloakDirectory, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts';
|
||||
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
||||
export { createOrgService } from './internal/usecase/org-service.ts';
|
||||
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
||||
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
||||
export { displayNameOf, filterOrgUsers, isServiceAccount, sortOrgUsers, toOrgGroups, toOrgRoles, toOrgUser, } from './internal/domain/directory.ts';
|
||||
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
||||
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
||||
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
|
||||
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
||||
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
||||
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
||||
export { createKeycloakDirectory } from './internal/adapter/keycloak-directory.ts';
|
||||
export { createAdminHttp, KeycloakMisconfiguredError, KeycloakRejectedError, KeycloakUnavailableError } from './internal/adapter/keycloak-admin-http.ts';
|
||||
export type { AdminHttp, KeycloakAdminOptions } from './internal/adapter/keycloak-admin-http.ts';
|
||||
export { createPositionRepository, createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, } from './internal/adapter/org-repository.ts';
|
||||
export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
|
||||
|
||||
+72
-3
@@ -3,7 +3,7 @@
|
||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||
*/
|
||||
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
import type { Identity } from './contract.ts';
|
||||
import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
@@ -11,7 +11,12 @@ export type Clock = {
|
||||
export type VerifiedClaims = {
|
||||
readonly sub: string;
|
||||
readonly preferred_username: string;
|
||||
/** Keycloak 拼好的 `firstName lastName` —— 西式顺序,只在拆不开时才用它 */
|
||||
readonly name?: string;
|
||||
/** 名(firstName) */
|
||||
readonly given_name?: string;
|
||||
/** 姓(lastName)—— 我们按「姓在前」自己拼,见 `personName` */
|
||||
readonly family_name?: string;
|
||||
readonly email?: string;
|
||||
readonly realm_access?: {
|
||||
readonly roles?: readonly string[];
|
||||
@@ -28,6 +33,65 @@ export type RosterUser = Identity & {
|
||||
export type RosterSource = {
|
||||
listUsers(): Promise<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;
|
||||
@@ -45,7 +109,12 @@ export type PositionRepository = {
|
||||
}): Promise<Position>;
|
||||
};
|
||||
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||||
export type StaffRecord = Staff & {
|
||||
/**
|
||||
* 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`),
|
||||
* 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。
|
||||
*/
|
||||
export type StoredStaff = Omit<Staff, 'kind'>;
|
||||
export type StaffRecord = StoredStaff & {
|
||||
readonly lastSeenAt?: Date;
|
||||
readonly syncedAt?: Date;
|
||||
};
|
||||
@@ -55,7 +124,7 @@ export type StaffRepository = {
|
||||
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
|
||||
findByAccount(account: string): Promise<StaffRecord | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||
insert(staff: Staff, marks: {
|
||||
insert(staff: StoredStaff, marks: {
|
||||
readonly lastSeenAt?: Date;
|
||||
readonly syncedAt?: Date;
|
||||
}): Promise<StaffRecord>;
|
||||
|
||||
@@ -19,6 +19,10 @@ export declare const listStaffRoute: {
|
||||
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;
|
||||
@@ -55,6 +59,10 @@ export declare const createStaffRoute: {
|
||||
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 */
|
||||
@@ -266,6 +274,10 @@ export declare const setStaffPositionRoute: {
|
||||
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;
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ 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 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */
|
||||
export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone"];
|
||||
export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone", "keycloak-misconfigured", "keycloak-unavailable"];
|
||||
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
|
||||
export type ApiError = {
|
||||
readonly code: ApiErrorCode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './http.ts';
|
||||
export * from './workbench.ts';
|
||||
export * from './governance.ts';
|
||||
export * from './org-directory.ts';
|
||||
export * from './ingest.ts';
|
||||
export * from './mail.ts';
|
||||
|
||||
@@ -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>;
|
||||
}];
|
||||
@@ -20,6 +20,10 @@ export declare const sessionRoute: {
|
||||
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">;
|
||||
@@ -80,6 +84,10 @@ export declare const loginRoute: {
|
||||
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">;
|
||||
@@ -158,6 +166,10 @@ export declare const meRoute: {
|
||||
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">;
|
||||
@@ -891,6 +903,7 @@ 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>>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -1475,6 +1488,7 @@ 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>>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -2796,6 +2810,7 @@ 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>>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -3380,6 +3395,7 @@ 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>>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
|
||||
+2
@@ -857,6 +857,7 @@ 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>>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
@@ -1470,6 +1471,7 @@ 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>>;
|
||||
gate: z.ZodOptional<z.ZodObject<{
|
||||
ready: z.ZodBoolean;
|
||||
why: z.ZodString;
|
||||
|
||||
+28
@@ -19,6 +19,22 @@ 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;
|
||||
@@ -30,6 +46,10 @@ export declare const staffSchema: z.ZodObject<{
|
||||
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<{
|
||||
@@ -44,6 +64,10 @@ export declare const principalSchema: z.ZodObject<{
|
||||
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">;
|
||||
@@ -226,6 +250,10 @@ export declare const sessionSchema: z.ZodObject<{
|
||||
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">;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"seed": "node dist/seed.js",
|
||||
"migrate": "node dist/migrate.js",
|
||||
"reset-runs": "node dist/reset-runs.js",
|
||||
"install-activities": "node dist/install-activities.js"
|
||||
"install-activities": "node dist/install-activities.js",
|
||||
"sync-roster": "node dist/sync-roster.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.14",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createTicketIssuer } from '../ticket.ts';
|
||||
|
||||
const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' };
|
||||
const principal = (ends: Surface[], roles: string[]): Principal => ({
|
||||
staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles },
|
||||
staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles, kind: 'person' },
|
||||
position,
|
||||
roles,
|
||||
ends,
|
||||
|
||||
@@ -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,160 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。
|
||||
* 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵:
|
||||
* A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/
|
||||
* C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';
|
||||
|
||||
const handler = (name: string) => {
|
||||
const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
|
||||
if (found === undefined) throw new Error(`没有工具 ${name}`);
|
||||
return found[1];
|
||||
};
|
||||
/** ToolHandler 的入参形状见 `apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts`:纯工具不用连接目标 */
|
||||
const call = async (name: string, input: Record<string, unknown>) => {
|
||||
const out = await handler(name)({ input, connection: {}, operation: undefined, idempotencyKey: undefined });
|
||||
if (!out.ok) throw new Error(`工具失败:${JSON.stringify(out)}`);
|
||||
return out.output as Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string };
|
||||
type Result = { candidateInternalPn: string; items: Item[]; counts: Record<string, number> };
|
||||
|
||||
const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record<string, string> = {}) => ({
|
||||
parameterName,
|
||||
originalValue,
|
||||
candidateValue,
|
||||
constraintType,
|
||||
evidenceRef: 'P-08 · P-10 Rev H',
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('边界上的 null:模型给 null 不该把工具打崩', () => {
|
||||
it('**requirement / evidenceRef 给 null 照常算**(2026-08-27 W05 e2e 实测:模型写 `requirement: null` 而不是省掉,`null.trim()` 当场崩,整次调用记成「行动被拒」)', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'LM2596S-ADJ',
|
||||
pairs: [
|
||||
{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '1.23~37', constraintType: 'hard', unit: 'V', requirement: null, evidenceRef: null },
|
||||
{ parameterName: '输入电压范围', originalValue: '4.5~40', candidateValue: '4.5~40', constraintType: 'hard', unit: 'V', requirement: '>= 40', evidenceRef: 'P-10 Rev H' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const items = (out as { results: Array<{ items: Array<{ parameterName: string; differenceStatus: string }> }> }).results[0]!.items;
|
||||
expect(items.find((i) => i.parameterName === '输出电压')?.differenceStatus).toBe('conditional');
|
||||
expect(items.find((i) => i.parameterName === '输入电压范围')?.differenceStatus).toBe('match');
|
||||
});
|
||||
|
||||
it('硬约束的 evidenceRef 给 null = 没给证据 → 不判 MATCH,退成 CONDITIONAL', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'X',
|
||||
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', constraintType: 'hard', evidenceRef: null }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((out as { results: Array<{ items: Array<{ differenceStatus: string }> }> }).results[0]!.items[0]!.differenceStatus).toBe('conditional');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compare-parameters:逐参数四态', () => {
|
||||
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'XL2596S-5.0',
|
||||
pairs: [
|
||||
pair('工作温度', '-40~+85', '-40~+125', 'hard', { unit: '℃' }),
|
||||
pair('输出电压', '5.0', '5.0', 'hard', { unit: 'V' }),
|
||||
pair('使能阈值', '1.4', '未披露', 'hard', { unit: 'V' }),
|
||||
pair('静态电流', '5', '4.2', 'soft', { unit: 'mA' }),
|
||||
],
|
||||
},
|
||||
{
|
||||
candidateInternalPn: 'LM2596S-ADJ',
|
||||
pairs: [pair('输出电压', '5.0', '1.23~37', 'hard', { unit: 'V' })],
|
||||
},
|
||||
],
|
||||
});
|
||||
const results = out['results'] as Result[];
|
||||
const a = results[0]!;
|
||||
const b = results[1]!;
|
||||
expect(out['ruleVersion']).toBe('R-2026.07');
|
||||
const by = (r: Result, name: string) => r.items.find((i) => i.parameterName === name);
|
||||
expect(by(a, '工作温度')).toMatchObject({ compareResult: 'covers', differenceStatus: 'match', riskLevel: 'low' });
|
||||
expect(by(a, '输出电压')).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
||||
expect(by(a, '使能阈值')).toMatchObject({ compareResult: 'unknown', differenceStatus: 'unknown', riskLevel: 'medium' });
|
||||
expect(by(a, '静态电流')).toMatchObject({ differenceStatus: 'conditional' }); // 软约束数值不同 → 条件
|
||||
expect(a.counts).toMatchObject({ match: 2, unknown: 1, conditional: 1, mismatch: 0, hardMismatch: 0 });
|
||||
expect(by(b, '输出电压')).toMatchObject({ compareResult: 'configurable', differenceStatus: 'conditional' });
|
||||
expect(b.counts['configurable']).toBe(1);
|
||||
});
|
||||
|
||||
it('硬约束范围变窄 → MISMATCH 高风险;客户约束(requirement)不满足也是 MISMATCH', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'CN-DCDC-32V',
|
||||
pairs: [
|
||||
pair('输入电压范围', '4.5~40', '4.5~32', 'hard', { unit: 'V', requirement: '>= 40' }),
|
||||
pair('封装', 'TO-263-5', 'TO-263-5', 'hard'),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const c = (out['results'] as Result[])[0]!;
|
||||
expect(c.items[0]).toMatchObject({ compareResult: 'narrower', differenceStatus: 'mismatch', riskLevel: 'high' });
|
||||
expect(c.items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
||||
expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
|
||||
});
|
||||
|
||||
it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
|
||||
});
|
||||
const x = (out['results'] as Result[])[0]!;
|
||||
expect(x.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'conditional' });
|
||||
});
|
||||
|
||||
it('数值带单位/括号注记也认得:「5.0(固定)」「3A」「150 kHz」;生命周期按活跃度比', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0(固定)', '5.0 V', 'hard'), pair('输出电流', '3A', '3', 'hard'), pair('开关频率', '150 kHz', '150', 'hard'), pair('生命周期', 'nrnd', 'active', 'soft')] }],
|
||||
});
|
||||
const x = (out['results'] as Result[])[0]!;
|
||||
// 生命周期不是数值:候选比原料更活(nrnd → active)算 covers → MATCH,替 NRND 料本就是为了这个
|
||||
expect(x.items.map((i) => i.differenceStatus)).toEqual(['match', 'match', 'match', 'match']);
|
||||
expect(x.items[3]?.compareResult).toBe('covers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recommend:推荐等级四档', () => {
|
||||
it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
|
||||
const out = await call('part-substitution-assessment.recommend', {
|
||||
candidates: [
|
||||
{ candidateInternalPn: 'C', counts: { match: 5, mismatch: 1, unknown: 0, conditional: 0, hardMismatch: 1, softMismatch: 0, configurable: 0 } },
|
||||
{ candidateInternalPn: 'A', counts: { match: 5, mismatch: 0, unknown: 1, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
||||
{ candidateInternalPn: 'B', counts: { match: 6, mismatch: 0, unknown: 0, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } },
|
||||
{ candidateInternalPn: 'D', counts: { match: 7, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
||||
],
|
||||
});
|
||||
const results = out['results'] as Array<{ candidateInternalPn: string; recommendationLevel: string; riskLevel: string }>;
|
||||
expect(results.map((r) => [r.candidateInternalPn, r.recommendationLevel, r.riskLevel])).toEqual([
|
||||
['C', 'not_recommended', 'high'],
|
||||
['A', 'conditional', 'medium'],
|
||||
['B', 'alternate', 'medium'],
|
||||
['D', 'recommended', 'low'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('既有 UNKNOWN 又有 configurable:UNKNOWN 优先 → 条件推荐(验证闭环前不给备选)', async () => {
|
||||
const out = await call('part-substitution-assessment.recommend', {
|
||||
candidates: [{ candidateInternalPn: 'E', counts: { match: 1, mismatch: 0, unknown: 1, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }],
|
||||
});
|
||||
expect((out['results'] as Array<{ recommendationLevel: string }>)[0]?.recommendationLevel).toBe('conditional');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,19 +6,52 @@
|
||||
* 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';
|
||||
|
||||
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']);
|
||||
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ']);
|
||||
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();
|
||||
expect(row.values['constraint_type']).toMatch(/^(hard|soft)$/);
|
||||
}
|
||||
// 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);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||
@@ -284,6 +284,36 @@ 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();
|
||||
});
|
||||
|
||||
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([]);
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
* · 本进程 publish 一条 → 顺手 `NOTIFY`(带上进程标识);
|
||||
* · 一条**专用连接**常年 `LISTEN`,收到别的进程发来的就在本地 publish 一次。
|
||||
* **自己发的自己不再收**(靠进程标识认),不然一条推送在两台之间来回弹。
|
||||
* **转发进来的不再发出去**(2026-08-26):从前收到别人的就 `publish` 到本地 broadcaster,
|
||||
* 而本桥自己也订阅着它 —— 那条转发被当成「自己推的」再 NOTIFY 一遍(from 换成自己),
|
||||
* 对面收到不是它发的、再转发、再 NOTIFY……只挡「自己发的自己不收」这一跳,两台接力就是永动机:
|
||||
* 实测两台后端连同一个库,一次 `POST /api/gov/process/validate` 引出 45 秒 19 万条广播,
|
||||
* 工作台三把查询被打成连续重取(用户看到的就是「疯狂轮询三个接口」)。
|
||||
* 转发的那一条在本地 publish 前打上记号,「本地 → 库」那一侧认出记号就不再发。
|
||||
*
|
||||
* 桥断了不影响业务:推送本就是「告诉在线浏览器去重取」的提示,丢一条最坏是
|
||||
* 那一屏晚两秒(前端还有轮询兜底)。所以这里所有错误都只记一行,不往上抛。
|
||||
@@ -33,14 +39,16 @@ type Wire = Touch & { readonly from: string };
|
||||
* `notify` 用参数化的 `pg_notify` 而不是拼 `NOTIFY` 语句:载荷里有中文与引号,
|
||||
* 拼字符串迟早出事(而这条路上的字符串全来自业务)。
|
||||
*/
|
||||
export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop(): void } {
|
||||
export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster, self: string = SELF): { stop(): void } {
|
||||
let listener: PoolClient | undefined;
|
||||
let stopped = false;
|
||||
/** 从库上转发进来的那几条 —— 它们不是本进程推的,不许再 NOTIFY 回去(按对象身份认,publish 同步派发) */
|
||||
const relayed = new WeakSet<Touch>();
|
||||
|
||||
/** 本地 → 库:自己推的顺手 NOTIFY 一条(不 await,广播不该拖慢业务) */
|
||||
const unsubscribe = broadcaster.subscribe((touch) => {
|
||||
if (stopped) return;
|
||||
const wire: Wire = { ...touch, from: SELF };
|
||||
if (stopped || relayed.has(touch)) return;
|
||||
const wire: Wire = { ...touch, from: self };
|
||||
void pool
|
||||
.query('select pg_notify($1, $2)', [TOUCH_CHANNEL, JSON.stringify(wire)])
|
||||
.catch((error: unknown) => {
|
||||
@@ -68,8 +76,10 @@ export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop(
|
||||
const wire = parsed as Partial<Wire>;
|
||||
if (typeof wire.kind !== 'string' || typeof wire.id !== 'string') return;
|
||||
// **自己发的自己不收** —— 否则一条推送在进程之间来回弹
|
||||
if (wire.from === SELF) return;
|
||||
broadcaster.publish({ kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' });
|
||||
if (wire.from === self) return;
|
||||
const touch: Touch = { kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' };
|
||||
relayed.add(touch);
|
||||
broadcaster.publish(touch);
|
||||
} catch {
|
||||
/* 载荷根本不是 JSON:丢这一条,不拖垮连接 */
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ import { createEventService, createEventStore } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import type { LoopEngineService } from '@modules/loop-engine';
|
||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||
import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org';
|
||||
import type { InFlightWorkProbe, KeycloakDirectory, OrgService, RosterSource, TokenVerifier } from '@modules/org';
|
||||
import {
|
||||
createKeycloakDirectory,
|
||||
createKeycloakRoster,
|
||||
createKeycloakTokenVerifier,
|
||||
createOrgService,
|
||||
@@ -185,6 +186,7 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
||||
export type ContainerOverrides = {
|
||||
readonly tokenVerifier?: TokenVerifier;
|
||||
readonly roster?: RosterSource;
|
||||
readonly directory?: KeycloakDirectory;
|
||||
};
|
||||
|
||||
export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {
|
||||
@@ -256,9 +258,22 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
||||
secret: keycloak.provisioning.secret,
|
||||
}));
|
||||
|
||||
/* 成员目录与名册同步同一枚 provisioning 服务账号 —— 没配就都不注入,界面如实说不可用 */
|
||||
const directory: KeycloakDirectory | undefined =
|
||||
overrides.directory ??
|
||||
(keycloak?.provisioning === undefined
|
||||
? undefined
|
||||
: createKeycloakDirectory({
|
||||
url: keycloak.url,
|
||||
realm: keycloak.realm,
|
||||
clientId: keycloak.provisioning.clientId,
|
||||
secret: keycloak.provisioning.secret,
|
||||
}));
|
||||
|
||||
const org = createOrgService({
|
||||
verifier,
|
||||
...(roster === undefined ? {} : { roster }),
|
||||
...(directory === undefined ? {} : { directory }),
|
||||
positions: createPositionRepository(db),
|
||||
staff: createStaffRepository(db),
|
||||
roleAccess: createRoleAccessRepository(db),
|
||||
|
||||
@@ -794,33 +794,74 @@ async function submitGate(
|
||||
if (task === undefined) return undefined;
|
||||
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
||||
if (manifest === undefined) return undefined;
|
||||
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
|
||||
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables, order.gateLedgers);
|
||||
if (addresses.length === 0) return undefined;
|
||||
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
||||
if (watched.length === 0) return undefined;
|
||||
/*
|
||||
* **卡自己点名要拦的表,「一行都没有」也算没完成**(2026-08-27 W05 e2e 实测)。
|
||||
*
|
||||
* 缺必填的算法只数**已有行**缺不缺格子 —— 零行 = 零缺,闸照开。实测那一单:
|
||||
* 模型压拍,骨架一行没写就把 P-08 数据工单发了出去;人打开件是空的,提交钮却绿着,
|
||||
* 交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被 refersTo 那道墙拦下。
|
||||
* 只对**卡自报**(`gateLedgers`)那几张表这么判 —— 那是发卡的人明说「这张卡就是来填它的」;
|
||||
* 回落到必交付物的老路一个字不变(那条路上零行是常态,比如还没到写的时候)。
|
||||
*/
|
||||
const declared = (order.gateLedgers ?? []).map((a) => a.trim()).filter((a) => a !== '');
|
||||
|
||||
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||
}
|
||||
return gateOf(missingReadings(mine, watched), watched);
|
||||
return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
|
||||
* 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
|
||||
* 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
|
||||
* 而失效的闸在页面上看不出来(按钮照常绿着)。
|
||||
* **卡自报要拦的表里,有没有「一行都没有」的** —— 有就是没完成,别的都不用算了。
|
||||
*
|
||||
* 「缺必填」的算法只数**已有行**缺不缺格子:零行 = 零缺,闸照开(2026-08-27 W05 e2e 实测)。
|
||||
* 那一单模型压拍、骨架一行没写就把数据工单发了出去 —— 人打开件是空的、什么也补不了,
|
||||
* 提交钮却绿着;交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被引用检查拦下。
|
||||
*
|
||||
* 只对**卡自报**(`gateLedgers`)那几张表这么判:那是发卡的人明说「这张卡就是来填它的」。
|
||||
* 回落到必交付物的老路一个字不变 —— 那条路上零行是常态(还没轮到写的时候)。
|
||||
*/
|
||||
export function emptyGate(
|
||||
mine: Readonly<Record<string, ReadonlyArray<unknown>>>,
|
||||
watched: ReadonlyArray<{ domain: string; object: string; label?: string | undefined }>,
|
||||
declared: ReadonlyArray<string>,
|
||||
): { ready: boolean; why: string; blanks: string[] } | undefined {
|
||||
if (declared.length === 0) return undefined;
|
||||
const empty = watched.filter(
|
||||
(l) => declared.includes(`${l.domain}.${l.object}`) && (mine[`mine:${l.domain}.${l.object}`] ?? []).length === 0,
|
||||
);
|
||||
if (empty.length === 0) return undefined;
|
||||
const names = empty.map((l) => l.label ?? `${l.domain}.${l.object}`);
|
||||
return { ready: false, why: `${names.join('、')} 这一单还一行都没有 —— 这张卡就是来建它的,先在件上加一行填好再提交`, blanks: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 闸看哪几张表,三级取一:
|
||||
* ①卡上绑了表(`blocks`)就看绑的那几张;
|
||||
* ②**卡自己声明了 `gateLedgers`** 就看它 —— 零输入卡里,只有发卡的人知道这一张卡要人补哪几张表
|
||||
* (2026-08-27 W05 实测:数据工单要补的是 `parts.*` 主数据,不在必交付物名单里,
|
||||
* 闸于是盯着一堆与这张卡无关的表、一格不缺、空手交卷照放);
|
||||
* ③都没有才回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
|
||||
* 三级全空才真的没有依据 —— **没有依据的时候不拦人**。
|
||||
* 抽成纯函数是为了钉测:回落断了闸就整个失效,而失效的闸在页面上看不出来(按钮照常绿着)。
|
||||
*/
|
||||
export function gateAddressesOf(
|
||||
blocks: ReadonlyArray<ViewBlock> | undefined,
|
||||
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
|
||||
declared?: ReadonlyArray<string> | undefined,
|
||||
): string[] {
|
||||
const bound = (blocks ?? [])
|
||||
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||
.map((b) => b.ledger as string);
|
||||
if (bound.length > 0) return bound;
|
||||
const own = (declared ?? []).map((a) => a.trim()).filter((a) => a !== '');
|
||||
if (own.length > 0) return own;
|
||||
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 成员与组织的路由组装(ADR-012)—— 运营中心「员工」那一屏的三个页签。
|
||||
* 端级授权由 server.ts 的鉴权中间件按契约上的 `access: 'ops'` 统一判,这里不写守卫。
|
||||
*
|
||||
* **写是穿透**:成功之后 Keycloak 已经变了,没有撤销。所以这一层只做三件事 ——
|
||||
* 把入参校出来、把模块的错误翻成 HTTP、把写完的那一行原样回给界面(界面照它重画,不自己拼)。
|
||||
*
|
||||
* 错误分档(手册 §15 + BPOS §17.0):
|
||||
* 502 `keycloak_misconfigured` 服务账号缺角色 / 没配 —— **重试无益**,去补权限;
|
||||
* 502 `keycloak_unavailable` 暂时够不着 —— 可退避重试;
|
||||
* 409 `conflict` 同级重名、收权会锁死运营中心;
|
||||
* 403 `forbidden` 对着服务账号下手。
|
||||
* 两种 502 分开说,是因为**人的下一步不同**:一个去找管理员,一个等一会儿再点。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import {
|
||||
createOrgGroupRoute,
|
||||
createOrgUserRoute,
|
||||
deleteOrgGroupRoute,
|
||||
deleteOrgUserRoute,
|
||||
getOrgUserRoute,
|
||||
listOrgGroupMembersRoute,
|
||||
listOrgGroupsRoute,
|
||||
listOrgRolesRoute,
|
||||
listOrgUsersRoute,
|
||||
patchOrgUserRoute,
|
||||
renameOrgGroupRoute,
|
||||
resetOrgUserPasswordRoute,
|
||||
setOrgUserEnabledRoute,
|
||||
setOrgUserGroupRoute,
|
||||
setOrgUserRoleRoute,
|
||||
} from '@contracts';
|
||||
import {
|
||||
AccessLockoutError,
|
||||
DirectoryUnavailableError,
|
||||
ServiceAccountProtectedError,
|
||||
} from '@modules/org';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** 出错时统一翻译 —— 每条路由各写一遍 try/catch 只会漏掉其中几条 */
|
||||
async function answer<T>(run: () => Promise<T>): Promise<{ body: T } | { error: ApiError; status: 403 | 404 | 409 | 502 }> {
|
||||
try {
|
||||
return { body: await run() };
|
||||
} catch (error) {
|
||||
if (error instanceof DirectoryUnavailableError) {
|
||||
return {
|
||||
error: { code: error.misconfigured ? 'keycloak-misconfigured' : 'keycloak-unavailable', message: error.message },
|
||||
status: 502,
|
||||
};
|
||||
}
|
||||
if (error instanceof ServiceAccountProtectedError) {
|
||||
return { error: { code: 'forbidden', message: error.message }, status: 403 };
|
||||
}
|
||||
if (error instanceof AccessLockoutError) {
|
||||
return { error: { code: 'conflict', message: error.message }, status: 409 };
|
||||
}
|
||||
// Keycloak 明确拒绝(重名 / 约束不过)—— 原话带给人看,别自己编一句更含糊的
|
||||
if (error instanceof Error && error.name === 'KeycloakRejectedError') {
|
||||
const status = (error as Error & { status?: number }).status;
|
||||
return { error: { code: status === 404 ? 'not-found' : 'conflict', message: error.message }, status: status === 404 ? 404 : 409 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOrgDirectoryRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/**
|
||||
* zod 出来的可选字段是 `string | undefined`,而入参类型在 `exactOptionalPropertyTypes`
|
||||
* 下要的是「要么有、要么这个键不存在」—— 显式的 undefined 在这儿不算「没填」。
|
||||
* 全仓同一个惯用法:条件展开。
|
||||
*/
|
||||
function defined<T extends Record<string, unknown>>(input: T): { [K in keyof T]: Exclude<T[K], undefined> } {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) if (v !== undefined) out[k] = v;
|
||||
return out as { [K in keyof T]: Exclude<T[K], undefined> };
|
||||
}
|
||||
|
||||
/** 校验 body,不合就 400 —— 与治理台其余路由同一套话 */
|
||||
const bad = (detail: unknown): ApiError => ({ code: 'validation-failed', message: '入参不合法', detail });
|
||||
|
||||
/* ── 成员 ───────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listOrgUsersRoute.path, async (c) => {
|
||||
const q = c.req.query('q') ?? '';
|
||||
// 只有 `1` 算真 —— `true` / `yes` 这类一律当假,免得两处理解不同(与 BPOS §1.1 同款)
|
||||
const includeDisabled = c.req.query('includeDisabled') === '1';
|
||||
const out = await answer(() => container.org.directoryUsers({ q, includeDisabled }));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.get(getOrgUserRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryUser(c.req.param('sub')));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(createOrgUserRoute.path, async (c) => {
|
||||
const parsed = createOrgUserRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.createDirectoryUser(defined(parsed.data)));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.patch(patchOrgUserRoute.path, async (c) => {
|
||||
const parsed = patchOrgUserRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.patchDirectoryUser(c.req.param('sub'), defined(parsed.data)));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(setOrgUserEnabledRoute.path, async (c) => {
|
||||
const parsed = setOrgUserEnabledRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.setDirectoryUserEnabled(c.req.param('sub'), parsed.data.enabled));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(resetOrgUserPasswordRoute.path, async (c) => {
|
||||
const parsed = resetOrgUserPasswordRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(async () => {
|
||||
await container.org.resetDirectoryUserPassword(c.req.param('sub'), parsed.data.password);
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.delete(deleteOrgUserRoute.path, async (c) => {
|
||||
const out = await answer(async () => {
|
||||
await container.org.deleteDirectoryUser(c.req.param('sub'));
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
/* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ───────────────────── */
|
||||
|
||||
app.get(listOrgRolesRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryRoles());
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(setOrgUserRoleRoute.path, async (c) => {
|
||||
const parsed = setOrgUserRoleRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() =>
|
||||
container.org.setDirectoryUserRole(c.req.param('sub'), c.req.param('role'), parsed.data.granted),
|
||||
);
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
/* ── 组织 ───────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listOrgGroupsRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryGroups());
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.get(listOrgGroupMembersRoute.path, async (c) => {
|
||||
const out = await answer(() => container.org.directoryGroupMembers(c.req.param('id')));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(createOrgGroupRoute.path, async (c) => {
|
||||
const parsed = createOrgGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.createDirectoryGroup(parsed.data.name, parsed.data.parentId));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.patch(renameOrgGroupRoute.path, async (c) => {
|
||||
const parsed = renameOrgGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(() => container.org.renameDirectoryGroup(c.req.param('id'), parsed.data.name));
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.delete(deleteOrgGroupRoute.path, async (c) => {
|
||||
const out = await answer(async () => {
|
||||
await container.org.deleteDirectoryGroup(c.req.param('id'));
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
|
||||
app.post(setOrgUserGroupRoute.path, async (c) => {
|
||||
const parsed = setOrgUserGroupRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json(bad(parsed.error.issues), 400);
|
||||
const out = await answer(async () => {
|
||||
await container.org.setDirectoryUserGroup(c.req.param('sub'), c.req.param('groupId'), parsed.data.member);
|
||||
return { ok: true as const };
|
||||
});
|
||||
return 'error' in out ? c.json(out.error, out.status) : c.json(out.body);
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import type { Container } from './container.ts';
|
||||
import { MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
||||
|
||||
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
||||
/*
|
||||
@@ -29,8 +30,9 @@ import { MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
* 再跑 activities:install 即可 —— 它们的 dist 照旧随 activities:build 产出。
|
||||
* 退役名单:quote-intake · quote-alternative · quote-sourcing · quote-pricing ·
|
||||
* quote-negotiate · quote-archive
|
||||
* 2026-08-26 加 ACT-WORK-05(part-substitution-assessment):差异评估活动开发完成,随本轮登记进安装通道。
|
||||
*/
|
||||
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit'] as const;
|
||||
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const;
|
||||
|
||||
/**
|
||||
* 活动包根目录。
|
||||
@@ -61,10 +63,21 @@ export async function installRealActivities(container: Container): Promise<void>
|
||||
const applied = await container.ledger.applyDeclarations(MASTER_OBJECTS);
|
||||
const conflicts = applied.flatMap((r) => r.conflicts);
|
||||
log(
|
||||
` 主数据四域已物化:${MASTER_OBJECTS.length} 张表` +
|
||||
` 主数据 ${MASTER_DOMAINS.length} 域已物化:${MASTER_OBJECTS.length} 张表` +
|
||||
// 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道
|
||||
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
|
||||
);
|
||||
|
||||
/*
|
||||
* **纯工具随活动一起注册**(2026-08-26):从前工具契约只在 db:seed 里注册,只跑 activities:install 的
|
||||
* 开发机上,活动清单声明了工具、白名单也发了,`sys.tool` 里却没有那件工具 —— 第一次跑就撞「工具不存在」。
|
||||
* 「安装即用」包含它声明过的纯工具能调得动。register 按 name+version 幂等,重复装是 no-op。
|
||||
*/
|
||||
for (const contract of W05_TOOLS) {
|
||||
const verdict = await container.toolbox.register({ contract, samples: [W05_TOOL_SAMPLE] });
|
||||
if (!verdict.accepted) log(` 工具 ${contract.name} 未过两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`);
|
||||
}
|
||||
|
||||
const root = activitiesRoot();
|
||||
|
||||
for (const dir of REAL_PACKAGES) {
|
||||
@@ -140,5 +153,5 @@ export async function installRealActivities(container: Container): Promise<void>
|
||||
cause: { kind: 'governance', ref: 'seed:master-sample' },
|
||||
});
|
||||
}
|
||||
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
|
||||
log(` 样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* 表建出来但一行数据都没有 —— 治理台「主数据」那一区能看见它们等着被接上。
|
||||
*/
|
||||
import { objectDeclarationSchema } from '@contracts';
|
||||
import type { LedgerFieldType, ObjectDeclaration } from '@contracts';
|
||||
import type { LedgerAddress, LedgerFieldType, LedgerKey, LedgerRow, ObjectDeclaration } from '@contracts';
|
||||
|
||||
const f = (name: string, type: LedgerFieldType, label: string, primaryKey = false) => ({
|
||||
name,
|
||||
@@ -142,10 +142,141 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
|
||||
f('stock', 'integer', '可见库存'),
|
||||
f('trend', 'text', '走势'),
|
||||
]),
|
||||
|
||||
/* ── 料件:P-08 芯片主数据 / P-10 原厂技术资料库(SS02 差异评估 2026-08-26 加,2026-08-27 结构改)──
|
||||
字段名与《SS02-SS03-SS04_Demo台账字段细化与统一方案》§4.2 一字不差;全链路只认 internal_pn。
|
||||
标准化参数集**改为独立子表 `parts.parameter`**(2026-08-27 用户当场核对实跑页面后指出:
|
||||
「做出来的页面结构必须与目标件一致」——目标件《ACT-WORK-05-数据工单-01》本就是两张表,
|
||||
表 1 一个料一行(主记录)、表 2 一个参数一行(子表,按所属料号归属);塞进主记录的一个文本
|
||||
列违反《统一方案》§六「重复明细用子表承载,不在单一文本字段堆叠」,§4.2 里
|
||||
`normalized_parameter_set` 的数据类型本就写的是 `Structure`。物理列 `parts.profile.
|
||||
normalized_parameter_set` 仍留在基线里(不删列),只是活动清单不再声明它、不再写它。
|
||||
老的 market.part(主键 pn)不复用,记 debt 待并。 */
|
||||
master('parts', 'profile', '芯片主数据(P-08)', [
|
||||
f('internal_pn', 'text', '内部料号', true),
|
||||
f('customer_pn', 'text', '客户料号'),
|
||||
f('brand_name', 'text', '品牌名称'),
|
||||
f('manufacturer_name', 'text', '制造商名称'),
|
||||
f('specification_description', 'text', '规格描述'),
|
||||
f('normalized_parameter_set', 'text', '标准化参数集(已改子表 parts.parameter,这一列不再声明/不再写)'),
|
||||
f('package', 'text', '封装'),
|
||||
f('process_technology', 'text', '工艺'),
|
||||
f('lifecycle_status', 'text', '生命周期状态'),
|
||||
f('eol_pcn_status', 'text', 'EOL/PCN 状态'),
|
||||
f('datasheet_ref', 'text', 'Datasheet 引用'),
|
||||
f('datasheet_version', 'text', 'Datasheet 版本'),
|
||||
f('application_scenarios', 'text', '适配应用场景'),
|
||||
f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'),
|
||||
]),
|
||||
/* P-08 子表:标准化参数集,一行一参数,按 internal_pn 归属主记录(《统一方案》§六:重复明细用子表) */
|
||||
master('parts', 'parameter', '标准化参数集(P-08 子表)', [
|
||||
f('internal_pn', 'text', '所属料号', true),
|
||||
f('param_name', 'text', '参数名', true),
|
||||
f('param_value', 'text', '值'),
|
||||
f('param_unit', 'text', '单位'),
|
||||
f('constraint_type', 'text', '约束类型'),
|
||||
f('param_note', 'text', '说明'),
|
||||
]),
|
||||
master('parts', 'document', '原厂技术资料库(P-10)', [
|
||||
f('internal_pn', 'text', '关联内部料号', true),
|
||||
f('doc_seq', 'text', '资料序号(同一料号下 01/02…)', true),
|
||||
f('document_type', 'text', '资料类型'),
|
||||
f('brand_name', 'text', '品牌名称'),
|
||||
f('document_title', 'text', '资料标题'),
|
||||
f('document_version', 'text', '资料版本'),
|
||||
f('document_date', 'date', '资料发布日期'),
|
||||
f('document_ref', 'text', '资料文件链接'),
|
||||
f('citation_locator', 'text', '证据定位(页码/章节/表格)'),
|
||||
f('source_contact_ref', 'text', '原厂支持联系人'),
|
||||
f('document_status', 'text', '资料状态'),
|
||||
]),
|
||||
|
||||
/* ── 需求:P-01 客户具体需求与项目任务主台账 ── */
|
||||
master('demand', 'request', '客户需求主记录(P-01)', [
|
||||
f('request_id', 'text', '需求编号', true),
|
||||
f('customer_code', 'text', '客户编码'),
|
||||
f('project_code', 'text', '项目编码'),
|
||||
f('requirement_version', 'text', '需求版本'),
|
||||
f('request_type', 'text', '需求类型'),
|
||||
f('request_description', 'text', '需求描述'),
|
||||
f('input_snapshot_ref', 'text', '输入快照'),
|
||||
f('bom_id', 'text', 'BOM ID'),
|
||||
f('bom_position', 'text', 'BOM 位'),
|
||||
f('original_internal_pn', 'text', '原内部料号'),
|
||||
f('customer_pn', 'text', '客户料号'),
|
||||
f('application_scenario', 'text', '应用场景'),
|
||||
f('technical_constraints', 'text', '技术约束'),
|
||||
f('risk_level', 'text', '风险等级'),
|
||||
f('verification_requirement', 'text', '验证/复测/确认要求'),
|
||||
f('request_status', 'text', '需求状态'),
|
||||
f('current_activity', 'text', '当前 Activity'),
|
||||
f('current_stage', 'text', '当前阶段'),
|
||||
f('next_action', 'text', '下一步动作'),
|
||||
f('output_ref', 'text', '标准输出链接'),
|
||||
]),
|
||||
|
||||
/* ── 替代:P-11 替代选型与批准关系库 —— 主记录 + 差异明细子表(《统一方案》§六:重复明细用子表)── */
|
||||
master('substitution', 'relation', '替代关系主记录(P-11)', [
|
||||
f('substitution_relation_id', 'text', '替代关系ID', true),
|
||||
f('request_id', 'text', '关联需求编号'),
|
||||
f('project_code', 'text', '关联项目编码'),
|
||||
f('bom_position', 'text', '关联 BOM 位'),
|
||||
f('original_internal_pn', 'text', '原内部料号'),
|
||||
f('candidate_internal_pn', 'text', '候选内部料号'),
|
||||
f('substitution_type', 'text', '替代类型'),
|
||||
f('recommendation_level', 'text', '推荐等级'),
|
||||
f('recommendation_reason', 'text', '推荐理由'),
|
||||
f('difference_summary', 'text', '差异摘要'),
|
||||
f('risk_level', 'text', '替代风险等级'),
|
||||
f('verification_requirement', 'text', '验证要求'),
|
||||
f('validation_id', 'text', '关联验证ID'),
|
||||
f('analysis_status', 'text', '分析状态'),
|
||||
/* 客户批准三列由独立审批流维护 —— 基线建列,差异评估活动**不声明写权** */
|
||||
f('customer_approval_status', 'text', '客户批准状态'),
|
||||
f('customer_approval_at', 'timestamp', '客户批准时间'),
|
||||
f('customer_approval_ref', 'text', '客户批准凭证'),
|
||||
f('customer_feedback', 'text', '客户反馈'),
|
||||
f('analysis_report_ref', 'text', '替代分析报告'),
|
||||
f('confidence_score', 'number', '证据可信度'),
|
||||
f('rule_version', 'text', '差异规则版本'),
|
||||
]),
|
||||
master('substitution', 'difference_item', '差异明细(P-11 子表)', [
|
||||
f('substitution_relation_id', 'text', '替代关系ID', true),
|
||||
f('difference_item_id', 'text', '差异明细ID', true),
|
||||
f('parameter_name', 'text', '标准参数名'),
|
||||
f('original_value', 'text', '原物料参数值'),
|
||||
f('candidate_value', 'text', '候选物料参数值'),
|
||||
f('parameter_unit', 'text', '参数单位'),
|
||||
f('constraint_type', 'text', '约束类型'),
|
||||
f('difference_status', 'text', '差异状态'),
|
||||
f('impact_statement', 'text', '影响说明'),
|
||||
f('risk_level', 'text', '风险等级'),
|
||||
f('evidence_ref', 'text', '差异证据引用'),
|
||||
f('confidence', 'number', 'AI 置信度'),
|
||||
f('rule_version', 'text', '差异规则版本'),
|
||||
]),
|
||||
|
||||
/* ── 验证:P-06 样品交付与芯片验证记录(事实类:只能实测产生,数据工单不许建)── */
|
||||
master('validation', 'record', '芯片验证记录(P-06)', [
|
||||
f('validation_id', 'text', '验证ID', true),
|
||||
f('request_id', 'text', '关联需求编号'),
|
||||
f('customer_code', 'text', '客户编码'),
|
||||
f('validation_internal_pn', 'text', '验证内部料号'),
|
||||
f('validation_type', 'text', '验证类型'),
|
||||
f('test_condition', 'text', '测试条件'),
|
||||
f('test_parameters', 'text', '测试参数'),
|
||||
f('test_result', 'text', '测试结果'),
|
||||
f('validation_status', 'text', '验证状态'),
|
||||
f('issue_risk', 'text', '问题与风险'),
|
||||
f('sample_version', 'text', '样品版本'),
|
||||
f('sample_lot', 'text', '样品批次'),
|
||||
f('evidence_ref', 'text', '验证证据引用'),
|
||||
f('next_validation_action', 'text', '后续验证动作'),
|
||||
]),
|
||||
];
|
||||
|
||||
/** 四域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 */
|
||||
export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market'] as const;
|
||||
/** 八域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 → 料件 → 需求 → 替代 → 验证 */
|
||||
export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation'] as const;
|
||||
|
||||
/**
|
||||
* **样例客户** —— 让「老客户」那条路试得出来。
|
||||
@@ -158,7 +289,7 @@ export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market'] as c
|
||||
* 只播这一位,且是**幂等的按主键写**:它是让样例跑得起来的最小夹具,
|
||||
* 不是往主数据里塞演示数据 —— 真实客户由 A1 建档时一位一位落进来。
|
||||
*/
|
||||
export const MASTER_SAMPLE_ROWS = [
|
||||
export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerKey; values: LedgerRow }[] = [
|
||||
/*
|
||||
* **Itelma** —— 老客户测试案例的那一位(照 `JCAI_PRD/docs/活动执行页/` 两份目标件)。
|
||||
* 俄罗斯车规电子、项目制;客户码 CU-0142 就是需求件上写的「已命中客户资料库」那一条。
|
||||
@@ -344,4 +475,88 @@ export const MASTER_SAMPLE_ROWS = [
|
||||
review_note: '',
|
||||
},
|
||||
},
|
||||
/*
|
||||
* ── SS02 差异评估的样例料号(2026-08-26,2026-08-27 参数集改子表)──
|
||||
* 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC
|
||||
* **刻意不播** —— 「候选缺库 → 派数据工单」「硬约束不推荐」两条路要试得出来,库里得真没有。
|
||||
* 标准化参数集是独立子表 `parts.parameter`,一颗料 7 行(与目标件《数据工单-01》的
|
||||
* 表 2 示例预填同形);数值照旧版 `normalized_parameter_set` 文本行逐条搬,一个字不改。
|
||||
*/
|
||||
{
|
||||
address: { domain: 'parts', object: 'profile' },
|
||||
key: { internal_pn: 'LM2596S-5.0' },
|
||||
values: {
|
||||
internal_pn: 'LM2596S-5.0',
|
||||
brand_name: 'TI',
|
||||
manufacturer_name: 'Texas Instruments',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||
package: 'TO-263-5',
|
||||
process_technology: 'BCD',
|
||||
lifecycle_status: 'nrnd',
|
||||
eol_pcn_status: 'PCN-2026-07:转 NRND,2027-01 起不接新设计',
|
||||
datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
datasheet_version: 'Rev H (2023-09)',
|
||||
application_scenarios: '工业电源;消费电源',
|
||||
},
|
||||
},
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '客户输入上限 40V' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard', param_note: '固定版' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
|
||||
{
|
||||
address: { domain: 'parts', object: 'profile' },
|
||||
key: { internal_pn: 'LM2596S-ADJ' },
|
||||
values: {
|
||||
internal_pn: 'LM2596S-ADJ',
|
||||
brand_name: 'TI',
|
||||
manufacturer_name: 'Texas Instruments',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,可调输出 1.23~37V,150kHz(LM2596 同族)',
|
||||
package: 'TO-263-5',
|
||||
process_technology: 'BCD',
|
||||
lifecycle_status: 'active',
|
||||
datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
datasheet_version: 'Rev H (2023-09)',
|
||||
application_scenarios: '工业电源;消费电源',
|
||||
},
|
||||
},
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '与固定版同' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard', param_note: '可调版,靠外围反馈网络定' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
|
||||
{
|
||||
address: { domain: 'parts', object: 'document' },
|
||||
key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' },
|
||||
values: {
|
||||
internal_pn: 'LM2596S-5.0',
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
brand_name: 'TI',
|
||||
document_title: 'LM2596 SIMPLE SWITCHER Power Converter 150-kHz 3-A Step-Down Voltage Regulator',
|
||||
document_version: 'Rev H (2023-09)',
|
||||
document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
citation_locator: '§6.5 Electrical Characteristics 表 / §6.3 Recommended Operating Conditions',
|
||||
document_status: 'valid',
|
||||
},
|
||||
},
|
||||
{
|
||||
address: { domain: 'parts', object: 'document' },
|
||||
key: { internal_pn: 'LM2596S-ADJ', doc_seq: '01' },
|
||||
values: {
|
||||
internal_pn: 'LM2596S-ADJ',
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
brand_name: 'TI',
|
||||
document_title: 'LM2596 SIMPLE SWITCHER Power Converter 150-kHz 3-A Step-Down Voltage Regulator',
|
||||
document_version: 'Rev H (2023-09)',
|
||||
document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
citation_locator: '§6.5 Electrical Characteristics 表(ADJ 列)',
|
||||
document_status: 'valid',
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Container } from './container.ts';
|
||||
import { installRealActivities } from './seed-activities.ts';
|
||||
import { installSlots } from './seed-mvp-slots.ts';
|
||||
import { seedMvpFlow } from './seed-mvp-flow.ts';
|
||||
import { W05_TOOLS } from './tools-w05.ts';
|
||||
|
||||
/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */
|
||||
const HISTORY_TOOL = {
|
||||
@@ -328,7 +329,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
||||
|
||||
/* ② 功能工具注册两审 + 逐槽位白名单 */
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) {
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL, ...W05_TOOLS]) {
|
||||
const verdict = await container.toolbox.register({
|
||||
contract,
|
||||
samples: [
|
||||
@@ -359,7 +360,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
||||
await container.toolbox.setAllowlist(activityId, tools);
|
||||
}
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)');
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 6,逐活动授权)');
|
||||
|
||||
/*
|
||||
* ③ 活动装入 —— 两批:
|
||||
|
||||
@@ -28,6 +28,7 @@ import { registerRegistrationRoutes } from './routes/registration.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
import { registerOrgDirectoryRoutes } from './routes/org-directory.ts';
|
||||
import { registerMailRoutes } from './routes/mail.ts';
|
||||
import { registerStreamRoute } from './routes/stream.ts';
|
||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
@@ -115,6 +116,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerOrgDirectoryRoutes(app, container);
|
||||
registerDeskRoutes(app, container);
|
||||
registerDeskElementRoutes(app, container);
|
||||
registerDeskApprovalRoutes(app, container);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 名册同步 —— 从 Keycloak Admin API 把整册照过来(ADR-012)。
|
||||
*
|
||||
* pnpm --filter @app/api sync-roster
|
||||
*
|
||||
* 与 `POST /api/gov/staff/sync` 是**同一段用例**(`org.syncRoster`),只是换了个入口:
|
||||
* 那条路由要 `ops`,而运维手上常常只有 shell —— 服务账号进不了运营中心,
|
||||
* 前端也未必摆着那枚钮。首登建档(JIT)覆盖的是「在用的人」,这条补的是「还没登录过的人」。
|
||||
*
|
||||
* 三个数说的是**真落进库的条数**;`failed` 单列 —— 半途出事的同步不许报成一排绿数字。
|
||||
*/
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
|
||||
export async function syncRoster(): Promise<void> {
|
||||
const container = await createContainer(loadConfig());
|
||||
try {
|
||||
const outcome = await container.org.syncRoster();
|
||||
if (!outcome.configured) {
|
||||
process.stderr.write(`✗ ${outcome.message}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`同步完成:远端 ${outcome.total} 人 · 新建 ${outcome.inserted} · 更新 ${outcome.updated} · 停用 ${outcome.disabled}\n`,
|
||||
);
|
||||
for (const f of outcome.failed) process.stderr.write(` ✗ ${f.account}:${f.reason}\n`);
|
||||
if (outcome.failed.length > 0) process.exitCode = 1;
|
||||
|
||||
const { staff } = await container.org.listStaff();
|
||||
process.stdout.write(`\n名册 ${staff.length} 人:\n`);
|
||||
for (const s of staff) {
|
||||
// 标出人 / 服务账号 —— 服务账号在 Keycloak 管理台的 Users 列表里也看不到,这里如实说
|
||||
const kind = s.kind === 'service' ? '服务账号' : '人 ';
|
||||
process.stdout.write(
|
||||
` ${kind} ${s.account.padEnd(32)} ${s.name.padEnd(14)} ${s.position.code.padEnd(5)} ${s.state.padEnd(9)} ${s.roles.join(',')}\n`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await syncRoster();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的两枚纯工具契约(活动私有作用域)。
|
||||
* 两处消费:`seed-mvp.ts`(db:seed 全套播种)与 `seed-activities.ts`(activities:install 装活动时顺带注册 ——
|
||||
* 「安装即用」包含它声明过的纯工具能调得动;从前 E04 的工具只在 db:seed 里注册,开发机上只跑
|
||||
* activities:install 的人第一次跑活动就撞「工具不存在」)。
|
||||
*/
|
||||
const PURE = {
|
||||
effect: 'read' as const,
|
||||
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
|
||||
idempotent: true,
|
||||
timeoutMs: 5_000,
|
||||
retries: 0,
|
||||
connectionKeys: [] as string[],
|
||||
pure: true,
|
||||
ownerActivityId: 'part-substitution-assessment',
|
||||
};
|
||||
|
||||
/** 逐参数比对 —— 出参键与台账列同名(difference_status / risk_level),模型照抄 */
|
||||
export const COMPARE_PARAMETERS_TOOL = {
|
||||
...PURE,
|
||||
name: 'part-substitution-assessment.compare-parameters',
|
||||
displayName: '替代料逐参数比对',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' +
|
||||
'compare_result、risk_level 与各候选的计数。一次可比一批候选(candidates 数组),每对参数可带客户约束 requirement(如 ">= 40")与证据引用 evidenceRef;' +
|
||||
'硬约束没有证据引用不判 match。规则版本 R-2026.07。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
candidates: {
|
||||
type: 'array',
|
||||
description: '[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}]',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ruleVersion: { type: 'string' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: '[{candidateInternalPn, items:[{parameterName, unit, constraintType, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 推荐定级 —— 出参 recommendationLevel / riskLevel / reason 直接落 P-11 */
|
||||
export const RECOMMEND_TOOL = {
|
||||
...PURE,
|
||||
name: 'part-substitution-assessment.recommend',
|
||||
displayName: '替代推荐定级',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'按每个候选的比对计数定推荐等级:硬约束 mismatch>0 → not_recommended;unknown>0 → conditional;' +
|
||||
'只有可调配成(configurable)→ alternate;其余 conditional → conditional;全 match → recommended。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
candidates: { type: 'array', description: '[{candidateInternalPn, counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ruleVersion: { type: 'string' },
|
||||
results: { type: 'array', description: '[{candidateInternalPn, recommendationLevel, riskLevel, reason}]' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const W05_TOOLS = [COMPARE_PARAMETERS_TOOL, RECOMMEND_TOOL] as const;
|
||||
|
||||
/** 试运行审的样例(两件共用一份:并集覆盖各家入参) */
|
||||
export const W05_TOOL_SAMPLE = {
|
||||
input: {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'XL2596S-5.0',
|
||||
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', unit: 'V', constraintType: 'hard', evidenceRef: 'P-08' }],
|
||||
counts: { match: 1, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
expectShape: { type: 'object' },
|
||||
};
|
||||
@@ -332,6 +332,165 @@ const slotPlaceholder: ToolHandler = async ({ input }) => {
|
||||
};
|
||||
};
|
||||
|
||||
/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)─────────────────
|
||||
*
|
||||
* 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份;
|
||||
* 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。
|
||||
*
|
||||
* 比对语义(按硬/软约束):
|
||||
* equal / covers(候选范围 ⊇ 原范围) → match,低风险;
|
||||
* configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围);
|
||||
* narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险;
|
||||
* 任一侧未披露 → unknown 中风险;
|
||||
* 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH);
|
||||
* requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。
|
||||
*/
|
||||
export const SUBSTITUTION_RULE_VERSION = 'R-2026.07';
|
||||
|
||||
type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' };
|
||||
type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown';
|
||||
type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional';
|
||||
type RiskLevel = 'low' | 'medium' | 'high';
|
||||
type ParamPair = {
|
||||
parameterName: string;
|
||||
originalValue: string;
|
||||
candidateValue: string;
|
||||
unit?: string;
|
||||
constraintType: 'hard' | 'soft';
|
||||
/**
|
||||
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
|
||||
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
|
||||
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
|
||||
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
|
||||
*/
|
||||
requirement?: string | null;
|
||||
evidenceRef?: string | null;
|
||||
};
|
||||
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
|
||||
function text(v: unknown): string {
|
||||
return typeof v === 'string' ? v.trim() : '';
|
||||
}
|
||||
|
||||
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
|
||||
|
||||
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
|
||||
const NUM = '[-+]?\\d+(?:\\.\\d+)?';
|
||||
const RANGE_RE = new RegExp(`^(${NUM})\\s*(?:~|~|-|–|—|至|to)\\s*(${NUM})`, 'i');
|
||||
const NUM_RE = new RegExp(`^(${NUM})`);
|
||||
|
||||
export function parseParamValue(raw: unknown): Parsed {
|
||||
const s = String(raw ?? '').trim().replace(/\s+/g, ' ');
|
||||
if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' };
|
||||
const r = RANGE_RE.exec(s);
|
||||
if (r !== null) {
|
||||
const lo = Number(r[1]);
|
||||
const hi = Number(r[2]);
|
||||
if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
|
||||
}
|
||||
const n = NUM_RE.exec(s);
|
||||
if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) };
|
||||
return { kind: 'text', value: s.toLowerCase() };
|
||||
}
|
||||
|
||||
function compareParsed(o: Parsed, c: Parsed): CompareResult {
|
||||
if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown';
|
||||
if (o.kind === 'range' && c.kind === 'range') {
|
||||
if (c.lo === o.lo && c.hi === o.hi) return 'equal';
|
||||
if (c.lo <= o.lo && c.hi >= o.hi) return 'covers';
|
||||
if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower';
|
||||
return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial';
|
||||
}
|
||||
if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs';
|
||||
if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs';
|
||||
if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs';
|
||||
if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs';
|
||||
return 'differs';
|
||||
}
|
||||
|
||||
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
|
||||
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
|
||||
const s = text(requirement);
|
||||
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
|
||||
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
|
||||
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
|
||||
const hi = c.kind === 'range' ? c.hi : c.value;
|
||||
const lo = c.kind === 'range' ? c.lo : c.value;
|
||||
if (ge !== null) return hi >= Number(ge[1]);
|
||||
if (le !== null) return lo <= Number(le[1]);
|
||||
const req = parseParamValue(s);
|
||||
if (req.kind === 'range') return lo <= req.lo && hi >= req.hi;
|
||||
if (req.kind === 'number') return lo <= req.value && hi >= req.value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
|
||||
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
|
||||
function compareLifecycle(o: string, c: string): CompareResult | undefined {
|
||||
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
|
||||
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
|
||||
if (ro === undefined || rc === undefined) return undefined;
|
||||
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
|
||||
}
|
||||
|
||||
function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } {
|
||||
const o = parseParamValue(p.originalValue);
|
||||
const c = parseParamValue(p.candidateValue);
|
||||
const hard = p.constraintType === 'hard';
|
||||
let compareResult = compareParsed(o, c);
|
||||
if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult;
|
||||
const req = satisfiesRequirement(p.requirement, c);
|
||||
if (req === false) {
|
||||
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
|
||||
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
|
||||
}
|
||||
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
|
||||
if (compareResult === 'equal' || compareResult === 'covers') {
|
||||
if (hard && text(p.evidenceRef) === '') {
|
||||
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
|
||||
}
|
||||
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
|
||||
}
|
||||
if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' };
|
||||
if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' };
|
||||
compareResult = compareResult === 'narrower' ? 'narrower' : 'differs';
|
||||
return hard
|
||||
? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' }
|
||||
: { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' };
|
||||
}
|
||||
|
||||
const compareParameters: ToolHandler = async ({ input }) => {
|
||||
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; pairs: ParamPair[] }>) : [];
|
||||
const results = candidates.map((cand) => {
|
||||
const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
|
||||
const items = (Array.isArray(cand.pairs) ? cand.pairs : []).map((p) => {
|
||||
const j = judgePair(p);
|
||||
counts[j.differenceStatus] += 1;
|
||||
if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1;
|
||||
if (j.compareResult === 'configurable') counts.configurable += 1;
|
||||
return { parameterName: p.parameterName, unit: p.unit ?? '', constraintType: p.constraintType, ...j };
|
||||
});
|
||||
return { candidateInternalPn: cand.candidateInternalPn, items, counts };
|
||||
});
|
||||
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
|
||||
};
|
||||
|
||||
/**
|
||||
* 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环);
|
||||
* 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。
|
||||
*/
|
||||
const recommend: ToolHandler = async ({ input }) => {
|
||||
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial<CompareCounts> }>) : [];
|
||||
const results = candidates.map((cand) => {
|
||||
const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0);
|
||||
if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` };
|
||||
if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` };
|
||||
if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` };
|
||||
if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` };
|
||||
return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` };
|
||||
});
|
||||
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
|
||||
};
|
||||
|
||||
export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> = [
|
||||
['crm.history-price-read', historyPriceRead],
|
||||
['quote-intake.classify', classify],
|
||||
@@ -343,4 +502,6 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
|
||||
['slot.placeholder', slotPlaceholder],
|
||||
['crm-relationship-coverage.recompute-score', recomputeScore],
|
||||
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
|
||||
['part-substitution-assessment.compare-parameters', compareParameters],
|
||||
['part-substitution-assessment.recommend', recommend],
|
||||
];
|
||||
|
||||
@@ -1002,7 +1002,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
throw new Error(
|
||||
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
|
||||
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
|
||||
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
|
||||
'要指的那一行还不存在的话,先把它真实建出来(该派数据工单的派数据工单、该发数据录入卡的发录入卡),' +
|
||||
'或者把这一笔挂到已存在的那一行名下',
|
||||
);
|
||||
}
|
||||
@@ -1685,6 +1685,15 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '')
|
||||
: [];
|
||||
const standard = String(input.card['standard'] ?? '').trim();
|
||||
/*
|
||||
* **这张卡该拦哪几张表** —— 零输入卡没有 `blocks`,闸只能回落到必交付物,
|
||||
* 而数据工单要人补的是 `parts.*` 这类主数据(不在交付物名单里):闸盯着一堆
|
||||
* 与这张卡无关的表,一格不缺,空手交卷一路放行(2026-08-27 W05 实测)。
|
||||
* 认不出形状的一律丢掉,不把脏东西端给闸。
|
||||
*/
|
||||
const gateLedgers = Array.isArray(input.card['gateLedgers'])
|
||||
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
|
||||
: [];
|
||||
const issued = await deps.humanTool.issueExecution(context, {
|
||||
title,
|
||||
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
|
||||
@@ -1693,6 +1702,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
...(steps.length === 0 ? {} : { steps }),
|
||||
...(standard === '' ? {} : { standard }),
|
||||
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
|
||||
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
|
||||
});
|
||||
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
|
||||
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } from '../internal/usecase/turn-runner.ts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
|
||||
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
|
||||
import type { PendingAcceptance } from '../port.ts';
|
||||
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
|
||||
@@ -992,7 +992,7 @@ describe('宣告完成', () => {
|
||||
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
|
||||
* 不是让模型自己判断,是让基座去核。
|
||||
*/
|
||||
it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
|
||||
it('声明了 doneWhen 的项:算出来是假,这一下 finish **不生效**(头一次只落观察,不记被拒)', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
@@ -1005,6 +1005,25 @@ describe('宣告完成', () => {
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
|
||||
});
|
||||
|
||||
/*
|
||||
* **早收一次不算撞墙,第二次才算**(2026-08-27 W05 e2e:五案例里三例各白吃一次被拒 ——
|
||||
* 模型把「写行 · 调工具 · finish」排在同一串,工具还没轮到调 finish 先到了,下一拍它自己补上)。
|
||||
* 与「重发已答过的卡头两次无事发生」同一把尺;真在原地打转的单仍看得见(第二次起照旧拒,喂熔断计数)。
|
||||
*/
|
||||
it('同一项第二次早收:照旧被拒 —— 熔断要看得见真卡死的单', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: finish('FREE'),
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0' }],
|
||||
sfeelCondition: () => false,
|
||||
trail: [{ kind: 'observation', title: earlyFinishTitleOf('FREE') }],
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.appended.some((m) => m.kind === 'result' && m.title.includes('行动被拒'))).toBe(true);
|
||||
});
|
||||
|
||||
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
@@ -1704,6 +1723,58 @@ describe('办完的项:话要说对,按一下不报错', () => {
|
||||
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
|
||||
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
|
||||
*/
|
||||
describe('「这张卡人已经答过了」摆成每拍都在的事实行', () => {
|
||||
const sent = (title: string) => ({ kind: 'card-sent', title });
|
||||
it('**决策卡:把人选的那个值摆出来**(2026-08-27 W05 e2e:五个案例四个死在这里 —— 剧本让模型自己去轨迹里翻回传,而那条痕会被挤走)', () => {
|
||||
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
|
||||
expect(line).toContain('人已经答过了');
|
||||
expect(line).toContain('他选的是「approve」');
|
||||
expect(line).toContain('finish');
|
||||
});
|
||||
|
||||
it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
|
||||
const line = answeredCardFact([sent('评估输入确认'), { kind: 'card-reply', title: '评估输入确认' }]);
|
||||
expect(line).toContain('空答复 = 照原样确认');
|
||||
expect(line).not.toContain('他选的是');
|
||||
});
|
||||
|
||||
it('回传之后又发了**别的**卡 → 有新问题在等,这条不摆', () => {
|
||||
const line = answeredCardFact([
|
||||
sent('第一问'),
|
||||
{ kind: 'card-reply', title: '第一问', detail: { value: 'approve' } },
|
||||
sent('另一件完全不同的事'),
|
||||
]);
|
||||
expect(line).toBe('');
|
||||
});
|
||||
|
||||
it('一张卡都没答过 → 空', () => {
|
||||
expect(answeredCardFact([sent('还没答的卡')])).toBe('');
|
||||
expect(answeredCardFact([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen 为假的拒回:差在哪要说得出口', () => {
|
||||
it('**缺的是一次工具调用就直说** —— 2026-08-27 W05 e2e:五个案例里三个撞上,拒回只教它去查「行与列」,而缺的是 recommend 没调过', () => {
|
||||
const why = unmetWhy('rows > 0 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 0 });
|
||||
expect(why).toContain('缺的不是行也不是列,是一次工具调用');
|
||||
expect(why).toContain('tool_w05_recommend_calls');
|
||||
expect(why).toContain('tool_w05_recommend_calls=0');
|
||||
});
|
||||
|
||||
it('工具调过了(读数 > 0)→ 不点它的名,只把判据与读数摆出来', () => {
|
||||
const why = unmetWhy('rows = 10 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 2 });
|
||||
expect(why).not.toContain('缺的不是行也不是列');
|
||||
expect(why).toContain('rows=9');
|
||||
});
|
||||
|
||||
it('判据里压根没有工具那一档 → 照旧只摆判据与读数', () => {
|
||||
const why = unmetWhy('a_rows = b_rows', { a_rows: 3, b_rows: 4 });
|
||||
expect(why).toContain('a_rows=3');
|
||||
expect(why).toContain('b_rows=4');
|
||||
expect(why).not.toContain('工具调用');
|
||||
});
|
||||
});
|
||||
|
||||
describe('同一张卡再发一遍的三档', () => {
|
||||
const sent = (title: string) => ({ kind: 'card-sent', title });
|
||||
const replied = (title: string) => ({ kind: 'card-reply', title });
|
||||
@@ -1717,6 +1788,21 @@ describe('同一张卡再发一遍的三档', () => {
|
||||
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
|
||||
});
|
||||
|
||||
it('**人选的那个值当场摆出来**(2026-08-27 W05 e2e:人点了通过,拒回只给「值在轨迹里」这个指针,模型对不准那一条,连发五次同一张卡熔断)', () => {
|
||||
const win = [sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: 'approve', note: '试跑代答' } }];
|
||||
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问', chose: 'approve' });
|
||||
const rejected = resendVerdict([...win, echoed('这一问'), echoed('这一问')], '这一问');
|
||||
expect(rejected).toEqual({ kind: 'reject', answered: '这一问', chose: 'approve' });
|
||||
});
|
||||
|
||||
it('回传痕里没有值(空串 / 缺 detail)→ 不编,退回原来那句指针话术', () => {
|
||||
expect(resendVerdict([sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: ' ' } }], '这一问')).toEqual({
|
||||
kind: 'echo',
|
||||
answered: '这一问',
|
||||
});
|
||||
expect(resendVerdict([sent('这一问'), replied('这一问')], '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
|
||||
});
|
||||
|
||||
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
|
||||
const win = [sent('这一问'), replied('这一问')];
|
||||
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
|
||||
|
||||
@@ -396,7 +396,10 @@ export function criterionKey(raw: string): string {
|
||||
}
|
||||
|
||||
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
||||
/** S-FEEL 保留字与**内建函数名** —— 摆读数时不该把它们当成台账名去查:
|
||||
* `count(candidateInternalPns)` 里的 `count` 从前被摆成「count=(没写过)」,
|
||||
* 而那是个函数,不是谁忘了写的一笔账 —— 拒回里多一个假线索,模型就会去追它(2026-08-27 实测)。 */
|
||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between', 'count', 'sum', 'min', 'max', 'all', 'any']);
|
||||
|
||||
/**
|
||||
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
||||
|
||||
@@ -210,6 +210,30 @@ function rejectReason(title: string): string {
|
||||
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
|
||||
* 一模糊就会把「问 A 料」当成「问 B 料」拦掉,那比多问一遍更坏。
|
||||
*/
|
||||
/**
|
||||
* **doneWhen 为假时,「差在哪」要说得出口** —— 拒回的话是模型下一拍的行动指南。
|
||||
*
|
||||
* 从前这条拒回只有一句通用话术:「常见的是该写 N 行只写了 M 行,或某一行缺了必填列」——
|
||||
* 把注意力全导向**行与列**。而收步判据里那半句常常根本不是行也不是列:
|
||||
* `tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条)。
|
||||
* 实测(2026-08-27 W05 e2e,五个案例里三个撞上):模型跳过工具、自己把推荐等级填了,
|
||||
* finish 恒被拒,而拒回教它去查行与列 —— 它只能把同样的明细再写一遍。
|
||||
* **指向两扇打不开的门,比不指还糟。**
|
||||
*
|
||||
* 现在把判据原文与它那几个名字**此刻的读数**一起摆出来(`readingsOf`,与断点提示同一段),
|
||||
* 并且:凡是 `tool_*_calls` 这一族读数为 0 的,单独点名说「缺的不是行也不是列,是一次工具调用」。
|
||||
*/
|
||||
export function unmetWhy(doneWhen: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const head = ` 干完的判据:${doneWhen}${readingsOf(doneWhen, scope)}`;
|
||||
const idle = [...new Set(doneWhen.match(/tool_[A-Za-z0-9_]+_calls/g) ?? [])].filter((name) => Number(scope[name] ?? 0) === 0);
|
||||
if (idle.length === 0) return head;
|
||||
return (
|
||||
`${head} —— **缺的不是行也不是列,是一次工具调用**:${idle.join(' / ')} 现在是 0,` +
|
||||
'说明这一步该用的工具你还没真调过(调失败不算)。先按剧本调它,拿工具算出来的结果写盘,' +
|
||||
'别自己填那几个值 —— 这一步的收步判据数的就是这枚工具的调用次数。'
|
||||
);
|
||||
}
|
||||
|
||||
export function sameQuestion(a: string, b: string): boolean {
|
||||
// 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截
|
||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
|
||||
@@ -229,6 +253,11 @@ export function sameQuestion(a: string, b: string): boolean {
|
||||
return short.length >= 6 && long.startsWith(short);
|
||||
}
|
||||
|
||||
/** 「还没到收步的时候」那条观察痕的标题 —— 与「已答过没再发」同族:记与数是同一句话 */
|
||||
export function earlyFinishTitleOf(itemId: string): string {
|
||||
return `还没到收步的时候,这一下 finish 没生效(${itemId})`;
|
||||
}
|
||||
|
||||
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
|
||||
export function echoTitleOf(answered: string): string {
|
||||
return `这张卡人已答过,没有再发(「${answered}」)`;
|
||||
@@ -246,16 +275,62 @@ export function echoTitleOf(answered: string): string {
|
||||
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
|
||||
*/
|
||||
export function resendVerdict(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
asked: string,
|
||||
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
|
||||
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string; readonly chose?: string } {
|
||||
const trimmed = asked.trim();
|
||||
if (trimmed === '') return { kind: 'send' };
|
||||
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
|
||||
const replies = window.filter((t) => t.kind === 'card-reply');
|
||||
const replied = new Set(replies.map((t) => t.title));
|
||||
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
|
||||
if (same === undefined) return { kind: 'send' };
|
||||
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
|
||||
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
|
||||
/*
|
||||
* **他选的是什么,当场摆出来**(2026-08-27 W05 e2e 实测的一单熔断):
|
||||
* 人点了「通过」,拒回却只说「值在你的轨迹里(card-reply 那一条)」—— 一个**指针**。
|
||||
* 而模型恰恰对不准那一条(轨迹里躺着好几张卡的 card-reply),于是连发五次同一张卡熔断。
|
||||
* 与 E04 那一课同源:**指南让模型去找的事实,引擎手里有现成的,就直接给它**。
|
||||
* 值就在回传痕的 `detail.value` 里(决策卡的载荷,与 payloadOf 同一约定)。
|
||||
*/
|
||||
const value = replies.find((t) => t.title === same.title)?.detail?.['value'];
|
||||
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title, ...(chose === undefined ? {} : { chose }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* **「这张卡人已经答过了、他选的是什么」——摆成每拍都在的一句事实**(2026-08-27 W05 e2e 逼出来的)。
|
||||
*
|
||||
* 剧本里那句「先在轨迹里找这一项已答过、还没处理的 card-reply」是**让模型自己去翻窗口**,
|
||||
* 而那条回传会被后面的痕挤走:实测五个案例里四个死在同一处 —— 人点了「通过」,
|
||||
* 模型每拍照剧本第一档「没有回音 → 发第一张决策卡」,同一张卡连发五次熔断;
|
||||
* 拒回把「他选的是 approve、接下来该写哪面旗」说得再明白也没用,因为**下一拍它又从头翻一次窗口**。
|
||||
*
|
||||
* 与 worldTouched 那条(2026-08-21)、E04 的「唯引擎事实行是从」(2026-08-24)同一条治法:
|
||||
* **指南让模型判的事,引擎手里有现成事实的,一律改成读事实**。
|
||||
*
|
||||
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
|
||||
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
|
||||
*/
|
||||
export function answeredCardFact(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
|
||||
): string {
|
||||
let at = -1;
|
||||
for (let i = window.length - 1; i >= 0; i -= 1) {
|
||||
if (window[i]?.kind === 'card-reply') {
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const reply = at < 0 ? undefined : window[at];
|
||||
if (reply === undefined) return '';
|
||||
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
|
||||
if (newer) return '';
|
||||
const value = reply.detail?.['value'];
|
||||
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
|
||||
return chose === undefined
|
||||
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
|
||||
: `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,7 +612,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
|
||||
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
|
||||
*/
|
||||
doneWhenOf?: (itemId: string) => boolean | undefined,
|
||||
doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined,
|
||||
/**
|
||||
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
|
||||
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
|
||||
@@ -1135,8 +1210,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
if (verdict.kind === 'reject') {
|
||||
throw new Error(
|
||||
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
||||
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
|
||||
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||
(verdict.chose === undefined
|
||||
? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):'
|
||||
: `**他选的是「${verdict.chose}」** —— `) +
|
||||
'照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' +
|
||||
'再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
@@ -1145,7 +1223,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
title: echoTitle,
|
||||
detail: {
|
||||
internal: true,
|
||||
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
|
||||
note:
|
||||
verdict.chose === undefined
|
||||
? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。'
|
||||
: `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`,
|
||||
},
|
||||
});
|
||||
// 什么也没做,局面原样交回
|
||||
@@ -1283,13 +1364,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
|
||||
*/
|
||||
const doneWhenNow = doneWhenOf?.(action.itemId);
|
||||
if (doneWhenNow === false) {
|
||||
throw new Error(
|
||||
if (doneWhenNow?.ok === false) {
|
||||
const say =
|
||||
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
|
||||
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
|
||||
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
|
||||
'基座会自己收,不必你再 finish。',
|
||||
);
|
||||
'不能凭这一下 finish 说它已经办完了。补齐了 doneWhen 自然为真,基座会自己收,不必你再 finish。' +
|
||||
doneWhenNow.why;
|
||||
/*
|
||||
* **早收一次不算撞墙**(2026-08-27 W05 e2e:五案例里三例各白吃一次)——
|
||||
* 与「重发已答过的卡头两次无事发生」同一把尺(设计律:按钮按两下不该变成墙)。
|
||||
* 实测那一形是**计划次序**的小错:模型把 [写行 · 调工具 · finish] 排在同一串,
|
||||
* 工具还没轮到调,finish 先到了;它下一拍自己就补上了 ——
|
||||
* 这种拍不该记成「行动被拒」去喂熔断计数(那是给真卡死留的)。
|
||||
* 头一次只落观察(把判据与差在哪原样摆回轨迹,和拒回一个字不差),
|
||||
* 第二次起照旧拒 —— 真在原地打转的单仍然看得见。
|
||||
*/
|
||||
const seen = (await deps.memory.window(taskId, STALL_WINDOW)).filter(
|
||||
(t) => t.kind === 'observation' && t.title === earlyFinishTitleOf(action.itemId),
|
||||
).length;
|
||||
if (seen === 0) {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: earlyFinishTitleOf(action.itemId),
|
||||
detail: { internal: true, note: say },
|
||||
});
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
throw new Error(say);
|
||||
}
|
||||
const ready = readyToWork(finishModel, runtime, action.itemId);
|
||||
await deps.memory.append({
|
||||
@@ -1745,6 +1846,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
: [
|
||||
`【当前这一步 · ${current.item}】${current.intent}`,
|
||||
currentTouch,
|
||||
answeredCardFact(trail),
|
||||
current.guide,
|
||||
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
|
||||
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
|
||||
@@ -1976,7 +2078,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const step = stepOf(itemId);
|
||||
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
|
||||
try {
|
||||
return deps.sfeel.condition(step.doneWhen, liveScope);
|
||||
const ok = deps.sfeel.condition(step.doneWhen, liveScope);
|
||||
return ok ? { ok, why: '' } : { ok, why: unmetWhy(step.doneWhen, liveScope) };
|
||||
} catch {
|
||||
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
|
||||
return undefined;
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
|
||||
不变量:
|
||||
- `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键
|
||||
- **姓在前、名在后**(2026-08-26 用户定):`Staff.name` 由 `personName` 一处拼 ——
|
||||
token 那条路取 `family_name` / `given_name`,名册同步取 `lastName` / `firstName`;
|
||||
**不吃 Keycloak 拼好的 `name` claim**(那是 `firstName lastName`,西式序)。
|
||||
两边都是汉字直拼(「柴浩」),否则留一个空格(「Admin Amy」);皆空回落用户名。
|
||||
拆不开的 realm(没配 profile scope)才退回 `name` claim —— 重排不了的东西如实用它。
|
||||
- `Staff.kind` 由**账号名现判**、不落库:`service-account-<clientId>` 是 Keycloak 自己的命名,
|
||||
开了 `serviceAccountsEnabled` 的 client 一律配一个。服务账号是**真身份**(试跑与 e2e 用它跑单),
|
||||
但不是人 —— 员工管理那一屏据此不摆它,试跑台的负责人下拉照旧摆(铁规矩 6)。
|
||||
按名字认不按角色认:角色是各 realm 自己配的,这个前缀由 Keycloak 保证
|
||||
- `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError,
|
||||
有效 token 不能把它翻回 active(撤权在同步之后即时生效)
|
||||
- **停用判定走在 JIT 写之前**:已停用的人攥着有效 token 也驱动不了 `updateIdentity`;
|
||||
@@ -45,6 +54,16 @@
|
||||
③落库之前按差异算一遍「同步后还有谁能进运营中心」,不过就整体拒绝(AccessLockoutError → 409),一条都不写
|
||||
- 同步逐条落库:**单条失败不停在半路**,失败的人点名进 `RosterSyncOutcome.failed`;
|
||||
三个计数说的是真落下的条数,不是打算写几条
|
||||
- **成员目录**(`directory*`,ADR-012):没配 provisioning 就**整个不可用**(抛
|
||||
`DirectoryUnavailableError` → 502),不给「读得了写不了」的半开状态 —— 那样人会以为是自己点错了。
|
||||
读是投影、写**穿透**到 Keycloak(没有本地草稿,也没有撤销)。四条不变量:
|
||||
①**服务账号一律不可见不可写**(目录读剔干净、写面抛 `ServiceAccountProtectedError` → 403);
|
||||
②**停用与收权当场踢会话** —— 否则那枚 token 到期前照样通行,而屏上已经写了「已停用」;
|
||||
③**收权先算自锁**:收完没人进得了运营中心就整体拒(409),与改「角色→端」同一道闸、同一个理由;
|
||||
④**删人本地只停不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。
|
||||
- 目录读有 **60 秒进程内缓存**,**任一写(成功或失败)之后整体清空**:写完立刻重拉一定看得到新结果,
|
||||
前端不必延时重试。失败也清 —— 半途失败之后那份缓存说不清停在哪一边。
|
||||
唯一滞后:**别的**管理员在另一个后端实例上写,这边最多滞后 60 秒。
|
||||
- `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 []
|
||||
- `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖
|
||||
- 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核
|
||||
@@ -59,3 +78,8 @@
|
||||
`RosterSyncOutcome` 增 `failed`(契约变更,api-report 已刷新,`docs/API文档-鉴权与人员.md` §3/§7 同步);
|
||||
`AccessLockoutError` / `assertNotLockedOut` 收可选 `message`(两个来路各说各的话)。
|
||||
下游 depcruise 反查:`rosterSyncOutcomeSchema` 只被 `syncStaffRoute.output` 与本模块用,web 尚无消费方。
|
||||
|
||||
影响半径(2026-08-26,姓在前;2026-08-27 合并 feat/act-work-05 时补记):`VerifiedClaims` 增可选 `given_name` / `family_name`
|
||||
(Keycloak 拼好的 `name` 是「名 姓」西式全名,展示「姓 名」得拿散件自己拼;两个散件都缺才回退 `name`),
|
||||
`identityOf` 与 `createKeycloakRoster`(`lastName firstName`)同一把尺。
|
||||
加的是可选列,非破坏性;api-report `org/port.d.ts` 随合并刷新(主支那条提交没刷)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** domain 测试零 mock(编码规范 §16) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts';
|
||||
import { identityOf, isNoiseRole, normalizeRoles, personName, staffKindOf } from '../internal/domain/claims.ts';
|
||||
|
||||
describe('identityOf —— claims 归一成一个人', () => {
|
||||
it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => {
|
||||
@@ -27,9 +27,9 @@ describe('identityOf —— claims 归一成一个人', () => {
|
||||
expect('email' in id).toBe(false);
|
||||
});
|
||||
|
||||
it('姓/名散件在 → 姓在前拼「姓 名」,不采用全名字段的名+姓序(2026-08-26 用户定)', () => {
|
||||
it('姓/名散件在 → 姓在前拼(汉字直拼,不留空格),不采用全名字段的名+姓序(2026-08-26 用户定)', () => {
|
||||
const id = identityOf({ sub: 's', preferred_username: 'shan', name: '亲 删', given_name: '亲', family_name: '删' });
|
||||
expect(id.name).toBe('删 亲');
|
||||
expect(id.name).toBe('删亲');
|
||||
});
|
||||
|
||||
it('realm_access 有但 roles 缺 → 空角色', () => {
|
||||
@@ -48,3 +48,58 @@ describe('噪声角色', () => {
|
||||
expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staffKindOf —— 人还是服务账号', () => {
|
||||
it('Keycloak 的 service-account-<clientId> 认作服务账号', () => {
|
||||
expect(staffKindOf('service-account-task-dispatcher')).toBe('service');
|
||||
});
|
||||
it('大小写与前后空格不影响判断', () => {
|
||||
expect(staffKindOf(' Service-Account-Provisioning ')).toBe('service');
|
||||
});
|
||||
it('真人一律 person —— 名字里带 service 也不算', () => {
|
||||
expect(staffKindOf('chaihao')).toBe('person');
|
||||
expect(staffKindOf('ai-test')).toBe('person');
|
||||
expect(staffKindOf('service-desk')).toBe('person');
|
||||
expect(staffKindOf('')).toBe('person');
|
||||
});
|
||||
});
|
||||
|
||||
describe('personName —— 姓在前、名在后', () => {
|
||||
it('中文名直拼,不留空格', () => {
|
||||
expect(personName({ familyName: '柴', givenName: '浩', fallback: 'chaihao' })).toBe('柴浩');
|
||||
expect(personName({ familyName: '丁', givenName: '子乔', fallback: 'x' })).toBe('丁子乔');
|
||||
});
|
||||
it('非汉字留一个空格 —— 拼在一起会连成一个词', () => {
|
||||
expect(personName({ familyName: 'Admin', givenName: 'Amy', fallback: 'amy' })).toBe('Admin Amy');
|
||||
});
|
||||
it('一边混着字母就留空格(拼死了读不出来是两截)', () => {
|
||||
expect(personName({ familyName: '李', givenName: 'Ken', fallback: 'x' })).toBe('李 Ken');
|
||||
});
|
||||
it('只有一半就摆那一半,不留孤零零的空格', () => {
|
||||
expect(personName({ familyName: '柴', fallback: 'chaihao' })).toBe('柴');
|
||||
expect(personName({ givenName: '浩', fallback: 'chaihao' })).toBe('浩');
|
||||
});
|
||||
it('两边都空(或只有空白)→ 回落,名字这一格不许空着', () => {
|
||||
expect(personName({ fallback: 'chaihao' })).toBe('chaihao');
|
||||
expect(personName({ familyName: ' ', givenName: '', fallback: 'chaihao' })).toBe('chaihao');
|
||||
});
|
||||
});
|
||||
|
||||
describe('identityOf 的名字:自己拼,不吃 Keycloak 拼好的那个', () => {
|
||||
it('有 family_name / given_name 就按姓在前拼 —— name claim 是西式序,不用它', () => {
|
||||
const id = identityOf({
|
||||
sub: 's',
|
||||
preferred_username: 'chaihao',
|
||||
name: '浩 柴',
|
||||
given_name: '浩',
|
||||
family_name: '柴',
|
||||
});
|
||||
expect(id.name).toBe('柴浩');
|
||||
});
|
||||
it('realm 没配 profile scope(拆不开)→ 如实用 name claim', () => {
|
||||
expect(identityOf({ sub: 's', preferred_username: 'bob', name: 'Bob Lee' }).name).toBe('Bob Lee');
|
||||
});
|
||||
it('两样都没有 → 回落用户名', () => {
|
||||
expect(identityOf({ sub: 's', preferred_username: 'bob' }).name).toBe('bob');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 成员目录的用例层 —— 两道闸与一处缓存(ADR-012)。
|
||||
* Keycloak 是外界,给它一个记账的替身(编码规范 §16:只有外界能 mock)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Position, RoleAccess, WorkGroup } from '@contracts';
|
||||
import { positionIdSchema } from '@contracts';
|
||||
import { AccessLockoutError, DirectoryUnavailableError, ServiceAccountProtectedError } from '../contract.ts';
|
||||
import type { DirectoryUser, KeycloakDirectory, OrgServiceDeps, StaffRecord } from '../index.ts';
|
||||
import { createOrgService } from '../index.ts';
|
||||
|
||||
const PEOPLE: DirectoryUser[] = [
|
||||
{ sub: 'sub-admin', username: 'chaihao', enabled: true, emailVerified: true, roles: ['admin', 'user'] },
|
||||
{ sub: 'sub-user', username: 'linpeixuan', enabled: true, emailVerified: false, roles: ['user'] },
|
||||
{ sub: 'sub-svc', username: 'service-account-task-dispatcher', enabled: true, emailVerified: false, roles: ['service'] },
|
||||
];
|
||||
|
||||
type Log = string[];
|
||||
|
||||
/** 记账替身 —— 只回预置的形状,把调过什么记下来 */
|
||||
function fakeDirectory(log: Log, users = PEOPLE): KeycloakDirectory {
|
||||
const find = (sub: string): DirectoryUser => {
|
||||
const u = users.find((x) => x.sub === sub);
|
||||
if (u === undefined) throw new Error(`没有这个人:${sub}`);
|
||||
return u;
|
||||
};
|
||||
return {
|
||||
listUsers: async () => {
|
||||
log.push('listUsers');
|
||||
return users;
|
||||
},
|
||||
getUser: async (sub) => ({ ...find(sub), groups: [] }),
|
||||
createUser: async (input) => {
|
||||
log.push(`createUser:${input.username}`);
|
||||
return 'sub-new';
|
||||
},
|
||||
updateUser: async (sub) => void log.push(`updateUser:${sub}`),
|
||||
setEnabled: async (sub, enabled) => void log.push(`setEnabled:${sub}:${String(enabled)}`),
|
||||
resetPassword: async (sub) => void log.push(`resetPassword:${sub}`),
|
||||
deleteUser: async (sub) => void log.push(`deleteUser:${sub}`),
|
||||
logout: async (sub) => void log.push(`logout:${sub}`),
|
||||
listRoles: async () => [
|
||||
{ name: 'admin', composite: false },
|
||||
{ name: 'user', composite: false },
|
||||
{ name: 'default-roles-corp', composite: true },
|
||||
],
|
||||
setRole: async (sub, role, granted) => void log.push(`setRole:${sub}:${role}:${String(granted)}`),
|
||||
listGroups: async () => {
|
||||
log.push('listGroups');
|
||||
return [{ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 }];
|
||||
},
|
||||
groupMembers: async () => users,
|
||||
groupMemberUsernames: async () => users.map((u) => u.username),
|
||||
createGroup: async (name) => {
|
||||
log.push(`createGroup:${name}`);
|
||||
return { id: 'g2', name, path: `/${name}`, depth: 0, memberCount: 0 };
|
||||
},
|
||||
renameGroup: async (id, name) => {
|
||||
log.push(`renameGroup:${id}:${name}`);
|
||||
return { id, name, path: `/${name}`, depth: 0, memberCount: 0 };
|
||||
},
|
||||
deleteGroup: async (id) => void log.push(`deleteGroup:${id}`),
|
||||
setUserGroup: async (sub, gid, member) => void log.push(`setUserGroup:${sub}:${gid}:${String(member)}`),
|
||||
};
|
||||
}
|
||||
|
||||
function harness(opts: { directory?: KeycloakDirectory } = {}) {
|
||||
const positions = new Map<string, Position>();
|
||||
const staff = new Map<string, StaffRecord>();
|
||||
const roleAccess = new Map<string, RoleAccess>([
|
||||
['admin', { role: 'admin', ends: ['design', 'ops', 'workbench'] }],
|
||||
['user', { role: 'user', ends: ['workbench'] }],
|
||||
]);
|
||||
const groups = new Map<string, WorkGroup>();
|
||||
let nowMs = Date.parse('2026-08-26T09:00:00Z');
|
||||
|
||||
const deps: OrgServiceDeps = {
|
||||
verifier: { verify: async () => ({ sub: 'x', preferred_username: 'x' }) },
|
||||
...(opts.directory === undefined ? {} : { directory: opts.directory }),
|
||||
positions: {
|
||||
list: async () => [...positions.values()],
|
||||
findByCode: async (code) => [...positions.values()].find((p) => p.code === code),
|
||||
findById: async (id) => positions.get(id),
|
||||
upsert: async (p) => {
|
||||
const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase());
|
||||
const row: Position = { id, code: p.code, name: p.name };
|
||||
positions.set(id, row);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
staff: {
|
||||
list: async () => [...staff.values()],
|
||||
findById: async (id) => staff.get(id),
|
||||
findByAccount: async (a) => [...staff.values()].find((s) => s.account === a),
|
||||
listByPosition: async () => [],
|
||||
insert: async (s, marks) => {
|
||||
const row: StaffRecord = { ...s, ...marks };
|
||||
staff.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
updateIdentity: async () => undefined,
|
||||
setPosition: async () => undefined,
|
||||
setState: async (id, state) => {
|
||||
const row = staff.get(id);
|
||||
if (row !== undefined) staff.set(id, { ...row, state });
|
||||
},
|
||||
},
|
||||
roleAccess: {
|
||||
list: async () => [...roleAccess.values()],
|
||||
upsert: async (row) => {
|
||||
roleAccess.set(row.role, row);
|
||||
return row;
|
||||
},
|
||||
remove: async (role) => void roleAccess.delete(role),
|
||||
},
|
||||
workGroups: {
|
||||
listByOwner: async () => [],
|
||||
upsert: async (g) => {
|
||||
groups.set(g.id, g);
|
||||
return g;
|
||||
},
|
||||
},
|
||||
inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] },
|
||||
clock: { now: () => new Date(nowMs) },
|
||||
ids: { next: () => 'id' },
|
||||
testAccounts: [],
|
||||
};
|
||||
return { org: createOrgService(deps), staff, tick: (ms: number) => (nowMs += ms) };
|
||||
}
|
||||
|
||||
describe('没配 provisioning —— 目录整个不可用,不给半开状态', () => {
|
||||
it('读也不行,写也不行;话里说清缺什么', async () => {
|
||||
const h = harness();
|
||||
await expect(h.org.directoryUsers({})).rejects.toBeInstanceOf(DirectoryUnavailableError);
|
||||
await expect(h.org.directoryGroups()).rejects.toBeInstanceOf(DirectoryUnavailableError);
|
||||
await h.org.directoryUsers({}).catch((e: unknown) => {
|
||||
expect((e as Error).message).toContain('KEYCLOAK_PROVISIONING_CLIENT_ID');
|
||||
expect((e as DirectoryUnavailableError).misconfigured).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务账号:是身份,不是人', () => {
|
||||
it('目录读里一个都不出现', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
const { users } = await h.org.directoryUsers({ includeDisabled: true });
|
||||
expect(users.map((u) => u.username)).toEqual(['chaihao', 'linpeixuan']);
|
||||
});
|
||||
|
||||
it('对着它下手一律拒 —— 停掉它,试跑与 e2e 第二天全趴下', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
await expect(h.org.setDirectoryUserEnabled('sub-svc', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
await expect(h.org.deleteDirectoryUser('sub-svc')).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
await expect(h.org.setDirectoryUserRole('sub-svc', 'admin', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
});
|
||||
|
||||
it('连建一个叫这名字的都不许 —— 免得从正门造出一个假服务账号', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
await expect(
|
||||
h.org.createDirectoryUser({ username: 'service-account-fake' }),
|
||||
).rejects.toBeInstanceOf(ServiceAccountProtectedError);
|
||||
});
|
||||
|
||||
it('组成员里也剔干净', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
const { members } = await h.org.directoryGroupMembers('g1');
|
||||
expect(members.map((m) => m.username)).toEqual(['chaihao', 'linpeixuan']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('收权不能把最后一个管理员关在门外', () => {
|
||||
it('收掉唯一 admin 的 admin → 拒,一个字都没写出去', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log, [PEOPLE[0]!, PEOPLE[1]!]) });
|
||||
await expect(h.org.setDirectoryUserRole('sub-admin', 'admin', false)).rejects.toBeInstanceOf(AccessLockoutError);
|
||||
expect(log.filter((l) => l.startsWith('setRole'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('还有另一个 admin 在 → 放行,并**当场踢掉他的会话**', async () => {
|
||||
const log: Log = [];
|
||||
const two: DirectoryUser[] = [
|
||||
PEOPLE[0]!,
|
||||
{ sub: 'sub-admin2', username: 'amy', enabled: true, emailVerified: false, roles: ['admin', 'user'] },
|
||||
];
|
||||
const h = harness({ directory: fakeDirectory(log, two) });
|
||||
await h.org.setDirectoryUserRole('sub-admin', 'admin', false);
|
||||
expect(log).toContain('setRole:sub-admin:admin:false');
|
||||
// 不踢的话他手上那枚 token 到期前还带着 admin —— 收权要等过期才生效
|
||||
expect(log).toContain('logout:sub-admin');
|
||||
});
|
||||
|
||||
it('授权那一路不查自锁(只会更宽,锁不死)', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log, [PEOPLE[1]!]) });
|
||||
await h.org.setDirectoryUserRole('sub-user', 'admin', true);
|
||||
expect(log).toContain('setRole:sub-user:admin:true');
|
||||
expect(log).not.toContain('logout:sub-user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('停用与删除:本地那一行跟手', () => {
|
||||
it('停用 → 踢会话 + 本地置 disabled(两屏不许说两句话)', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] });
|
||||
await h.org.setDirectoryUserEnabled('sub-user', false);
|
||||
expect(log).toContain('setEnabled:sub-user:false');
|
||||
expect(log).toContain('logout:sub-user');
|
||||
expect(h.staff.get('sub-user')?.state).toBe('disabled');
|
||||
});
|
||||
|
||||
it('启用不踢会话(他本来就登不上,踢什么)', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.setDirectoryUserEnabled('sub-user', true);
|
||||
expect(log).not.toContain('logout:sub-user');
|
||||
});
|
||||
|
||||
it('删人 → 本地那一行**停用不删**:留痕、在途、台账都还指着这个 sub', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] });
|
||||
await h.org.deleteDirectoryUser('sub-user');
|
||||
expect(log).toContain('deleteUser:sub-user');
|
||||
expect(h.staff.get('sub-user')?.state).toBe('disabled');
|
||||
expect(h.staff.has('sub-user')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('60 秒读缓存 —— 写完立刻清,不必延时重试', () => {
|
||||
it('60 秒内不再问 Keycloak', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryUsers({ q: 'chai' });
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('过了 60 秒自己重取', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
h.tick(61_000);
|
||||
await h.org.directoryUsers({});
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(2);
|
||||
});
|
||||
|
||||
/*
|
||||
* 清缓存**按写真正碰到的东西清**。全清的代价看得见:删一个组之后下一次读要把
|
||||
* 「全量成员的角色」那趟 N+1 重付一遍,屏上那棵树因此多钉着已经没了的组好几秒。
|
||||
*/
|
||||
it('建组只清组那一份 —— 人的角色与端没变,别陪着重付一趟 N+1', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
await h.org.createDirectoryGroup('新组');
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(1);
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('改资料只清人那一份 —— 组的成员数没变', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
await h.org.patchDirectoryUser('sub-user', { firstName: '佩萱' });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(2);
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('删人两边都清 —— 人没了,他在的那些组也少一个人', async () => {
|
||||
const log: Log = [];
|
||||
const h = harness({ directory: fakeDirectory(log) });
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
await h.org.deleteDirectoryUser('sub-user');
|
||||
await h.org.directoryUsers({});
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listUsers')).toHaveLength(2);
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('写**失败**了也清 —— 半途失败之后那份缓存说不清是哪一边', async () => {
|
||||
const log: Log = [];
|
||||
const dir = fakeDirectory(log);
|
||||
const h = harness({
|
||||
directory: { ...dir, createGroup: async () => { throw new Error('同级重名'); } },
|
||||
});
|
||||
await h.org.directoryGroups();
|
||||
await expect(h.org.createDirectoryGroup('技术部')).rejects.toThrow('同级重名');
|
||||
await h.org.directoryGroups();
|
||||
expect(log.filter((l) => l === 'listGroups')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色清单', () => {
|
||||
it('家具角色不摆(授收它没有意义),持有数按生效角色数', async () => {
|
||||
const h = harness({ directory: fakeDirectory([]) });
|
||||
const { roles } = await h.org.directoryRoles();
|
||||
expect(roles.map((r) => r.name)).toEqual(['admin', 'user']);
|
||||
// service-account 已剔,所以 user 是 2 不是 3
|
||||
expect(roles.find((r) => r.name === 'user')?.holders).toBe(2);
|
||||
expect(roles.find((r) => r.name === 'admin')?.holders).toBe(1);
|
||||
expect(roles.find((r) => r.name === 'admin')?.ends).toEqual(['design', 'ops', 'workbench']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/** domain 测试零 mock(编码规范 §16) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RoleAccess } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../port.ts';
|
||||
import {
|
||||
displayNameOf,
|
||||
filterOrgUsers,
|
||||
isServiceAccount,
|
||||
localMarks,
|
||||
sortOrgUsers,
|
||||
toOrgGroups,
|
||||
toOrgRoles,
|
||||
toOrgUser,
|
||||
} from '../internal/domain/directory.ts';
|
||||
|
||||
const TABLE: RoleAccess[] = [
|
||||
{ role: 'admin', ends: ['design', 'ops', 'workbench'] },
|
||||
{ role: 'user', ends: ['workbench'] },
|
||||
];
|
||||
|
||||
const kc = (over: Partial<DirectoryUser> = {}): DirectoryUser => ({
|
||||
sub: 'sub-1',
|
||||
username: 'chaihao',
|
||||
enabled: true,
|
||||
emailVerified: false,
|
||||
roles: ['admin', 'user'],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('服务账号:是身份,不是人', () => {
|
||||
it('Keycloak 的 service-account-<clientId> 认得出', () => {
|
||||
expect(isServiceAccount('service-account-task-dispatcher')).toBe(true);
|
||||
expect(isServiceAccount('chaihao')).toBe(false);
|
||||
// 名字里带 service 不算 —— 认的是 Keycloak 那个固定前缀,不是关键词
|
||||
expect(isServiceAccount('service-desk')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayNameOf', () => {
|
||||
it('姓在前、名在后 —— 中文名直拼', () => {
|
||||
expect(displayNameOf({ username: 'a', firstName: '浩', lastName: '柴' })).toBe('柴浩');
|
||||
});
|
||||
it('只有一半就摆那一半', () => {
|
||||
expect(displayNameOf({ username: 'a', lastName: '柴' })).toBe('柴');
|
||||
});
|
||||
it('两个都空 → 回落用户名,不回空串', () => {
|
||||
expect(displayNameOf({ username: 'amy', firstName: ' ', lastName: '' })).toBe('amy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrgUser —— Keycloak 那份 + 本地那两列', () => {
|
||||
it('ends 由本地表算,不是 Keycloak 的字段', () => {
|
||||
expect(toOrgUser(kc(), TABLE).ends).toEqual(['design', 'ops', 'workbench']);
|
||||
expect(toOrgUser(kc({ roles: ['user'] }), TABLE).ends).toEqual(['workbench']);
|
||||
});
|
||||
it('角色一行都没命中 → 无端可进(不是报错,是事实)', () => {
|
||||
expect(toOrgUser(kc({ roles: ['service'] }), TABLE).ends).toEqual([]);
|
||||
});
|
||||
it('本地两列合进来;没有就整个键不出现', () => {
|
||||
const withLocal = toOrgUser(kc(), TABLE, { lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'TEST' });
|
||||
expect(withLocal.lastSeenAt).toBe('2026-08-26T07:00:00.000Z');
|
||||
expect(withLocal.positionCode).toBe('TEST');
|
||||
const bare = toOrgUser(kc(), TABLE);
|
||||
expect('lastSeenAt' in bare).toBe(false);
|
||||
expect('positionCode' in bare).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortOrgUsers —— 来过的在前', () => {
|
||||
it('来过的按时刻倒序在前,没来过的按用户名在后', () => {
|
||||
const rows = [
|
||||
toOrgUser(kc({ sub: 'c', username: 'cathy' }), TABLE),
|
||||
toOrgUser(kc({ sub: 'a', username: 'amy' }), TABLE, { lastSeenAt: new Date('2026-08-01T00:00:00Z') }),
|
||||
toOrgUser(kc({ sub: 'b', username: 'bob' }), TABLE),
|
||||
toOrgUser(kc({ sub: 'd', username: 'dan' }), TABLE, { lastSeenAt: new Date('2026-08-20T00:00:00Z') }),
|
||||
];
|
||||
expect(sortOrgUsers(rows).map((u) => u.username)).toEqual(['dan', 'amy', 'bob', 'cathy']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterOrgUsers —— 检索与「含停用」', () => {
|
||||
const rows = [
|
||||
toOrgUser(kc({ sub: '1', username: 'chaihao', firstName: '浩', lastName: '柴', email: 'a@x.com' }), TABLE),
|
||||
toOrgUser(kc({ sub: '2', username: 'amy', enabled: false }), TABLE),
|
||||
];
|
||||
it('缺省不摆停用的人', () => {
|
||||
expect(filterOrgUsers(rows, '', false).map((u) => u.username)).toEqual(['chaihao']);
|
||||
});
|
||||
it('含停用 → 都摆', () => {
|
||||
expect(filterOrgUsers(rows, '', true)).toHaveLength(2);
|
||||
});
|
||||
it('用户名 / 姓名 / 邮箱任一含关键词即命中,不区分大小写', () => {
|
||||
expect(filterOrgUsers(rows, 'CHAI', true)).toHaveLength(1);
|
||||
expect(filterOrgUsers(rows, '柴', true)).toHaveLength(1);
|
||||
expect(filterOrgUsers(rows, 'a@x', true)).toHaveLength(1);
|
||||
});
|
||||
it('**含在中间**也搜得到 —— 这正是不转给 Keycloak 的 search= 的理由(那个是前缀式)', () => {
|
||||
expect(filterOrgUsers(rows, 'aiha', true).map((u) => u.username)).toEqual(['chaihao']);
|
||||
});
|
||||
it('搜不到就是空,不是全量', () => {
|
||||
expect(filterOrgUsers(rows, '查无此人', true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrgRoles', () => {
|
||||
const roles: DirectoryRole[] = [
|
||||
{ name: 'admin', composite: false },
|
||||
{ name: 'user', composite: false, description: '员工' },
|
||||
];
|
||||
it('每个角色配上本地算的 ends 与调用方数好的持有数', () => {
|
||||
const out = toOrgRoles(roles, TABLE, [5, 8]);
|
||||
expect(out[0]).toMatchObject({ name: 'admin', ends: ['design', 'ops', 'workbench'], holders: 5 });
|
||||
expect(out[1]).toMatchObject({ name: 'user', ends: ['workbench'], holders: 8, description: '员工' });
|
||||
});
|
||||
it('持有数没给到那一位 → 0,不是 undefined', () => {
|
||||
expect(toOrgRoles(roles, TABLE, [3])[1]?.holders).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrgGroups —— 平表 + 成员数', () => {
|
||||
const groups: DirectoryGroup[] = [
|
||||
{ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 },
|
||||
{ id: 'g2', name: '平台组', path: '/技术部/平台组', parentId: 'g1', depth: 1, memberCount: 0 },
|
||||
];
|
||||
it('成员数由调用方现数;顶层组没有 parentId 那个键', () => {
|
||||
const out = toOrgGroups(groups, new Map([['g1', 3]]));
|
||||
expect(out[0]).toEqual({ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 3 });
|
||||
expect(out[1]?.parentId).toBe('g1');
|
||||
});
|
||||
it('没数过的组是 0 —— 不给 undefined 让界面摆一个空格', () => {
|
||||
expect(toOrgGroups(groups, new Map())[1]?.memberCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localMarks', () => {
|
||||
const none = positionIdSchema.parse('pos-none');
|
||||
const staff: Array<StoredStaff & { lastSeenAt?: Date }> = [
|
||||
{
|
||||
id: staffIdSchema.parse('sub-1'),
|
||||
account: 'chaihao',
|
||||
name: '柴浩',
|
||||
positionId: none,
|
||||
state: 'active',
|
||||
roles: ['admin'],
|
||||
lastSeenAt: new Date('2026-08-26T07:00:00Z'),
|
||||
},
|
||||
{ id: staffIdSchema.parse('sub-2'), account: 'amy', name: 'Amy', positionId: none, state: 'active', roles: [] },
|
||||
];
|
||||
it('按 sub 索引;岗位查不到就不摆那个键', () => {
|
||||
const marks = localMarks(staff, (id) => (id === none ? 'NONE' : undefined));
|
||||
expect(marks.get('sub-1')).toEqual({ lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'NONE' });
|
||||
expect('lastSeenAt' in (marks.get('sub-2') ?? {})).toBe(false);
|
||||
});
|
||||
it('岗位表里没有那一行 → 键不出现,不摆空串', () => {
|
||||
const marks = localMarks(staff, () => undefined);
|
||||
expect('positionCode' in (marks.get('sub-1') ?? {})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,9 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } {
|
||||
id: `sub-${i}`,
|
||||
username: `u${i}`,
|
||||
enabled: i !== 3,
|
||||
firstName: i === 0 ? '' : '名',
|
||||
lastName: i === 0 ? '' : `${i}`,
|
||||
// 第 0 位两边都空 —— 试的是「名字这一格不许空着,回落用户名」那一支
|
||||
firstName: i === 0 ? '' : '浩',
|
||||
lastName: i === 0 ? '' : '柴',
|
||||
email: `u${i}@x.test`,
|
||||
}));
|
||||
const json = (body: unknown, status = 200): Response =>
|
||||
@@ -42,13 +43,14 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } {
|
||||
}
|
||||
|
||||
describe('Keycloak Admin API 名册', () => {
|
||||
it('分页拉全(100 + 5)、姓名姓在前拼 lastName firstName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => {
|
||||
it('分页拉全(100 + 5)、姓在前名在后、缺名回退用户名、角色滤噪声、enabled 照抄', async () => {
|
||||
const kc = fakeKeycloak();
|
||||
const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch });
|
||||
const users = await roster.listUsers();
|
||||
expect(users).toHaveLength(105);
|
||||
expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true });
|
||||
expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '1 名', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true });
|
||||
// 姓在前:lastName「柴」+ firstName「浩」= 柴浩(中文直拼,不留空格)
|
||||
expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '柴浩', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true });
|
||||
expect(users[3]?.enabled).toBe(false);
|
||||
expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -62,6 +62,24 @@ describe('Keycloak 验签', () => {
|
||||
expect(claims.realm_access?.roles).toContain('user');
|
||||
});
|
||||
|
||||
/*
|
||||
* 验签这一层是**挑字段**拼 `VerifiedClaims` 的 —— 类型上加了字段而这儿忘了挑,
|
||||
* 编译一声不吭,下游拿到的永远是 undefined。实测栽过一次:
|
||||
* 姓名改「姓在前」之后每个请求还是把名字写回西式序,查了两台后端才发现漏在这儿。
|
||||
* 所以这条钉的是**整条链**:真 token 的形状进去,`identityOf` 认得出的形状出来。
|
||||
*/
|
||||
it('given_name / family_name 要原样带出来 —— 姓在前的拼法全靠它俩', async () => {
|
||||
const claims = await verifier.verify(await mint({ claims: { given_name: '浩', family_name: '柴' } }));
|
||||
expect(claims.given_name).toBe('浩');
|
||||
expect(claims.family_name).toBe('柴');
|
||||
});
|
||||
|
||||
it('token 里没有那两个 claim → 键干脆不出现(不是 undefined)', async () => {
|
||||
const claims = await verifier.verify(await mint());
|
||||
expect('given_name' in claims).toBe(false);
|
||||
expect('family_name' in claims).toBe(false);
|
||||
});
|
||||
|
||||
it('过期 30 秒内(时钟容差)仍通过', async () => {
|
||||
await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -243,6 +243,22 @@ describe('名册与岗位', () => {
|
||||
expect(await h.org.staffOfPosition('NONE')).toEqual([]);
|
||||
});
|
||||
|
||||
it('listStaff 给每行标 kind —— 服务账号是身份不是人(员工管理那一屏据此不摆它)', async () => {
|
||||
const h = harness();
|
||||
await h.org.ensureSeeded();
|
||||
await h.org.registerIdentity({ sub: 'p', account: 'chaihao', name: '柴浩', roles: ['admin'] });
|
||||
await h.org.registerIdentity({
|
||||
sub: 's',
|
||||
account: 'service-account-task-dispatcher',
|
||||
name: 'service-account-task-dispatcher',
|
||||
roles: ['service'],
|
||||
});
|
||||
const { staff } = await h.org.listStaff();
|
||||
const kinds = Object.fromEntries(staff.map((x) => [x.account, x.kind]));
|
||||
expect(kinds['chaihao']).toBe('person');
|
||||
expect(kinds['service-account-task-dispatcher']).toBe('service');
|
||||
});
|
||||
|
||||
it('syncRoster 未配置 → configured:false', async () => {
|
||||
const h = harness();
|
||||
await h.org.ensureSeeded();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/** domain 测试零 mock(编码规范 §16) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Staff } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import { activeRolesAfter, diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts';
|
||||
import type { RosterUser } from '../port.ts';
|
||||
import type { RosterUser, StoredStaff } from '../port.ts';
|
||||
|
||||
const none = positionIdSchema.parse('none');
|
||||
const local = (sub: string, over: Partial<Staff> = {}): Staff => ({
|
||||
const local = (sub: string, over: Partial<StoredStaff> = {}): StoredStaff => ({
|
||||
id: staffIdSchema.parse(sub),
|
||||
account: sub,
|
||||
name: sub,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
*/
|
||||
import type {
|
||||
InFlightWork,
|
||||
OrgGroup,
|
||||
OrgRole,
|
||||
OrgUser,
|
||||
OrgUserDetail,
|
||||
Position,
|
||||
PositionId,
|
||||
Principal,
|
||||
@@ -19,7 +23,27 @@ import type {
|
||||
WorkGroupBinding,
|
||||
} from '@contracts';
|
||||
|
||||
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||
export type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
|
||||
/**
|
||||
* 目录写面的两个入参 —— 住在 contract.ts 而不是 port.ts:它们是**服务出口的形状**
|
||||
* (路由照它填),port 只是恰好也要用。反过来放会让 port ↔ contract 成环。
|
||||
*/
|
||||
export type CreateDirectoryUser = {
|
||||
readonly username: string;
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
/** 临时口令;不给就建一个还登不进来的壳 */
|
||||
readonly password?: string;
|
||||
/** 一并授的 realm 角色 */
|
||||
readonly roles?: readonly string[];
|
||||
};
|
||||
|
||||
export type DirectoryUserPatch = {
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
};
|
||||
|
||||
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
||||
export type StaffWithPosition = Staff & { readonly position: Position };
|
||||
@@ -78,6 +102,32 @@ export class AccessLockoutError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。
|
||||
* **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。
|
||||
*/
|
||||
export class DirectoryUnavailableError extends Error {
|
||||
/** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */
|
||||
readonly misconfigured: boolean;
|
||||
constructor(message: string, misconfigured: boolean) {
|
||||
super(message);
|
||||
this.name = 'DirectoryUnavailableError';
|
||||
this.misconfigured = misconfigured;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`),
|
||||
* 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。
|
||||
* 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。
|
||||
*/
|
||||
export class ServiceAccountProtectedError extends Error {
|
||||
constructor(readonly account: string) {
|
||||
super(`「${account}」是服务账号,不是人 —— 成员目录不管它;要停它去 Keycloak 停对应的 client`);
|
||||
this.name = 'ServiceAccountProtectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export type OrgService = {
|
||||
/* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */
|
||||
|
||||
@@ -109,6 +159,31 @@ export type OrgService = {
|
||||
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
||||
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
||||
|
||||
/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */
|
||||
|
||||
/**
|
||||
* 目录**没配 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>;
|
||||
|
||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
|
||||
|
||||
/** 建组 / 改组;id 留空即新建 */
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
CreateDirectoryUser,
|
||||
DirectoryUserPatch,
|
||||
Identity,
|
||||
InFlightWork,
|
||||
OrgGroup,
|
||||
OrgRole,
|
||||
OrgService,
|
||||
OrgUser,
|
||||
OrgUserDetail,
|
||||
Position,
|
||||
Principal,
|
||||
RoleAccess,
|
||||
@@ -18,16 +24,22 @@ export type {
|
||||
export {
|
||||
AccessLockoutError,
|
||||
AccountDisabledError,
|
||||
DirectoryUnavailableError,
|
||||
PositionNotFoundError,
|
||||
ServiceAccountProtectedError,
|
||||
StaffNotFoundError,
|
||||
TokenRejectedError,
|
||||
} from './contract.ts';
|
||||
|
||||
export type {
|
||||
Clock,
|
||||
DirectoryGroup,
|
||||
DirectoryRole,
|
||||
DirectoryUser,
|
||||
IdGenerator,
|
||||
IdentityPatch,
|
||||
InFlightWorkProbe,
|
||||
KeycloakDirectory,
|
||||
PositionRepository,
|
||||
RoleAccessRepository,
|
||||
RosterSource,
|
||||
@@ -44,6 +56,15 @@ export { createOrgService } from './internal/usecase/org-service.ts';
|
||||
|
||||
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
||||
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
||||
export {
|
||||
displayNameOf,
|
||||
filterOrgUsers,
|
||||
isServiceAccount,
|
||||
sortOrgUsers,
|
||||
toOrgGroups,
|
||||
toOrgRoles,
|
||||
toOrgUser,
|
||||
} from './internal/domain/directory.ts';
|
||||
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
||||
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
||||
|
||||
@@ -51,6 +72,9 @@ export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapte
|
||||
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
|
||||
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
|
||||
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
|
||||
export { createKeycloakDirectory } from './internal/adapter/keycloak-directory.ts';
|
||||
export { createAdminHttp, KeycloakMisconfiguredError, KeycloakRejectedError, KeycloakUnavailableError } from './internal/adapter/keycloak-admin-http.ts';
|
||||
export type { AdminHttp, KeycloakAdminOptions } from './internal/adapter/keycloak-admin-http.ts';
|
||||
export {
|
||||
createPositionRepository,
|
||||
createRoleAccessRepository,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Keycloak Admin API 的 HTTP 底座(手册 §11 / §14;ADR-012)。
|
||||
*
|
||||
* 名册同步与成员目录**共用同一枚管理 token** —— 从前只有名册那一条路,
|
||||
* token 缓存写在它里面;目录接上来之后各缓存一份就是两条独立的续期时钟,
|
||||
* 边界上谁先过期谁自己去换,白烧两次握手,还没人说得清当下在用哪一枚。
|
||||
*
|
||||
* 三条路径规则照手册 §14.1:`{client-uuid}` 不是 clientId、创建返回 201 空体
|
||||
* (新 id 在 `Location` 末段)、列表默认 `max=100` 必须分页。
|
||||
*
|
||||
* 错误分两种说 —— **重试有没有意义,决定了界面该说什么话**:
|
||||
* `KeycloakMisconfiguredError`(401/403)= 服务账号缺角色或凭证不对,重试无益;
|
||||
* `KeycloakUnavailableError`(网络 / 5xx)= 暂时够不着,过会儿再来。
|
||||
*/
|
||||
|
||||
/** 服务账号缺 `realm-management` 角色,或凭证不对 —— 重试无益,去补权限 */
|
||||
export class KeycloakMisconfiguredError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'KeycloakMisconfiguredError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Keycloak 暂时够不着(网络 / 5xx)—— 可退避重试 */
|
||||
export class KeycloakUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'KeycloakUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Keycloak 明确拒绝了这次写(重名 / 约束不过)—— 原话带出去给人看 */
|
||||
export class KeycloakRejectedError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'KeycloakRejectedError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type KeycloakAdminOptions = {
|
||||
readonly url: string;
|
||||
readonly realm: string;
|
||||
readonly clientId: string;
|
||||
readonly secret: string;
|
||||
/** 测试注入;缺省全局 fetch */
|
||||
readonly fetch?: typeof fetch;
|
||||
/** 毫秒时刻;测试注入 */
|
||||
readonly now?: () => number;
|
||||
};
|
||||
|
||||
export type AdminHttp = {
|
||||
get<T>(path: string): Promise<T>;
|
||||
/** 建资源:成功 201 空体,回 `Location` 末段(新 id);Keycloak 不给就回 undefined */
|
||||
post(path: string, body?: unknown): Promise<string | undefined>;
|
||||
put(path: string, body?: unknown): Promise<void>;
|
||||
/** `body` 可选是因为**撤 realm 角色是带 body 的 DELETE**(手册 §14.4),不是笔误 */
|
||||
delete(path: string, body?: unknown): Promise<void>;
|
||||
};
|
||||
|
||||
/** 列表接口默认 `max=100`(手册 §14.1 第三条),分页取全 */
|
||||
export const ADMIN_PAGE = 100;
|
||||
|
||||
export function createAdminHttp(options: KeycloakAdminOptions): AdminHttp {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const now = options.now ?? ((): number => Date.now());
|
||||
const base = options.url.replace(/\/+$/, '');
|
||||
const realmBase = `${base}/admin/realms/${options.realm}`;
|
||||
let cached: { token: string; expiresAt: number } | undefined;
|
||||
|
||||
async function call(path: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetchImpl(path, init);
|
||||
} catch (error) {
|
||||
throw new KeycloakUnavailableError(`够不着 Keycloak:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function adminToken(): Promise<string> {
|
||||
if (cached !== undefined && now() < cached.expiresAt) return cached.token;
|
||||
const resp = await call(`${base}/realms/master/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: options.clientId,
|
||||
client_secret: options.secret,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
// 401/400 在这条路上一律是凭证不对(client_id / secret)—— 重试一百次也是同一句
|
||||
if (resp.status < 500) throw new KeycloakMisconfiguredError(`取管理 token 失败:${resp.status} ${text}`);
|
||||
throw new KeycloakUnavailableError(`取管理 token 失败:${resp.status} ${text}`);
|
||||
}
|
||||
const data = (await resp.json()) as { access_token: string; expires_in: number };
|
||||
// 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调
|
||||
cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 };
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
async function send(method: string, path: string, body?: unknown): Promise<Response> {
|
||||
const token = await adminToken();
|
||||
const resp = await call(`${realmBase}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (resp.ok) return resp;
|
||||
const text = await resp.text();
|
||||
const where = `${method} ${path}`;
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
// 手册 §14.6:403 就是服务账号缺 realm-management 角色 —— 说清缺在哪一步
|
||||
throw new KeycloakMisconfiguredError(
|
||||
`${where} 被拒(${resp.status})—— provisioning 服务账号缺 realm-management 角色(手册 §14.6);原话:${text}`,
|
||||
);
|
||||
}
|
||||
if (resp.status >= 500) throw new KeycloakUnavailableError(`${where} 失败(${resp.status}):${text}`);
|
||||
throw new KeycloakRejectedError(resp.status, `${where} 被拒(${resp.status}):${text}`);
|
||||
}
|
||||
|
||||
return {
|
||||
async get<T>(path: string): Promise<T> {
|
||||
return (await (await send('GET', path)).json()) as T;
|
||||
},
|
||||
async post(path: string, body?: unknown): Promise<string | undefined> {
|
||||
const resp = await send('POST', path, body);
|
||||
// 手册 §14.1 第二条:新资源 id 在 Location 末段,响应体是空的
|
||||
const location = resp.headers.get('location');
|
||||
return location === null ? undefined : location.split('/').pop();
|
||||
},
|
||||
async put(path: string, body?: unknown): Promise<void> {
|
||||
await send('PUT', path, body);
|
||||
},
|
||||
async delete(path: string, body?: unknown): Promise<void> {
|
||||
await send('DELETE', path, body);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。
|
||||
*/
|
||||
import type { RosterSource, RosterUser } from '../../port.ts';
|
||||
import { normalizeRoles } from '../domain/claims.ts';
|
||||
import { normalizeRoles, personName } from '../domain/claims.ts';
|
||||
|
||||
export type KeycloakRosterOptions = {
|
||||
readonly url: string;
|
||||
@@ -76,15 +76,15 @@ export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSour
|
||||
const out: RosterUser[] = [];
|
||||
for (const u of users) {
|
||||
const roles = await adminGet<KcRole[]>(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`);
|
||||
/* 姓在前(2026-08-26 用户定)—— 与 token 那条路(claims.ts `identityOf`)同一把尺 */
|
||||
const name = [u.lastName, u.firstName]
|
||||
.filter((s): s is string => s !== undefined && s.trim() !== '')
|
||||
.join(' ')
|
||||
.trim();
|
||||
out.push({
|
||||
sub: u.id,
|
||||
account: u.username,
|
||||
name: name === '' ? u.username : name,
|
||||
// 姓在前、名在后 —— 与 token 那条路同一把尺(`personName`),两处理解不许分叉
|
||||
name: personName({
|
||||
...(u.lastName === undefined ? {} : { familyName: u.lastName }),
|
||||
...(u.firstName === undefined ? {} : { givenName: u.firstName }),
|
||||
fallback: u.username,
|
||||
}),
|
||||
// 与 token 那条路同一把尺 —— 归一自己再写一遍,两处理解一分叉,
|
||||
// `identityChanged` 的逐字比对就会天天判「变了」(或天天判「没变」)
|
||||
roles: normalizeRoles(roles.map((r) => r.name)),
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 成员目录 —— Keycloak Admin API 的读写面(手册 §14;ADR-012)。
|
||||
*
|
||||
* 与名册同步(`keycloak-admin-roster.ts`)共用 `createAdminHttp` 的那枚管理 token。
|
||||
* 这里**不做过滤、不做归并**:剔服务账号、拼 `ends`、合本地岗位都归 domain 与 usecase ——
|
||||
* 适配器只把 Keycloak 的形状原样翻过来,翻错了一眼看得出。
|
||||
*/
|
||||
import type {
|
||||
AdminHttp,
|
||||
KeycloakAdminOptions,
|
||||
} from './keycloak-admin-http.ts';
|
||||
import { ADMIN_PAGE, createAdminHttp } from './keycloak-admin-http.ts';
|
||||
import type { CreateDirectoryUser, DirectoryUserPatch } from '../../contract.ts';
|
||||
import type {
|
||||
DirectoryGroup,
|
||||
DirectoryRole,
|
||||
DirectoryUser,
|
||||
KeycloakDirectory,
|
||||
} from '../../port.ts';
|
||||
import { normalizeRoles } from '../domain/claims.ts';
|
||||
|
||||
type KcUser = {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
readonly enabled: boolean;
|
||||
readonly emailVerified?: boolean;
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
};
|
||||
type KcRole = { readonly name: string; readonly description?: string; readonly composite?: boolean };
|
||||
type KcGroup = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly subGroups?: readonly KcGroup[];
|
||||
};
|
||||
|
||||
/** 空串与 undefined 是同一件事:Keycloak 那边没填,别在契约里变成一个空格子 */
|
||||
function opt(value: string | undefined): { [k: string]: string } {
|
||||
return value === undefined || value.trim() === '' ? {} : { v: value.trim() };
|
||||
}
|
||||
|
||||
function toUser(u: KcUser, roles: readonly string[]): DirectoryUser {
|
||||
const first = opt(u.firstName);
|
||||
const last = opt(u.lastName);
|
||||
const mail = opt(u.email);
|
||||
return {
|
||||
sub: u.id,
|
||||
username: u.username,
|
||||
enabled: u.enabled,
|
||||
emailVerified: u.emailVerified === true,
|
||||
roles: normalizeRoles([...roles]),
|
||||
...(first.v === undefined ? {} : { firstName: first.v }),
|
||||
...(last.v === undefined ? {} : { lastName: last.v }),
|
||||
...(mail.v === undefined ? {} : { email: mail.v }),
|
||||
};
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
/**
|
||||
* 有限并发地跑一批 —— Keycloak 没有「一次拿回全部人的角色」的接口(手册 §14.4 逐人查),
|
||||
* 串行跑 8 个人就是 8 趟远程往返(实测整页 37 秒)。并发上限压着,
|
||||
* 免得一屏把管理端打成一次小型压测。
|
||||
*/
|
||||
async function mapLimit<T, R>(items: readonly T[], limit: number, run: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
for (;;) {
|
||||
const i = cursor;
|
||||
cursor += 1;
|
||||
if (i >= items.length) return;
|
||||
out[i] = await run(items[i]!);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return out;
|
||||
}
|
||||
|
||||
const ROLE_FETCH_CONCURRENCY = 8;
|
||||
|
||||
export function createKeycloakDirectory(options: KeycloakAdminOptions, http?: AdminHttp): KeycloakDirectory {
|
||||
const api = http ?? createAdminHttp(options);
|
||||
|
||||
/** 实际生效的 realm 角色 —— 排查权限永远看 `/composite`(手册 §14.4) */
|
||||
async function rolesOf(sub: string): Promise<string[]> {
|
||||
const roles = await api.get<KcRole[]>(`/users/${enc(sub)}/role-mappings/realm/composite`);
|
||||
return roles.map((r) => r.name);
|
||||
}
|
||||
|
||||
async function allUsers(): Promise<KcUser[]> {
|
||||
const out: KcUser[] = [];
|
||||
for (let first = 0; ; first += ADMIN_PAGE) {
|
||||
const page = await api.get<KcUser[]>(`/users?first=${first}&max=${ADMIN_PAGE}&briefRepresentation=false`);
|
||||
out.push(...page);
|
||||
if (page.length < ADMIN_PAGE) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 一行一个组;`subGroups` 递归摊平,顺手记住谁是谁的爹 */
|
||||
function flatten(nodes: readonly KcGroup[], parentId: string | undefined, depth: number, into: DirectoryGroup[]): void {
|
||||
for (const g of nodes) {
|
||||
into.push({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
path: g.path,
|
||||
depth,
|
||||
memberCount: 0, // 由 usecase 按组成员现数 —— Keycloak 的组表不带这个数
|
||||
...(parentId === undefined ? {} : { parentId }),
|
||||
});
|
||||
flatten(g.subGroups ?? [], g.id, depth + 1, into);
|
||||
}
|
||||
}
|
||||
|
||||
/** 口令一律**临时**:本人首登必须改(手册 §14.2);我们不存、不回显、不留痕 */
|
||||
async function resetPassword(sub: string, password: string): Promise<void> {
|
||||
await api.put(`/users/${enc(sub)}/reset-password`, { type: 'password', value: password, temporary: true });
|
||||
}
|
||||
|
||||
async function setRole(sub: string, role: string, granted: boolean): Promise<void> {
|
||||
// 授/撤的 body 是**角色对象数组**(手册 §14.4)—— 光给名字 Keycloak 不认
|
||||
const all = await api.get<Array<KcRole & { id: string }>>('/roles');
|
||||
const target = all.find((r) => r.name === role);
|
||||
if (target === undefined) throw new Error(`realm 里没有角色「${role}」`);
|
||||
const body = [{ id: target.id, name: target.name }];
|
||||
const path = `/users/${enc(sub)}/role-mappings/realm`;
|
||||
if (granted) await api.post(path, body);
|
||||
else await api.delete(path, body);
|
||||
}
|
||||
|
||||
async function rawGroupMembers(groupId: string): Promise<KcUser[]> {
|
||||
const members: KcUser[] = [];
|
||||
for (let first = 0; ; first += ADMIN_PAGE) {
|
||||
const page = await api.get<KcUser[]>(`/groups/${enc(groupId)}/members?first=${first}&max=${ADMIN_PAGE}`);
|
||||
members.push(...page);
|
||||
if (page.length < ADMIN_PAGE) break;
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
async function groupById(groupId: string): Promise<DirectoryGroup> {
|
||||
const g = await api.get<KcGroup>(`/groups/${enc(groupId)}`);
|
||||
const segments = g.path.split('/').filter((s) => s !== '');
|
||||
return { id: g.id, name: g.name, path: g.path, depth: segments.length - 1, memberCount: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
async listUsers() {
|
||||
const users = await allUsers();
|
||||
return mapLimit(users, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id)));
|
||||
},
|
||||
|
||||
async getUser(sub) {
|
||||
const u = await api.get<KcUser>(`/users/${enc(sub)}`);
|
||||
const [roles, groups] = await Promise.all([
|
||||
rolesOf(sub),
|
||||
api.get<Array<{ id: string; path: string }>>(`/users/${enc(sub)}/groups`),
|
||||
]);
|
||||
return { ...toUser(u, roles), groups: groups.map((g) => ({ id: g.id, path: g.path })) };
|
||||
},
|
||||
|
||||
async createUser(input: CreateDirectoryUser) {
|
||||
const first = opt(input.firstName);
|
||||
const last = opt(input.lastName);
|
||||
const mail = opt(input.email);
|
||||
const sub = await api.post('/users', {
|
||||
username: input.username,
|
||||
enabled: true,
|
||||
...(first.v === undefined ? {} : { firstName: first.v }),
|
||||
...(last.v === undefined ? {} : { lastName: last.v }),
|
||||
...(mail.v === undefined ? {} : { email: mail.v }),
|
||||
});
|
||||
if (sub === undefined) throw new Error('Keycloak 建用户成功但没回 Location —— 拿不到新 id,别往下走');
|
||||
if (input.password !== undefined) await resetPassword(sub, input.password);
|
||||
for (const role of input.roles ?? []) await setRole(sub, role, true);
|
||||
return sub;
|
||||
},
|
||||
|
||||
async updateUser(sub, patch: DirectoryUserPatch) {
|
||||
// 手册 §14.2:PUT 是全量覆盖 —— 先 GET 再改再 PUT,不然没带的字段会被清空
|
||||
const current = await api.get<KcUser>(`/users/${enc(sub)}`);
|
||||
const first = opt(patch.firstName ?? current.firstName);
|
||||
const last = opt(patch.lastName ?? current.lastName);
|
||||
const mail = opt(patch.email ?? current.email);
|
||||
await api.put(`/users/${enc(sub)}`, {
|
||||
...current,
|
||||
firstName: first.v ?? '',
|
||||
lastName: last.v ?? '',
|
||||
email: mail.v ?? '',
|
||||
});
|
||||
},
|
||||
|
||||
async setEnabled(sub, enabled) {
|
||||
const current = await api.get<KcUser>(`/users/${enc(sub)}`);
|
||||
await api.put(`/users/${enc(sub)}`, { ...current, enabled });
|
||||
},
|
||||
|
||||
resetPassword,
|
||||
|
||||
async deleteUser(sub) {
|
||||
await api.delete(`/users/${enc(sub)}`);
|
||||
},
|
||||
|
||||
async logout(sub) {
|
||||
await api.post(`/users/${enc(sub)}/logout`);
|
||||
},
|
||||
|
||||
async listRoles(): Promise<readonly DirectoryRole[]> {
|
||||
const roles = await api.get<KcRole[]>('/roles');
|
||||
return roles.map((r) => ({
|
||||
name: r.name,
|
||||
composite: r.composite === true,
|
||||
...(r.description === undefined || r.description.trim() === '' ? {} : { description: r.description.trim() }),
|
||||
}));
|
||||
},
|
||||
|
||||
setRole,
|
||||
|
||||
async listGroups() {
|
||||
const tree = await api.get<KcGroup[]>('/groups?briefRepresentation=false');
|
||||
const flat: DirectoryGroup[] = [];
|
||||
flatten(tree, undefined, 0, flat);
|
||||
return flat.sort((a, b) => a.path.localeCompare(b.path));
|
||||
},
|
||||
|
||||
async groupMembers(groupId) {
|
||||
const members = await rawGroupMembers(groupId);
|
||||
return mapLimit(members, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id)));
|
||||
},
|
||||
|
||||
async groupMemberUsernames(groupId) {
|
||||
return (await rawGroupMembers(groupId)).map((u) => u.username);
|
||||
},
|
||||
|
||||
|
||||
async createGroup(name, parentId) {
|
||||
const path = parentId === undefined ? '/groups' : `/groups/${enc(parentId)}/children`;
|
||||
const id = await api.post(path, { name });
|
||||
if (id === undefined) throw new Error('Keycloak 建组成功但没回 Location —— 拿不到新 id,别往下走');
|
||||
return groupById(id);
|
||||
},
|
||||
|
||||
async renameGroup(groupId, name) {
|
||||
const current = await api.get<KcGroup>(`/groups/${enc(groupId)}`);
|
||||
await api.put(`/groups/${enc(groupId)}`, { ...current, name });
|
||||
return groupById(groupId);
|
||||
},
|
||||
|
||||
async deleteGroup(groupId) {
|
||||
await api.delete(`/groups/${enc(groupId)}`);
|
||||
},
|
||||
|
||||
async setUserGroup(sub, groupId, member) {
|
||||
const path = `/users/${enc(sub)}/groups/${enc(groupId)}`;
|
||||
if (member) await api.put(path);
|
||||
else await api.delete(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const claimsSchema = z.object({
|
||||
sub: z.string().min(1),
|
||||
preferred_username: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
/* 姓与名分开的两个 claim —— 我们按「姓在前」自己拼(`personName`),不吃拼好的 `name` */
|
||||
given_name: z.string().optional(),
|
||||
family_name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
|
||||
@@ -5,11 +5,51 @@
|
||||
* Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉:
|
||||
* `default-roles-<realm>` / `offline_access` / `uma_authorization`。
|
||||
*/
|
||||
import type { StaffKind } from '@contracts';
|
||||
import type { Identity } from '../../contract.ts';
|
||||
import type { VerifiedClaims } from '../../port.ts';
|
||||
|
||||
const NOISE_ROLES: ReadonlySet<string> = new Set(['offline_access', 'uma_authorization']);
|
||||
|
||||
/**
|
||||
* Keycloak 给服务账号用户的固定命名 —— **它自己定的**,不是我们的约定:
|
||||
* 开了 `serviceAccountsEnabled` 的 client 一律配一个 `service-account-<clientId>`。
|
||||
* 按名字认而不按角色认,是因为角色是每个 realm 自己配的(这边叫 `service`,别处未必),
|
||||
* 而这个前缀由 Keycloak 保证。
|
||||
*/
|
||||
const SERVICE_ACCOUNT_PREFIX = 'service-account-';
|
||||
|
||||
/**
|
||||
* 姓在前、名在后(2026-08-26 用户定)。
|
||||
*
|
||||
* Keycloak 自己拼的 `name` claim 是 `firstName lastName` —— 西式顺序,中文名读出来是「浩 柴」。
|
||||
* 所以**不吃那个 claim**,拿分开的 `family_name` / `given_name` 自己拼;
|
||||
* 名册同步那条路同理,拿 `lastName` / `firstName`。
|
||||
*
|
||||
* 中间那个空格按**字形**决定,不按语言设置:两边都是汉字就直拼(「柴浩」),
|
||||
* 否则留一个空格(「Admin Amy」)—— 中文名里塞个空格,读的人会以为那是两个词。
|
||||
*/
|
||||
const CJK = /^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]+$/u;
|
||||
|
||||
export function personName(input: {
|
||||
readonly familyName?: string;
|
||||
readonly givenName?: string;
|
||||
/** 两边都空时回落到这个(通常是用户名)—— 名字这一格永远不许是空的 */
|
||||
readonly fallback: string;
|
||||
}): string {
|
||||
const family = (input.familyName ?? '').trim();
|
||||
const given = (input.givenName ?? '').trim();
|
||||
if (family === '' && given === '') return input.fallback;
|
||||
if (family === '') return given;
|
||||
if (given === '') return family;
|
||||
return CJK.test(family) && CJK.test(given) ? `${family}${given}` : `${family} ${given}`;
|
||||
}
|
||||
|
||||
/** 这个账号是人还是服务账号 —— 员工管理那一屏据此不摆服务账号(它是身份,不是人) */
|
||||
export function staffKindOf(account: string): StaffKind {
|
||||
return account.trim().toLowerCase().startsWith(SERVICE_ACCOUNT_PREFIX) ? 'service' : 'person';
|
||||
}
|
||||
|
||||
export function isNoiseRole(role: string): boolean {
|
||||
return NOISE_ROLES.has(role) || role.startsWith('default-roles-');
|
||||
}
|
||||
@@ -21,13 +61,21 @@ export function normalizeRoles(roles: readonly string[]): string[] {
|
||||
|
||||
export function identityOf(claims: VerifiedClaims): Identity {
|
||||
const account = claims.preferred_username;
|
||||
/* 姓名展示姓在前(2026-08-26 用户定):有姓/名散件就按「姓 名」拼;
|
||||
没有散件才用 Keycloak 的全名 `name`(它按「名 姓」拼,中文场景顺序是反的) */
|
||||
const cjk = [claims.family_name, claims.given_name]
|
||||
.filter((s): s is string => s !== undefined && s.trim() !== '')
|
||||
.join(' ');
|
||||
const fallback = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim();
|
||||
const name = cjk !== '' ? cjk : fallback;
|
||||
/*
|
||||
* 先按姓在前自己拼;`family_name` / `given_name` 都没有的 realm(没配 profile scope)
|
||||
* 才退回 Keycloak 拼好的 `name` —— 拆不开的东西没法重排,如实用它。
|
||||
*/
|
||||
const composed = personName({
|
||||
...(claims.family_name === undefined ? {} : { familyName: claims.family_name }),
|
||||
...(claims.given_name === undefined ? {} : { givenName: claims.given_name }),
|
||||
fallback: '',
|
||||
});
|
||||
const name =
|
||||
composed !== ''
|
||||
? composed
|
||||
: claims.name === undefined || claims.name.trim() === ''
|
||||
? account
|
||||
: claims.name.trim();
|
||||
const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim();
|
||||
return {
|
||||
sub: claims.sub,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 成员目录的归一(纯函数,零 IO;ADR-012)。
|
||||
*
|
||||
* Keycloak 那一份(身份 · 角色 · 组)与本地那一份(岗位 · 几时来过)在这里合成一行 ——
|
||||
* **两份事实各有各的主人**:改身份去 Keycloak,改岗位在本地;这段只负责摆在一起给人看。
|
||||
*
|
||||
* 服务账号在这一层就剔掉:它是真身份、跑得了单,但**不是人**(见 `staffKindSchema`),
|
||||
* 而这三个页签管的是人。剔在 domain 而不是各路由自己记得滤 —— 漏一处就是一处泄漏,
|
||||
* 而泄漏的后果是有人在界面上把 e2e 账号停用了,试跑第二天全趴下。
|
||||
*/
|
||||
import type { OrgGroup, OrgRole, OrgUser, RoleAccess } from '@contracts';
|
||||
import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../../port.ts';
|
||||
import { personName, staffKindOf } from './claims.ts';
|
||||
import { endsOf } from './ends.ts';
|
||||
|
||||
/** 本地名册里跟这个人有关的那两列 —— 目录只读它们,不写 */
|
||||
export type LocalMark = { readonly lastSeenAt?: Date; readonly positionCode?: string };
|
||||
|
||||
export function isServiceAccount(username: string): boolean {
|
||||
return staffKindOf(username) === 'service';
|
||||
}
|
||||
|
||||
/** 姓在前、名在后;皆空回落 username —— 名字这一格不会是空的。拼法归 `personName` 一处 */
|
||||
export function displayNameOf(user: Pick<DirectoryUser, 'username' | 'firstName' | 'lastName'>): string {
|
||||
return personName({
|
||||
...(user.lastName === undefined ? {} : { familyName: user.lastName }),
|
||||
...(user.firstName === undefined ? {} : { givenName: user.firstName }),
|
||||
fallback: user.username,
|
||||
});
|
||||
}
|
||||
|
||||
export function toOrgUser(user: DirectoryUser, table: readonly RoleAccess[], local?: LocalMark): OrgUser {
|
||||
return {
|
||||
sub: user.sub,
|
||||
username: user.username,
|
||||
displayName: displayNameOf(user),
|
||||
enabled: user.enabled,
|
||||
roles: [...user.roles],
|
||||
ends: endsOf(user.roles, table),
|
||||
...(user.email === undefined ? {} : { email: user.email }),
|
||||
...(local?.lastSeenAt === undefined ? {} : { lastSeenAt: local.lastSeenAt.toISOString() }),
|
||||
...(local?.positionCode === undefined ? {} : { positionCode: local.positionCode }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序:来过的在前(倒序),没来过的在后(按用户名升序)。
|
||||
* 「没有 `lastSeenAt`」说的是**还没拿这枚身份访问过本系统**,不等于「从未登录 Keycloak」——
|
||||
* 名册同步照过来的人天生就是这一档。
|
||||
*/
|
||||
export function sortOrgUsers(users: readonly OrgUser[]): OrgUser[] {
|
||||
return [...users].sort((a, b) => {
|
||||
if (a.lastSeenAt !== undefined && b.lastSeenAt !== undefined) {
|
||||
return a.lastSeenAt === b.lastSeenAt ? a.username.localeCompare(b.username) : b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||||
}
|
||||
if (a.lastSeenAt !== undefined) return -1;
|
||||
if (b.lastSeenAt !== undefined) return 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索:用户名 / 姓名 / 邮箱任一含关键词(不区分大小写)。
|
||||
* 在**我们这一侧**过滤而不是转给 Keycloak 的 `search=` —— 那个是前缀式,
|
||||
* 「按姓名搜中间那两个字」搜不出来,而人会以为这个人不在。realm 不大,全量在手上。
|
||||
*/
|
||||
export function filterOrgUsers(users: readonly OrgUser[], q: string, includeDisabled: boolean): OrgUser[] {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return users.filter((u) => {
|
||||
if (!includeDisabled && !u.enabled) return false;
|
||||
if (needle === '') return true;
|
||||
return [u.username, u.displayName, u.email ?? ''].some((f) => f.toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个角色配上「能进哪几个端」与「几个人持有」—— 前者本地说了算,后者 Keycloak 说了算。
|
||||
* `holders` 由调用方按角色现数后传进来(每个角色一趟),不在这儿从全量用户里筛 ——
|
||||
* 那样等于逼调用方先把每个人的角色都拉一遍。
|
||||
*/
|
||||
export function toOrgRoles(
|
||||
roles: readonly DirectoryRole[],
|
||||
table: readonly RoleAccess[],
|
||||
holders: readonly number[],
|
||||
): OrgRole[] {
|
||||
return roles.map((r, i) => ({
|
||||
name: r.name,
|
||||
composite: r.composite,
|
||||
ends: endsOf([r.name], table),
|
||||
holders: holders[i] ?? 0,
|
||||
...(r.description === undefined ? {} : { description: r.description }),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 组树平表配上直属成员数(已剔服务账号)—— Keycloak 的组表本身不带这个数 */
|
||||
export function toOrgGroups(groups: readonly DirectoryGroup[], counts: ReadonlyMap<string, number>): OrgGroup[] {
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
path: g.path,
|
||||
depth: g.depth,
|
||||
memberCount: counts.get(g.id) ?? 0,
|
||||
...(g.parentId === undefined ? {} : { parentId: g.parentId }),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 本地名册 → 按 sub 取那两列 */
|
||||
export function localMarks(
|
||||
staff: ReadonlyArray<StoredStaff & { readonly lastSeenAt?: Date }>,
|
||||
positionCodeOf: (positionId: string) => string | undefined,
|
||||
): Map<string, LocalMark> {
|
||||
const out = new Map<string, LocalMark>();
|
||||
for (const s of staff) {
|
||||
const code = positionCodeOf(s.positionId);
|
||||
out.set(s.id, {
|
||||
...(s.lastSeenAt === undefined ? {} : { lastSeenAt: s.lastSeenAt }),
|
||||
...(code === undefined ? {} : { positionCode: code }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import type { Staff, StaffId } from '@contracts';
|
||||
import type { Identity } from '../../contract.ts';
|
||||
import type { IdentityPatch, RosterUser } from '../../port.ts';
|
||||
import type { IdentityPatch, RosterUser, StoredStaff } from '../../port.ts';
|
||||
import { isTestAccount } from './test-account.ts';
|
||||
|
||||
export type RosterDiff = {
|
||||
@@ -27,7 +27,7 @@ export function sameRoles(a: readonly string[], b: readonly string[]): boolean {
|
||||
}
|
||||
|
||||
/** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */
|
||||
export function identityChanged(staff: Staff, identity: Identity): boolean {
|
||||
export function identityChanged(staff: StoredStaff, identity: Identity): boolean {
|
||||
return (
|
||||
staff.account !== identity.account ||
|
||||
staff.name !== identity.name ||
|
||||
@@ -51,10 +51,10 @@ export function patchOf(identity: Identity): IdentityPatch {
|
||||
*/
|
||||
export function diffRoster(
|
||||
remote: readonly RosterUser[],
|
||||
local: readonly Staff[],
|
||||
local: readonly StoredStaff[],
|
||||
testAccounts: readonly string[],
|
||||
): RosterDiff {
|
||||
const bySub = new Map<string, Staff>(local.map((s) => [s.id, s]));
|
||||
const bySub = new Map<string, StoredStaff>(local.map((s) => [s.id, s]));
|
||||
const diff: RosterDiff = { insert: [], update: [], setState: [] };
|
||||
const seen = new Set<string>();
|
||||
for (const user of remote) {
|
||||
@@ -90,7 +90,7 @@ export function diffRoster(
|
||||
* 一次同步能停很多人;要是把最后一个能进运营中心的人也停了,那张表与同步按钮就都没人点得动了,
|
||||
* 只剩去库里手改 `state`。所以要**在落 setState 之前**先按差异算一遍结果。
|
||||
*/
|
||||
export function activeRolesAfter(local: readonly Staff[], diff: RosterDiff): string[][] {
|
||||
export function activeRolesAfter(local: readonly StoredStaff[], diff: RosterDiff): string[][] {
|
||||
const state = new Map<string, Staff['state']>(local.map((s) => [s.id, s.state]));
|
||||
const roles = new Map<string, readonly string[]>(local.map((s) => [s.id, s.roles]));
|
||||
for (const u of diff.update) roles.set(u.id, u.patch.roles);
|
||||
|
||||
@@ -6,15 +6,30 @@
|
||||
* `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 ——
|
||||
* 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。
|
||||
*/
|
||||
import type { Position, Principal, RoleAccess, Staff } from '@contracts';
|
||||
import type { OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, Staff } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts';
|
||||
import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts';
|
||||
import type {
|
||||
CreateDirectoryUser,
|
||||
DirectoryUserPatch,
|
||||
Identity,
|
||||
OrgService,
|
||||
RosterSyncOutcome,
|
||||
StaffWithPosition,
|
||||
} from '../../contract.ts';
|
||||
import {
|
||||
AccountDisabledError,
|
||||
DirectoryUnavailableError,
|
||||
PositionNotFoundError,
|
||||
ServiceAccountProtectedError,
|
||||
StaffNotFoundError,
|
||||
} from '../../contract.ts';
|
||||
import type {
|
||||
Clock,
|
||||
DirectoryUser,
|
||||
IdGenerator,
|
||||
InFlightWorkProbe,
|
||||
PositionRepository,
|
||||
KeycloakDirectory,
|
||||
RoleAccessRepository,
|
||||
RosterSource,
|
||||
StaffRecord,
|
||||
@@ -22,7 +37,17 @@ import type {
|
||||
TokenVerifier,
|
||||
WorkGroupRepository,
|
||||
} from '../../port.ts';
|
||||
import { identityOf } from '../domain/claims.ts';
|
||||
import { identityOf, isNoiseRole, staffKindOf } from '../domain/claims.ts';
|
||||
import type { LocalMark } from '../domain/directory.ts';
|
||||
import {
|
||||
filterOrgUsers,
|
||||
isServiceAccount,
|
||||
localMarks,
|
||||
sortOrgUsers,
|
||||
toOrgGroups,
|
||||
toOrgRoles,
|
||||
toOrgUser,
|
||||
} from '../domain/directory.ts';
|
||||
import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts';
|
||||
import { buildInFlightWork } from '../domain/in-flight.ts';
|
||||
import { activeRolesAfter, diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts';
|
||||
@@ -33,6 +58,8 @@ export type OrgServiceDeps = {
|
||||
readonly verifier: TokenVerifier;
|
||||
/** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */
|
||||
readonly roster?: RosterSource;
|
||||
/** 同上;缺省时成员目录整个不可用(抛 DirectoryUnavailableError → 502) */
|
||||
readonly directory?: KeycloakDirectory;
|
||||
readonly positions: PositionRepository;
|
||||
readonly staff: StaffRepository;
|
||||
readonly roleAccess: RoleAccessRepository;
|
||||
@@ -47,10 +74,19 @@ export type OrgServiceDeps = {
|
||||
/** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */
|
||||
const LAST_SEEN_REWRITE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 成员目录读缓存的寿命。Keycloak 没有「一次拿回全部人的生效角色」的接口(手册 §14.4 逐人查),
|
||||
* 一屏就是十几趟远程往返 —— 实测 37 秒。缓存之后:
|
||||
* **本进程写完立刻整体清空** → 写后马上重拉一定看得到新结果,前端不必延时重试;
|
||||
* 唯一的滞后是**别的**管理员在另一个后端实例上写,这边最多滞后这么久。
|
||||
*/
|
||||
const DIRECTORY_TTL_MS = 60_000;
|
||||
|
||||
/** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */
|
||||
function toStaff(record: StaffRecord): Staff {
|
||||
const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record;
|
||||
return staff;
|
||||
// `kind` 由账号名现判,不落库 —— 它是 Keycloak 命名的事实,不是我们存的一列状态
|
||||
return { ...staff, kind: staffKindOf(staff.account) };
|
||||
}
|
||||
|
||||
export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
@@ -147,6 +183,117 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
return toStaff(fresh ?? created);
|
||||
}
|
||||
|
||||
/* ── 成员目录的几个共用件 ─────────────────────────────────────────── */
|
||||
|
||||
/** 只缓存**贵的那两趟**(全量成员 / 组树);详情与成员单查照旧现取 */
|
||||
let usersCache: { at: number; value: OrgUser[] } | undefined;
|
||||
let groupsCache: { at: number; value: OrgGroup[] } | undefined;
|
||||
|
||||
/**
|
||||
* 写完清缓存 —— **按这次写真正碰到的东西清**,不是一律全清。
|
||||
* 全清的代价看得见:删一个组之后,下一次读要把「全量成员的角色」那趟 N+1 重付一遍,
|
||||
* 屏上那棵树因此多钉着已经没了的组好几秒(实测删 4 秒 + 重取 5.5 秒)。
|
||||
*
|
||||
* 'users' 改的是人本身(资料 / 口令)—— 组的成员数没变
|
||||
* 'groups' 改的是组(建 / 改名 / 删)—— 人的角色与端没变
|
||||
* 'all' 两边都动了(删人 = 人没了 + 他所在的组少一个人;进出组同理)
|
||||
*/
|
||||
function bustDirectoryCache(scope: 'users' | 'groups' | 'all'): void {
|
||||
if (scope !== 'groups') usersCache = undefined;
|
||||
if (scope !== 'users') groupsCache = undefined;
|
||||
}
|
||||
|
||||
function fresh<T>(entry: { at: number; value: T } | undefined): T | undefined {
|
||||
if (entry === undefined) return undefined;
|
||||
return deps.clock.now().getTime() - entry.at < DIRECTORY_TTL_MS ? entry.value : undefined;
|
||||
}
|
||||
|
||||
function needDirectory(): KeycloakDirectory {
|
||||
if (deps.directory === undefined) {
|
||||
throw new DirectoryUnavailableError(
|
||||
'未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 成员与组织的真源是 Keycloak,读写都要经它的 Admin API',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return deps.directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把适配器抛的两类 Keycloak 故障翻成模块自己的错误 —— **重试有没有意义**是分界:
|
||||
* 配错了(缺角色 / 凭证不对)重试一百次也是同一句;暂时够不着才值得过会儿再来。
|
||||
* 别的错(重名、约束不过)原样往上抛,路由译成 4xx 并带原话。
|
||||
*/
|
||||
async function guardKeycloak<T>(run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'KeycloakMisconfiguredError') {
|
||||
throw new DirectoryUnavailableError(error.message, true);
|
||||
}
|
||||
if (error instanceof Error && error.name === 'KeycloakUnavailableError') {
|
||||
throw new DirectoryUnavailableError(error.message, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地名册那两列,按 sub 索引 —— 目录只读它们 */
|
||||
async function localMarkMap(): Promise<Map<string, LocalMark>> {
|
||||
const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]);
|
||||
const codeOf = new Map(positions.map((p) => [p.id as string, p.code]));
|
||||
return localMarks(rows, (id) => codeOf.get(id));
|
||||
}
|
||||
|
||||
/** 目录全量(已剔服务账号、已配好端与本地两列、已排序)—— 检索在这之后做 */
|
||||
async function allDirectoryUsers(): Promise<OrgUser[]> {
|
||||
const dir = needDirectory();
|
||||
const hit = fresh(usersCache);
|
||||
if (hit !== undefined) return hit;
|
||||
const [raw, table, marks] = await Promise.all([
|
||||
guardKeycloak(() => dir.listUsers()),
|
||||
deps.roleAccess.list(),
|
||||
localMarkMap(),
|
||||
]);
|
||||
const people = raw.filter((u) => !isServiceAccount(u.username));
|
||||
const value = sortOrgUsers(people.map((u) => toOrgUser(u, table, marks.get(u.sub))));
|
||||
usersCache = { at: deps.clock.now().getTime(), value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次穿透写 —— 跑完**一律清缓存**。
|
||||
* 包起来而不是各写面自己记得调:漏一处就是一处「写完看不到」,
|
||||
* 而那种漏不报错,只是画面比事实旧(与治理台写路由统一广播同一个道理)。
|
||||
*/
|
||||
async function writeThrough<T>(scope: 'users' | 'groups' | 'all', run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await guardKeycloak(run);
|
||||
} finally {
|
||||
bustDirectoryCache(scope);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次写之后回那一行的当下样子 —— 界面照它重画,不必自己拼 */
|
||||
async function oneUser(sub: string): Promise<OrgUser> {
|
||||
const dir = needDirectory();
|
||||
const [raw, table, marks] = await Promise.all([
|
||||
guardKeycloak(() => dir.getUser(sub)),
|
||||
deps.roleAccess.list(),
|
||||
localMarkMap(),
|
||||
]);
|
||||
return toOrgUser(raw, table, marks.get(sub));
|
||||
}
|
||||
|
||||
function assertPerson(user: Pick<DirectoryUser, 'username'>): void {
|
||||
if (isServiceAccount(user.username)) throw new ServiceAccountProtectedError(user.username);
|
||||
}
|
||||
|
||||
/** 写面开工之前先问一句「这个 sub 是人吗」—— 服务账号一律不许动 */
|
||||
async function assertPersonBySub(sub: string): Promise<void> {
|
||||
const dir = needDirectory();
|
||||
assertPerson(await guardKeycloak(() => dir.getUser(sub)));
|
||||
}
|
||||
|
||||
const service: OrgService = {
|
||||
async authenticate(token) {
|
||||
const claims = await deps.verifier.verify(token);
|
||||
@@ -308,6 +455,169 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface));
|
||||
},
|
||||
|
||||
/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ──────────────── */
|
||||
|
||||
async directoryUsers(query) {
|
||||
const users = await allDirectoryUsers();
|
||||
const q = query.q ?? '';
|
||||
return { users: filterOrgUsers(users, q, query.includeDisabled === true) };
|
||||
},
|
||||
|
||||
async directoryUser(sub): Promise<OrgUserDetail> {
|
||||
const dir = needDirectory();
|
||||
const raw = await guardKeycloak(() => dir.getUser(sub));
|
||||
assertPerson(raw);
|
||||
const [table, marks] = await Promise.all([deps.roleAccess.list(), localMarkMap()]);
|
||||
return {
|
||||
...toOrgUser(raw, table, marks.get(raw.sub)),
|
||||
emailVerified: raw.emailVerified,
|
||||
groups: raw.groups.map((g) => ({ ...g })),
|
||||
...(raw.firstName === undefined ? {} : { firstName: raw.firstName }),
|
||||
...(raw.lastName === undefined ? {} : { lastName: raw.lastName }),
|
||||
};
|
||||
},
|
||||
|
||||
async createDirectoryUser(input: CreateDirectoryUser) {
|
||||
const dir = needDirectory();
|
||||
if (isServiceAccount(input.username)) throw new ServiceAccountProtectedError(input.username);
|
||||
const sub = await writeThrough('all', () => dir.createUser(input));
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async patchDirectoryUser(sub, patch: DirectoryUserPatch) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('users', () => dir.updateUser(sub, patch));
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async setDirectoryUserEnabled(sub, enabled) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('users', () => dir.setEnabled(sub, enabled));
|
||||
/*
|
||||
* 停用之后**踢会话** —— 界面上已经写了「已停用」,而他手上那枚 token 到期前照样通行。
|
||||
* 这是「界面不撒谎」在鉴权上的样子:说停了就得真的进不来。
|
||||
*/
|
||||
if (!enabled) await writeThrough('users', () => dir.logout(sub));
|
||||
// 本地那一行跟手:两屏说的是同一件事(名册同步也会做,但那要等人去按)
|
||||
const local = await deps.staff.findById(staffIdSchema.parse(sub));
|
||||
if (local !== undefined) await deps.staff.setState(local.id, enabled ? 'active' : 'disabled', deps.clock.now());
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async resetDirectoryUserPassword(sub, password) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('users', () => dir.resetPassword(sub, password));
|
||||
},
|
||||
|
||||
async deleteDirectoryUser(sub) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('all', () => dir.deleteUser(sub));
|
||||
/*
|
||||
* 本地那一行**停用,不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。
|
||||
* 删了那些地方就成了悬空的 id,而历史是只增不改的。
|
||||
*/
|
||||
const local = await deps.staff.findById(staffIdSchema.parse(sub));
|
||||
if (local !== undefined) await deps.staff.setState(local.id, 'disabled', deps.clock.now());
|
||||
},
|
||||
|
||||
async directoryRoles(): Promise<{ roles: OrgRole[] }> {
|
||||
const dir = needDirectory();
|
||||
const [roles, table, users] = await Promise.all([
|
||||
guardKeycloak(() => dir.listRoles()),
|
||||
deps.roleAccess.list(),
|
||||
allDirectoryUsers(),
|
||||
]);
|
||||
// 家具角色不摆:它们是 Keycloak 给每个用户自动挂的,授收都没有意义(见 isNoiseRole)
|
||||
const real = roles.filter((r) => !isNoiseRole(r.name));
|
||||
/*
|
||||
* 持有数从**生效角色**里数(每人的 `/composite`),不用 `GET /roles/{name}/users` ——
|
||||
* 那个只回**直接授予**的:`user` 是经 `default-roles-corp` 继承来的,
|
||||
* 那条路会报「持有 0 人」而实际人人都有。快了却是错的,不要。
|
||||
* 速度靠 `allDirectoryUsers` 的 60 秒缓存拿。
|
||||
*/
|
||||
const counts = real.map((r) => users.filter((u) => u.roles.includes(r.name)).length);
|
||||
return { roles: toOrgRoles(real, table, counts) };
|
||||
},
|
||||
|
||||
async setDirectoryUserRole(sub, role, granted) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
/*
|
||||
* 收权之前先算一遍「收完还有谁进得了运营中心」—— 与改「角色→端」那条同一道闸,
|
||||
* 理由也同一个:这两条路都能把最后一个管理员关在门外,而**恢复动作本身要 ops**。
|
||||
*/
|
||||
if (!granted) {
|
||||
const [table, users] = await Promise.all([deps.roleAccess.list(), allDirectoryUsers()]);
|
||||
const after = users
|
||||
.filter((u) => u.enabled)
|
||||
.map((u) => (u.sub === sub ? u.roles.filter((r) => r !== role) : u.roles));
|
||||
assertNotLockedOut(
|
||||
table,
|
||||
after,
|
||||
`收掉「${role}」之后就没有人能进运营中心了 —— 这扇门会锁死(恢复动作本身要运营中心),拒绝`,
|
||||
);
|
||||
}
|
||||
await writeThrough('users', () => dir.setRole(sub, role, granted));
|
||||
// 收权立刻踢会话:否则他手上那枚 token 还带着旧角色,收权要等到过期才生效
|
||||
if (!granted) await writeThrough('users', () => dir.logout(sub));
|
||||
return oneUser(sub);
|
||||
},
|
||||
|
||||
async directoryGroups(): Promise<{ groups: OrgGroup[] }> {
|
||||
const dir = needDirectory();
|
||||
const hit = fresh(groupsCache);
|
||||
if (hit !== undefined) return { groups: hit };
|
||||
const groups = await guardKeycloak(() => dir.listGroups());
|
||||
// 数数就只数数 —— 别为了一个数字把每个成员的角色都拉一遍(N+1 的第二处)
|
||||
const pairs = await Promise.all(
|
||||
groups.map(async (g) => {
|
||||
const names = await guardKeycloak(() => dir.groupMemberUsernames(g.id));
|
||||
return [g.id, names.filter((n) => !isServiceAccount(n)).length] as const;
|
||||
}),
|
||||
);
|
||||
const value = toOrgGroups(groups, new Map(pairs));
|
||||
groupsCache = { at: deps.clock.now().getTime(), value };
|
||||
return { groups: value };
|
||||
},
|
||||
|
||||
async directoryGroupMembers(groupId) {
|
||||
const dir = needDirectory();
|
||||
const [raw, table, marks] = await Promise.all([
|
||||
guardKeycloak(() => dir.groupMembers(groupId)),
|
||||
deps.roleAccess.list(),
|
||||
localMarkMap(),
|
||||
]);
|
||||
const members = raw.filter((u) => !isServiceAccount(u.username)).map((u) => toOrgUser(u, table, marks.get(u.sub)));
|
||||
return { members: sortOrgUsers(members) };
|
||||
},
|
||||
|
||||
async createDirectoryGroup(name, parentId) {
|
||||
const dir = needDirectory();
|
||||
const g = await writeThrough('groups', () => dir.createGroup(name, parentId));
|
||||
return toOrgGroups([g], new Map())[0]!;
|
||||
},
|
||||
|
||||
async renameDirectoryGroup(groupId, name) {
|
||||
const dir = needDirectory();
|
||||
const g = await writeThrough('groups', () => dir.renameGroup(groupId, name));
|
||||
return toOrgGroups([g], new Map())[0]!;
|
||||
},
|
||||
|
||||
async deleteDirectoryGroup(groupId) {
|
||||
const dir = needDirectory();
|
||||
await writeThrough('groups', () => dir.deleteGroup(groupId));
|
||||
},
|
||||
|
||||
async setDirectoryUserGroup(sub, groupId, member) {
|
||||
const dir = needDirectory();
|
||||
await assertPersonBySub(sub);
|
||||
await writeThrough('groups', () => dir.setUserGroup(sub, groupId, member));
|
||||
},
|
||||
|
||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
|
||||
|
||||
async saveWorkGroup(input) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||
*/
|
||||
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
import type { Identity } from './contract.ts';
|
||||
import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
@@ -13,10 +13,11 @@ export type Clock = { now(): Date };
|
||||
export type VerifiedClaims = {
|
||||
readonly sub: string;
|
||||
readonly preferred_username: string;
|
||||
/** Keycloak 拼好的 `firstName lastName` —— 西式顺序,只在拆不开时才用它 */
|
||||
readonly name?: string;
|
||||
/* 名/姓分开收(2026-08-26 用户定:中文姓名姓在前)—— Keycloak 的 `name` 是全名(名+姓),
|
||||
展示要「姓 名」得靠这两个散件拼;没带就回退 `name` 原样 */
|
||||
/** 名(firstName) */
|
||||
readonly given_name?: string;
|
||||
/** 姓(lastName)—— 我们按「姓在前」自己拼,见 `personName` */
|
||||
readonly family_name?: string;
|
||||
readonly email?: string;
|
||||
readonly realm_access?: { readonly roles?: readonly string[] };
|
||||
@@ -29,6 +30,66 @@ export type TokenVerifier = { verify(token: string): Promise<VerifiedClaims> };
|
||||
export type RosterUser = Identity & { readonly enabled: boolean };
|
||||
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
|
||||
|
||||
/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */
|
||||
|
||||
/**
|
||||
* Keycloak 那一侧的一个用户 —— **原样的形状**,不掺本地字段。
|
||||
* 与 `RosterUser` 的区别:名册只要「同步要比对的那几列」,目录要摆给人看的全部。
|
||||
*/
|
||||
export type DirectoryUser = {
|
||||
readonly sub: string;
|
||||
readonly username: string;
|
||||
readonly firstName?: string;
|
||||
readonly lastName?: string;
|
||||
readonly email?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly emailVerified: boolean;
|
||||
/** realm 角色(家具角色已滤) */
|
||||
readonly roles: readonly string[];
|
||||
};
|
||||
|
||||
/** 组树里的一行 —— 平表,层级看 `parentId` / `depth`(契约同款,理由见 org-directory.ts) */
|
||||
export type DirectoryGroup = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly parentId?: string;
|
||||
readonly depth: number;
|
||||
readonly memberCount: number;
|
||||
};
|
||||
|
||||
export type DirectoryRole = {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly composite: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 成员目录 —— app 未配 provisioning 服务账号时不注入(与 `RosterSource` 同款)。
|
||||
* 读是投影,写**直接穿透**到 Keycloak:这里建的人立刻能登录,这里删的人从 Keycloak 消失。
|
||||
*/
|
||||
export type KeycloakDirectory = {
|
||||
listUsers(): Promise<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;
|
||||
@@ -48,7 +109,13 @@ export type PositionRepository = {
|
||||
};
|
||||
|
||||
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||||
export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
|
||||
/**
|
||||
* 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`),
|
||||
* 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。
|
||||
*/
|
||||
export type StoredStaff = Omit<Staff, 'kind'>;
|
||||
|
||||
export type StaffRecord = StoredStaff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
|
||||
|
||||
export type StaffRepository = {
|
||||
list(): Promise<StaffRecord[]>;
|
||||
@@ -56,7 +123,7 @@ export type StaffRepository = {
|
||||
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
|
||||
findByAccount(account: string): Promise<StaffRecord | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||
insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): 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>;
|
||||
|
||||
@@ -9,6 +9,8 @@ export default defineConfig({
|
||||
'reset-runs': 'src/app/reset-runs.ts',
|
||||
// 只装活动包(不碰治理台配置)—— 开发活动时一天要装十几次
|
||||
'install-activities': 'src/app/install-activities.ts',
|
||||
// 名册同步:整册从 Keycloak 照过来(ops 路由够不着时走这条)
|
||||
'sync-roster': 'src/app/sync-roster.ts',
|
||||
},
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
|
||||
@@ -135,6 +135,7 @@ export function GovShell({ surface }: { surface: Surface }) {
|
||||
const domains = sortDomains([...new Set((objects.data?.objects ?? []).map((o) => o.domain))]);
|
||||
// 进台账没带域(左轨点进来)时落到首域 —— 页签与内容同一判定
|
||||
const effectiveTab = zone === 'ledgers' && tab === '' ? (domains[0] ?? '') : tab;
|
||||
const tabHint = TABS[zone].find((t) => t.key === tab)?.hint;
|
||||
|
||||
/* 面包屑:台面 › 区 › 页签 › 下钻各级 */
|
||||
const crumbs: Array<{ label: string; go?: () => void }> = [
|
||||
@@ -249,7 +250,8 @@ export function GovShell({ surface }: { surface: Surface }) {
|
||||
* 摆一个只有一颗按钮的导航条,是在给人做无谓的选择。
|
||||
* 台账例外 —— 它的页签是随发布生长的数据域,数量本来就不定。
|
||||
*/}
|
||||
<div className={'g-tabs' + (zone !== 'ledgers' && TABS[zone].length <= 1 ? ' hidden' : '')}>
|
||||
<div className={'g-tabsrow' + (zone !== 'ledgers' && TABS[zone].length <= 1 ? ' hidden' : '')}>
|
||||
<div className="g-tabs">
|
||||
{zone === 'ledgers'
|
||||
? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上
|
||||
domains.map((d) => (
|
||||
@@ -273,6 +275,9 @@ export function GovShell({ surface }: { surface: Surface }) {
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 右端一句话:这一格管什么、写下去会发生什么 —— 界面自己说清楚,不靠人记 */}
|
||||
{tabHint !== undefined && <span className="g-tabs-hint">{tabHint}</span>}
|
||||
</div>
|
||||
<ZoneHome zone={zone} tab={effectiveTab} nav={nav} />
|
||||
</>
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
.g-head-t p { margin: 5px 0 0; font-size: 12.5px; line-height: 1.7; color: var(--ink-3); max-width: 108ch; }
|
||||
|
||||
/* 区内页签:一个页签 = 一个维度 */
|
||||
/* 页签栏一行两件:左边那颗药丸是页签,右端一句话说清这一格管什么(可缺席) */
|
||||
.g-tabsrow { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.g-tabsrow.hidden { display: none; }
|
||||
.g-tabs-hint { font-size: 12px; color: var(--ink-4); text-align: right; }
|
||||
.g-tabs.hidden { display: none; }
|
||||
.g-tabs { display: flex; flex-wrap: wrap; gap: 3px; padding: 4px; border-radius: 12px; background: #e3e8ef; align-self: flex-start; }
|
||||
.g-tab { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 14px; border: 0; border-radius: 9px; background: transparent; color: var(--ink-3); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); }
|
||||
@@ -862,6 +866,19 @@
|
||||
/* 供应商:一行小签 —— 挑一个即预填它的默认端点。
|
||||
从前是 146px 宽的大牌子,一个人吃掉 118px 高,把下面的输入框全顶出屏幕。 */
|
||||
.g-chiprow { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
/* 组织那一屏:左树右详情;窄屏叠成一列(树在上,详情在下) */
|
||||
.g-split { display: grid; grid-template-columns: minmax(240px, 340px) 1fr; gap: 14px; align-items: start; }
|
||||
@media (max-width: 900px) { .g-split { grid-template-columns: 1fr; } }
|
||||
.g-tree { display: flex; flex-direction: column; gap: 2px; }
|
||||
.g-tree-r { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ink-2); font: inherit; font-size: 12.5px; text-align: left; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.g-tree-r:hover { background: #f1f4f8; color: var(--ink); }
|
||||
.g-tree-r.on { background: var(--brand-soft, #e8f0ff); color: var(--ink); font-weight: 700; }
|
||||
.g-tree-r b { flex: 1; font-weight: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.g-tree-r em { font-style: normal; font-size: 11px; color: var(--ink-4); }
|
||||
/* 一枚勾选 + 一句话 —— `.g-swrow` 会把行内**所有** input 压成 14px,
|
||||
而这一行里还站着检索框,所以单起一个,只管勾选框自己 */
|
||||
.g-tick { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; white-space: nowrap; }
|
||||
.g-tick input { width: 14px; height: 14px; accent-color: var(--brand); cursor: pointer; }
|
||||
.g-chip { padding: 6px 11px; border: 1px solid var(--cline); border-radius: 15px; background: #fff; font: 500 12px/1.2 var(--sans); color: var(--ink-3); cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast), color var(--t-fast); }
|
||||
.g-chip:hover { border-color: #aebdcd; color: var(--ink); }
|
||||
.g-chip.on { border-color: var(--brand); background: var(--brand-wash); color: var(--brand-deep); font-weight: 600; }
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 成员详情 —— 一个人的全部:身份、能进哪几个端、角色授收、所属组、启停与删除。
|
||||
*
|
||||
* 这一屏的每个动作都**穿透到 Keycloak**,没有撤销;所以一律走二次确认带。
|
||||
* 两处顺手做的事要说出来,不然人不知道:
|
||||
* 停用与收权都**当场踢掉他的全部会话** —— 否则他手上那枚 token 到期前照样通行,
|
||||
* 而屏上已经写了「已停用」。界面不撒谎。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
deleteOrgUserRoute,
|
||||
patchOrgUserRoute,
|
||||
resetOrgUserPasswordRoute,
|
||||
setOrgUserEnabledRoute,
|
||||
setOrgUserRoleRoute,
|
||||
} from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Acts, Card, Chip, Clear, Field, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { ORG_KEYS, useOrgRoles, useOrgUser } from '@web-shared/gov/gov-api.ts';
|
||||
import { EndChips } from './members.tsx';
|
||||
|
||||
export function MemberPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
const qc = useQueryClient();
|
||||
const q = useOrgUser(id);
|
||||
const roles = useOrgRoles();
|
||||
const u = q.data;
|
||||
const [err, setErr] = useState<string | undefined>(undefined);
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [editing, setEditing] = useState<{ firstName: string; lastName: string; email: string } | undefined>(undefined);
|
||||
|
||||
/** 改一个人:他自己 + 名单 + 角色持有数;组的成员数没变(删人那一路另说) */
|
||||
const bust = (): void => {
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.user });
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.users });
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.roles });
|
||||
};
|
||||
const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e));
|
||||
|
||||
const setEnabled = useMutation({
|
||||
mutationFn: (enabled: boolean) => call(setOrgUserEnabledRoute, { params: { sub: id }, body: { enabled } }),
|
||||
onSuccess: () => { setErr(undefined); bust(); },
|
||||
onError: fail,
|
||||
});
|
||||
const setRole = useMutation({
|
||||
mutationFn: (v: { role: string; granted: boolean }) =>
|
||||
call(setOrgUserRoleRoute, { params: { sub: id, role: v.role }, body: { granted: v.granted } }),
|
||||
onSuccess: () => { setErr(undefined); bust(); },
|
||||
onError: fail,
|
||||
});
|
||||
const patch = useMutation({
|
||||
mutationFn: (v: { firstName: string; lastName: string; email: string }) =>
|
||||
call(patchOrgUserRoute, { params: { sub: id }, body: v }),
|
||||
onSuccess: () => { setEditing(undefined); setErr(undefined); bust(); },
|
||||
onError: fail,
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => call(resetOrgUserPasswordRoute, { params: { sub: id }, body: { password: pwd } }),
|
||||
onSuccess: () => { setPwd(''); setErr(undefined); },
|
||||
onError: fail,
|
||||
});
|
||||
const drop = useMutation({
|
||||
mutationFn: () => call(deleteOrgUserRoute, { params: { sub: id } }),
|
||||
// 删人是唯一会动到组的那一路:他从他所在的每个组里没了
|
||||
onSuccess: () => {
|
||||
bust();
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.groups });
|
||||
nav.jump('people', 'members');
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
{u === undefined ? (
|
||||
<Clear>{`Keycloak 里没有这个人(${id})—— 可能刚被删了。`}</Clear>
|
||||
) : (
|
||||
<>
|
||||
<PageHead
|
||||
ico="user-round"
|
||||
kicker="成员"
|
||||
title={u.displayName}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="at-sign">{u.username}</Chip>
|
||||
<St tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
|
||||
{u.enabled ? '在职' : '已停用'}
|
||||
</St>
|
||||
</>
|
||||
}
|
||||
desc="身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。"
|
||||
/>
|
||||
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
|
||||
<Card ico="user-check" title="身份">
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '用户名', v: u.username, mono: true },
|
||||
{ k: '姓名', v: u.displayName },
|
||||
{ k: '邮箱', v: u.email ?? '—' },
|
||||
{ k: '邮箱已验证', v: u.emailVerified ? '是' : '否' },
|
||||
{ k: 'sub', v: u.sub, mono: true },
|
||||
{ k: '本地岗位', v: u.positionCode ?? '未分配' },
|
||||
{ k: '几时来过', v: u.lastSeenAt ?? '还没拿这枚身份访问过本系统' },
|
||||
]}
|
||||
/>
|
||||
{editing === undefined ? (
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{
|
||||
label: '改资料',
|
||||
ico: 'pencil-line',
|
||||
onClick: () =>
|
||||
setEditing({ firstName: u.firstName ?? '', lastName: u.lastName ?? '', email: u.email ?? '' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="g-prop">
|
||||
<div className="g-prop-h">改资料(用户名建后不可改)</div>
|
||||
<div className="g-prop-b">
|
||||
<Field k="姓">
|
||||
<input className="g-input" value={editing.lastName} onChange={(e) => setEditing({ ...editing, lastName: e.target.value })} />
|
||||
</Field>
|
||||
<Field k="名">
|
||||
<input className="g-input" value={editing.firstName} onChange={(e) => setEditing({ ...editing, firstName: e.target.value })} />
|
||||
</Field>
|
||||
<Field k="邮箱">
|
||||
<input className="g-input" value={editing.email} onChange={(e) => setEditing({ ...editing, email: e.target.value })} />
|
||||
</Field>
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '取消', onClick: () => setEditing(undefined) },
|
||||
{ label: '保存', ico: 'check', primary: true, busy: patch.isPending, onClick: () => patch.mutate(editing) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card ico="shield-check" title="能进哪几个端" extra={<span className="x">由下面的角色 × 「权限」页签的配置算出来</span>}>
|
||||
<div className="g-chiprow">
|
||||
<EndChips ends={u.ends} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card ico="key" title="角色" extra={<span className="x">授收都穿透 Keycloak;收权当场踢掉他的会话</span>}>
|
||||
<QueryGate query={roles}>
|
||||
<div className="g-chiprow">
|
||||
{(roles.data?.roles ?? []).map((r) => {
|
||||
const has = u.roles.includes(r.name);
|
||||
return (
|
||||
<Btn
|
||||
key={r.name}
|
||||
className={'g-btn' + (has ? ' primary' : '')}
|
||||
busy={setRole.isPending && setRole.variables?.role === r.name}
|
||||
onClick={() => setRole.mutate({ role: r.name, granted: !has })}
|
||||
title={
|
||||
has
|
||||
? `收掉「${r.name}」—— 他手上的会话会被当场踢掉`
|
||||
: `授予「${r.name}」(可进:${r.ends.length === 0 ? '无端' : r.ends.join(' / ')})`
|
||||
}
|
||||
>
|
||||
{(has ? '✓ ' : '+ ') + r.name}
|
||||
</Btn>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</QueryGate>
|
||||
</Card>
|
||||
|
||||
<Card ico="network" title="所属组">
|
||||
{u.groups.length === 0 ? (
|
||||
<Clear>不属于任何组 —— 进出组在「组织」页签里做。</Clear>
|
||||
) : (
|
||||
<div className="g-chiprow">
|
||||
{u.groups.map((g) => (
|
||||
<Chip key={g.id} tone="brand" ico="network">
|
||||
{g.path}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card ico="lock" title="口令" extra={<span className="x">一律临时口令 —— 本人首登必须改</span>}>
|
||||
<div className="g-chiprow" style={{ alignItems: 'center' }}>
|
||||
<input
|
||||
className="g-input"
|
||||
style={{ flex: 1 }}
|
||||
type="password"
|
||||
placeholder="新的临时口令"
|
||||
value={pwd}
|
||||
onChange={(e) => setPwd(e.target.value)}
|
||||
/>
|
||||
<Btn className="g-btn" disabled={pwd === ''} busy={reset.isPending} onClick={() => reset.mutate()}>
|
||||
重置口令
|
||||
</Btn>
|
||||
</div>
|
||||
<Note>服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。</Note>
|
||||
</Card>
|
||||
|
||||
<Card ico="power" title="启停与删除">
|
||||
<Acts
|
||||
items={[
|
||||
{
|
||||
label: u.enabled ? '停用' : '启用',
|
||||
ico: 'power',
|
||||
danger: u.enabled,
|
||||
busy: setEnabled.isPending,
|
||||
ask: {
|
||||
title: `${u.enabled ? '停用' : '启用'}「${u.displayName}」`,
|
||||
note: u.enabled
|
||||
? '停用会当场踢掉他的全部会话 —— 手上那枚 token 立刻失效,不用等过期。'
|
||||
: '启用之后他就能重新登录了。',
|
||||
impact: {
|
||||
who: u.displayName,
|
||||
undo: '可以再改回来',
|
||||
when: '立刻',
|
||||
},
|
||||
okLabel: u.enabled ? '确认停用' : '确认启用',
|
||||
onOk: () => setEnabled.mutate(!u.enabled),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
ico: 'trash-2',
|
||||
danger: true,
|
||||
busy: drop.isPending,
|
||||
ask: {
|
||||
title: `把「${u.displayName}」从 Keycloak 删掉`,
|
||||
note: '本地名册那一行会停用但不删 —— 留痕、在途任务、台账里都还指着这个 sub,删了那些地方就成了悬空的 id。',
|
||||
impact: {
|
||||
who: u.displayName,
|
||||
undo: '不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub',
|
||||
when: '按下即生效',
|
||||
},
|
||||
okLabel: '确认删除',
|
||||
onOk: () => drop.mutate(),
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 成员 —— Keycloak 目录的投影(ADR-012)。
|
||||
*
|
||||
* **这里的写是穿透的**:建的人立刻能登录、停的人立刻登不上,没有本地草稿,也没有撤销。
|
||||
* 所以每个写动作都走 `Acts` 的二次确认带(三必答:谁受影响 / 能不能撤 / 几时生效)。
|
||||
*
|
||||
* 检索与「含停用」都交给服务端 —— 那边握着全量,前端只拿要摆的那些。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createOrgUserRoute, SURFACE_LABEL } from '@contracts';
|
||||
import type { Surface } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Acts, Card, Chip, Clear, Field, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { ORG_KEYS, useOrgUsers } from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
/** 能进的端 —— 摆人读名;一个都没有就直说,别留一片空白让人以为没加载出来 */
|
||||
export function EndChips({ ends }: { ends: readonly Surface[] }) {
|
||||
if (ends.length === 0) return <Chip tone="mute">无端可进</Chip>;
|
||||
return (
|
||||
<>
|
||||
{ends.map((e) => (
|
||||
<Chip key={e} tone="brand">
|
||||
{SURFACE_LABEL[e]}
|
||||
</Chip>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MembersTab({ nav }: { nav: Nav }) {
|
||||
const [q, setQ] = useState('');
|
||||
const [includeDisabled, setIncludeDisabled] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const users = useOrgUsers(q, includeDisabled);
|
||||
const rows = users.data?.users ?? [];
|
||||
const active = rows.filter((u) => u.enabled).length;
|
||||
/*
|
||||
* 数还没取回来就别摆数 —— `?? []` 会让计数在载入那几秒显示「0 人 · 0 在职」,
|
||||
* 而那是**一句假话**:不是没有人,是还不知道有几个人(铁规矩 8)。
|
||||
*/
|
||||
const counted = users.data !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
ico="users"
|
||||
title="成员"
|
||||
extra={
|
||||
<span className="g-chiprow" style={{ alignItems: 'center' }}>
|
||||
<input
|
||||
className="g-input"
|
||||
style={{ width: 240 }}
|
||||
placeholder="按用户名 / 姓名 / 邮箱检索…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<label className="g-tick">
|
||||
<input type="checkbox" checked={includeDisabled} onChange={(e) => setIncludeDisabled(e.target.checked)} />
|
||||
含停用
|
||||
</label>
|
||||
<Chip tone={counted ? 'brand' : 'mute'}>{counted ? `${rows.length} 人 · ${active} 在职` : '数着…'}</Chip>
|
||||
<Btn className="g-btn primary" onClick={() => setCreating((v) => !v)}>
|
||||
{creating ? '收起' : '新建成员'}
|
||||
</Btn>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Note>
|
||||
真源是 Keycloak · 停用的人<b>立刻</b>登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 ——
|
||||
它是身份不是人。
|
||||
</Note>
|
||||
{creating && <CreateMemberForm onDone={() => setCreating(false)} />}
|
||||
<QueryGate query={users}>
|
||||
{rows.length === 0 ? (
|
||||
<Clear>{q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`}</Clear>
|
||||
) : (
|
||||
<NList>
|
||||
{rows.map((u) => (
|
||||
<NRow
|
||||
key={u.sub}
|
||||
tone={u.enabled ? 'brand' : 'mute'}
|
||||
ico="user-round"
|
||||
title={u.displayName}
|
||||
sub={`${u.username}${u.email === undefined ? '' : ` · ${u.email}`}`}
|
||||
cols={[
|
||||
{ k: '角色', v: u.roles.length === 0 ? '—' : u.roles.join(', ') },
|
||||
{ k: '可进', v: <EndChips ends={u.ends} /> },
|
||||
]}
|
||||
right={
|
||||
<St tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
|
||||
{u.enabled ? '在职' : '已停用'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'emp', id: u.sub })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
)}
|
||||
</QueryGate>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建成员 —— 口令一律**临时**:本人首登必须改。
|
||||
* 我们不存它、不回显 —— 它经过这个表单就再也不出现了,所以建完那一句话得把它说清楚。
|
||||
*/
|
||||
function CreateMemberForm({ onDone }: { onDone: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [username, setUsername] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | undefined>(undefined);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
call(createOrgUserRoute, {
|
||||
body: {
|
||||
username: username.trim(),
|
||||
...(lastName.trim() === '' ? {} : { lastName: lastName.trim() }),
|
||||
...(firstName.trim() === '' ? {} : { firstName: firstName.trim() }),
|
||||
...(email.trim() === '' ? {} : { email: email.trim() }),
|
||||
...(password === '' ? {} : { password }),
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// 多了一个人 → 名单变了,角色的持有数也跟着变;组没动
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.users });
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.roles });
|
||||
onDone();
|
||||
},
|
||||
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="g-prop">
|
||||
<div className="g-prop-h">新建成员</div>
|
||||
<div className="g-prop-b">
|
||||
<Field k="用户名" hint="登录名,建后不可改">
|
||||
<input className="g-input" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="如 zhangsan" />
|
||||
</Field>
|
||||
<Field k="姓" hint="Keycloak 的 lastName">
|
||||
<input className="g-input" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
</Field>
|
||||
<Field k="名" hint="Keycloak 的 firstName">
|
||||
<input className="g-input" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
</Field>
|
||||
<Field k="邮箱" hint="选填">
|
||||
<input className="g-input" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Field k="临时口令" hint="留空则建一个还登不进来的壳;填了则本人首登必须改">
|
||||
<input className="g-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</Field>
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '取消', onClick: onDone },
|
||||
{
|
||||
label: '建这个人',
|
||||
ico: 'user-plus',
|
||||
primary: true,
|
||||
disabled: username.trim() === '',
|
||||
busy: create.isPending,
|
||||
ask: {
|
||||
title: `在 Keycloak 里建「${username.trim()}」`,
|
||||
note: '姓名照 Keycloak 的 lastName + firstName 拼;角色先不给 —— 建完去「权限」页签授。',
|
||||
impact: {
|
||||
who: '这个人立刻能登录(若给了临时口令)',
|
||||
undo: '不能撤销 —— 建错了只能再删掉',
|
||||
when: '按下即生效,Keycloak 那边当场就有',
|
||||
},
|
||||
okLabel: '确认新建',
|
||||
onOk: () => create.mutate(),
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 组织 —— Keycloak 的组(ADR-012)。左边一棵树,右边选中那个组的成员与操作。
|
||||
*
|
||||
* 树是**平表**渲染的:一行一个组,靠 `depth` 缩进(契约里就不放递归结构 ——
|
||||
* 递归 schema 到了声明文件那一层只会变成一坨 any)。按 `path` 升序,父在子前。
|
||||
*
|
||||
* 删组连子组一起没(Keycloak 语义),人不会被删 —— 只是不再属于这个组。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createOrgGroupRoute,
|
||||
deleteOrgGroupRoute,
|
||||
renameOrgGroupRoute,
|
||||
setOrgUserGroupRoute,
|
||||
} from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Acts, Card, Chip, Clear, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
import { ORG_KEYS, useOrgGroupMembers, useOrgGroups, useOrgUsers } from '@web-shared/gov/gov-api.ts';
|
||||
import type { GovOrgGroups } from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export function OrgsTab() {
|
||||
const groups = useOrgGroups();
|
||||
const rows = groups.data?.groups ?? [];
|
||||
const [picked, setPicked] = useState<string | undefined>(undefined);
|
||||
const current = rows.find((g) => g.id === picked);
|
||||
const tops = rows.filter((g) => g.depth === 0).length;
|
||||
/* 取回来之前一个数都别摆、一句「还没有组」也别说 —— 那时候我们并不知道有没有组 */
|
||||
const loaded = groups.data !== undefined;
|
||||
|
||||
return (
|
||||
<div className="g-split">
|
||||
<Card ico="network" title="组织树" extra={<Chip tone={loaded ? 'brand' : 'mute'}>{loaded ? `${tops} 顶层组` : '数着…'}</Chip>}>
|
||||
<Note>Keycloak 组 · 点一行看它的成员与操作</Note>
|
||||
<QueryGate query={groups}>
|
||||
{rows.length === 0 ? (
|
||||
<Clear>还没有组 —— 先在下面「建顶层组」。</Clear>
|
||||
) : (
|
||||
<div className="g-tree">
|
||||
{rows.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={'g-tree-r' + (picked === g.id ? ' on' : '')}
|
||||
style={{ paddingLeft: 10 + g.depth * 18 }}
|
||||
onClick={() => setPicked(g.id)}
|
||||
title={g.path}
|
||||
>
|
||||
<b>{g.name}</b>
|
||||
<em>{`${g.memberCount} 人`}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</QueryGate>
|
||||
<GroupCreate label="建顶层组" hint="组名(同级不得重名)" />
|
||||
</Card>
|
||||
|
||||
{current === undefined ? (
|
||||
<Card ico="users" title="成员">
|
||||
<Clear>
|
||||
{!loaded
|
||||
? '组织树还在取…'
|
||||
: rows.length === 0
|
||||
? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。'
|
||||
: '在左侧点一个组,这里看它的成员与操作。'}
|
||||
</Clear>
|
||||
</Card>
|
||||
) : (
|
||||
<GroupDetail
|
||||
key={current.id}
|
||||
group={current}
|
||||
onGone={() => setPicked(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 建组 —— `parentId` 给了就是子组,不给就是顶层组 */
|
||||
function GroupCreate({ label, hint, parentId }: { label: string; hint: string; parentId?: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [err, setErr] = useState<string | undefined>(undefined);
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
call(createOrgGroupRoute, {
|
||||
body: { name: name.trim(), ...(parentId === undefined ? {} : { parentId }) },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('');
|
||||
setErr(undefined);
|
||||
// 只有组变了 —— 成员那一趟很贵,别拖它下水
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.groups });
|
||||
},
|
||||
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
|
||||
});
|
||||
return (
|
||||
<div className="g-prop">
|
||||
<div className="g-prop-h">{label}</div>
|
||||
<div className="g-prop-b">
|
||||
<div className="g-chiprow" style={{ alignItems: 'center' }}>
|
||||
<input className="g-input" style={{ flex: 1 }} placeholder={hint} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Btn className="g-btn" disabled={name.trim() === ''} busy={create.isPending} onClick={() => create.mutate()}>
|
||||
建组
|
||||
</Btn>
|
||||
</div>
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupDetail({
|
||||
group,
|
||||
onGone,
|
||||
}: {
|
||||
group: { id: string; name: string; path: string; depth: number; memberCount: number };
|
||||
onGone: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const members = useOrgGroupMembers(group.id);
|
||||
const all = useOrgUsers('', true);
|
||||
const [renaming, setRenaming] = useState<string | undefined>(undefined);
|
||||
const [err, setErr] = useState<string | undefined>(undefined);
|
||||
/** 组这一屏的写只动组与组成员;人的角色与端一个字没变 */
|
||||
const bust = (): void => {
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.groups });
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.groupMembers });
|
||||
};
|
||||
const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e));
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: (name: string) => call(renameOrgGroupRoute, { params: { id: group.id }, body: { name } }),
|
||||
onSuccess: () => { setRenaming(undefined); setErr(undefined); bust(); },
|
||||
onError: fail,
|
||||
});
|
||||
const drop = useMutation({
|
||||
mutationFn: () => call(deleteOrgGroupRoute, { params: { id: group.id } }),
|
||||
onSuccess: () => {
|
||||
/*
|
||||
* 删成功了 —— **「这个组没了」是已知事实**,不必等服务端再说一遍。
|
||||
* 那一趟重取要好几秒(组树得逐组数成员),期间树上还钉着已经没了的组;
|
||||
* 撞上 token 到期强刷的那一次更久,实测有一轮干脆没刷过来。
|
||||
* 手上有的答案先落进缓存,重取照发 —— 回来时一比,一样。
|
||||
* 子组跟着一起没(Keycloak 语义),按**路径前缀**连它们一起摘,不是只摘直属那一层。
|
||||
*/
|
||||
qc.setQueryData<GovOrgGroups>(ORG_KEYS.groups, (old) =>
|
||||
old === undefined
|
||||
? old
|
||||
: { groups: old.groups.filter((g) => g.id !== group.id && !g.path.startsWith(`${group.path}/`)) },
|
||||
);
|
||||
onGone();
|
||||
bust();
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
const setMember = useMutation({
|
||||
mutationFn: (v: { sub: string; member: boolean }) =>
|
||||
call(setOrgUserGroupRoute, { params: { sub: v.sub, groupId: group.id }, body: { member: v.member } }),
|
||||
onSuccess: () => { setErr(undefined); bust(); },
|
||||
onError: fail,
|
||||
});
|
||||
|
||||
const inGroup = members.data?.members ?? [];
|
||||
const inIds = new Set(inGroup.map((m) => m.sub));
|
||||
const outside = (all.data?.users ?? []).filter((u) => !inIds.has(u.sub));
|
||||
|
||||
return (
|
||||
<Card
|
||||
ico="users"
|
||||
title={group.name}
|
||||
extra={<St tone="ok" ico="users">{`${inGroup.length} 人`}</St>}
|
||||
>
|
||||
<Note>{`路径 ${group.path}`}</Note>
|
||||
|
||||
{renaming !== undefined && (
|
||||
<div className="g-prop">
|
||||
<div className="g-prop-h">改组名</div>
|
||||
<div className="g-prop-b">
|
||||
<div className="g-chiprow" style={{ alignItems: 'center' }}>
|
||||
<input className="g-input" style={{ flex: 1 }} value={renaming} onChange={(e) => setRenaming(e.target.value)} />
|
||||
<Btn className="g-btn" onClick={() => setRenaming(undefined)}>取消</Btn>
|
||||
<Btn
|
||||
className="g-btn primary"
|
||||
disabled={renaming.trim() === '' || renaming.trim() === group.name}
|
||||
busy={rename.isPending}
|
||||
onClick={() => rename.mutate(renaming.trim())}
|
||||
>
|
||||
改名
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
|
||||
<QueryGate query={members}>
|
||||
{inGroup.length === 0 ? (
|
||||
<Clear>这个组还没有人 —— 在下面「把人加进来」。</Clear>
|
||||
) : (
|
||||
<NList>
|
||||
{inGroup.map((m) => (
|
||||
<NRow
|
||||
key={m.sub}
|
||||
tone={m.enabled ? 'brand' : 'mute'}
|
||||
ico="user-round"
|
||||
title={m.displayName}
|
||||
sub={m.username}
|
||||
right={
|
||||
<Btn
|
||||
className="g-btn danger"
|
||||
busy={setMember.isPending && setMember.variables?.sub === m.sub}
|
||||
onClick={() => setMember.mutate({ sub: m.sub, member: false })}
|
||||
>
|
||||
移出
|
||||
</Btn>
|
||||
}
|
||||
onClick={() => undefined}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
)}
|
||||
</QueryGate>
|
||||
|
||||
<div className="g-prop">
|
||||
<div className="g-prop-h">把人加进来</div>
|
||||
<div className="g-prop-b">
|
||||
{outside.length === 0 ? (
|
||||
<Clear>所有人都已经在这个组里了。</Clear>
|
||||
) : (
|
||||
<div className="g-chiprow">
|
||||
{outside.map((u) => (
|
||||
<Btn
|
||||
key={u.sub}
|
||||
className="g-btn"
|
||||
busy={setMember.isPending && setMember.variables?.sub === u.sub}
|
||||
onClick={() => setMember.mutate({ sub: u.sub, member: true })}
|
||||
>
|
||||
{`+ ${u.displayName}`}
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GroupCreate label="在这个组下建子组" hint="子组名(同级不得重名)" parentId={group.id} />
|
||||
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '改组名', ico: 'pencil-line', onClick: () => setRenaming(group.name) },
|
||||
{
|
||||
label: '删掉这个组',
|
||||
ico: 'trash-2',
|
||||
danger: true,
|
||||
busy: drop.isPending,
|
||||
ask: {
|
||||
title: `删掉「${group.name}」`,
|
||||
note: '子组会跟着一起没。人不会被删 —— 只是不再属于这个组。',
|
||||
impact: {
|
||||
who: `这个组里的 ${inGroup.length} 个人,以及它下面的所有子组`,
|
||||
undo: '不能撤销 —— 组和它的层级关系直接从 Keycloak 消失',
|
||||
when: '按下即生效',
|
||||
},
|
||||
okLabel: '确认删组',
|
||||
onOk: () => drop.mutate(),
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,367 +1,31 @@
|
||||
/**
|
||||
* feature `gov-staff` —— 员工管理:账号、岗位授权、停用前置检查
|
||||
* feature `gov-staff` —— 成员与组织(ADR-012,2026-08-26 用户按目标件定的三页签)。
|
||||
*
|
||||
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
* 三个页签一一对着 Keycloak 的三样东西:
|
||||
* **成员** = users(目录投影;新建 / 启停 / 删除都穿透过去)
|
||||
* **权限** = realm roles × 本系统的「角色 → 端」表(每个请求都按它判)
|
||||
* **组织** = groups(建组 / 改名 / 删组 / 成员进出组)
|
||||
*
|
||||
* 原「岗位授权」与「工作组」两个页签已拿掉(用户定)。**后端数据与接口原样留着** ——
|
||||
* 岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流;拿掉的只是入口。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { GOVERNANCE_FUNCTION_LABEL, GRANT_ACTION_LABEL, saveWorkGroupRoute, staffIdSchema } from '@contracts';
|
||||
import type { StaffId } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import {
|
||||
Acts,
|
||||
Card,
|
||||
Chip,
|
||||
Clear,
|
||||
Entry,
|
||||
Field,
|
||||
NList,
|
||||
NRow,
|
||||
Note,
|
||||
PageHead,
|
||||
QueryGate,
|
||||
Rows,
|
||||
St,
|
||||
} from '@web-shared/gov/primitives.tsx';
|
||||
import { MembersTab } from './members.tsx';
|
||||
import { MemberPage } from './member-page.tsx';
|
||||
import { OrgsTab } from './orgs.tsx';
|
||||
import { PermsTab, RolePage } from './perms.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { useGovGrants, useGovStaff, useGovTasks, useGovWorkGroups } from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function PeopleHome({ tab, nav }: HomeProps) {
|
||||
if (tab === 'groups') return <WorkGroupsTab />;
|
||||
return <StaffHome tab={tab} nav={nav} />;
|
||||
if (tab === 'perms') return <PermsTab />;
|
||||
if (tab === 'orgs') return <OrgsTab />;
|
||||
return <MembersTab nav={nav} />;
|
||||
}
|
||||
|
||||
function StaffHome({ tab, nav }: HomeProps) {
|
||||
const q = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
const staff = q.data?.staff ?? [];
|
||||
const positions = q.data?.positions ?? [];
|
||||
|
||||
if (tab === 'roles') {
|
||||
return (
|
||||
<>
|
||||
<Note>
|
||||
权限按<b>岗位</b>授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。
|
||||
人换岗即换权,权限随岗位走。
|
||||
</Note>
|
||||
<NList>
|
||||
{positions.map((p) => (
|
||||
<NRow
|
||||
key={p.id}
|
||||
tone="brand"
|
||||
ico="key"
|
||||
title={p.name}
|
||||
sub={`岗位编码 ${p.code}`}
|
||||
cols={[{ k: '在岗', v: staff.filter((s) => s.position.id === p.id).length }]}
|
||||
onClick={() => nav.push({ k: 'role', id: p.id })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<NList>
|
||||
{staff.map((s) => {
|
||||
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined);
|
||||
return (
|
||||
<NRow
|
||||
key={s.id}
|
||||
tone={s.state === 'active' ? 'brand' : 'mute'}
|
||||
ico="user-round"
|
||||
title={s.name}
|
||||
sub={`${s.account} · ${s.position.name}(${s.position.code})`}
|
||||
cols={[{ k: '在途任务', v: mine.length }]}
|
||||
right={
|
||||
<St tone={s.state === 'active' ? 'ok' : 'mute'} ico={s.state === 'active' ? 'circle-check' : 'power'}>
|
||||
{s.state === 'active' ? '在职' : '已停用'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'emp', id: s.id })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NList>
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- ③ 工作流管理 ---------------- */
|
||||
|
||||
/** 详情页 `emp` —— 路由名沿用(URL 里已有的书签不该失效),内容是成员详情 */
|
||||
export function EmpPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
const q = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
const s = (q.data?.staff ?? []).find((x) => x.id === id);
|
||||
if (s === undefined) return <Note>员工不存在。</Note>;
|
||||
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name);
|
||||
const live = mine.filter((t) => t.terminal === undefined);
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="user-round"
|
||||
kicker={`员工 · ${s.account}`}
|
||||
title={s.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="key">
|
||||
{s.position.name} · {s.position.code}
|
||||
</Chip>
|
||||
<Chip tone={s.state === 'active' ? 'ok' : 'mute'}>{s.state === 'active' ? '在职' : '已停用'}</Chip>
|
||||
</>
|
||||
}
|
||||
desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。"
|
||||
/>
|
||||
<Card ico="list-checks" title="在途任务" extra={<span className="g-mono">{live.length}</span>}>
|
||||
<NList>
|
||||
{live.map((t) => (
|
||||
<NRow key={t.id} tone="brand" ico="list-checks" title={t.title} onClick={() => nav.push({ k: 'task', id: t.id })} />
|
||||
))}
|
||||
{live.length === 0 && <Clear>无在途任务——可安全停用。</Clear>}
|
||||
</NList>
|
||||
</Card>
|
||||
<Entry ico="key" title="岗位授权" desc={`查看 ${s.position.name} 的授权清单`} onClick={() => nav.push({ k: 'role', id: s.position.id })} />
|
||||
</>
|
||||
);
|
||||
return <MemberPage id={id} nav={nav} />;
|
||||
}
|
||||
|
||||
export function RolePage({ id }: { id: string }) {
|
||||
const staff = useGovStaff();
|
||||
const grants = useGovGrants(id);
|
||||
const position = (staff.data?.positions ?? []).find((p) => p.id === id);
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="key"
|
||||
kicker="岗位授权"
|
||||
title={position?.name ?? id}
|
||||
chips={<Chip ico="hash">{position?.code ?? '—'}</Chip>}
|
||||
desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。"
|
||||
/>
|
||||
<div className="g-rows">
|
||||
{(grants.data?.grants ?? []).map((g, i) => (
|
||||
<div key={i}>
|
||||
<em>
|
||||
{g.object.kind === 'data'
|
||||
? `数据 · ${g.object.domain}.${g.object.object}`
|
||||
: g.object.kind === 'governance'
|
||||
? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}`
|
||||
: `任务 · ${g.object.activityId ?? '全部活动'}`}
|
||||
</em>
|
||||
<b className="mono">{GRANT_ACTION_LABEL[g.action]}</b>
|
||||
</div>
|
||||
))}
|
||||
{(grants.data?.grants ?? []).length === 0 && <Clear>未授予任何权限。</Clear>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 流程包 ---------------- */
|
||||
|
||||
|
||||
/* ---------------- 工作组(ADR-011) ---------------- */
|
||||
|
||||
/**
|
||||
* 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。
|
||||
*
|
||||
* 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人;
|
||||
* 没有组才回落四策略(独任 / 轮询 / 抢单 / 指派)。
|
||||
* 装进工作流的是**快照** —— 改组不改已起的单,留痕不被改写。
|
||||
*/
|
||||
function WorkGroupsTab() {
|
||||
const groups = useGovWorkGroups();
|
||||
const staffQ = useGovStaff();
|
||||
const [editing, setEditing] = useState<string | undefined>(undefined);
|
||||
|
||||
const staff = staffQ.data?.staff ?? [];
|
||||
const positions = staffQ.data?.positions ?? [];
|
||||
const nameOf = (id: string) => staff.find((s) => s.id === id)?.name ?? id;
|
||||
const rows = groups.data?.groups ?? [];
|
||||
|
||||
return (
|
||||
<QueryGate query={groups}>
|
||||
<>
|
||||
<Note>
|
||||
工作组把<b>岗位绑到人</b>:谁起的单,整条流的岗位就按谁的组指人 —— 绑定先于策略,
|
||||
没绑定的岗位仍走四策略。绑定在起单那一刻<b>快照</b>进工作流,此后改组不影响已起的单。
|
||||
</Note>
|
||||
{rows.length === 0 ? (
|
||||
<Clear>还没有工作组 —— 给谁建一个,他起的单整条流就能一次指到人。</Clear>
|
||||
) : (
|
||||
rows.map((g) =>
|
||||
editing === g.ownerId ? (
|
||||
<WorkGroupForm
|
||||
key={g.id}
|
||||
ownerId={g.ownerId}
|
||||
ownerName={g.ownerName}
|
||||
name={g.name}
|
||||
members={g.members}
|
||||
positions={positions}
|
||||
staff={staff}
|
||||
onDone={() => setEditing(undefined)}
|
||||
onCancel={() => setEditing(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
key={g.id}
|
||||
ico="users"
|
||||
title={g.name}
|
||||
extra={
|
||||
<button className="g-btn" onClick={() => setEditing(g.ownerId)}>
|
||||
改绑定
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Rows
|
||||
items={[
|
||||
{ k: '组长', v: g.ownerName },
|
||||
...positions.map((p) => ({
|
||||
k: `${p.name} · ${p.code}`,
|
||||
v: g.members[p.code] === undefined ? '未绑定 · 走四策略' : nameOf(g.members[p.code]!),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
)
|
||||
)}
|
||||
<Card ico="user-round" title="给还没有组的人建一个">
|
||||
<NList>
|
||||
{staff
|
||||
.filter((s) => !rows.some((g) => g.ownerId === s.id))
|
||||
.map((s) =>
|
||||
editing === s.id ? null : (
|
||||
<NRow
|
||||
key={s.id}
|
||||
tone="mute"
|
||||
ico="user-round"
|
||||
title={s.name}
|
||||
sub={`${s.position.name} · ${s.position.code}`}
|
||||
right={<span className="g-mono">建组 →</span>}
|
||||
onClick={() => setEditing(s.id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{staff.every((s) => rows.some((g) => g.ownerId === s.id)) && <Clear>每个人都有组了。</Clear>}
|
||||
</NList>
|
||||
</Card>
|
||||
{staff
|
||||
.filter((s) => !rows.some((g) => g.ownerId === s.id) && editing === s.id)
|
||||
.map((s) => (
|
||||
<WorkGroupForm
|
||||
key={s.id}
|
||||
ownerId={s.id}
|
||||
ownerName={s.name}
|
||||
name={`${s.name}的工作组`}
|
||||
members={{}}
|
||||
positions={positions}
|
||||
staff={staff}
|
||||
onDone={() => setEditing(undefined)}
|
||||
onCancel={() => setEditing(undefined)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/** 建组 / 改组表单:逐岗位选人,留空即该岗位不绑定 */
|
||||
function WorkGroupForm({
|
||||
ownerId,
|
||||
ownerName,
|
||||
name,
|
||||
members,
|
||||
positions,
|
||||
staff,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
name: string;
|
||||
members: Readonly<Record<string, string>>;
|
||||
positions: ReadonlyArray<{ id: string; code: string; name: string }>;
|
||||
staff: ReadonlyArray<{ id: string; name: string; position: { code: string; name: string } }>;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [draftName, setDraftName] = useState(name);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({ ...members });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (input: { name: string; members: Record<string, StaffId> }) =>
|
||||
call(saveWorkGroupRoute, {
|
||||
body: { ownerId: staffIdSchema.parse(ownerId), name: input.name, members: input.members },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'work-groups'] });
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
|
||||
const bound = Object.values(draft).filter((v) => v !== '').length;
|
||||
|
||||
return (
|
||||
<Card ico="users" title={`工作组:${ownerName}`}>
|
||||
<Field k="组名" hint="人读的一句话,如「柴浩的工作组」">
|
||||
<input className="g-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
|
||||
</Field>
|
||||
{positions.map((p) => (
|
||||
<Field key={p.id} k={`${p.name} · ${p.code}`} hint="留空即不绑定,该岗位仍走四策略">
|
||||
<select
|
||||
className="g-input"
|
||||
value={draft[p.code] ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, [p.code]: e.target.value })}
|
||||
>
|
||||
<option value="">— 不绑定 —</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}({s.position.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
))}
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '取消', ico: 'x', onClick: onCancel },
|
||||
{
|
||||
label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`,
|
||||
ico: 'circle-check',
|
||||
primary: true,
|
||||
disabled: draftName.trim() === '',
|
||||
busy: save.isPending,
|
||||
ask: {
|
||||
title: `保存工作组:${draftName}`,
|
||||
note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>,
|
||||
impact: {
|
||||
who: '仅此后新起的工作流;在途不受影响 —— 绑定是起单时的快照',
|
||||
undo: '可随时改;解绑某岗位即回落四策略',
|
||||
when: '确认后即时生效',
|
||||
},
|
||||
okLabel: '确认保存',
|
||||
onOk: () => {
|
||||
const picked: Record<string, StaffId> = {};
|
||||
for (const [code, id] of Object.entries(draft)) {
|
||||
if (id !== '') picked[code] = staffIdSchema.parse(id);
|
||||
}
|
||||
save.mutate({ name: draftName, members: picked });
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{save.isError && <Note tone="warn">保存失败,请重试。</Note>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
export { RolePage };
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 权限 —— **两份事实拼在一起**(ADR-012):
|
||||
* 角色本身是 Keycloak 的(realm roles,授收穿透过去);
|
||||
* 「这个角色能进哪几个端」是**本系统自己的**(`sys.role_access`),Keycloak 不知道这回事。
|
||||
*
|
||||
* 权限只到端:设计中心 / 运营中心 / 工作台。一个请求进不进得来,鉴权中间件每次都按这张表判 ——
|
||||
* 所以这一屏改的不是一句描述,是**下一个请求的成败**。
|
||||
*
|
||||
* 锁死不变量:改完之后至少还得有人能进运营中心 —— 否则这张表就再也没人改得了。
|
||||
* 服务端算完了才落库,不过就 409;这边如实把那句话摆出来,不自己拦(拦了两处口径会分叉)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { deleteRoleAccessRoute, setRoleAccessRoute, SURFACE_LABEL, SURFACES } from '@contracts';
|
||||
import type { Surface } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Acts, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { ORG_KEYS, useOrgRoles, useRoleAccess } from '@web-shared/gov/gov-api.ts';
|
||||
import { EndChips } from './members.tsx';
|
||||
|
||||
/**
|
||||
* 改「角色 → 端」之后,**每个人的可进端都跟着变** —— 所以人那一份也得失效,
|
||||
* 不然成员那一屏还摆着旧的端胶囊(同一件事两屏说两句话)。组没动。
|
||||
*/
|
||||
function bustAccess(qc: QueryClient): void {
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.roleAccess });
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.roles });
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.users });
|
||||
void qc.invalidateQueries({ queryKey: ORG_KEYS.user });
|
||||
}
|
||||
|
||||
export function PermsTab() {
|
||||
const roles = useOrgRoles();
|
||||
const access = useRoleAccess();
|
||||
const rows = roles.data?.roles ?? [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
ico="shield-check"
|
||||
title="角色 → 能进哪几个端"
|
||||
// 取回来之前不摆数 —— 「0 个角色」在那一刻是假话
|
||||
extra={<Chip tone={roles.data === undefined ? 'mute' : 'brand'}>{roles.data === undefined ? '数着…' : `${rows.length} 个角色`}</Chip>}
|
||||
>
|
||||
<Note>
|
||||
角色来自 Keycloak(realm roles),<b>能进哪几个端由这里说了算</b> —— 每个请求都按它判,
|
||||
不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。
|
||||
</Note>
|
||||
<QueryGate query={roles}>
|
||||
{rows.length === 0 ? (
|
||||
<Clear>realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。</Clear>
|
||||
) : (
|
||||
<div className="g-rows">
|
||||
{rows.map((r) => (
|
||||
<RoleRow key={r.name} role={r} configured={(access.data?.roles ?? []).some((x) => x.role === r.name)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</QueryGate>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleRow({
|
||||
role,
|
||||
configured,
|
||||
}: {
|
||||
role: { name: string; description?: string | undefined; composite: boolean; ends: readonly Surface[]; holders: number };
|
||||
configured: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<readonly Surface[] | undefined>(undefined);
|
||||
const [err, setErr] = useState<string | undefined>(undefined);
|
||||
const editing = draft !== undefined;
|
||||
const shown = draft ?? role.ends;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (ends: readonly Surface[]) =>
|
||||
// 一个端都不给 = 这个角色哪儿都进不去。删掉那一行与存一行空的是同一件事,存空的更好读
|
||||
call(setRoleAccessRoute, { params: { role: role.name }, body: { ends: [...ends] } }),
|
||||
onSuccess: () => {
|
||||
setDraft(undefined);
|
||||
setErr(undefined);
|
||||
bustAccess(qc);
|
||||
},
|
||||
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => call(deleteRoleAccessRoute, { params: { role: role.name } }),
|
||||
onSuccess: () => {
|
||||
setDraft(undefined);
|
||||
setErr(undefined);
|
||||
bustAccess(qc);
|
||||
},
|
||||
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="g-prop">
|
||||
<div className="g-prop-h">
|
||||
<span className="g-mono">{role.name}</span>
|
||||
{role.composite && <Chip tone="warn">组合角色</Chip>}
|
||||
<span className="x">{`${role.holders} 人持有`}</span>
|
||||
</div>
|
||||
<div className="g-prop-b">
|
||||
{role.description !== undefined && <Note>{role.description}</Note>}
|
||||
{editing ? (
|
||||
<div className="g-swrow">
|
||||
{SURFACES.map((s) => (
|
||||
<label key={s}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shown.includes(s)}
|
||||
onChange={(e) =>
|
||||
setDraft(e.target.checked ? [...shown, s] : shown.filter((x) => x !== s))
|
||||
}
|
||||
/>
|
||||
{SURFACE_LABEL[s]}
|
||||
</label>
|
||||
))}
|
||||
<small>勾上的端,持有这个角色的人就能进;一个都不勾 = 有这个角色也哪儿都进不去。</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="g-chiprow">
|
||||
<EndChips ends={role.ends} />
|
||||
{!configured && <Chip tone="mute">还没配过</Chip>}
|
||||
</div>
|
||||
)}
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
<Acts
|
||||
align="end"
|
||||
items={
|
||||
editing
|
||||
? [
|
||||
{ label: '取消', onClick: () => { setDraft(undefined); setErr(undefined); } },
|
||||
{
|
||||
label: '保存',
|
||||
ico: 'check',
|
||||
primary: true,
|
||||
busy: save.isPending,
|
||||
ask: {
|
||||
title: `把「${role.name}」的可进端改成:${shown.length === 0 ? '无' : shown.map((s) => SURFACE_LABEL[s]).join(' / ')}`,
|
||||
note: '这张表每个请求都在读 —— 改完下一个请求就按新的判。',
|
||||
impact: {
|
||||
who: `持有这个角色的 ${role.holders} 个人`,
|
||||
undo: '可以改回来 —— 但改完得还有人进得了运营中心,否则服务端会拒',
|
||||
when: '立刻;他们手上的 token 不用重新登录也按新表判',
|
||||
},
|
||||
okLabel: '确认改',
|
||||
onOk: () => save.mutate(shown),
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ label: '改可进端', ico: 'pencil-line', onClick: () => setDraft(role.ends) },
|
||||
...(configured
|
||||
? [
|
||||
{
|
||||
label: '删掉这一行',
|
||||
ico: 'trash-2',
|
||||
danger: true,
|
||||
busy: remove.isPending,
|
||||
ask: {
|
||||
title: `删掉「${role.name}」的端配置`,
|
||||
note: '删掉之后这个角色不再命中任何端 —— 与「一个端都不勾」是同一个效果。',
|
||||
impact: {
|
||||
who: `持有这个角色的 ${role.holders} 个人`,
|
||||
undo: '可以再配回来',
|
||||
when: '立刻',
|
||||
},
|
||||
okLabel: '确认删',
|
||||
onOk: () => remove.mutate(),
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 角色详情:谁持有它 —— 从成员那一屏点进来,或从这一屏的角色名点进来 */
|
||||
export function RolePage({ id }: { id: string }) {
|
||||
const roles = useOrgRoles();
|
||||
const role = (roles.data?.roles ?? []).find((r) => r.name === id);
|
||||
return (
|
||||
<QueryGate query={roles}>
|
||||
{role === undefined ? (
|
||||
<Clear>{`realm 里没有角色「${id}」—— 可能刚在 Keycloak 里删了。`}</Clear>
|
||||
) : (
|
||||
<Card ico="shield-check" title={role.name} extra={<St tone="ok" ico="users">{`${role.holders} 人持有`}</St>}>
|
||||
<div className="g-chiprow">
|
||||
<EndChips ends={role.ends} />
|
||||
</div>
|
||||
<Note>授收这个角色在「成员」那一屏的成员详情里做 —— 一次改一个人,收权当场踢掉他的会话。</Note>
|
||||
</Card>
|
||||
)}
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
GovTriggersOutput,
|
||||
GovWorkGroupsOutput,
|
||||
GovWorkflowOutput,
|
||||
RouteOutput,
|
||||
ProcessPackage,
|
||||
GovLedgerByActivityOutput,
|
||||
} from '@contracts';
|
||||
@@ -44,6 +45,12 @@ import {
|
||||
listLlmInstancesRoute,
|
||||
listLlmProvidersRoute,
|
||||
listProcessPackagesRoute,
|
||||
getOrgUserRoute,
|
||||
listOrgGroupMembersRoute,
|
||||
listOrgGroupsRoute,
|
||||
listOrgRolesRoute,
|
||||
listOrgUsersRoute,
|
||||
listRoleAccessRoute,
|
||||
listStaffRoute,
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
@@ -190,3 +197,65 @@ export const useGovWorkflow = (id: string): UseQueryResult<GovWorkflow> =>
|
||||
|
||||
export const useGovTaskLog = (taskId: string): UseQueryResult<GovTaskLog> =>
|
||||
useQuery({ queryKey: ['gov', 'tasklog', taskId], queryFn: () => call<GovTaskLog>(govTaskLogRoute, { params: { taskId } }) });
|
||||
|
||||
/* ── 成员与组织(ADR-012)———————————————————————————————————————— */
|
||||
|
||||
/**
|
||||
* 各屏的查询键 —— 写完**按碰到的那几屏**失效,别一把 `['gov','org']` 全清。
|
||||
* 成员那一趟最贵(服务端要逐人问角色,见 debt),把它拖进来重取,
|
||||
* 屏上那棵组织树就多钉着已经没了的组好几秒。
|
||||
*/
|
||||
export const ORG_KEYS = {
|
||||
users: ['gov', 'org', 'users'] as const,
|
||||
user: ['gov', 'org', 'user'] as const,
|
||||
roles: ['gov', 'org', 'roles'] as const,
|
||||
groups: ['gov', 'org', 'groups'] as const,
|
||||
groupMembers: ['gov', 'org', 'group-members'] as const,
|
||||
roleAccess: ['gov', 'org', 'role-access'] as const,
|
||||
};
|
||||
|
||||
export type GovOrgUsers = RouteOutput<typeof listOrgUsersRoute>;
|
||||
export type GovOrgUserDetail = RouteOutput<typeof getOrgUserRoute>;
|
||||
export type GovOrgRoles = RouteOutput<typeof listOrgRolesRoute>;
|
||||
export type GovOrgGroups = RouteOutput<typeof listOrgGroupsRoute>;
|
||||
export type GovOrgGroupMembers = RouteOutput<typeof listOrgGroupMembersRoute>;
|
||||
export type GovRoleAccess = RouteOutput<typeof listRoleAccessRoute>;
|
||||
|
||||
/**
|
||||
* 成员列表 —— 检索与「含停用」都在**服务端**过滤(那边握着全量,前端只拿要摆的那些)。
|
||||
* 服务端第一次取要几秒(Keycloak 没有批量取角色的口,见 debt),它自己有 60 秒缓存;
|
||||
* 这边 `staleTime` 跟着设一档,免得同一屏的几个组件各要一遍。
|
||||
*/
|
||||
export const useOrgUsers = (q: string, includeDisabled: boolean): UseQueryResult<GovOrgUsers> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'org', 'users', q, includeDisabled],
|
||||
queryFn: () =>
|
||||
call<GovOrgUsers>(listOrgUsersRoute, {
|
||||
query: { q: q === '' ? undefined : q, includeDisabled: includeDisabled ? '1' : undefined },
|
||||
}),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
export const useOrgUser = (sub: string | undefined): UseQueryResult<GovOrgUserDetail> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'org', 'user', sub],
|
||||
queryFn: () => call<GovOrgUserDetail>(getOrgUserRoute, { params: { sub: sub! } }),
|
||||
enabled: sub !== undefined,
|
||||
});
|
||||
|
||||
export const useOrgRoles = (): UseQueryResult<GovOrgRoles> =>
|
||||
useQuery({ queryKey: ['gov', 'org', 'roles'], queryFn: () => call<GovOrgRoles>(listOrgRolesRoute), staleTime: 30_000 });
|
||||
|
||||
export const useOrgGroups = (): UseQueryResult<GovOrgGroups> =>
|
||||
useQuery({ queryKey: ['gov', 'org', 'groups'], queryFn: () => call<GovOrgGroups>(listOrgGroupsRoute), staleTime: 30_000 });
|
||||
|
||||
export const useOrgGroupMembers = (groupId: string | undefined): UseQueryResult<GovOrgGroupMembers> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'org', 'group-members', groupId],
|
||||
queryFn: () => call<GovOrgGroupMembers>(listOrgGroupMembersRoute, { params: { id: groupId! } }),
|
||||
enabled: groupId !== undefined,
|
||||
});
|
||||
|
||||
/** 角色 → 端 —— 这张表是**本地**的(ADR-012),不是 Keycloak 的字段 */
|
||||
export const useRoleAccess = (): UseQueryResult<GovRoleAccess> =>
|
||||
useQuery({ queryKey: ['gov', 'org', 'role-access'], queryFn: () => call<GovRoleAccess>(listRoleAccessRoute) });
|
||||
|
||||
@@ -113,10 +113,10 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
desc: '活动的运行时:任务 = 活动被调起的一次运行,恰有一名负责人。这里做任务与审批单的在途监控、干预与控制——暂停可恢复、叫停归负责人、终止级联作废。工作流一级去「工作流」区看。',
|
||||
},
|
||||
people: {
|
||||
title: '员工管理',
|
||||
title: '成员与组织',
|
||||
icon: 'users',
|
||||
accent: 'linear-gradient(140deg,#2bc7d4,#0e9fd8)',
|
||||
desc: '员工负载、岗位与授权。权限按岗位授予:客体三类 × 动作三种;停用前置检查——在途任务与待答卡须先改派完毕。',
|
||||
desc: '成员与组织的真源是 Keycloak:这里建的人立刻能登录、停的人立刻登不上,没有本地草稿,也没有撤销。权限只到端——一个角色能进设计中心 / 运营中心 / 工作台的哪几个,由本系统的「角色 → 端」表说了算。',
|
||||
},
|
||||
workflows: {
|
||||
title: '流程包管理',
|
||||
@@ -179,7 +179,10 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
* 还留着多格的只有运营侧的任务与员工(它们确实是几个不同维度),
|
||||
* 以及 `ledgers`:它的页签是数据域,随发布动态生成(壳层运行时铺开),故此表留空。
|
||||
*/
|
||||
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
|
||||
/** `hint` 摆在页签栏右端 —— 一句话说清这一格管什么、写下去会发生什么 */
|
||||
export const TABS: Readonly<
|
||||
Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string; hint?: string }>>
|
||||
> = {
|
||||
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
|
||||
/*
|
||||
* 测试台三格 —— 从前是一格加一个「两档旋钮」,旋钮长在内容里,别处跳不进来、
|
||||
@@ -209,10 +212,16 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
|
||||
{ key: 'active', label: '激活中', ico: 'zap' },
|
||||
{ key: 'disabled', label: '已禁用', ico: 'ban' },
|
||||
],
|
||||
/*
|
||||
* 成员与组织(2026-08-26 用户定):三个页签一一对着 Keycloak 的三样东西 ——
|
||||
* 成员 = users、权限 = realm roles、组织 = groups。
|
||||
* 原「岗位授权」与「工作组」两个页签先拿掉(用户定);两处**后端数据与接口原样留着**,
|
||||
* 拿掉的只是入口 —— 岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流。
|
||||
*/
|
||||
people: [
|
||||
{ key: 'emp', label: '员工', ico: 'users' },
|
||||
{ key: 'roles', label: '岗位授权', ico: 'key' },
|
||||
{ key: 'groups', label: '工作组', ico: 'users' },
|
||||
{ key: 'members', label: '成员', ico: 'users', hint: 'Keycloak 目录投影:成员列表 · 新建 · 启停,点行进详情' },
|
||||
{ key: 'perms', label: '权限', ico: 'shield-check', hint: '角色 → 能进哪几个端(本地配置)· 授收角色(穿透 Keycloak)' },
|
||||
{ key: 'orgs', label: '组织', ico: 'network', hint: '组织树:建组 / 改名 / 删组 / 成员进出组' },
|
||||
],
|
||||
ledgers: [], // 动态:页签即数据域(短名呈现),由壳层按已发布域生成
|
||||
system: [{ key: 'chan', label: '通道实例', ico: 'mail' }],
|
||||
|
||||
@@ -5,7 +5,31 @@ import { fileURLToPath } from 'node:url';
|
||||
const root = fileURLToPath(new URL('../..', import.meta.url));
|
||||
const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url));
|
||||
|
||||
/*
|
||||
* 稳定版(./stable.sh)用 `vite preview` 托 .build/stable 的静态文件。preview 有一道宿主校验
|
||||
* (防 DNS rebinding),默认只放 localhost 与 IP —— 经域名 / 反代进来会被拦:
|
||||
* 「Blocked request. This host ("…") is not allowed.」
|
||||
* 放哪些宿主由起它的人说(PREVIEW_ALLOWED_HOSTS,逗号分隔;`.example.com` 放整个子域;`*` 全放)。
|
||||
* 开发那台 `vite` 不读这个,不受影响。
|
||||
*/
|
||||
const previewHosts = process.env.PREVIEW_ALLOWED_HOSTS?.trim();
|
||||
const preview =
|
||||
previewHosts === undefined || previewHosts === ''
|
||||
? {}
|
||||
: {
|
||||
preview: {
|
||||
allowedHosts:
|
||||
previewHosts === '*'
|
||||
? (true as const)
|
||||
: previewHosts
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== ''),
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
...preview,
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# ACT-WORK-05 差异评估 —— 端到端测试记录
|
||||
|
||||
> 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。
|
||||
> 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。
|
||||
> 台账字段口径见 `docs/数据域字段说明-W05.md`。
|
||||
> 走查分两条线:**浏览器**(测试台起单 → 工作台真人点,六轮)与**脚本 e2e**
|
||||
> (`node tools/e2e-a2.mjs w05`,五案例代答,可一键复跑)。两条线逮到的缺陷不重合 ——
|
||||
> 浏览器逮呈现与交互(件、闸、徽标、翻页、画面切换),脚本逮机制(空手交卷、循环、崩、归属)。
|
||||
|
||||
## 一、这个活动是什么
|
||||
|
||||
原 PN 与候选替代料之间**逐项差异评估**:比参数、判四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)、
|
||||
定推荐等级(preferred / alternate / conditional / not_recommended)、留证据与验证任务,
|
||||
最后把结论落进 P-11(替代关系 + 差异明细)并回写 P-01 需求,发射 `assessment-released` 交 ACT-GATE-03 评审。
|
||||
|
||||
**九个计划项**(五处不给 doneWhen —— 触达人的一律交人收):
|
||||
|
||||
| 项 | 谁办 | 卡 / 件 | doneWhen |
|
||||
|---|---|---|---|
|
||||
| `S0_bind` | AI | —— | 有 |
|
||||
| `S0d_p08_master` | 人 · 数据工单 | `p08-master-create`(表1 芯片主数据 + 表2 标准化参数集) | 无 |
|
||||
| `S0d_p10_document` | 人 · 数据工单 | `p10-document-create`(原厂技术资料) | 无 |
|
||||
| `S1_input_confirm` | 人 · 执行工单 | `eval-input-list`(评估输入清单) | 无 |
|
||||
| `S2_normalize` | AI | —— | 有 |
|
||||
| `S3_assess` | AI + 两枚工具 | —— | 有 |
|
||||
| `S4_review` | 人 · 决策卡(可循环) | `difference-review` | 无 |
|
||||
| `S5_prepare_landing` | AI | —— | 有 |
|
||||
| `T_release` | 人 · 终局验收 | `landing-confirm` | 无 |
|
||||
|
||||
## 二、逐轮走查
|
||||
|
||||
### R1 · 样例「都在库·常路」
|
||||
|
||||
**第 1 次 —— 熔断**,逮到两条真缺陷:
|
||||
|
||||
- 轨迹里 `S0_bind` 写完三张表后连续 5 拍只做一件事「弃用计划项 `S0d_p08_master`」,每拍被拒
|
||||
「生命周期迁移非法:Available → Disabled」→ 熔断交人;工作区还摆着一张「待建 0 条记录」的空数据工单件。
|
||||
- **根因一(平台事实,本轮不改)**:`lifecycle.ts` 里 `Disabled` 只能从 `Enabled` 来,而 `Enabled` 只由
|
||||
`plan` 动作写;普通计划项一直停在 `Available`。全仓只有本活动用过 `disable` —— **这条路从没人走通过**。
|
||||
- **根因二(活动缺陷,已修)**:哨兵 `s_bound` 只看「需求写了 + 核对表写了」,而 `S0_bind` 在**每条路**上都写这两张表,
|
||||
于是都在库时数据工单那两项照样进「当下可为」,剧本就让模型去 disable。
|
||||
- 修:哨兵加 `sub_eval_pn_check_exists_status_absent > 0`;全包清 `disable`;两条回归测试。**v1.0.1**。
|
||||
|
||||
**第 2 次 —— 通了**:两拍写完 `demand.request` / `pn_check` / `verdict`,直接派出执行工单;
|
||||
提交闸如实拦住空手交卷并点名三条缺项(与件上红带同名同数);填三格保存 → 闸自收 → 提交。
|
||||
|
||||
**收工:`released` · 12 拍 · 人环 3 · 行动被拒 0**。落账逐条 SQL 核过:
|
||||
|
||||
- P-11 主记录 1 条:`pin_to_pin | alternate | fae_confirmed | 客批=(空) | R-2026.07` —— **客户批准状态确实留空**(只读边界守住),规则版本随结论落账;
|
||||
- P-11 差异明细 9 条(8 MATCH / 1 CONDITIONAL);P-01 回写五项齐;出口 `assessment-released` 已发射;
|
||||
- 验收卡 11 条判据全部达标(L1×2 / L2×6 / L4×3),一次通过。
|
||||
|
||||
### R2 · 样例「候选缺库·派数据工单」
|
||||
|
||||
四次开跑,逼出四条缺陷(E / F / G / H),逐条治了再跑;第 4 次(v1.2.3)全绿。
|
||||
|
||||
| 次 | 版本 | 结果 | 逼出什么 |
|
||||
|---|---|---|---|
|
||||
| 1 | 1.2.0 | 中途停 | E(决策卡被跳过)已在本次前修好并当场验证;F 现形 |
|
||||
| 2 | 1.2.1 | 熔断 | F 修复通过;**G 现形**(`recommend` 工具被跳过 → S3 收步恒假) |
|
||||
| 3 | 1.2.2 | 中途停 | G 修复通过;**H 现形**(压拍发卡,清单一行没有) |
|
||||
| 4 | 1.2.3 | **`released` · 被拒 0 · 人环 5** | 四条修复全部实测通过 |
|
||||
|
||||
第 4 次逐段实证:
|
||||
|
||||
- 决策卡按规格出现(候选总数 2 ≥ 2)→ 答「创建主数据」;
|
||||
- P-08 数据工单**一件两表**(主数据 1 行 + 参数 6 行),两张表各有自己的编辑闸与缺项红带;
|
||||
填好保存 → 提交 → **画面自己切到 P-10**(发现 F 的修复稳定);
|
||||
- **执行工单出来时清单已有 12 行**(上一轮是 0)—— 发现 H 的修复见效;提交闸如实拦住剩下那条
|
||||
「未披露」缺口,补上才放行 —— 闸与件同源;
|
||||
- **S3 两枚工具都真调了**(轨迹里 `compare-parameters 返回` / `recommend 返回` 各一条),自动收步,没有熔断;
|
||||
- **审核驳回循环**(本轮顺带覆盖):在「写下你的口径」里写两条具体意见 → 模型改台账 → **换问法重发**新卡。
|
||||
逐条核过落点:XL2596S-5.0 降 `conditional`、两条 UNKNOWN 明细写明处置(挂 `VAL-…` 验证任务、
|
||||
「闭环前不升级推荐等级」)、LM2596S-ADJ 的推荐理由点名「R1/R2 分压电阻」—— **意见落在被点名的那两处**;
|
||||
- 终局验收 11 条判据全部达标 → 确认通过 → `released`。
|
||||
|
||||
落账 SQL 复核:P-11 两条关系(`pin_to_pin | conditional | fae_confirmed` / `pin_to_pin | alternate | fae_confirmed`,
|
||||
客批均空、规则版本 `R-2026.07`)· 差异明细 18 条(15 MATCH / 2 UNKNOWN / 1 CONDITIONAL)·
|
||||
验证任务 1 条(`not_started`)· P-01 回写五项齐 · **XL2596S-5.0 真进了主数据**(`XLSEMI | active`,6 行参数 + 1 份资料)。
|
||||
|
||||
### R3 · 样例「三候选·含硬约束不推荐」—— `released` · 被拒 0
|
||||
|
||||
一次跑通,验的是**硬约束这条路**:
|
||||
|
||||
- 决策卡按规格出现(3 颗候选,只有 CN-DCDC-32V 缺库)→ 建主数据 → P-08 填
|
||||
**输入电压范围 4.5~32(hard)**、工作温度 -20~+85(hard);
|
||||
- 清单 13 行、三行 default 的 detail 写对了默认值本身;缺口里把客户约束写成可解析式「输入上限硬指标 >= 40(V)」;
|
||||
- S2 铺 3 条关系 + 27 条明细;S3 两枚工具都调过;
|
||||
- **CN-DCDC-32V = `not_recommended` / high**(两条硬约束 MISMATCH:输入 4.5~32 vs 4.5~40、温域 -20 vs -40),
|
||||
XL2596S-5.0 `conditional`、LM2596S-ADJ `alternate`;
|
||||
- 验收件上三颗候选各一份「拟写 P-11」字段表,徽标逐个核过(不推荐/高 · 条件推荐/中 · 备选/中 · Pin-to-Pin · FAE确认)。
|
||||
|
||||
**一次自纠**:我先按 `innerText` 判定「四个枚举列是空的」,险些记成缺陷 ——
|
||||
徽标文字是 CSS `::after` 生成的,`innerText` 读不到。**验证呈现要看 computed style,不看 innerText**。
|
||||
|
||||
### R4 · 样例「原PN缺库·硬阻塞」
|
||||
|
||||
三次开跑,逼出两条**同族**的真缺陷:
|
||||
|
||||
**第 1 次(v1.2.3)—— 发现 I:「在库」是模型自报的一句话**
|
||||
|
||||
原 PN `LM2596S-3.3` 主数据里**一行都没有**(SQL 复核 count = 0),模型把原 PN 与候选**两颗都写成 `found`**,
|
||||
交代里还写「原 PN 与候选都在库」—— 数据工单一张没派,直接把评估输入执行工单发给了人。
|
||||
剧本早写着「`parts.profile` 整表在你的作用域里,按 internal_pn 逐个核对」——**措辞已到头**。
|
||||
|
||||
**结构治**:`sub_eval.pn_check` 加一列 `found_pn_ref`,
|
||||
`refersTo: parts.profile.internal_pn` + `requiredWhen: {exists_status: found}`。
|
||||
于是「判在库」这句话必须配一个**基座拿去主数据里查得到**的料号:
|
||||
|
||||
- 查无此值 → `draftWrite` 当场拒;
|
||||
- 填不出 → `pn_check_missing > 0` → `S0_bind` 的 `doneWhen` 收不掉。
|
||||
|
||||
两条路都堵死,唯一走得通的就是如实写 `absent`。**这是「机检只读基座数」第六次现形**
|
||||
(前五次是 missing_key_total / missing_minor_total / untyped_total / draft_total / 哨兵读 line_total),
|
||||
这次的形状不是计数,是**存在性**。
|
||||
|
||||
**第 2 次(v1.2.4)—— 墙生效了,但逮出发现 J:数据工单派给了在库的那颗**
|
||||
|
||||
核对表这次写对了(`LM2596S-3.3 = absent`、`LM2596S-ADJ = found` 且依据经基座查过),
|
||||
可数据工单派给了 **LM2596S-ADJ**(它在库、参数资料齐全),真正缺的那颗没人建。
|
||||
交代原话:「候选只有 LM2596S-ADJ 一颗,按规则直接建主数据」——
|
||||
它把「候选总数 = 1 → 不问人」这条**关于问不问**的规则,读成了**关于建哪颗**的规则。
|
||||
病根:剧本把两个问题写在同一串项目符号里,三档分岔每档都以「→ 直接进入建的名单」收尾,
|
||||
而**从没写过「found 的不建」**。与发现 E 同形。
|
||||
|
||||
**治法(措辞,精准化)**:拆成「**问题一 · 建哪几颗**」(名单 = `absent` 的每一行,`found` 的一行都不建,并写明后果)
|
||||
与「**问题二 · 建之前要不要先问人**」(只有 absent 的候选才谈得上问)。这一处不上结构 ——
|
||||
系统其实兜得住(原 PN 还 absent → S1 哨兵不开 → 依赖断点),只是要白烧一次人环;
|
||||
而这一刀是**补上从没写过的那句话**,不是把已经写过的话再说一遍。
|
||||
|
||||
**第 3 次(v1.2.5)—— `released`**:数据工单派给了 `LM2596S-3.3`,**没发决策卡**(硬阻塞不问人),两条修复都实测通过。
|
||||
|
||||
这一轮还留下了**发现 I 那道墙开火的现场证据**(轨迹 seq 6):
|
||||
|
||||
> 行动被拒:在库核对依据「LM2596S-3.3」在芯片主数据(P-08)里不存在 —— 这一列只许引用台账里真实存在的值……
|
||||
|
||||
模型确实又想判「在库」,基座当场拒回并点名;它随后如实写了 `absent`,硬阻塞照规格走了下去。
|
||||
|
||||
**终局「重做」分支也在这一轮验了**(本活动此前未验):在验收卡上写明两条意见后点「重做」——
|
||||
模型没有原样再递一次,而是**真去改了台账**(`demand.request.verification_requirement` 重写、
|
||||
5 行 `difference_item.evidence_ref` 从「只写文件名」改成指到 `Rev H (2023-09) §6.5 Electrical Characteristics`),
|
||||
再重新裁量 + 提交;第二张验收卡确认通过 → `released`。
|
||||
|
||||
整单 2 次被拒:一次是上面那道墙(它该拒),一次是 S3 的「先 finish 后调工具」——
|
||||
拒回文案照旧只谈「行与列」(平台欠账已记),但模型下一拍自己去调了 `recommend`、随即自动收步,没有熔断。
|
||||
|
||||
落账:`parts.profile.LM2596S-3.3`(TI / nrnd,7 行参数)真进了主数据;
|
||||
P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回写齐。
|
||||
|
||||
### R5 · 样例「新客户·先建档」
|
||||
|
||||
验的是**数据录入卡**这条路(客户不在档案里,先收人、再评估):
|
||||
|
||||
- 卡按规格出现:「客户 CU-9001 不在客户档案里,请核对建档信息后确认」,客户名称已从入参带出、行业与区域留白给人补;
|
||||
- 补两格 → 确认写入 → 客户档案落草稿层(`深圳恒星电源有限公司 / 工业电源 / 华南`)→ 流程直奔 S1(两颗料都在库);
|
||||
- 缺口补齐 → 审核通过 → 终局确认 → `released`。
|
||||
|
||||
**发现 K**:那张录入卡的「参考信息」栏摆的是引擎兜底的**内部家具** ——
|
||||
「这一步 / 当下可为 / 目标缺口」,后面跟着**九个计划项的全名**,人一个字用不上。
|
||||
病根:`entryCard.basis` 在契约里是**可选**的,活动的卡骨架没声明,基座就把内部事实兜了上去。
|
||||
与 A1 2026-08-03 那次同族(那次是执行卡的 steps/standard 没进说明书)。
|
||||
治法:说明书里的录入卡骨架补 `basis` 三行(触发原因 / 这一单要评估的 / 确认之后会发生什么),
|
||||
并在骨架下写明「不给基座会兜内部家具」。配一条测试。
|
||||
|
||||
**一处操作教训(不是缺陷)**:测试台在「清场」之后会回落到样例 0 ——
|
||||
选样例与「起单试跑」必须在同一次操作里完成,否则起的是上一个默认样例。
|
||||
|
||||
## 二·五、六轮走查一览
|
||||
|
||||
| 轮 | 样例 | 结果 | 这一轮覆盖的分支 |
|
||||
|---|---|---|---|
|
||||
| R1 | 都在库·常路 | `released` · 12 拍 · 人环 3 · 被拒 0 | 无数据工单直通;验收判据 11/11 |
|
||||
| R2 | 候选缺库·派数据工单 | `released` · 被拒 0 · 人环 5 | 决策卡(创建)· P-08 一件两表 · P-10 · **审核驳回循环** |
|
||||
| R3 | 三候选·含硬约束不推荐 | `released` · 被拒 0 | 三候选并行 · **硬约束 MISMATCH → `not_recommended`** |
|
||||
| R4 | 原PN缺库·硬阻塞 | `released` · 被拒 2(其中一次是墙该拒) | **硬阻塞不问人** · **终局「重做」循环** |
|
||||
| R5 | 新客户·先建档 | `released` · 被拒 1 | **数据录入卡建档** |
|
||||
| R5b | 新客户(v1.2.6) | 验完即停 | 录入卡 `basis` 呈现 |
|
||||
|
||||
版本轨迹:1.0.0 → 1.0.1(A)→ 1.1.0/1.1.1(B·C·D)→ 1.2.0(参数子表)→ 1.2.1(F)→ 1.2.2(G)
|
||||
→ 1.2.3(H)→ 1.2.4(I)→ 1.2.5(J)→ **1.2.6(K)**。包内自测 16 → **61 条**。
|
||||
|
||||
## 二·六、脚本 e2e 的六轮(2026-08-27)
|
||||
|
||||
浏览器那六轮之后,凭证到位、`w05` 套件跑起来,又逼出六条 —— **全是浏览器逮不到的那一类**
|
||||
(脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候会先把格子填好再按提交):
|
||||
|
||||
| 轮 | 结果 | 这一轮逼出什么 |
|
||||
|---|---|---|
|
||||
| 1 | 1 通过 | **L 数据工单的提交闸没牙** |
|
||||
| 2 | 3 通过 | **M 拒回数不出「缺的是一次工具调用」· N 决策卡拒回只给指针** |
|
||||
| 3 | 1 通过 | N 的措辞版没用 → **改结构**;**O 工具被 null 打崩** · P `count` 假线索 |
|
||||
| 4 | 2 通过 | **H 的残留面:零行同时骗过闸与哨兵** |
|
||||
| 5 | 4 通过 | **created 逐颗对账太松 · S1 卡的前提没写在说明书** |
|
||||
| 6 | 跑到的 4 例全 released,3 例被拒 0 | 案例 4 停在 `Insufficient Balance`(大模型余额) |
|
||||
| 7 | 前 3 例 released | 充值后:先撞上我自己拉起的旧稳定实例抢单(`recommend` 无可用实现);**T** P-10 骨架零行 · **U** 脚本一例翻车整套退出 |
|
||||
| 8 | **5 例全 released,4 例被拒 0** | 案例 3 被拒 6:**V** 建完第一颗就收掉数据工单那一项 |
|
||||
| 9 | 案例 3 单跑 | 用户纠正「别每次全跑」;逮到一次**假绿**:**W** 参数名写成英文 key · **X** 两颗缺库只问一次 —— 断言收紧 |
|
||||
| 10–11 | 案例 0/1 被拒 0 | **W 的乘法对账只在 ≥2 颗时咬得住**,单颗照漏 → 自指 `refersTo`;**Y** 脚本按闸的指示「加一行」;案例 2 撞 **Z** |
|
||||
| 12 | 案例 2 · 4 各单跑 | **Z** 修后 released · 被拒 0 · 卡先发人再答 |
|
||||
|
||||
**最终状态(收紧断言下)**:五例全部 `released` · 全部被拒 0。
|
||||
案例 0/1 验于 1.3.5、案例 3 验于 1.3.4、案例 2/4 验于 1.3.6 —— 1.3.6 只改了 S0d 剧本里「何时标 created」那一句,
|
||||
案例 0/3 走过那一步、未在最终文本上复验(如实记)。
|
||||
|
||||
第 6 轮逐条实证:早收容忍让案例 1/2 的最后 1 次被拒消失(轨迹里是**观察**不是拒回);
|
||||
三候选那例**第一次跑通**(前三轮:超时 / 超时 / 烧穿预算),两颗缺库一起办、三条结论全对。
|
||||
|
||||
## 三、走查发现总表
|
||||
|
||||
| # | 现象 | 病根 | 治法 | 层 |
|
||||
|---|---|---|---|---|
|
||||
| A | 缺口表**每一行**都摆红「必填」,而闸只认 3 条 | 那一格用静态 `req` class,`supplement` 的声明是 `requiredWhen:{status:'gap'}` | 按行投影 `data-class-of="status"`,判断留样式表 | 活动 |
|
||||
| B | 五张模版是 E04/I01 的 `jc-rp-*` 外壳,不是目标件的版面 | 计划文本自相矛盾(一边说逐字量自目标件,一边让用 `prd-style.ts`) | 目标件 `<style>` 逐字节抽成 `target-style.ts`,标记逐块搬,删 `prd-style.ts` | 活动 |
|
||||
| C | 带下划线的 tally 值一律显示「待定」(`pin_to_pin` 等 14 个值) | **两套消毒规则打架**:`view-render.classToken` 只留 `[a-z0-9-]`(吃下划线),而基座读数 slug 只留 `[a-z0-9_]`(吃连字符) | 徽标 CSS 选择器改由 `vtok()` 从 tally 值算,永不漂移;平台那两套规则不一致记欠账 | 活动(平台记账) |
|
||||
| D | 主键列 `item_key` 丢了 `data-col`;`status` 降成只读徽标而它是必填 | 模版重做时的回归 | 补回主键 `data-col`;`status` 改混排(只读徽标 + 编辑态下拉);**两条回归测试**(主键列必有钥匙 / 必填列必有可填控件) | 活动 |
|
||||
| E | 候选 ≥2 时该问的决策卡被跳过 | guide 写的是复合条件「候选 absent 且候选数 ≥2」,模型读成「absent 的候选 ≥2 颗」 | 改两步式:先数两个数各给名字,再按三档分岔;显式写「不在库的只有一颗不是跳过的理由」 | 活动 |
|
||||
| F | P-10 的卡发出来了,工作区还停在 P-08 的件 | 模型没先 `finish` 上一项就发下一项的卡;引擎按「当下还没干完的第一步」取 view | guide 把动作串写死「恰好两个、顺序不能反:①先 finish ②再发卡」 | 活动(平台记账) |
|
||||
| G | `S3_assess` 熔断:`recommend` 工具从没被调用,推荐等级是模型自己拍的 | doneWhen 里 `tool_..._recommend_calls > 0` 恒假;而拒回文案只谈「行与列」,把模型指向错方向 | guide 写盘前自检「两个 tool_*_calls 都必须 > 0」+ 写明「自己填看着对、机制上是你拍的」 | 活动(平台记账) |
|
||||
| H | 执行工单发出来了,清单一行都没有 —— 人对着空表 | 「先写清单再发卡」只写在 S1 剧本里,而那一拍模型**压拍**,摊开的是上一项的剧本 | 规矩搬进**说明书**(每拍都在);包内自测钉死 | 活动(平台记账) |
|
||||
| I | 原 PN 根本不在库,模型两颗都判「在库」,硬阻塞整条路被跳过 | 存在性核对是模型自报的一句话,基座数不出来;剧本已写到头 | 加 `found_pn_ref`(`refersTo` + `requiredWhen`):判在库就必须填一个基座查得到的料号,查无此值当场拒、填不出则收不了步 | 活动(结构) |
|
||||
| J | 数据工单派给了在库的那颗,缺的那颗没人建 | 剧本把「建哪几颗」与「要不要问人」写在同一串分岔里,从没写过「found 的不建」 | 拆成两个问题分别作答,并写明后果 | 活动 |
|
||||
| K | 数据录入卡的「参考信息」栏摆着引擎内部家具(九个计划项全名) | `entryCard.basis` 契约里可选,活动没声明,基座兜底把内部事实摆了上去 | 卡骨架补 `basis` 三行,并写明不给的后果 | 活动 |
|
||||
| L | **数据工单的提交闸没牙**:件上红带点着「缺品牌名称」,提交钮却是绿的,空手交卷一路放行 | `gateAddressesOf` 只有两级 —— 没绑表就回落到**必交付物**,而数据工单要人补的是 `parts.*` 主数据,不在交付物名单里:闸盯着一堆与这张卡无关的表,一格不缺 | 契约新增 `ExecutionWorkOrder.gateLedgers`(**这张卡该拦哪几张表,发卡的人说了算**),闸改三级取一:绑的表 > 卡自报 > 交付物 | **平台**(结构) |
|
||||
| M | `finish` 撞 doneWhen 为假时,拒回只谈「行与列」,而缺的是一次**工具调用** | 拒回从不摆判据本身,更不摆读数 | 拒回摆出**判据原文 + 那几个名字此刻的读数**;`tool_*_calls` 读到 0 的单独点名 | **平台** |
|
||||
| N | 人点了决策卡,模型每拍再发一张,连拒五拍熔断 | 剧本让模型「去轨迹里找 card-reply」,而那条痕会被挤走 —— 拒回把「他选的是 approve」写明白也没用,**下一拍它又从头翻一次** | `answeredCardFact`:把「这张卡已答过 → 他选的是什么」做成**每拍都在的事实行** | **平台**(结构) |
|
||||
| O | 模型传 `requirement: null`,工具 `null.trim()` 当场崩,整次调用记成「行动被拒」 | 工具入参是宽松描述式 schema,null 一路落到处理器;处理器只判了 `undefined` | 边界上一律经 `text()` 归一,类型如实写成可空 | **平台**(我写的工具) |
|
||||
| P | 拒回里摆着 `count=(没写过)` —— 那是 S-FEEL 的函数名,不是谁忘了记的账 | `readingsOf` 把内建函数当台账名 | `SFEEL_WORDS` 补上 count/sum/min/max/all/any | 平台 |
|
||||
| Q | 模型压拍没写骨架就发数据工单,人打开是空表,而**零行 = 零缺**,闸与哨兵一起放过 | 「缺必填」只数已有行;`_missing = 0` 在零行时恒真 | 平台 `emptyGate`(**卡自报的表一行都没有 = 没完成**)+ 说明书③「发卡前骨架必须先有行」+ 哨兵 `profile_rows >= created` 逐颗对账 | 平台 + 活动 |
|
||||
| R | 两颗缺库只办了第一颗,第二颗还 absent 就把 S1 的卡发了出去 | **发卡的授权面是调人预算,不是计划项** —— 它绕得过哨兵;而 S1 那张卡的**前提**只写在剧本里 | 前提写进说明书的卡骨架(「一颗 absent 都不剩」「有几颗 absent 就要办几颗」) | 活动 |
|
||||
| S | 每例白吃一次的「早收」:工具还没轮到调,`finish` 先到了 | 那是**计划次序**的小错,却被记成「行动被拒」去喂熔断计数 | 与「重发已答过的卡」同一把尺:**头一次只落观察,第二次起才算被拒** | **平台** |
|
||||
| T | P-10 数据工单发出来时 `parts.document` 一行都没有 | 说明书③给 P-08 补了「发卡前骨架必须先有行」,④(P-10)没补 | ④ 补同款前提 | 活动 |
|
||||
| U | 一例卡住,脚本整套抛出退出,后面两例连跑都没跑 | `main` 逐例调用没兜 | 一例翻车判失败、接着跑下一例 —— 一套五例的价值在「哪几例过、哪几例没过」 | 脚本 |
|
||||
| V | 建完第一颗料就把数据工单那一项收掉,再给第二颗发卡撞「这一步已经办完」 | 剧本【P-08 确认回来了】没分岔「还有没有 absent」 | absent 没清零不许 finish;写明「收掉了就回不去了」 | 活动 |
|
||||
| W | 参数名写成英文 key(`input_voltage_range`),与库里中文行**不是同一行** → 27 条里 14 条 UNKNOWN | `param_name` 是主键的一半;措辞两遍 + 乘法对账(行数 = 不同名字数 × 料数)都拦不住**单颗**那一路 | **自指 `refersTo: parts.parameter.param_name`** —— 一颗料时也咬得住 | 活动(结构) |
|
||||
| X | 两颗缺库只问了一次;`expectDecision` 只看张数,审核卡就把它喂饱 | 剧本「还有 absent → 写骨架再发卡」把「先问」压掉;断言太宽 | 剧本补「第二颗也要先问」;脚本新增 `expectAsks`(按问法数) | 活动 + 脚本 |
|
||||
| Y | 模型漏铺骨架 → 闸如实拦「一行都没有」→ 脚本只会填已有行,连吃 400 抛出 | 脚本没有「加一行」这条路,而人在浏览器里正是这么做的 | 主键齐了就加一行(与件上「加一行」同一条路) | 脚本 |
|
||||
| Z | 铺完骨架就标 `created`、直接 finish,撞「从没给它发过卡」连拒五拍 | 我 1.3.1 的措辞「表里真有一行才标 created」—— 而骨架是模型自己铺的 | 「created 认的是卡交回来了」;顺序写死:骨架 → 发卡 → 拍结束 → 人交回 → 才标 | 活动 |
|
||||
|
||||
**可推广的三条**(已记 `docs/debt.md`):
|
||||
|
||||
1. 凡「同一条路上前后两个人环项」,前一项不收掉,后一项的件就推不出来(发现 F);
|
||||
2. 拒回文案只谈行与列,数不出「缺的是一次工具调用」(发现 G);
|
||||
3. 执行工单绑的必交付台账**一行都没有**时提交闸无牙 —— 回落只数「已有行缺不缺必填」,零行 = 零缺(发现 H)。
|
||||
|
||||
发现 L 是这三条里**唯一当场治了平台的**:闸看的表与件上摆的表不是同一张,闸就等于没有 ——
|
||||
而这件事只有脚本 e2e 逮得到(它每张卡都先空手交一次;人手点的时候会先把格子填好再按提交)。
|
||||
|
||||
**还有一条**(平台侧,已记 debt):`disable` 这条路从来没通过(发现 A)——
|
||||
`Disabled` 只能从 `Enabled` 来,而 `Enabled` 只由 `plan` 动作写。
|
||||
|
||||
## 四、这一轮沉淀下来的三条方法
|
||||
|
||||
1. **措辞治不了的结构治,但要先分清「已经说过」和「从没说过」。**
|
||||
发现 I 是措辞已到头(剧本白纸黑字写着「整表在你的作用域里逐个核对」),该上结构;
|
||||
发现 J 是**从没写过那句话**(「found 的不建」),补一句话就够 —— 两者形状像,处方不同。
|
||||
|
||||
2. **「机检只读基座数」这条纪律,第六次以新形状现形。**
|
||||
前五次都是**计数**(missing_key_total / missing_minor_total / untyped_total / draft_total / line_total),
|
||||
这次是**存在性**。凡是「模型说了算、基座数不出来」的判断,迟早会在某一单里说反。
|
||||
本活动的解法是 `refersTo` + `requiredWhen` 组合成一道「说了就得拿出依据」的门。
|
||||
|
||||
3. **验证呈现要看 computed style,不看 `innerText`。**
|
||||
徽标文字是 CSS `::after` 生成的 —— 用 `innerText` 判空,会把好端端的页面判成缺陷(R3 差点误报)。
|
||||
|
||||
## 五、活动侧的验收口径
|
||||
|
||||
- 六条路径全部 `released`;三条零被拒,另三条的被拒各有交代(一次是墙该拒,两次是同一处平台缺口且模型自行恢复);
|
||||
- 落账逐单 SQL 复核:P-11 主记录 / 差异明细 / P-01 回写 / 验证任务 / 主数据 P-08 与 P-10,值与业务一致;
|
||||
- **只读边界守住**:`customer_approval_status` 每单都留空(本活动不产生),`rule_version` 随结论落账;
|
||||
- 出口只有一口 `assessment-released`,每单都真发射了。
|
||||
|
||||
4. **两条线逮到的缺陷不重合,缺一条就漏一半。**
|
||||
浏览器逮的是**人看见什么、点得动什么**(件、闸、徽标、翻页、画面切换、快照 1:1);
|
||||
脚本逮的是**机制**(空手交卷、循环、崩、归属)。发现 L–S 八条没有一条是浏览器走查逮到的 ——
|
||||
因为人手点的时候会先把格子填好再按提交,永远不会去试「空着交上去会怎样」。
|
||||
|
||||
5. **断言报的通过是假的,比报的失败是假的更糟。**
|
||||
第 9 轮案例 3 报「1/1 通过」,而参数名全是英文、两颗缺库只问了一次 —— 脚本只把它们记成 ⚠️ 就放过。
|
||||
假失败还有人去查;假通过没有人会看第二眼。此后:点名的行整例跑完仍没出现 = 失败;决策卡按**问的是哪件事**数。
|
||||
|
||||
6. **只跑受影响的那一例;改了平台共享面才全跑回归。**
|
||||
前八轮每次把已绿的四例重跑一遍 —— 每轮多烧一百多万 token,而且什么新东西都测不出。
|
||||
脚本本来就支持 `w05 N`。
|
||||
|
||||
7. **拒回喂事实,不喂指针。**
|
||||
发现 N 那一轮把「他选的是 approve、下一步写哪面旗」写进拒回,日志里逐字可见,模型照发不误 ——
|
||||
因为**下一拍它又从头翻一次会滚走的窗口**。把同一件事做成「每拍都在的事实行」之后,一次就好了。
|
||||
同一条道理已经在 worldTouched(2026-08-21)、E04 的 worldTouched 事实行(2026-08-24)上各现过一次。
|
||||
@@ -0,0 +1,46 @@
|
||||
# ACT-WORK-05 差异评估 — 一键启动入参说明(运营端)
|
||||
|
||||
> 本文档与《SS02-SS03-SS04_Demo台账字段细化与统一方案》字段口径一致(统一字段名、编号归并、主记录/子表结构均按该方案 4.2 节与第五节执行)。
|
||||
|
||||
## 一、背景
|
||||
|
||||
ACT-WORK-05 差异评估平时由上游触发:ACT-WORK-03 检索调用,或 ACT-GATE-02 路径判定后进入。
|
||||
|
||||
**本文档是运营端一键启动功能的入参规格**:表单在独立运营端完成,参数在启动时填好传入,系统据此直接启动活动(人工扮演上游,把进入条件变成表单)。
|
||||
|
||||
**极简原则(2026-08-26 定)**:一键启动只收 **4 个参数** —— 客户、客户需求、原 PN、替代 PN(数组)。**启动不等资料**:应用场景、BOM 位、替代类型、datasheet、需求版本调整等一切其余信息,都在活动启动后、执行过程中由工作台的数据工单逐项补齐(数据工单一单一类可多条,确认即入库,活动从断点续跑)。
|
||||
|
||||
**渠道无感知原则**:活动与工单对触发渠道无感知——上游触发(ACT-WORK-03 / ACT-GATE-02)传入的字段集与本规格完全一致(同样 4 项)。运营端按本规格传参,活动按进入条件消费,不记录渠道;仅 `source_type=手动启动` 在运营端留痕,供 ACT-GATE-03 评审放行时追溯。
|
||||
|
||||
## 二、入参总表
|
||||
|
||||
一键启动的全部参数,就这四行:
|
||||
|
||||
| 中文字段 | 统一字段名 | 必填性 | 输入方式 | 默认值 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| 客户 | customer_id | 必填 | L-02 选择器 | — | 新建 P-01 需求时挂客户 ID;决定通知对象与默认负责人线。 |
|
||||
| 客户需求 | request_description | 必填 | 文本 | — | 保留原始需求语义;据此新建 P-01 需求主记录(版本 V1)。 |
|
||||
| 原内部料号 | original_internal_pn | 必填 | P-08 选择器 | — | 差异评估基准,引用 P-08.internal_pn。 |
|
||||
| 候选内部料号 | candidate_internal_pn[] | 必填(**数组,≥1**) | P-08 选择器(**多选**) | — | 被评估物料,**一次可传多个**,逐个评估、逐个做存在性检查;P-08 无记录不拦启动——启动后按存在性检查逐个派数据工单创建(候选 ≥2 也可选择放弃,记录放弃原因)。 |
|
||||
|
||||
**系统带出 / 默认值**(不在表单上出现,启动时自动落):需求版本 V1、替代类型 Pin-to-Pin、规则版本当前发布版 R-2026.07(as_of 锁定,随结论落账)、期限 1 个工作日、负责人(PW)与审核(FAE)按客户线默认、`source_type=手动启动`。以上任何一项要改,都在启动后的工单里改,不回表单。
|
||||
|
||||
## 三、点击启动后系统行为
|
||||
|
||||
1. 生成任务号(如 TASK-BT02-20260818-007);
|
||||
2. 新建 P-01 需求主记录(V1):客户 ID + 需求描述落账,绑定原 PN 与候选 PN 数组;
|
||||
3. **存在性检查**(按 internal_pn 查 P-08 等台账,候选数组逐个查):原 PN 无数据 → 硬阻塞,派数据工单,活动停「等待数据」;候选 PN 无数据 → 候选 ≥2 可放弃【记录放弃原因】或派数据工单创建,候选 =1 必须创建;数据工单**确认即入库**(确认人=责任人留痕)后活动自动唤醒。**启动时的存在性检查只是惰性触发的第一次**——活动执行中每次读台账仍可能派发新数据工单(一单一类可多条,D02、D03…,全部确认后从断点续跑);
|
||||
4. 生成输入快照 input_snapshot_ref(活动自持中间产物,支持版本回溯);
|
||||
5. 进入步骤 1 · 建立评估上下文,派执行工单 01——启动传入的 4 项只读核对,**未齐资料全部在工单里补**:应用场景、BOM 位、替代类型确认、需求版本调整等缺口字段,PW 在工作台逐项填写,不回流表单。
|
||||
|
||||
## 四、极简参数集
|
||||
|
||||
手工必给的只有 4 个:**客户、客户需求、原 PN、候选 PN(数组)** —— 这就是一键启动的全部入参,没有第二张表。
|
||||
|
||||
设计意图:**启动的门槛越低,活动跑得越早**;资料不齐不该拦启动,缺口在执行中由数据工单追着人补,而不是在表单里追着人填。数据工单里 datasheet 选填(传了 AI 预填,没传全手填),同样不拦流程。
|
||||
|
||||
## 五、与现有工单的衔接
|
||||
|
||||
执行工单 01 的「评估输入清单」(`in.evaluation_inputs`)中,启动传入的 4 项回显为「已绑定 / 已锁定」的灰底只读行:客户、客户需求(及其新建的 P-01 需求与版本 V1)、原 PN、候选 PN(数组逐行);其余行(应用场景、BOM 位、替代类型、规则版本确认、负责人/期限调整等)为 PW 补缺口的可编辑行。**启动参数如有误,须退回启动人在运营端修订后重新启动,工单内不可改**。工单不展示、也不区分触发渠道——上游触发传入同一字段集时,呈现与本表单回显完全相同。
|
||||
|
||||
PN 不在库时派发的**数据工单**(`in.create_p08_fields` / `in.create_p08_params`):datasheet 在**数据工单里**上传(选填)——传了则 AI 预填字段与参数集(示例 8 项字段 + 7 行参数),没传则人全填;人在对话区【数据卡】确认即正式入库,确认人=数据责任人并留痕,入库后活动自动唤醒进步骤 1。
|
||||
@@ -0,0 +1,370 @@
|
||||
<!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 差异评估 · 全流程与分支一览</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||
--brand:#3370ff; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
||||
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
|
||||
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
|
||||
--ok:#278717; --ok-soft:#e8f8e4;
|
||||
--danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
.page{max-width:1240px;margin:0 auto;padding:28px 22px 40px}
|
||||
|
||||
/* 标题区 */
|
||||
.mast{margin-bottom:18px}
|
||||
.mast .kick{font-size:11px;letter-spacing:.1em;color:var(--ink-4);font-weight:700}
|
||||
.mast h1{margin:4px 0 4px;font-size:24px;font-weight:800;line-height:1.3}
|
||||
.mast .sub{font-size:13px;color:var(--ink-3)}
|
||||
.mast .sub b{color:var(--ink-2)}
|
||||
|
||||
/* 通用卡片 */
|
||||
.card{background:#fff;border:1px solid var(--line-soft);border-radius:10px;padding:16px 18px;margin-bottom:14px}
|
||||
.card p{margin:0 0 6px;font-size:13px;color:var(--ink-2)}
|
||||
.card p:last-child{margin-bottom:0}
|
||||
.card b{color:var(--ink)}
|
||||
.muted{color:var(--ink-3);font-size:12.5px}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
|
||||
/* 角色 pill */
|
||||
.role{display:inline-block;padding:1px 10px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;white-space:nowrap;border:1px solid transparent;vertical-align:1px}
|
||||
.role.ai{color:var(--brand);background:var(--brand-soft);border-color:#c3d6ff}
|
||||
.role.pw{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
.role.ri{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||
.role.gate{color:#fff;background:var(--ledger);border-color:var(--ledger)}
|
||||
.role.data{color:var(--brand);background:var(--brand-soft);border-color:#c3d6ff}
|
||||
|
||||
/* 推荐等级 pill */
|
||||
.lv{display:inline-block;padding:1px 10px;border-radius:999px;font-size:11.5px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
||||
.lv.cond{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
.lv.alt{color:var(--brand);background:var(--brand-soft);border-color:#c3d6ff}
|
||||
.lv.no{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
||||
|
||||
/* 场景卡 */
|
||||
.scene{border-left:4px solid var(--brand)}
|
||||
.scene .tagline{font-size:12px;font-weight:800;letter-spacing:.08em;color:var(--brand);margin-bottom:6px}
|
||||
|
||||
/* 范围横幅 */
|
||||
.scope{border:2px solid var(--ok);background:linear-gradient(120deg,#f2fbf0 0%,#fff 60%,#f0f6ff 100%);padding:16px 20px}
|
||||
.scope .q{font-size:15px;font-weight:800;color:var(--ink);margin:0 0 6px}
|
||||
.scope .q em{font-style:normal;color:var(--ok)}
|
||||
.scope .nq{margin:0;font-size:12.5px;color:var(--ink-3)}
|
||||
.scope .nq b{color:var(--ink-2)}
|
||||
|
||||
/* 进入条件卡 */
|
||||
.entry{border-left:4px solid var(--human)}
|
||||
.entry ul{margin:4px 0 0;padding-left:18px;font-size:13px;color:var(--ink-2)}
|
||||
.entry li{margin:3px 0}
|
||||
.entry .ds{display:inline-block;padding:0 8px;border-radius:999px;font-size:11px;font-weight:800;color:var(--brand);background:var(--brand-soft);border:1px solid #c3d6ff;white-space:nowrap}
|
||||
|
||||
/* 小图标 */
|
||||
.sic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
|
||||
|
||||
/* 主链 */
|
||||
.chain{margin-top:6px}
|
||||
.steprow{display:grid;grid-template-columns:minmax(0,7fr) minmax(0,5fr);gap:14px;align-items:start}
|
||||
.step{position:relative;background:#fff;border:1px solid var(--line-soft);border-radius:10px;padding:15px 18px 14px 22px;overflow:hidden}
|
||||
.step::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--ink-4)}
|
||||
.step.ai::before{background:var(--brand)}
|
||||
.step.pw::before{background:var(--human)}
|
||||
.step.ri::before{background:var(--ledger)}
|
||||
.step .s-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.step .s-no{font-family:var(--mono);font-size:12px;font-weight:800;color:#fff;background:var(--ink-4);border-radius:6px;padding:1px 8px}
|
||||
.step.ai .s-no{background:var(--brand)}
|
||||
.step.pw .s-no{background:var(--human)}
|
||||
.step.ri .s-no{background:var(--ledger)}
|
||||
.step h3{margin:0;font-size:15px;font-weight:800}
|
||||
.step .wo{font-size:12px;font-weight:800;color:var(--ink-2);background:var(--soft);border:1px solid var(--line);border-radius:6px;padding:1px 8px}
|
||||
.step .wo.data{color:var(--brand);background:var(--brand-soft);border-color:#c3d6ff}
|
||||
.step p{margin:0 0 5px;font-size:13px;color:var(--ink-2)}
|
||||
.step .ledgers{font-size:12px;color:var(--ink-3)}
|
||||
.step .ledgers b{color:var(--ink-2)}
|
||||
.step .ledgers code{font-family:var(--mono);font-size:11.5px;background:var(--soft);border:1px solid var(--line-soft);border-radius:4px;padding:0 5px;color:var(--ink-2)}
|
||||
|
||||
/* 步骤间箭头 */
|
||||
.arrow{display:flex;justify-content:flex-start;padding:2px 0 2px 46px;color:var(--ink-4);font-size:15px;line-height:1.4;user-select:none}
|
||||
.arrow::before{content:"▼"}
|
||||
|
||||
/* 分支卡 */
|
||||
.branches{display:flex;flex-direction:column;gap:8px;padding-top:2px}
|
||||
.branches .b-title{font-size:11px;font-weight:800;letter-spacing:.1em;color:var(--ink-4);padding-left:16px}
|
||||
.br{position:relative;background:#fff;border:1px solid var(--line-soft);border-left:3px solid var(--ink-4);border-radius:8px;padding:9px 12px 9px 14px;font-size:12.5px;color:var(--ink-2);margin-left:16px}
|
||||
.br::before{content:"";position:absolute;left:-17px;top:16px;width:16px;border-top:2px dashed var(--ink-4)}
|
||||
.br.warn{border-left-color:var(--ledger)}
|
||||
.br.warn::before{border-top-color:var(--ledger)}
|
||||
.br.okp{border-left-color:var(--ok)}
|
||||
.br.okp::before{border-top-color:var(--ok)}
|
||||
.br.data{border-left-color:var(--brand);background:var(--brand-wash)}
|
||||
.br.data::before{border-top-color:var(--brand)}
|
||||
.br .b-id{font-family:var(--mono);font-weight:800;font-size:11px;color:var(--ink-3);margin-right:6px}
|
||||
.br.warn .b-id{color:var(--ledger)}
|
||||
.br.okp .b-id{color:var(--ok)}
|
||||
.br.data .b-id{color:var(--brand)}
|
||||
.br .go{display:block;margin-top:3px;font-size:11.5px;color:var(--ink-3)}
|
||||
.br .go::before{content:"╌▶ ";color:var(--ink-4);letter-spacing:-1px}
|
||||
.br.warn .go::before{color:var(--ledger)}
|
||||
.br.data .go::before{color:var(--brand)}
|
||||
.br.data .go{color:var(--ink-2)}
|
||||
|
||||
/* 完结结果矩阵 */
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
.matrix{width:100%;border-collapse:collapse;font-size:13px;background:#fff}
|
||||
.matrix 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)}
|
||||
.matrix td{padding:10px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
.matrix tr:last-child td{border-bottom:0}
|
||||
|
||||
/* 完结分支条 */
|
||||
.endbr{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px}
|
||||
.endbr .br{margin-left:0}
|
||||
.endbr .br::before{display:none}
|
||||
|
||||
/* 下游灰显区 */
|
||||
.downstream{background:#f1f2f5;border:1px dashed var(--line);border-radius:10px;padding:14px 18px;color:var(--ink-3)}
|
||||
.downstream .d-tag{display:inline-block;font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ink-4);border:1px solid var(--line);border-radius:999px;padding:1px 10px;background:#fff;margin-bottom:8px}
|
||||
.downstream ul{margin:0;padding-left:18px;font-size:12.5px}
|
||||
.downstream li{margin:3px 0}
|
||||
|
||||
/* 图例表 */
|
||||
.legend{width:100%;border-collapse:collapse;font-size:12.5px;background:#fff}
|
||||
.legend th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:8px 12px;border-bottom:1px solid var(--line)}
|
||||
.legend td{padding:8px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top;color:var(--ink-2)}
|
||||
.legend tr:last-child td{border-bottom:0}
|
||||
.legend .b-id{font-family:var(--mono);font-weight:800;font-size:11px;color:var(--ink-2);white-space:nowrap}
|
||||
|
||||
/* 页脚 */
|
||||
.foot{margin-top:18px;font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
|
||||
.sec{font-size:12px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:20px 0 8px}
|
||||
@media(max-width:900px){.steprow{grid-template-columns:1fr}.endbr{grid-template-columns:1fr 1fr}.branches .b-title{padding-left:0}.br{margin-left:0}.br::before{display:none}}
|
||||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- 1. 标题区 -->
|
||||
<div class="mast">
|
||||
<div class="kick">SS02 · 需求分析与方案匹配 | BT-02 跨品牌替代选型场景</div>
|
||||
<h1>ACT-WORK-05 差异评估 · 全流程与分支一览</h1>
|
||||
<div class="sub">SS02 需求分析与方案匹配 | 负责人 <b>FAE/RI</b> | 门禁 <b>ACT-GATE-03</b> 评审放行 | 工单:<b>1 数据(条件触发)+ 1 执行 + 2 验收</b></div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 场景卡 -->
|
||||
<div class="card scene">
|
||||
<div class="tagline">业务场景(演示示例)</div>
|
||||
<p>客户工业电源项目在用 <b>LM2596S-5.0</b>(TI,12V→5V、工业温域、40V 输入上限),原厂发布 PCN 该料转 <b>NRND</b>,客户要求验证替代料。你(FAE)从料库筛出 3 种候选:<b>A = XL2596S-5.0</b>(XLSEMI)、<b>B = LM2596S-ADJ</b>(TI 同族可调版)、<b>C = 国产某 DCDC</b>。</p>
|
||||
<p class="muted">触发双通道:①上游触发(ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定)②运营端一键启动(可快速创建需求单:request_id 系统生成、requirement_version 默认 V1)。两通道传入同一字段集——<b>活动内部不区分渠道</b>,工单无差别呈现。</p>
|
||||
</div>
|
||||
|
||||
<!-- 3. 范围横幅 -->
|
||||
<div class="card scope">
|
||||
<p class="q">本活动只回答一个问题——<em>技术上哪种料能替</em>(输出:每个候选的推荐等级与证据)。</p>
|
||||
<p class="nq">它<b>不回答</b>:买不买得到、数量够不够、多少钱——那些是下游活动的事(SS03 询价、ACT-WORK-24 报价),本图末尾灰显仅作衔接示意。</p>
|
||||
</div>
|
||||
|
||||
<!-- 4. 进入条件卡 -->
|
||||
<div class="card entry">
|
||||
<p style="font-size:12px;font-weight:800;letter-spacing:.08em;color:var(--human);margin:0 0 4px">进入条件 · 启动校验(agent 创建活动实例前必查)</p>
|
||||
<ul>
|
||||
<li><b>任务锚点</b>(二选一):关联已有需求 request_id,或快速创建(customer_id + 需求描述 → 系统建 P-01、生成 request_id、版本默认 V1;原 PN 与候选 PN 数组作为独立入参绑定);</li>
|
||||
<li><b>启动仅绑定 4 项</b>:客户 + 客户需求 + 原 PN + 候选 PN(数组);应用场景、BOM 位等其余资料启动后在工单内补齐,不回表单;</li>
|
||||
<li><b>datasheet 选填</b> <span class="ds">选填</span>:只影响数据工单是「AI 预填」还是「人全填」,<b>不影响能否启动</b>;</li>
|
||||
<li><b>存在性检查 = 全链路惰性触发</b>:按主键查台账,查无记录即派数据工单;启动校验只是第一次,之后每次读数仍可能再派(D02、D03…)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="sec"><svg class="sic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>主 链 · 启动期 + 5 步工作链条(右侧挂分支)</p>
|
||||
<div class="chain">
|
||||
|
||||
<!-- 0 启动期 -->
|
||||
<div class="steprow">
|
||||
<div class="step pw">
|
||||
<div class="s-head"><span class="s-no">0</span><h3>启动期 · 参数绑定与存在性检查</h3><span class="role pw">运营端 / 上游 · 系统</span><span class="wo data">数据工单 01(条件触发)</span></div>
|
||||
<p>参数绑定(任务 / 版本 / 双 PN / BOM 位 / 规则版本)→ 生成输入快照 <span class="mono">input_snapshot_ref</span>;存在性检查发现「完全没有」→ 派数据工单:活动停「等待数据」→ 确认入库 → 唤醒进步骤 1。</p>
|
||||
<div class="ledgers"><b>读写台账:</b><code>P-01</code>(W 写入/回链)· <code>P-08</code>(R 校验)· <code>P-10</code>(R/W 校验)</div>
|
||||
</div>
|
||||
<div class="branches">
|
||||
<div class="b-title">分支 · 启动前置 / 数据补齐</div>
|
||||
<div class="br warn"><span class="b-id">0a</span>新客户无档案<span class="go">先建 L-02 客户档案(数据工单),再返回启动</span></div>
|
||||
<div class="br data"><span class="b-id">0b</span>候选无 P-08 主数据<span class="go">同分支 1d:派数据工单创建,确认入库后继续</span></div>
|
||||
<div class="br"><span class="b-id">0c</span>未传 datasheet<span class="go">不再拦截:datasheet 已为选填,仅意味着数据工单全手填</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow"></div>
|
||||
|
||||
<!-- 步骤 1 -->
|
||||
<div class="steprow">
|
||||
<div class="step pw">
|
||||
<div class="s-head"><span class="s-no">1</span><h3>建立评估上下文</h3><span class="role pw">PW / AI</span><span class="wo">执行工单 01</span></div>
|
||||
<p>评估输入清单(一张表):<b>已绑定行灰底只读核对</b>(启动仅绑定 4 项:客户 / 需求 / 原 PN / 候选数组)+ <b>缺口行可编辑补齐</b>(应用场景、BOM 位等待补项 + 默认·可改项,补充内容必填*,可加行/删行)。工单内不可改绑定值。</p>
|
||||
<div class="ledgers"><b>读台账:</b><code>P-01</code> · <code>P-09</code> · <code>EXT-02</code>(快照:<code>input_snapshot_ref</code>)</div>
|
||||
</div>
|
||||
<div class="branches">
|
||||
<div class="b-title">分支 · 绑定、缺口与数据创建</div>
|
||||
<div class="br data"><span class="b-id">1d</span><b>任何读数点查无记录 → 派数据工单</b><span class="go">一单一类可多条(字段即表头、每行一条,可批量创建);模板统一(实例仅两处可变:目标台账决定表头 schema、预填内容决定行集);多实例 D01/D02…。原 PN=硬阻塞·必须创建;候选 ≥2 可放弃或创建(人判断);候选 =1 必须创建。确认即正式入库(无草稿无审核、确认人=责任人留痕)→ 断点续跑。只建主数据/资料,事实/证据类(P-06)不可创建、挂验证任务</span></div>
|
||||
<div class="br warn"><span class="b-id">1a</span>绑定参数有误<span class="go">退回触发方修订后重新触发(工单内不可改;手动→退回运营端启动人,上游→退回上游活动)</span></div>
|
||||
<div class="br"><span class="b-id">1b</span>补缺口四情形(有而不全):参数未披露→挂验证任务 | 资料版本过旧→补最新版 | 应用约束不清→问客户并更新需求单版本(V1→V1.1)| 外部来源补充</div>
|
||||
<div class="br"><span class="b-id">1c</span>缺口补不了<span class="go">留 UNKNOWN 进入评估(合法路径,不阻断)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow"></div>
|
||||
|
||||
<!-- 步骤 2 -->
|
||||
<div class="steprow">
|
||||
<div class="step ai">
|
||||
<div class="s-head"><span class="s-no">2</span><h3>读取并标准化资料</h3><span class="role ai">AI</span><span class="wo">不派工单</span></div>
|
||||
<p>统一参数名 / 单位 / 版本 / 证据引用,产出标准化参数对照表。<b>每次读数做存在性检查</b>:主数据 / 资料类查无 → 派数据工单;事实 / 证据类(P-06)查无 → 挂验证任务。缺失项标记 UNKNOWN。</p>
|
||||
<div class="ledgers"><b>读台账:</b><code>P-08</code> · <code>P-10</code> · <code>P-06</code> · <code>GOV</code>(规则版本 <code>rule_version</code> 锁定)</div>
|
||||
</div>
|
||||
<div class="branches">
|
||||
<div class="b-title">分支 · 标准化冲突</div>
|
||||
<div class="br warn"><span class="b-id">2a</span>单位冲突<span class="go">阻断,回步骤 1 补证</span></div>
|
||||
<div class="br"><span class="b-id">2b</span>证据版本冲突<span class="go">人工复核哪份资料为准</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow"></div>
|
||||
|
||||
<!-- 步骤 3 -->
|
||||
<div class="steprow">
|
||||
<div class="step ai">
|
||||
<div class="s-head"><span class="s-no">3</span><h3>逐项差异评估</h3><span class="role ai">AI</span><span class="wo">不派工单</span></div>
|
||||
<p>按硬约束(封装 / 工艺 / 电气上限等)与软约束逐项判定,输出 <b>MATCH / MISMATCH / UNKNOWN / CONDITIONAL</b>,附影响、风险、证据与置信度,形成 P-11 差异明细子表草稿。</p>
|
||||
<div class="ledgers"><b>读:</b>步骤 2 对照表 + GOV 规则(取参数同样做存在性检查)| <b>产出:</b>P-11 差异明细子表草稿(审核前不落账)</div>
|
||||
</div>
|
||||
<div class="branches">
|
||||
<div class="b-title">分支 · 硬约束失败</div>
|
||||
<div class="br warn"><span class="b-id">3a</span>硬约束 MISMATCH<span class="go">该候选直接「不推荐」——如候选 C 输入上限 32V < 客户 40V</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow"></div>
|
||||
|
||||
<!-- 步骤 4 -->
|
||||
<div class="steprow">
|
||||
<div class="step ri">
|
||||
<div class="s-head"><span class="s-no">4</span><h3>FAE/RI 审核与补证</h3><span class="role ri">FAE / RI</span><span class="wo">验收工单 01</span></div>
|
||||
<p>审核 P-11 差异明细子表草稿(含<b>约束类型</b>列):确认参数解释、风险等级、证据可信度与验证要求;四态逐项无漏项,UNKNOWN 必须有去向。</p>
|
||||
<div class="ledgers"><b>条件写:</b><code>P-06</code>(审核意见与验证要求 <code>verification_requirement</code>)</div>
|
||||
</div>
|
||||
<div class="branches">
|
||||
<div class="b-title">分支 · 审核驳回</div>
|
||||
<div class="br warn"><span class="b-id">4a</span>UNKNOWN 无去向<span class="go">退回步骤 1~2 补证 / 挂验证任务</span></div>
|
||||
<div class="br warn"><span class="b-id">4b</span>证据链不全<span class="go">退回补齐证据回链(证据来源含数据工单创建记录:人工录入 · 工单号 · 确认人)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow"></div>
|
||||
|
||||
<!-- 步骤 5 -->
|
||||
<div class="steprow">
|
||||
<div class="step ri">
|
||||
<div class="s-head"><span class="s-no">5</span><h3>落账与提交 ACT-GATE-03</h3><span class="role ri">FAE / RI</span><span class="role gate">门禁</span><span class="wo">验收工单 02</span></div>
|
||||
<p>确认拟落账内容后提交门禁:<b>P-11 替代关系主记录落账</b>——推荐等级 recommendation_level 四态(推荐 / 条件推荐 / 备选 / 不推荐)、analysis_status=FAE确认;customer_approval_status 由独立审批流维护,本活动不碰。并回写 P-01。</p>
|
||||
<div class="ledgers"><b>写台账:</b><code>P-11</code>(W)· <code>P-01</code>(W 回写)| 每个候选各一条 P-11 记录</div>
|
||||
</div>
|
||||
<div class="branches">
|
||||
<div class="b-title">分支 · 门禁结果</div>
|
||||
<div class="br okp"><span class="b-id">5a</span>GATE-03 放行<span class="go">P-11 落账生效,衔接下游</span></div>
|
||||
<div class="br warn"><span class="b-id">5b</span>GATE-03 驳回<span class="go">REWORK:回步骤 4 重审,或回步骤 1 重补输入</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 7. 完结结果矩阵 -->
|
||||
<p class="sec"><svg class="sic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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>完 结 · 本示例三候选评估结果</p>
|
||||
<div class="card">
|
||||
<div class="tbl-wrap">
|
||||
<table class="matrix">
|
||||
<thead><tr><th style="width:22%">候选</th><th style="width:14%">推荐等级</th><th>关键依据</th><th style="width:24%">下一步</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>A</b> <span class="mono">XL2596S-5.0</span><br><span class="muted">XLSEMI</span></td>
|
||||
<td><span class="lv cond">条件推荐</span></td>
|
||||
<td>温度范围更优(-40~+125℃ vs +85℃);使能阈值 UNKNOWN,挂 <span class="mono">VAL-2026-0188</span> 实测</td>
|
||||
<td>验证闭环后转正</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>B</b> <span class="mono">LM2596S-ADJ</span><br><span class="muted">TI 同族可调版</span></td>
|
||||
<td><span class="lv alt">备选</span></td>
|
||||
<td>反馈网络不同(可调版 vs 固定 5V),需改 BOM 外围</td>
|
||||
<td>客户接受改设计则启用</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>C</b> <span class="mono">国产某 DCDC</span></td>
|
||||
<td><span class="lv no">不推荐</span></td>
|
||||
<td>硬约束失败:输入上限 32V < 客户 40V(分支 3a)</td>
|
||||
<td>归档,不再评估</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:10px">注:多候选<b>并行评估、独立落账</b>(各一条 P-11 记录),不存在「选一个再评下一个」。</p>
|
||||
<div class="endbr">
|
||||
<div class="br okp"><span class="b-id">6a</span>至少一个推荐<span class="go">交下游衔接(见灰显区)</span></div>
|
||||
<div class="br"><span class="b-id">6b</span>全是条件推荐<span class="go">等验证闭环:通过→升级推荐;不过→降级不推荐</span></div>
|
||||
<div class="br warn"><span class="b-id">6c</span>全不推荐<span class="go">重新检索替代料 / 继续用原 PN / 与客户协商放宽约束</span></div>
|
||||
<div class="br"><span class="b-id">6d</span>客户不批准<span class="go">走独立审批流(customer_approval_status),不在本活动</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. 下游灰显区 -->
|
||||
<p class="sec"><svg class="sic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>下 游 · 范围外,仅作衔接示意</p>
|
||||
<div class="downstream">
|
||||
<span class="d-tag">范围外 · 仅作衔接示意</span>
|
||||
<ul>
|
||||
<li><b>ACT-WORK-08 证据归集</b>:GATE-03 放行后自动衔接,消费本活动结论。</li>
|
||||
<li><b>SS03 询价 / ACT-WORK-24 报价</b>:商务与供货问题(价格、交期、数量),不在本活动回答。</li>
|
||||
<li><b>客户批准审批流</b>:维护 <span class="mono">P-11.customer_approval_status</span>,独立于本活动。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 9. 分支图例表 -->
|
||||
<p class="sec"><svg class="sic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/></svg>图 例 · 全部分支对照表(18 条)</p>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="legend">
|
||||
<thead><tr><th style="width:70px">分支</th><th>触发条件</th><th style="width:44%">去向</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="b-id">0a</td><td>启动期:新客户无档案</td><td>先建 L-02 客户档案(数据工单),再返回启动</td></tr>
|
||||
<tr><td class="b-id">0b</td><td>启动期:候选无 P-08 主数据</td><td>同分支 1d:派数据工单登记创建</td></tr>
|
||||
<tr><td class="b-id">0c</td><td>启动期:未传 datasheet</td><td>不再拦截——datasheet 已是选填,仅意味着数据工单由「AI 预填」变为「人全填」</td></tr>
|
||||
<tr><td class="b-id">1a</td><td>步骤 1:绑定参数有误</td><td>退回触发方修订后重新触发(工单内不可改)</td></tr>
|
||||
<tr><td class="b-id">1b</td><td>步骤 1:输入存在缺口(有而不全)</td><td>补缺口四情形:候选 PDF 未入库→补链接+版本;参数原厂未披露→挂验证任务(P-06);原 PN 资料版本过旧→补最新版;应用约束不清→回头问客户并更新需求单版本</td></tr>
|
||||
<tr><td class="b-id">1c</td><td>步骤 1:缺口补不了</td><td>留 UNKNOWN 进入评估(合法路径,不阻断)</td></tr>
|
||||
<tr><td class="b-id">1d</td><td>任何读数点:查无记录(完全没有)</td><td>惰性触发派数据工单(一单一类可多条,模板统一,可多实例 D01/D02/…):原 PN=硬阻塞·必须创建;候选 ≥2 可放弃【记录放弃原因】或创建(人判断);候选 =1 必须创建。确认即入库(责任人留痕)→ 活动从断点续跑。事实/证据类查无(如 P-06)不可创建,挂验证任务</td></tr>
|
||||
<tr><td class="b-id">2a</td><td>步骤 2:单位冲突</td><td>阻断,回步骤 1 补证</td></tr>
|
||||
<tr><td class="b-id">2b</td><td>步骤 2:证据版本冲突</td><td>人工复核哪份资料为准</td></tr>
|
||||
<tr><td class="b-id">3a</td><td>步骤 3:硬约束 MISMATCH</td><td>该候选直接「不推荐」(如候选 C 输入上限 32V < 40V)</td></tr>
|
||||
<tr><td class="b-id">4a</td><td>步骤 4:UNKNOWN 无去向</td><td>退回步骤 1~2 补证 / 挂验证任务</td></tr>
|
||||
<tr><td class="b-id">4b</td><td>步骤 4:证据链不全</td><td>退回补齐证据回链(证据来源含数据工单创建记录:人工录入 · 工单号 · 确认人)</td></tr>
|
||||
<tr><td class="b-id">5a</td><td>步骤 5:ACT-GATE-03 放行</td><td>P-11 落账(analysis_status=FAE确认),衔接下游</td></tr>
|
||||
<tr><td class="b-id">5b</td><td>步骤 5:ACT-GATE-03 驳回</td><td>REWORK:回步骤 4 重审,或回步骤 1 重补输入</td></tr>
|
||||
<tr><td class="b-id">6a</td><td>完结:至少一个推荐</td><td>交下游衔接(ACT-WORK-08 / SS03 / ACT-WORK-24)</td></tr>
|
||||
<tr><td class="b-id">6b</td><td>完结:全是条件推荐</td><td>等验证闭环——通过→升级推荐;不过→降级不推荐</td></tr>
|
||||
<tr><td class="b-id">6c</td><td>完结:全不推荐</td><td>重新检索替代料 / 继续用原 PN / 与客户协商放宽约束</td></tr>
|
||||
<tr><td class="b-id">6d</td><td>完结:客户不批准</td><td>走独立审批流(customer_approval_status),不在本活动</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 10. 页脚 -->
|
||||
<div class="foot">
|
||||
<span>口径与同目录工单及三份文档(活动流程与工单说明 / 一键启动入参说明 / 数据工单 note)一致;示例数据为演示占位。</span>
|
||||
<span class="mono">ACT-WORK-05 · 差异评估 · 全流程分支图</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,296 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>执行工单 1 · 评估输入与资料补齐确认 · SS02 ACT-WORK-05 差异评估</title>
|
||||
<style>
|
||||
: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;
|
||||
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--human);font-weight:700}
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;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:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--human);border-color:var(--human)}
|
||||
.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(--human-soft);border-color:var(--human-line);color:#7c4a12}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
||||
table.on td>.jc-vcell{display:none}
|
||||
table.on td>.jc-cell{display:block}
|
||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
table.on td.rn .jc-rn-num{display:none}
|
||||
table.on td.rn .jc-rowdel{display:block}
|
||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:评估输入清单状态标识(仅追加,不改动基件样式) */
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.st{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.st.bound{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#c9ecc2}
|
||||
.st.miss{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
.st.def{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||
tr.ro td{background:var(--soft)}
|
||||
table.on td>.jc-vcell.ro{display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<header class="card-head">
|
||||
<div class="badge">执行</div>
|
||||
<div class="tit">
|
||||
<div class="kick">SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1</div>
|
||||
<h1>评估输入与资料补齐确认</h1>
|
||||
<div class="sub">收件人:<span data-slot="meta.task.owner">PW · 王工</span> · 关联任务:<span data-slot="meta.task.id">TASK-BT02-20260818-007</span></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<span class="tag">执行工单</span>
|
||||
<span class="step" data-slot="meta.phase">步骤 1/5 · 建立评估上下文</span>
|
||||
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card-bd">
|
||||
|
||||
<p class="zone"><b>【做什么】</b>—— 启动 4 项已绑定,其余信息在工单里补齐</p>
|
||||
<div class="note">
|
||||
<p><span class="done">AI 已完成</span>:系统已按启动传入的 <b>4 项</b>完成绑定——客户 customer_id、客户需求 request_description(新建 P-01 · 版本 V1)、原 PN、候选 PN(数组,逐个评估)——并生成输入快照(input_snapshot_ref)。本轮评估对象:<b>LM2596S-5.0(TI)→ 3 个候选(XL2596S-5.0 / LM2596S-ADJ / 国产 DCDC)Pin-to-Pin 跨品牌替代</b>(BT-02 场景)。</p>
|
||||
<p><span class="need">需要你补齐</span>:启动时未收的信息(应用场景、BOM 位——<b>不回表单,直接在下表补</b>);系统已带出默认值(替代类型 Pin-to-Pin、规则版本 R-2026.07、负责人/期限),可确认可改。本单缺口只覆盖「<b>有而不全</b>」;「<b>完全没有</b>」(PN 不在库)由系统派数据工单创建整条记录,不在本单。datasheet 在数据工单里上传(选填),启动与本单都不收。点「编辑」在下表「补充内容」列填入;<b>加一行 = 补充 AI 未列出的资料项</b>,<b>删一行 = 该输入项不适用</b>。未补齐的缺口项将按 <b>UNKNOWN</b> 进入差异评估。</p>
|
||||
</div>
|
||||
|
||||
<p class="zone"><b>【交回什么】</b>—— 评估输入清单(已绑定项只读核对,缺口项补齐,*为必填)</p>
|
||||
|
||||
<div class="jc-editgate" id="editgate">
|
||||
<span class="jc-editgate-t"><b data-slot="sd.input_gap_count">3 项资料存在缺口或待确认</b>(应用场景 / BOM 位 / 使能阈值),另有 3 项默认值已带出可改;「已绑定」行启动时冻结,不可改</span>
|
||||
<button class="jc-egbtn eg-open" type="button">编辑</button>
|
||||
<button class="jc-egbtn eg-cancel" type="button">取消</button>
|
||||
<button class="jc-egbtn eg-save" type="button">保存</button>
|
||||
</div>
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<table id="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th data-h="输入项">输入项</th>
|
||||
<th data-h="来源">来源</th>
|
||||
<th data-h="状态">状态</th>
|
||||
<th data-h="绑定值/缺口说明">绑定值 / 缺口说明</th>
|
||||
<th class="req" data-h="补充内容">补充内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-rows="in.evaluation_inputs">
|
||||
<tr class="ro">
|
||||
<td class="rn"><span class="jc-rn-num">1</span></td>
|
||||
<td data-field="input_item"><span class="jc-vcell ro">客户 customer_id</span></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell ro mono">L-02</span></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st bound">已绑定</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell ro mono">CUS-2025-0007</span></td>
|
||||
<td data-field="supplement"><span class="jc-vcell ro">—</span></td>
|
||||
</tr>
|
||||
<tr class="ro">
|
||||
<td class="rn"><span class="jc-rn-num">2</span></td>
|
||||
<td data-field="input_item"><span class="jc-vcell ro">客户需求 request_description</span></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell ro mono">P-01</span></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st bound">已绑定</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell ro">REQ-20260826-001 · V1(启动时新建:工业电源 12V→5V,替代 LM2596S-5.0)</span></td>
|
||||
<td data-field="supplement"><span class="jc-vcell ro">—</span></td>
|
||||
</tr>
|
||||
<tr class="ro">
|
||||
<td class="rn"><span class="jc-rn-num">3</span></td>
|
||||
<td data-field="input_item"><span class="jc-vcell ro">原内部料号 original_internal_pn</span></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell ro mono">P-08</span></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st bound">已绑定</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell ro mono">LM2596S-5.0(TI)</span></td>
|
||||
<td data-field="supplement"><span class="jc-vcell ro">—</span></td>
|
||||
</tr>
|
||||
<tr class="ro">
|
||||
<td class="rn"><span class="jc-rn-num">4</span></td>
|
||||
<td data-field="input_item"><span class="jc-vcell ro">候选内部料号 ① candidate_internal_pn[]</span></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell ro mono">P-08</span></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st bound">已绑定</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell ro mono">XL2596S-5.0(XLSEMI)</span></td>
|
||||
<td data-field="supplement"><span class="jc-vcell ro">—</span></td>
|
||||
</tr>
|
||||
<tr class="ro">
|
||||
<td class="rn"><span class="jc-rn-num">5</span></td>
|
||||
<td data-field="input_item"><span class="jc-vcell ro">候选内部料号 ② candidate_internal_pn[]</span></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell ro mono">P-08</span></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st bound">已绑定</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell ro mono">LM2596S-ADJ(TI 同族可调版)</span></td>
|
||||
<td data-field="supplement"><span class="jc-vcell ro">—</span></td>
|
||||
</tr>
|
||||
<tr class="ro">
|
||||
<td class="rn"><span class="jc-rn-num">6</span></td>
|
||||
<td data-field="input_item"><span class="jc-vcell ro">候选内部料号 ③ candidate_internal_pn[]</span></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell ro mono">P-08</span></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st bound">已绑定</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell ro">国产 DCDC(32V 输入上限 · 占位型号)</span></td>
|
||||
<td data-field="supplement"><span class="jc-vcell ro">—</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">7</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">应用场景 application_scenario</span><input class="jc-cell" value="应用场景 application_scenario"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell mono">P-01</span><input class="jc-cell mono" value="P-01"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st miss">待补*</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell">启动时未收,需向客户确认</span><input class="jc-cell" value="启动时未收,需向客户确认"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell">12V→5V、工业温域、40V 输入上限(示例已补)</span><input class="jc-cell" value="12V→5V、工业温域、40V 输入上限(示例已补)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">8</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">BOM 位 bom_position</span><input class="jc-cell" value="BOM 位 bom_position"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell mono">P-09</span><input class="jc-cell mono" value="P-09"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st miss">待补*</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell">启动时未收</span><input class="jc-cell" value="启动时未收"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell">U3 电源位(示例已补)</span><input class="jc-cell" value="U3 电源位(示例已补)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">9</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">替代类型 substitution_type</span><input class="jc-cell" value="替代类型 substitution_type"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st def">默认·可改</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell">默认 Pin-to-Pin</span><input class="jc-cell" value="默认 Pin-to-Pin"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">10</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">规则版本 rule_version</span><input class="jc-cell" value="规则版本 rule_version"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell mono">GOV</span><input class="jc-cell mono" value="GOV"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st def">默认·可改</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell mono">默认当前发布版 R-2026.07(as_of 锁定,随结论落账)</span><input class="jc-cell mono" value="默认当前发布版 R-2026.07(as_of 锁定,随结论落账)"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">11</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">负责人 / 期限 owner · deadline</span><input class="jc-cell" value="负责人 / 期限 owner · deadline"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st def">默认·可改</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell">PW · 王工 / 审核 FAE · 李工 · 1 个工作日(按客户线默认)</span><input class="jc-cell" value="PW · 王工 / 审核 FAE · 李工 · 1 个工作日(按客户线默认)"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">12</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">候选 PN 使能阈值</span><input class="jc-cell" value="候选 PN 使能阈值"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell mono">P-08</span><input class="jc-cell mono" value="P-08"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st miss">待确认·需补</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell">原厂未披露(有而不全)</span><input class="jc-cell" value="原厂未披露(有而不全)"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell mono">关联验证任务 VAL-2026-0188</span><input class="jc-cell mono" value="关联验证任务 VAL-2026-0188"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">13</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">原 PN 资料版本</span><input class="jc-cell" value="原 PN 资料版本"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell mono">P-10</span><input class="jc-cell mono" value="P-10"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st ok">齐备</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell">Rev H(2023-09)已核验</span><input class="jc-cell" value="Rev H(2023-09)已核验"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">14</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="input_item"><span class="jc-vcell">外部来源清单</span><input class="jc-cell" value="外部来源清单"></td>
|
||||
<td data-field="source_ledger"><span class="jc-vcell mono">EXT-02</span><input class="jc-cell mono" value="EXT-02"></td>
|
||||
<td data-field="status"><span class="jc-vcell ro"><span class="st ok">齐备</span></span></td>
|
||||
<td data-field="detail"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
<td data-field="supplement"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="jc-addrow" id="addrow">
|
||||
<button type="button">+ 加一行(补充资料项)</button>
|
||||
<em>新加行的「补充内容」为必填;「绑定值 / 缺口说明」栏写清这项资料是什么、AI 为什么拿不到</em>
|
||||
</div>
|
||||
|
||||
<div class="flow"><b>审批 / 提交:</b>本工单只承载内容;「提交」请在对话区的【执行卡】中进行。<b>提交后去向:</b>补齐后进入步骤 2 · AI 读取与标准化,仍缺失的项将按 UNKNOWN 进入差异评估。<b>注意:</b>启动参数如有误,需退回触发方修订后重新触发,本单不可改。</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
<span>SS02 · ACT-WORK-05 差异评估 · 执行工单 1/1 · 评估输入与资料补齐确认</span>
|
||||
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:执行 · <span data-slot="card.id">WO-ACT-WORK-05-01</span></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var gate = document.getElementById('editgate');
|
||||
var tbl = document.getElementById('tbl');
|
||||
var addrow = document.getElementById('addrow');
|
||||
var tbody = tbl.querySelector('tbody');
|
||||
function setEdit(on){ gate.classList.toggle('on',on); tbl.classList.toggle('on',on); addrow.classList.toggle('show',on); }
|
||||
gate.querySelector('.eg-open').addEventListener('click', function(){ setEdit(true); });
|
||||
gate.querySelector('.eg-cancel').addEventListener('click', function(){
|
||||
tbody.querySelectorAll('tr').forEach(function(tr){ tr.querySelectorAll('td').forEach(function(td){ var v=td.querySelector('.jc-vcell'),c=td.querySelector('.jc-cell'); if(v&&c){c.value=v.textContent;} }); });
|
||||
setEdit(false);
|
||||
});
|
||||
gate.querySelector('.eg-save').addEventListener('click', function(){
|
||||
tbody.querySelectorAll('tr').forEach(function(tr){ tr.querySelectorAll('td').forEach(function(td){ var v=td.querySelector('.jc-vcell'),c=td.querySelector('.jc-cell'); if(v&&c){v.textContent=c.value;} }); });
|
||||
renumber(); setEdit(false);
|
||||
});
|
||||
addrow.querySelector('button').addEventListener('click', function(){
|
||||
var tr=document.createElement('tr');
|
||||
tr.innerHTML='<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>'+'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="输入项"></td>'+'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="来源"></td>'+'<td><span class="jc-vcell ro"><span class="st miss">新增·待补</span></span></td>'+'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="资料说明 / AI 为什么拿不到"></td>'+'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="补充内容(必填)"></td>';
|
||||
tbody.appendChild(tr); renumber();
|
||||
});
|
||||
tbody.addEventListener('click', function(e){ if(e.target.classList.contains('jc-rowdel')){ e.target.closest('tr').remove(); renumber(); } });
|
||||
function renumber(){ tbody.querySelectorAll('tr').forEach(function(tr,i){ tr.querySelector('.jc-rn-num').textContent=i+1; }); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,341 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>数据工单 1 · 芯片主数据创建(P-08)· SS02 ACT-WORK-05 差异评估</title>
|
||||
<style>
|
||||
: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;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--data);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(51,112,255,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0 0 6px;font-size:13px;color:var(--ink-2)}
|
||||
.note p:last-child{margin-bottom:0}
|
||||
.note b{color:var(--ink)}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--data);font-weight:700}
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;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:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
|
||||
.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(--brand-wash);border-color:var(--data-line);color:#1e40af}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
||||
table.on td>.jc-vcell{display:none}
|
||||
table.on td>.jc-cell{display:block}
|
||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
table.on td.rn .jc-rn-num{display:none}
|
||||
table.on td.rn .jc-rowdel{display:block}
|
||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:数据工单专用标识(仅追加,不改动基件样式) */
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.pre{font-style:normal;font-size:10.5px;font-weight:800;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:0 7px;margin-left:6px;white-space:nowrap}
|
||||
table.on td>.pre{display:none}
|
||||
.ct{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.ct.hard{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
||||
.ct.soft{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
.tbl-gap{margin-top:18px}
|
||||
</style>
|
||||
<style>
|
||||
/* ===== 视觉补充段 v3(通用批量模板:字段即表头、每行一条;不改基件样式与 JS 钩子) ===== */
|
||||
.hic{flex:none;width:26px;height:26px;color:var(--data)}
|
||||
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
|
||||
/* 表头内字段说明(第二行小字) */
|
||||
.thd{display:block;font-size:10px;font-weight:600;color:var(--ink-4);letter-spacing:0;margin-top:1px;line-height:1.4}
|
||||
/* 表 1:字段即表头,内容多列,允许横向滚动 */
|
||||
#tbl1{min-width:1240px}
|
||||
#tbl1 .jc-vcell{display:inline}
|
||||
/* 表 2 列宽 */
|
||||
#tbl2 th:nth-child(2){width:150px}
|
||||
#tbl2 th:nth-child(3){width:18%}
|
||||
#tbl2 th:nth-child(4){width:14%}
|
||||
#tbl2 th:nth-child(5){width:8%;text-align:center;white-space:nowrap}
|
||||
#tbl2 th:nth-child(6){width:13%;text-align:center;white-space:nowrap}
|
||||
#tbl2 td:nth-child(5),#tbl2 td:nth-child(6){text-align:center;white-space:nowrap}
|
||||
/* 说明列(只读注释,编辑态保持可见) */
|
||||
.desc{color:var(--ink-3);font-size:12px;line-height:1.5}
|
||||
table.on td>.jc-vcell.ro{display:block}
|
||||
.tbl-wrap{overflow-x:auto}
|
||||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<header class="card-head">
|
||||
<div class="badge">数据</div>
|
||||
<svg class="hic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="10" cy="5" rx="7" ry="3"/><path d="M3 5v14c0 1.66 3.13 3 7 3 1.13 0 2.21-.09 3.2-.26"/><path d="M3 12c0 1.66 3.13 3 7 3 1.13 0 2.21-.09 3.2-.26"/><circle cx="18" cy="18" r="4.2"/><path d="M18 15.8v4.4M15.8 18h4.4"/></svg>
|
||||
<div class="tit">
|
||||
<div class="kick">SS02 · ACT-WORK-05 差异评估 · 数据工单 1/1</div>
|
||||
<h1>芯片主数据创建(P-08)</h1>
|
||||
<div class="sub">收件人:<span data-slot="meta.task.owner">PW · 王工</span> · 关联任务:<span data-slot="meta.task.id">TASK-BT02-20260818-007</span></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<span class="tag">数据工单</span>
|
||||
<span class="step" data-slot="meta.phase">等待数据 · 步骤 0.5/5</span>
|
||||
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card-bd">
|
||||
|
||||
<p class="zone"><svg class="zic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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><b>【做什么】</b>—— 批量创建 P-08 主数据记录</p>
|
||||
<div class="note">
|
||||
<p><span class="need">模板定位</span>:本单是「数据工单」统一模板的实例——<b>一单一类(可多条)</b>:目标台账=<b data-slot="meta.data.target_ledger">P-08</b>、本批创建=<b data-slot="meta.data.target_key">XL2596S-5.0(1 条,可加行增建)</b>;<b>字段即表头、每行一条新记录</b>。同一活动每次读数缺数据都可能再派发新实例(D02、D03…),模板与规则完全相同。</p>
|
||||
<p><span class="need">触发原因</span>:系统按 internal_pn 查 P-08 <b>无「XL2596S-5.0」记录</b>——是「完全没有」,不是字段缺失。本单为<b>系统判断派发</b>的数据创建工单(与执行工单的「有而不全」严格区分,互不嵌套)。</p>
|
||||
<p><span class="done">AI 预填</span>:本单已上传 datasheet(选填),AI 已预填 <b data-slot="sd.prefill_count">1 条记录 · 8 项字段 + 7 行参数</b>(标「预填」),请核对并补齐空缺;若本单未上传 datasheet,则全部手填,同为合法路径。</p>
|
||||
<p><span class="need">纪律</span>:<b>确认即正式入库</b>(无草稿、无审核),确认人 = 数据责任人并留痕;只建主数据 / 资料,事实与证据记录(如验证记录)须实测产生;在对话区【数据卡】确认后,活动自动从「等待数据」唤醒进入步骤 1,本单不复用。</p>
|
||||
</div>
|
||||
|
||||
<p class="zone"><svg class="zic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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><b>【创建什么 · 表 1】</b>—— 主数据记录(每行一条,*为必填列)</p>
|
||||
|
||||
<div class="jc-editgate" id="gate1">
|
||||
<span class="jc-editgate-t"><b>每行一条新记录</b>,点「编辑」核对预填、补齐空缺;「+ 加一行」= 再建一条;必填列(*)全齐才允许确认</span>
|
||||
<button class="jc-egbtn eg-open" type="button">编辑</button>
|
||||
<button class="jc-egbtn eg-cancel" type="button">取消</button>
|
||||
<button class="jc-egbtn eg-save" type="button">保存</button>
|
||||
</div>
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<table id="tbl1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th class="req" data-h="内部料号">内部料号 internal_pn<span class="thd">全链路统一基准,不可自由文本</span></th>
|
||||
<th class="req" data-h="品牌名称">品牌 brand_name<span class="thd">与制造商、供应商分开</span></th>
|
||||
<th data-h="制造商名称">制造商 manufacturer_name<span class="thd">与品牌不一致时分开记录</span></th>
|
||||
<th class="req" data-h="规格描述">规格描述 specification_description<span class="thd">按原厂资料口径</span></th>
|
||||
<th class="req" data-h="封装">封装 package<span class="thd">影响硬约束比对</span></th>
|
||||
<th data-h="工艺">工艺 process_technology<span class="thd">硬约束比较项</span></th>
|
||||
<th class="req" data-h="生命周期状态">生命周期 lifecycle_status<span class="thd">Active/NRND/EOL/停产/风险</span></th>
|
||||
<th data-h="EOL/PCN状态">EOL/PCN eol_pcn_status<span class="thd">条件必填:通知/影响/生效时间</span></th>
|
||||
<th data-h="Datasheet引用+版本">Datasheet 引用+版本<span class="thd">成对条件:有引用必填版本</span></th>
|
||||
<th data-h="适配应用场景">适配场景 application_scenarios<span class="thd">判断实际适用性</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-rows="in.create_p08_fields">
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">1</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="brand_name"><span class="jc-vcell">XLSEMI</span><em class="pre">预填</em><input class="jc-cell" value="XLSEMI"></td>
|
||||
<td data-field="manufacturer_name"><span class="jc-vcell">XLSEMI</span><em class="pre">预填</em><input class="jc-cell" value="XLSEMI"></td>
|
||||
<td data-field="specification_description"><span class="jc-vcell">降压型 DCDC 稳压器,3A,固定 5.0V,150kHz</span><em class="pre">预填</em><input class="jc-cell" value="降压型 DCDC 稳压器,3A,固定 5.0V,150kHz"></td>
|
||||
<td data-field="package"><span class="jc-vcell mono">TO-263-5</span><em class="pre">预填</em><input class="jc-cell mono" value="TO-263-5"></td>
|
||||
<td data-field="process_technology"><span class="jc-vcell">BCD</span><em class="pre">预填</em><input class="jc-cell" value="BCD"></td>
|
||||
<td data-field="lifecycle_status"><span class="jc-vcell">Active</span><em class="pre">预填</em><input class="jc-cell" value="Active"></td>
|
||||
<td data-field="eol_pcn_status"><span class="jc-vcell">—</span><input class="jc-cell" value="—"></td>
|
||||
<td data-field="datasheet_ref"><span class="jc-vcell mono">xlsemi.com/datasheet/XL2596S-5.0.pdf · Rev C</span><em class="pre">预填</em><input class="jc-cell mono" value="xlsemi.com/datasheet/XL2596S-5.0.pdf · Rev C"></td>
|
||||
<td data-field="application_scenarios"><span class="jc-vcell">工业电源;消费电源</span><input class="jc-cell" value="工业电源;消费电源"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="jc-addrow" id="addrow1">
|
||||
<button type="button">+ 加一行(再建一条记录)</button>
|
||||
<em>新行的必填列(*)全部填写后才允许确认;删一行 = 该记录不建</em>
|
||||
</div>
|
||||
|
||||
<p class="zone tbl-gap"><svg class="zic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg><b>【创建什么 · 表 2】</b>—— 标准化参数集 normalized_parameter_set(每行一个参数,按所属料号归属,*必填)</p>
|
||||
|
||||
<div class="jc-editgate" id="gate2">
|
||||
<span class="jc-editgate-t"><b>AI 已预填 7 行参数</b>(全部归属 XL2596S-5.0),点「编辑」核对修正;「+ 加一行」补充未列出的参数</span>
|
||||
<button class="jc-egbtn eg-open" type="button">编辑</button>
|
||||
<button class="jc-egbtn eg-cancel" type="button">取消</button>
|
||||
<button class="jc-egbtn eg-save" type="button">保存</button>
|
||||
</div>
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<table id="tbl2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th class="req" data-h="所属料号">所属料号 internal_pn<span class="thd">归属表 1 哪条记录</span></th>
|
||||
<th class="req" data-h="参数名">参数名</th>
|
||||
<th class="req" data-h="值">值</th>
|
||||
<th data-h="单位">单位</th>
|
||||
<th class="req" data-h="约束类型">约束(硬/软)</th>
|
||||
<th data-h="说明">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-rows="in.create_p08_params">
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">1</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="param"><span class="jc-vcell">输入电压范围</span><input class="jc-cell" value="输入电压范围"></td>
|
||||
<td data-field="value"><span class="jc-vcell mono">4.5~40</span><input class="jc-cell mono" value="4.5~40"></td>
|
||||
<td data-field="unit"><span class="jc-vcell">V</span><input class="jc-cell" value="V"></td>
|
||||
<td data-field="constraint"><span class="jc-vcell"><span class="ct hard">硬约束</span></span><input class="jc-cell" value="硬约束"></td>
|
||||
<td data-field="desc" class="desc"><span class="jc-vcell ro">必须覆盖客户输入(本例上限 40V)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">2</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="param"><span class="jc-vcell">输出电压</span><input class="jc-cell" value="输出电压"></td>
|
||||
<td data-field="value"><span class="jc-vcell mono">5.0(固定)</span><input class="jc-cell mono" value="5.0(固定)"></td>
|
||||
<td data-field="unit"><span class="jc-vcell">V</span><input class="jc-cell" value="V"></td>
|
||||
<td data-field="constraint"><span class="jc-vcell"><span class="ct hard">硬约束</span></span><input class="jc-cell" value="硬约束"></td>
|
||||
<td data-field="desc" class="desc"><span class="jc-vcell ro">固定版 vs 可调版是硬差异点</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">3</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="param"><span class="jc-vcell">输出电流</span><input class="jc-cell" value="输出电流"></td>
|
||||
<td data-field="value"><span class="jc-vcell mono">3</span><input class="jc-cell mono" value="3"></td>
|
||||
<td data-field="unit"><span class="jc-vcell">A</span><input class="jc-cell" value="A"></td>
|
||||
<td data-field="constraint"><span class="jc-vcell"><span class="ct hard">硬约束</span></span><input class="jc-cell" value="硬约束"></td>
|
||||
<td data-field="desc" class="desc"><span class="jc-vcell ro">满载能力,须 ≥ 设计电流</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">4</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="param"><span class="jc-vcell">开关频率</span><input class="jc-cell" value="开关频率"></td>
|
||||
<td data-field="value"><span class="jc-vcell mono">150</span><input class="jc-cell mono" value="150"></td>
|
||||
<td data-field="unit"><span class="jc-vcell">kHz</span><input class="jc-cell" value="kHz"></td>
|
||||
<td data-field="constraint"><span class="jc-vcell"><span class="ct soft">软约束</span></span><input class="jc-cell" value="软约束"></td>
|
||||
<td data-field="desc" class="desc"><span class="jc-vcell ro">影响外围电感 / 电容选型</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">5</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="param"><span class="jc-vcell">静态电流</span><input class="jc-cell" value="静态电流"></td>
|
||||
<td data-field="value"><span class="jc-vcell mono">4.2</span><input class="jc-cell mono" value="4.2"></td>
|
||||
<td data-field="unit"><span class="jc-vcell">mA</span><input class="jc-cell" value="mA"></td>
|
||||
<td data-field="constraint"><span class="jc-vcell"><span class="ct soft">软约束</span></span><input class="jc-cell" value="软约束"></td>
|
||||
<td data-field="desc" class="desc"><span class="jc-vcell ro">影响待机功耗</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">6</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="param"><span class="jc-vcell">工作温度</span><input class="jc-cell" value="工作温度"></td>
|
||||
<td data-field="value"><span class="jc-vcell mono">-40~+125</span><input class="jc-cell mono" value="-40~+125"></td>
|
||||
<td data-field="unit"><span class="jc-vcell">℃</span><input class="jc-cell" value="℃"></td>
|
||||
<td data-field="constraint"><span class="jc-vcell"><span class="ct hard">硬约束</span></span><input class="jc-cell" value="硬约束"></td>
|
||||
<td data-field="desc" class="desc"><span class="jc-vcell ro">工业温域必须覆盖</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rn"><span class="jc-rn-num">7</span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>
|
||||
<td data-field="internal_pn"><span class="jc-vcell mono">XL2596S-5.0</span><em class="pre">预填</em><input class="jc-cell mono" value="XL2596S-5.0"></td>
|
||||
<td data-field="param"><span class="jc-vcell">使能阈值</span><input class="jc-cell" value="使能阈值"></td>
|
||||
<td data-field="value"><span class="jc-vcell mono">未披露</span><input class="jc-cell mono" value="未披露"></td>
|
||||
<td data-field="unit"><span class="jc-vcell">V</span><input class="jc-cell" value="V"></td>
|
||||
<td data-field="constraint"><span class="jc-vcell"><span class="ct hard">硬约束</span></span><input class="jc-cell" value="硬约束"></td>
|
||||
<td data-field="desc" class="desc"><span class="jc-vcell ro">未披露项将进入 UNKNOWN 并挂验证任务</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="jc-addrow" id="addrow2">
|
||||
<button type="button">+ 加一行(补充参数)</button>
|
||||
<em>所属料号 / 参数名 / 值 / 单位 / 约束类型齐全;「未披露」请直接写未披露,将进入 UNKNOWN 并挂验证任务;「说明」列为系统注释,无需填写</em>
|
||||
</div>
|
||||
|
||||
<div class="flow"><b>提交 / 确认:</b>本工单只承载内容;确认请在对话区【数据卡】中进行。</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
<span>SS02 · ACT-WORK-05 差异评估 · 数据工单 1/1 · 芯片主数据创建(P-08)</span>
|
||||
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:数据 · <span data-slot="card.id">WO-ACT-WORK-05-D01</span></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function wire(gateId, tblId, addrowId, addTpl){
|
||||
var gate = document.getElementById(gateId);
|
||||
var tbl = document.getElementById(tblId);
|
||||
var addrow = addrowId ? document.getElementById(addrowId) : null;
|
||||
var tbody = tbl.querySelector('tbody');
|
||||
function setEdit(on){ gate.classList.toggle('on',on); tbl.classList.toggle('on',on); if(addrow){ addrow.classList.toggle('show',on); } }
|
||||
function sync(fromVtoC){ tbody.querySelectorAll('tr').forEach(function(tr){ tr.querySelectorAll('td').forEach(function(td){ var v=td.querySelector('.jc-vcell'),c=td.querySelector('.jc-cell'); if(v&&c){ if(fromVtoC){c.value=v.textContent;}else{v.textContent=c.value;} } }); }); }
|
||||
function renumber(){ tbody.querySelectorAll('tr').forEach(function(tr,i){ tr.querySelector('.jc-rn-num').textContent=i+1; }); }
|
||||
gate.querySelector('.eg-open').addEventListener('click', function(){ setEdit(true); });
|
||||
gate.querySelector('.eg-cancel').addEventListener('click', function(){ sync(true); setEdit(false); });
|
||||
gate.querySelector('.eg-save').addEventListener('click', function(){ sync(false); renumber(); setEdit(false); });
|
||||
if(addrow && addTpl){
|
||||
addrow.querySelector('button').addEventListener('click', function(){
|
||||
var tr=document.createElement('tr');
|
||||
tr.innerHTML=addTpl;
|
||||
tbody.appendChild(tr); renumber();
|
||||
});
|
||||
tbody.addEventListener('click', function(e){ if(e.target.classList.contains('jc-rowdel')){ e.target.closest('tr').remove(); renumber(); } });
|
||||
}
|
||||
}
|
||||
wire('gate1','tbl1','addrow1',
|
||||
'<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>'+
|
||||
'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="内部料号*"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="品牌*"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="制造商"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="规格描述*"></td>'+
|
||||
'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="封装*"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="工艺"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="生命周期*"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="EOL/PCN"></td>'+
|
||||
'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="Datasheet 引用 · 版本"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="适配场景"></td>');
|
||||
wire('gate2','tbl2','addrow2',
|
||||
'<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" title="删除此行">×</button></td>'+
|
||||
'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="所属料号*"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="参数名"></td>'+
|
||||
'<td><span class="jc-vcell mono"></span><input class="jc-cell mono" placeholder="值"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="单位"></td>'+
|
||||
'<td><span class="jc-vcell"></span><input class="jc-cell" placeholder="硬约束 / 软约束"></td>'+
|
||||
'<td><span class="jc-vcell ro">—</span></td>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,297 @@
|
||||
# ACT-WORK-05 差异评估 — 活动流程与工单说明
|
||||
|
||||
> 素材来源:`activity/SS02-需求分析与方案匹配/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2.html`(v1.2 Demo 架构版)
|
||||
> 工单目录:`SS02-ACT-WORK-05-html/`(1 数据(条件触发)+ 1 执行 + 2 验收,共 4 份)
|
||||
> 本套工单已按《SS02-SS03-SS04_Demo台账字段细化与统一方案》(2026-08)完成字段级对齐。
|
||||
|
||||
---
|
||||
|
||||
## 一、活动定位
|
||||
|
||||
- **活动**:ACT-WORK-05 差异评估,P0,所属 SS02 需求分析与方案匹配子系统 / BT-02 跨品牌替代选型场景,负责人 FAE/RI。
|
||||
- **是什么**:将「候选 PN 是否可替代」拆解为参数、封装、工艺、生命周期、应用边界和验证条件的逐项比较,形成逐项可复核的差异结论(MATCH / MISMATCH / UNKNOWN / CONDITIONAL),并保留证据、风险和下一步动作。输出 AI 分析草稿和 FAE 审核结论,**不自动批准替代关系**。
|
||||
- **产出物**:差异评估结论 + 推荐等级 recommendation_level(推荐 / 条件推荐 / 备选 / 不推荐),审核通过后落账 **P-11 替代关系主记录**(含 P-11 差异明细子表,一条参数差异一条记录 difference_item_id),并回写 **P-01**(风险等级、验证要求、输出链接、下一步动作、当前阶段)。
|
||||
- **触发**:双通道——①上游触发:ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定后;②一键手动启动:运营端按钮表单直接启动(绕过 ACT-GATE-02 路径判定,故启动理由必填、source_type=手动启动 留痕,详见《ACT-WORK-05-一键启动入参说明.md》)。**触发渠道仅是活动外部关注点,活动内部与工单不承载触发渠道信息——两种通道传入同一字段集,工单无差别呈现。**
|
||||
- **门禁**:ACT-GATE-03 评审放行(本活动终点)。
|
||||
- **下游**:ACT-WORK-08 证据归集消费本活动结论。
|
||||
- **冷启动能力**:依赖库为空(PN 按 internal_pn 查无记录)时,系统派**数据工单**创建主数据,确认即入库后活动自动从「等待数据」唤醒继续——活动不因空库终止。
|
||||
- **台账读写**:P-01(R+W)、P-06(R/C,条件写)、P-08(R;无记录时经数据工单 W 创建)、P-09(R)、P-10(R;无记录时经数据工单 W 创建)、P-11(R+W;其中客户批准状态 customer_approval_status **只读边界**,由审批流程维护,本活动不产生)、GOV 规则(R,带版本锁定 rule_version)。
|
||||
- **进入条件(极简口径)**:启动入参仅 **4 项**——客户 customer_id、客户需求 request_description(新建 P-01·V1)、原 PN、候选 PN(数组 ≥1);应用场景、BOM 位、替代类型、规则版本、负责人/期限等**启动后在工单内补齐,不回表单**;datasheet 在数据工单里上传(选填),只影响 AI 预填程度。
|
||||
- **退出条件**:每个候选 PN 都有差异状态、证据/缺口、风险和验证建议,并完成 FAE/RI 审核。
|
||||
|
||||
## 二、整体流程(5 步工作链条)
|
||||
|
||||
**步骤 1 · 建立评估上下文(PW/AI)**
|
||||
- AI 做什么:绑定评估对象,生成输入快照。
|
||||
- AI 怎么做:读 → 经读数工具读 P-01(启动新建的 demand 主记录:客户、需求描述、版本 V1)、P-09(BOM 位 bom_position)、EXT-02(外部来源清单);加工 → 绑定启动 4 项(客户/需求/原 PN/候选数组),核对资料齐备度,标出缺口项(应用场景、BOM 位等待补);输出 → 输入快照 input_snapshot_ref(活动自持中间产物)。
|
||||
- 人做什么:**PW 介入**——补启动时未收的信息(应用场景、BOM 位),确认或调整系统默认值(替代类型、规则版本、负责人/期限),补 AI 拿不到的资料(实测记录等)(执行工单 01)。
|
||||
- 触发纪律:触发传入参数即活动进入条件,系统据此生成 input_snapshot_ref 并完成绑定后派执行工单 01,PW 仅补缺口,绑定参数不可改(双通道与渠道无感知原则详见第七节)。
|
||||
|
||||
**步骤 2 · 读取并标准化资料(AI)**
|
||||
- AI 做什么:把多来源资料整理成可比对的标准参数表。
|
||||
- AI 怎么做:读 → P-08(原 PN 物料基准)、P-10(候选原厂 Datasheet、补证、承诺函,资料类型由 document_type 区分)、P-06(历史验证事实)、GOV 规则(评估口径);**每次读台账均做存在性检查:查无记录→主数据/资料类派数据工单(一单一类可多条),事实/证据类(如 P-06)挂验证任务**;加工 → 统一参数名称、单位、版本和证据引用,缺失项标记 UNKNOWN;输出 → 标准化参数对照表(活动自持)。
|
||||
- 人做什么:无需介入(纯 AI 加工态)。
|
||||
|
||||
**步骤 3 · 逐项差异评估(AI)**
|
||||
- AI 做什么:逐项给出差异结论。
|
||||
- AI 怎么做:读 → 步骤 2 的标准化对照表 + GOV 规则版本 rule_version(取参数时同样做存在性检查:查无记录→派数据工单或挂验证任务);加工 → 按硬约束(封装/工艺等)与软约束(constraint_type)逐项判定,输出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL(difference_status),并说明影响、风险等级、证据引用与置信度;输出 → P-11 差异明细子表草稿(一条参数差异一条记录 difference_item_id,**审核前不落账**),不改变客户批准关系。
|
||||
- 人做什么:无需介入(AI 计算态),结论交步骤 4 审核。
|
||||
|
||||
**步骤 4 · FAE/RI 审核与补证(FAE/RI)**
|
||||
- AI 做什么:把步骤 3 草稿整理成可审核视图(四态 pill + 证据链)。
|
||||
- AI 怎么做:读 → P-11 差异明细子表草稿;加工 → 按验收判据组织展示(漏项检查、UNKNOWN 去向、证据回链、硬约束检查);输出 → 审核工单视图。
|
||||
- 人做什么:**FAE/RI 介入**——确认参数解释、风险等级、证据可信度和验证要求,必要时退回补证;审核意见与补充条件条件写 P-06(验收工单 01)。
|
||||
|
||||
**步骤 5 · 提交评审放行(FAE/RI + Gate)**
|
||||
- AI 做什么:汇总拟落账内容供确认。
|
||||
- AI 怎么做:读 → 审后结论;加工 → 生成拟写 P-11 替代关系主记录字段值(替代类型/原内部料号/候选内部料号/推荐等级/推荐理由/差异摘要/替代分析报告/分析状态/风险等级/验证要求/证据可信度/差异规则版本)与拟回写 P-01 字段值(风险等级/验证要求/输出链接/下一步动作/当前阶段);输出 → 落账确认视图。
|
||||
- 人做什么:**FAE/RI 介入**——确认拟落账内容无误后,在对话区卡提交 ACT-GATE-03(验收工单 02)。放行后更新 P-01 状态,结论供 ACT-WORK-08 证据归集消费;客户批准状态 customer_approval_status 仍由审批流程维护,本活动不写。
|
||||
|
||||
## 三、工单划分逻辑
|
||||
|
||||
本活动为**纯数据/内容生产型**(同 I01),无物理执行环节(没有拜访、递送、线下执行),因此**没有执行回执类工单**。本活动使用**三类工单**,触发条件与分工严格区分:
|
||||
|
||||
| 类型 | 触发条件 | 判断方 | 人做什么 | 入库方式 |
|
||||
|---|---|---|---|---|
|
||||
| **数据工单** | **完全没有**:按 internal_pn 查台账(P-08 等主数据/资料类)无此记录 | 系统(硬查询) | 从零创建整条记录 | **确认即正式入库**(无草稿态、无审核环节),确认人=数据责任人并留痕 |
|
||||
| **执行工单** | **有而不全**:PN 在库、个别字段/资料缺失 | AI(字段级判断) | 补缺字段、补资料链接 | 对话区【执行卡】提交,缺口可留 UNKNOWN |
|
||||
| **验收工单** | AI 已产出结论,需人拍板 | AI 产出 + 人判断 | 通过 / 驳回 | 对话区【验收卡】通过后落账 |
|
||||
|
||||
- 数据工单与执行工单**严格区分、互不嵌套**:一个是「整条记录不存在」(系统判断),一个是「记录存在但字段不全」(AI 判断)。
|
||||
- 数据工单**铁律一**:只能建主数据/资料类记录(P-08 / P-10 / S-03 / L-02),不能建事实/证据记录(P-06 验证记录须实测产生,只能挂任务)。
|
||||
|
||||
对生产流程逐步判定:
|
||||
|
||||
- **步骤 0.5(存在性检查,条件触发)**:启动校验发现 PN 不在库 → 派 **数据工单 01**(芯片主数据创建,本活动唯一数据单),确认入库前活动停在「等待数据」状态,确认后自动唤醒。
|
||||
- **步骤 1(建立评估上下文)**:AI 能读台账建快照,但「参数原厂未披露、资料版本过旧」这类 **AI 拿不到的可信来源资料只有人能补** → 派 **执行工单 01**(评估输入与资料补齐确认,本活动唯一执行单)。
|
||||
- **步骤 2(读取并标准化)、步骤 3(逐项差异评估)**:均为**纯 AI 加工态**——读台账、标准化、按规则计算,不需要人补内容、也没有产出要人拍板的中间结论 → **不派工单**,其结论并入步骤 4 的验收单呈现。
|
||||
- **步骤 4(FAE/RI 审核)**:AI 已产出差异结论(四态 + 风险 + 证据),需要人判断「结论可信不可信、UNKNOWN 有没有去向」→ 派 **验收工单 01**(差异评估结论审核)。
|
||||
- **步骤 5(提交 ACT-GATE-03)**:AI 拟好了落账内容,需要人确认「无误后入库 / 放行」→ 派 **验收工单 02**(评审放行与落账确认)。
|
||||
|
||||
结论:**1 数据(条件触发)+ 1 执行 + 2 验收**。本活动无独立复盘流程(结论复用由下游 ACT-WORK-08 与后续替代/方案流程承担),无需拆时间轴。三条硬约束全程成立:工单不承载审批/确认动作(动作在对话区卡);要素完整(期限/判据/必填/去向);交回内容可校验(执行单必填*、验收单判据清单、数据单必填全齐才允许确认)。
|
||||
|
||||
## 四、每个工单详细说明
|
||||
|
||||
### 工单 0:数据工单 01 · 芯片主数据创建(WO-ACT-WORK-05-D01,条件触发)
|
||||
- **对应步骤**:步骤 0.5 · 等待数据(惰性触发点之一;启动校验期的存在性检查只是第一次)。
|
||||
- **三条定稿规则**:
|
||||
- **一单一类(可多条)**:每张数据工单只针对**一种台账**;**字段即表头、每行一条新记录,可一次创建多条**(如 P-08 一次建多个料号);主记录与结构子表(如参数集)分表承载,子表行以主键(所属料号)归属记录;其它台账的缺失另行派发新实例。
|
||||
- **惰性触发(不限于启动)**:活动执行到任何一步,**每次读台账**发现「完全没有」(按主键查无记录)即派一张;同一活动可派发多张,按派发顺序编号 WO-<活动>-D01/D02/…;活动进入「等待数据」状态,全部确认入库后从断点续跑。启动校验只是惰性触发的第一次,不是唯一一次。并发去重不考虑(一活动一负责人)。
|
||||
- **模板统一**:数据工单只有**一个统一模板**(类型级),职责只有「数据创建」;实例化时按目标台账的字段 schema(《统一方案》字段表)渲染表头 + AI 预填/人全填。实例之间只有两处不同:**目标台账(决定表头 schema)、预填内容(决定行集)**(本单:目标台账=P-08 `meta.data.target_ledger`、本批记录=XL2596S-5.0 `meta.data.target_key`)。
|
||||
- **触发**:系统按主键查台账**无此记录**(完全没有,系统硬查询)——原 PN 无数据=硬阻塞必须创建;候选 PN 无数据:候选数 ≥2 时人可二选一(放弃该候选【记录放弃原因】或创建),候选数 =1 时不能放弃、必须创建。事实/证据类查无(如 P-06 无验证记录)**不可创建**,只能挂验证任务。
|
||||
- **创建什么**:①表 1 主数据记录(**字段即表头、每行一条新记录、可加行批量创建**:internal_pn★ / brand_name★ / manufacturer_name / specification_description★ / package★ / process_technology / lifecycle_status★ / eol_pcn_status(条件)/ datasheet_ref+datasheet_version(成对条件)/ application_scenarios,字段说明放表头第二行小字,tbody `in.create_p08_fields`);②表 2 标准化参数集 normalized_parameter_set★(**每行一个参数、以「所属料号 internal_pn」列归属表 1 记录**:所属料号★/参数名/值/单位/约束类型/说明只读注释列,可加行,tbody `in.create_p08_params`,示例预填 7 行)。启动时传了 datasheet → AI 预填(计数槽 `sd.prefill_count`,示例 1 条记录 · 8 项字段 + 7 行参数);没传 → 人全填,同为合法路径。
|
||||
- **为什么两张表(粒度不同,一张表摆不平)**:表 1 的粒度是「一个料一行」——每个字段对每条记录只有一个值,字段集合由台账 schema 定死(主记录);表 2 的粒度是「一个参数一行」——每个料挂一串行数不固定的参数,每行内部还有 参数名/值/单位/硬软约束 四件套(结构子表)。两者行数没有固定比例,合成一张表只有两条死路:①参数塞进料号行的某格——一坨文本,机器没法逐项消费(差异评估要逐项比对)、人没法校验必填,违反《统一方案》「重复明细用子表承载,不在单一文本字段堆叠」;②参数也变成列——不同料的参数清单完全不同(DCDC 和 MCU 没有共同列),列数不固定,表头无法按 schema 统一渲染,通用模板破产。所以必须两张表,靠表 2 的「所属料号」列做连接键(映射主表+子表的外键关系):建 3 个料 → 表 1 三行、表 2 按料号分 3 组;入库时系统按该键把参数集挂到各自主记录下。
|
||||
- **确认即入库与责任人**:人在对话区【数据卡】点确认 → **直接入正式库(record_status=有效),无草稿态、无审核环节**;确认人=该数据责任人,系统留痕(确认人 / 数据工单号 / 所属活动);必填字段全齐才允许确认(表单级校验,不算审核)。入库后活动自动唤醒进步骤 1;入库后发现错误走主数据治理修正,工单不复用。
|
||||
- **铁律一**:只能建主数据/资料类记录(P-08 / P-10 / S-03 / L-02),不能建事实/证据记录(P-06 验证记录须实测产生,只能挂任务)。
|
||||
- **为什么是独立类型**:条件触发(只在「完全没有」时派发,非常驻);产出是**共享资产**(进正式库、跨活动复用,不是本活动的中间产物);模板直接来自台账字段定义(P-08 字段表),不是活动自定义表单;与执行工单「有而不全」严格区分、互不嵌套。
|
||||
|
||||
### 工单 1:执行工单 01 · 评估输入与资料补齐确认(WO-ACT-WORK-05-01)
|
||||
- **对应步骤**:步骤 1/5 · 建立评估上下文。
|
||||
- **做什么**:确认触发传入参数已绑定(只读回显),补齐「有而不全」的缺口资料。
|
||||
- **AI 产出**:一张「评估输入清单」(`in.evaluation_inputs`)——按状态列区分:**已绑定**(灰底只读行,启动时冻结,仅 4 项:客户 customer_id、客户需求 request_description(新建 P-01 · V1)、原内部料号 original_internal_pn、候选内部料号 candidate_internal_pn[] 数组逐行)、**待补**(应用场景 application_scenario、BOM 位 bom_position——启动时未收)、**默认·可改**(替代类型 substitution_type 默认 Pin-to-Pin、规则版本 rule_version 默认 R-2026.07、负责人/期限按客户线默认)、**待确认·需补**(候选 PN 使能阈值原厂未披露,P-08)、**齐备**(原 PN 资料版本 P-10 Rev H 已核验、外部来源清单 EXT-02);编辑闸读数 `sd.input_gap_count` 显示缺口项数。
|
||||
- **人填什么**:缺口行「补充内容」列(必填*)——资料链接 / 任务编号;可加行补充 AI 未列出的资料项(新行自动标「新增·待补」),可删行声明不适用;已绑定行在编辑态下仍只读、不可删。未补齐项按 UNKNOWN 进入评估。启动参数如有误,退回触发方修订后重新触发,本单不可改。
|
||||
- **为什么是执行**:本单只覆盖「**有而不全**」——PN 已在库、个别字段缺失(AI 判断);「**完全没有**」(PN 不在库)由系统派数据工单创建,不在本单,两类互不嵌套。可信来源资料的获取在 AI 识别规则之外(原厂未公开、实测未做),只有 PW 能补。
|
||||
|
||||
### 工单 2:验收工单 01 · 差异评估结论审核(WO-ACT-WORK-05-02)
|
||||
- **对应步骤**:步骤 3~4/5 · 逐项差异评估 + FAE/RI 审核(步骤 2~3 为纯 AI 加工,不单独派单,结论并入本单)。
|
||||
- **AI 产出**:P-11 差异明细子表草稿(7 行示例:输出电压/输出电流/开关频率/封装 MATCH,工作温度 MISMATCH 高风险,使能阈值 UNKNOWN 挂 VAL-2026-0188,生命周期 CONDITIONAL 凭 P-10 供货承诺函),每项带约束类型 constraint_type(硬/软约束)、四态 pill difference_status、风险等级、证据引用 evidence_ref、AI 置信度。
|
||||
- **人审什么**(4 条判据):关键参数差异均有明确状态(无漏项);UNKNOWN 项均有去向(挂验证任务或补来源);证据链完整(可追溯 P-08/P-10/P-06 或 EXT-02);硬约束项未被自动判 MATCH。
|
||||
- **为什么是验收**:结论是 AI 按规则算出的,FAE/RI 只拍板通过/驳回,不改内容;审核意见与补充条件写 P-06。
|
||||
|
||||
### 工单 3:验收工单 02 · 评审放行与落账确认(WO-ACT-WORK-05-03)
|
||||
- **对应步骤**:步骤 5/5 · 提交 ACT-GATE-03。
|
||||
- **AI 产出**:两张只读表——拟写 P-11 替代关系主记录(替代类型 substitution_type / 原内部料号 original_internal_pn / 候选内部料号 candidate_internal_pn / 推荐等级 recommendation_level「条件推荐」pill / 推荐理由 / 差异摘要 / 替代分析报告 analysis_report_ref RPT-2026-0188 / 分析状态 analysis_status「FAE确认」/ 客户批准状态 customer_approval_status「未提交」/ 风险等级 / 验证要求 / 证据可信度 / 差异规则版本 rule_version);拟回写 P-01(风险等级 / 验证要求 VAL-2026-0188 / 输出链接 / 下一步动作 / 当前阶段)。
|
||||
- **人审什么**(4 条判据):推荐等级与差异明细一致(存在 UNKNOWN/CONDITIONAL 不得给「推荐」);分析状态与客户批准状态分离(本单只写分析层 analysis_status=FAE确认,customer_approval_status 保持未提交,不由差异评估产生);GOV 规则版本已锁定(R-2026.07);回写 P-01 字段完整。
|
||||
- **为什么是验收**:落账内容已由 AI 拟好,人只确认「无误后入库 + 放行」;客户批准不在本单范围(customer_approval_status 只读边界,由审批流程维护)。
|
||||
|
||||
## 五、文件对应关系与闭环流程图
|
||||
|
||||
| 文件 | 类型 | 对应步骤 | 一句话 |
|
||||
|---|---|---|---|
|
||||
| `ACT-WORK-05-数据工单-01-芯片主数据创建.html` | 数据 1/1(条件触发) | 步骤 0.5 | PN 不在库时系统派发,人创建整条 P-08 记录,确认即入库(责任人留痕)后活动唤醒 |
|
||||
| `ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html` | 执行 1/1 | 步骤 1 | 评估输入清单:已绑定项只读核对 + 「有而不全」缺口项 PW 补齐,缺口项按 UNKNOWN 进入评估 |
|
||||
| `ACT-WORK-05-验收工单-01-差异评估结论审核.html` | 验收 1/2 | 步骤 3~4 | FAE/RI 审核 AI 逐项差异结论(四态 + 证据链),意见写 P-06 |
|
||||
| `ACT-WORK-05-验收工单-02-评审放行与落账确认.html` | 验收 2/2 | 步骤 5 | 确认拟写 P-11 / 回写 P-01 内容,提交 ACT-GATE-03 |
|
||||
| `ACT-WORK-05-一键启动入参说明.md` | 说明文档 | 触发 | 运营端一键启动的入参总表、系统行为与工单衔接说明 |
|
||||
| `ACT-WORK-05-全流程分支图.html` | 可视化 | 全流程 | 面向业务负责人的流程与全部分支一览(自包含零依赖) |
|
||||
|
||||
```
|
||||
触发(双通道:ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定 | 一键手动启动,绕过 GATE-02,启动理由留痕)
|
||||
│
|
||||
▼
|
||||
步骤0.5 存在性检查(系统,条件触发)── PN 不在库?(全链路惰性检查的第一次;之后每次读台账仍可能再派 D02/D03…)
|
||||
│──►【数据工单 01】芯片主数据创建 ── 一单一类可多条(字段即表头、每行一条),对话区【数据卡】确认即入库
|
||||
│ 确认前活动停「等待数据」;全部确认入库后从断点续跑
|
||||
▼
|
||||
步骤1 建立评估上下文(PW/AI)
|
||||
│──►【执行工单 01】评估输入与资料补齐确认 ── PW 补资料,对话区【执行卡】提交
|
||||
▼
|
||||
步骤2 读取并标准化资料(AI,不派单)── 缺失项标 UNKNOWN
|
||||
▼
|
||||
步骤3 逐项差异评估(AI,不派单)── P-11 差异明细子表草稿(不落账)
|
||||
▼
|
||||
步骤4 FAE/RI 审核与补证
|
||||
│──►【验收工单 01】差异评估结论审核 ── 对话区【验收卡】通过/驳回
|
||||
│ 通过:意见与验证要求条件写 P-06
|
||||
│ 驳回:退回补证 / 验证(回步骤 1 或挂 VAL 任务)
|
||||
▼
|
||||
步骤5 提交评审放行
|
||||
│──►【验收工单 02】评审放行与落账确认 ── 对话区【验收卡】通过/驳回
|
||||
│ 通过:P-11 替代关系主记录落账 + P-01 回写 + 提交 ACT-GATE-03
|
||||
▼
|
||||
ACT-GATE-03 评审放行(门禁,本活动终点)
|
||||
▼
|
||||
下游 ACT-WORK-08 证据归集消费
|
||||
(P-11.customer_approval_status 客户批准:全程只读边界,由审批流程另行维护)
|
||||
```
|
||||
|
||||
## 六、动态数据标注
|
||||
|
||||
命名空间速查:`meta.*` 任务元数据 | `card.*` 工单身份 | `in.*` 执行/数据工单输入行集 | `out.*` 验收单输出行集 | `sd.*` 状态读数。
|
||||
|
||||
### 工单 0(数据 01)动态位置
|
||||
|
||||
| 位置 | 动态数据 | 绑定地址 | 类型 |
|
||||
|---|---|---|---|
|
||||
| 卡头 sub | 收件人 | `meta.task.owner` | data-slot |
|
||||
| 卡头 sub | 关联任务 | `meta.task.id` | data-slot |
|
||||
| 卡头 side | 当前步骤(等待数据 · 步骤 0.5/5) | `meta.phase` | data-slot |
|
||||
| 卡头 side | 最迟交回 | `card.deadline` | data-slot |
|
||||
| note 读数 | AI 预填项数 | `sd.prefill_count` | data-slot |
|
||||
| 表 1 tbody | 主数据记录行集(每行一条记录,可加行批量创建) | `in.create_p08_fields` | data-rows |
|
||||
| 表 1 表头 th / 单元格 td | 内部料号★/品牌★/制造商/规格描述★/封装★/工艺/生命周期★/EOL-PCN(条件)/Datasheet 引用+版本(成对)/适配场景 | internal_pn / brand_name / manufacturer_name / specification_description / package / process_technology / lifecycle_status / eol_pcn_status / datasheet_ref / application_scenarios | data-h / data-field | 列标签 / 列绑定 |
|
||||
| 表 2 tbody | 标准化参数集行集(每行一个参数,按所属料号归属,可加行) | `in.create_p08_params` | data-rows |
|
||||
| 表 2 表头 th / 单元格 td | 所属料号★ / 参数名 / 值 / 单位 / 约束类型 / 说明(只读注释 ro) | internal_pn / param / value / unit / constraint / desc | data-h / data-field | 列标签 / 列绑定 |
|
||||
| 页脚 | 工单编号 | `card.id` | data-slot |
|
||||
|
||||
### 工单 1(执行 01)动态位置
|
||||
|
||||
| 位置 | 动态数据 | 绑定地址 | 类型 |
|
||||
|---|---|---|---|
|
||||
| 卡头 sub | 收件人 | `meta.task.owner` | data-slot |
|
||||
| 卡头 sub | 关联任务 | `meta.task.id` | data-slot |
|
||||
| 卡头 side | 当前步骤 | `meta.phase` | data-slot |
|
||||
| 卡头 side | 最迟交回 | `card.deadline` | data-slot |
|
||||
| 编辑闸读数 | 缺口项数 | `sd.input_gap_count` | data-slot |
|
||||
| 表格 tbody | 评估输入清单行集(已绑定行只读 + 缺口行可编辑) | `in.evaluation_inputs` | data-rows |
|
||||
| 表头 th | 输入项 / 来源 / 状态 / 绑定值·缺口说明 / 补充内容(req*) | data-h | 列标签 |
|
||||
| 单元格 td | input_item / source_ledger / status / detail / supplement | data-field | 列绑定 |
|
||||
| 页脚 | 工单编号 | `card.id` | data-slot |
|
||||
|
||||
### 工单 2(验收 01)动态位置
|
||||
|
||||
| 位置 | 动态数据 | 绑定地址 | 类型 |
|
||||
|---|---|---|---|
|
||||
| 卡头 sub | 收件人 | `meta.task.owner` | data-slot |
|
||||
| 卡头 sub | 关联任务 | `meta.task.id` | data-slot |
|
||||
| 卡头 side | 当前步骤 | `meta.phase` | data-slot |
|
||||
| 卡头 side | 最迟交回 | `card.deadline` | data-slot |
|
||||
| 表格 tbody | 差异明细行集(P-11 差异明细子表草稿,difference_item_id) | `out.difference_items` | data-rows |
|
||||
| 表头 th | 参数项 parameter_name / 原PN值 original_value / 候选PN值 candidate_value / 单位 parameter_unit / 约束类型 constraint_type / 结论 difference_status / 风险等级 / 证据引用 evidence_ref / AI置信度 | data-h | 列标签 |
|
||||
| 单元格 td | parameter_name / original_value / candidate_value / parameter_unit / constraint_type / difference_status / risk_level / evidence_ref / confidence | data-field | 列绑定 |
|
||||
| 页脚 | 工单编号 | `card.id` | data-slot |
|
||||
|
||||
### 工单 3(验收 02)动态位置
|
||||
|
||||
| 位置 | 动态数据 | 绑定地址 | 类型 |
|
||||
|---|---|---|---|
|
||||
| 卡头 sub | 收件人 | `meta.task.owner` | data-slot |
|
||||
| 卡头 sub | 关联任务 | `meta.task.id` | data-slot |
|
||||
| 卡头 side | 当前步骤 | `meta.phase` | data-slot |
|
||||
| 卡头 side | 最迟交回 | `card.deadline` | data-slot |
|
||||
| 表 1 tbody | 拟写 P-11 替代关系主记录行集 | `out.ledger_p11` | data-rows |
|
||||
| 表 2 tbody | 拟回写 P-01 行集 | `out.writeback_p01` | data-rows |
|
||||
| 表头 th | 字段 / 拟写入值 | data-h | 列标签 |
|
||||
| 单元格 td | field / value | data-field | 列绑定 |
|
||||
| 页脚 | 工单编号 | `card.id` | data-slot |
|
||||
|
||||
---
|
||||
|
||||
## 七、启动与触发(实现规格)
|
||||
|
||||
1. **双通道与渠道无感知原则**:触发方式 = ①上游触发(ACT-WORK-03 检索调用 / ACT-GATE-02 路径判定)②运营端一键手动启动。两通道传入**同一字段集**;活动内部与工单不承载、不区分触发渠道。source_type / 启动理由属运营端留痕字段,不传入活动。
|
||||
2. **进入条件(启动校验,agent 必须在创建活动实例前校验,极简口径)**:启动入参仅 **4 项**——客户 customer_id、客户需求 request_description(无 P-01 记录则新建·版本 V1)、原 PN、候选 PN(数组 ≥1)。**应用场景、BOM 位、替代类型、规则版本、负责人/期限不是启动入参**——替代类型默认 Pin-to-Pin、规则版本默认当前发布版、负责人/期限按客户线默认,均在执行工单 01 列为「默认·可改」行由 PW 确认或调整;应用场景、BOM 位为「待补」行由 PW 补录。**datasheet 在数据工单里上传(选填)**——传了则 AI 在数据工单里预填,没传则人全填,不影响能否启动。
|
||||
3. **存在性检查 = 全链路惰性触发(系统硬查询)**:启动校验期的存在性检查只是**第一次**;活动执行中**每次读台账**(步骤 2 读 P-08/P-10/P-06、步骤 3 取参数等)发现「完全没有」均再派新数据工单(一单一类可多条,按派发顺序 D01/D02/…)。活动可并行等待多张数据工单,全部确认入库后从断点续跑。启动期的两档语义:
|
||||
- 原 PN 无数据 → **硬阻塞**:必须派数据工单创建,确认入库前活动停在「等待数据」状态;
|
||||
- 候选 PN 无数据 → 候选数 **≥2** 时人可二选一(放弃该候选【记录放弃原因】或派数据工单创建);候选数 **=1** 时不能放弃、必须创建;
|
||||
- 数据工单确认入库后,活动自动唤醒进入步骤 1。
|
||||
- datasheet 的有无不参与拦截,只决定数据工单是「AI 预填」还是「人全填」。
|
||||
4. **任务锚点二选一(重点:request_id 与 requirement_version 是系统产出,不是入参)**:
|
||||
- **关联已有需求**:选 request_id,版本自动带出;
|
||||
- **快速创建**(客户口述需求未建档场景):入参 = customer_id(老客户下拉选;新客户先建 L-02,属分支 0a)+ 需求描述(口述整理成文字);系统行为 = **自动创建 P-01 记录、生成 request_id、requirement_version 默认 V1**,并把原 PN 与候选 PN 数组作为独立启动参数挂载绑定。
|
||||
5. **口述需求的版本纪律**:版本属于需求记录而非客户口述;客户后续补充 / 澄清 → 更新 P-01 记录,requirement_version 递增(V1→V1.1,旧版保留);每次评估锁 as_of 版本(input_snapshot_ref 记录当时按哪版评),全程可回溯。
|
||||
6. **启动后系统行为序列**:校验进入条件(4 项) →(快速创建时建 P-01 · V1)→ **存在性检查**(按 internal_pn 查台账,候选数组逐个查;缺则派数据工单,活动停「等待数据」,确认入库后唤醒)→ 绑定客户 / 需求 / 原 PN / 候选数组 + 落系统默认值(替代类型、规则版本、负责人/期限)→ 生成 input_snapshot_ref → 进入步骤 1 派执行工单 01。
|
||||
7. **冻结与退回纪律**:触发时绑定的参数在工单内只读不可改;绑错的唯一处理 = 退回触发方修订后重新触发(手动 → 退回运营端启动人;上游 → 退回上游活动)。
|
||||
8. 参数字段集详见同目录《ACT-WORK-05-一键启动入参说明.md》(运营端规格)。
|
||||
|
||||
## 八、完整流程分支总表(含示例)
|
||||
|
||||
1. **示例场景**(口径注意:本活动只评技术可替代性,示例不涉及库存数量 / 采购情节):客户工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代;候选 A=XL2596S-5.0、B=LM2596S-ADJ、C=国产某 DCDC;一键快速创建需求单 REQ-20260826-001(V1);应用场景(12V→5V、工业温域、40V 输入上限)启动后在执行工单 01 补录。
|
||||
2. **分支总表**(18 条全列):
|
||||
|
||||
| 分支 | 触发条件 | 处理与去向 |
|
||||
|---|---|---|
|
||||
| 0a | 启动期:新客户无档案 | 先建 L-02 客户档案(数据工单),再返回启动 |
|
||||
| 0b | 启动期:候选无 P-08 主数据 | 同分支 1d:派数据工单登记创建 |
|
||||
| 0c | 启动期:未传 datasheet | **不再拦截**——datasheet 已是选填,仅意味着数据工单由「AI 预填」变为「人全填」 |
|
||||
| 1a | 步骤 1:绑定参数有误 | 退回触发方修订后重新触发(工单内不可改) |
|
||||
| 1b | 步骤 1:输入存在缺口(有而不全) | 补缺口四情形:候选 PDF 未入库→补链接+版本;参数原厂未披露→挂验证任务(P-06);原 PN 资料版本过旧→补最新版;应用约束不清→回头问客户并更新需求单版本 |
|
||||
| 1c | 步骤 1:缺口补不了 | 留 UNKNOWN 进入评估(合法路径,不阻断) |
|
||||
| 1d | 任何读数点:查无记录(完全没有) | 惰性触发派数据工单(一单一类可多条,模板统一,可多实例 D01/D02/…):原 PN=硬阻塞·必须创建;候选 ≥2 可放弃【记录放弃原因】或创建(人判断);候选 =1 必须创建。确认即入库(责任人留痕)→ 活动从断点续跑。事实/证据类查无(如 P-06)不可创建,挂验证任务 |
|
||||
| 2a | 步骤 2:单位冲突 | 阻断,回步骤 1 补证 |
|
||||
| 2b | 步骤 2:证据版本冲突 | 人工复核哪份资料为准 |
|
||||
| 3a | 步骤 3:硬约束 MISMATCH | 该候选直接「不推荐」(示例:候选 C 输入上限 32V < 客户 40V) |
|
||||
| 4a | 步骤 4:UNKNOWN 无去向 | 退回步骤 1~2 补证 / 挂验证任务 |
|
||||
| 4b | 步骤 4:证据链不全 | 退回补齐证据回链 |
|
||||
| 5a | 步骤 5:ACT-GATE-03 放行 | P-11 落账(analysis_status=FAE确认),衔接下游 |
|
||||
| 5b | 步骤 5:ACT-GATE-03 驳回 | REWORK:回步骤 4 重审,或回步骤 1 重补输入 |
|
||||
| 6a | 完结:至少一个推荐 | 交下游衔接(ACT-WORK-08 / SS03 / ACT-WORK-24) |
|
||||
| 6b | 完结:全是条件推荐 | 等验证闭环——通过→升级推荐;不过→降级不推荐 |
|
||||
| 6c | 完结:全不推荐 | 重新检索替代料 / 继续用原 PN / 与客户协商放宽约束 |
|
||||
| 6d | 完结:客户不批准 | 走独立审批流(customer_approval_status),不在本活动 |
|
||||
|
||||
3. **结果矩阵(示例)**:
|
||||
|
||||
| 候选 | 推荐等级 | 关键依据 | 下一步 |
|
||||
|---|---|---|---|
|
||||
| A · XL2596S-5.0(XLSEMI) | 条件推荐 | 温度范围更优(-40~+125℃ vs +85℃);使能阈值 UNKNOWN,挂 VAL-2026-0188 实测 | 验证闭环后转正 |
|
||||
| B · LM2596S-ADJ(TI 同族可调版) | 备选 | 反馈网络不同(可调版 vs 固定 5V),需改 BOM 外围 | 客户接受改设计则启用 |
|
||||
| C · 国产某 DCDC | 不推荐 | 硬约束失败:输入上限 32V < 客户 40V(分支 3a) | 归档,不再评估 |
|
||||
|
||||
4. **多候选并行原则**:N 个候选 = N 条独立 P-11 记录,并行评估、独立落账,不存在「选一个再评下一个」。
|
||||
5. 可视化版本见同目录《ACT-WORK-05-全流程分支图.html》。
|
||||
|
||||
## 九、后端实现纪律(agent 必读,逐条不可违反)
|
||||
|
||||
1. request_id / requirement_version / input_snapshot_ref 一律系统生成,agent 不得要求调用方传入。
|
||||
2. 活动实例创建前必须做进入条件校验与存在性检查;原 PN 无数据必须硬阻塞并派数据工单,返回缺什么;候选 PN 无数据按规则处理(≥2 可放弃【记录放弃原因】或创建,=1 必须创建)。
|
||||
3. 已绑定参数任何接口不允许修改;修改请求一律返回「退回触发方重新触发」。
|
||||
4. UNKNOWN 是一等公民:缺参数不得伪造、不得留空字符串冒充,必须显式标 UNKNOWN 并记录去向(挂验证任务 / 补来源)。
|
||||
5. 硬约束 MISMATCH 不得被任何人工操作改判 MATCH;人只能补证据后重评。
|
||||
6. analysis_status 与 customer_approval_status 严格分离:本活动只能推进 analysis_status(AI草稿→FAE确认),customer_approval_status 一律不碰(由独立审批流维护)。
|
||||
7. 规则版本 as_of 锁定:评估开始即记录 rule_version;评估中途 GOV 规则更新不得影响进行中的评估。
|
||||
8. 本活动输出只回答「技术可替代性」:不得在活动内做数量、价格、交期、采购判断(那是 SS03 / ACT-WORK-24 的事)。
|
||||
9. 执行工单缺口的处理闭环:补不了的缺口必须能以 UNKNOWN 合法放行,不得阻塞流程等人填(人可以不填)。
|
||||
10. 多候选并行:任何「单候选」假设(字段只存一个 candidate)都是错的,必须支持 N 候选独立评估与落账。
|
||||
11. 数据工单**确认即入库**:无草稿态、无审核环节,确认直接写正式库(record_status=有效);确认人=数据责任人,系统必须留痕(确认人 / 数据工单号 / 所属活动)。
|
||||
12. 数据工单必填字段全齐才允许确认(表单级校验,不算审核);缺必填不得放行确认。
|
||||
13. 事实/证据类记录禁止工单创建:P-06 验证记录等只能实测产生、挂任务闭环;数据工单只能建主数据/资料类记录(P-08 / P-10 / S-03 / L-02)。
|
||||
14. datasheet 选填:不得作为启动拦截条件;其有无只决定数据工单 AI 预填程度。无 datasheet 创建的数据,差异明细证据来源必须标「人工录入 · 数据工单 WO-xxx · 确认人 xxx」。
|
||||
15. 并发去重不考虑:一个活动只绑一个负责人,不实现数据工单的去重 / 合并逻辑。
|
||||
16. **一单一类(可多条)**:每张数据工单只针对一种台账,禁止混合台账;一张单可批量创建多条记录(字段即表头、每行一条);不同台账 / 不同读数点的缺失 = 多张单(D01/D02/…,按派发顺序编号)。
|
||||
17. **惰性触发不限于启动**:每次读台账都必须做存在性检查,查无记录即派单;不得只在启动时检查一次。活动可同时等待多张数据工单,全部确认后从断点续跑。
|
||||
18. **模板统一**:数据工单只有一个统一模板(类型级,职责只有数据创建);实例化按目标台账字段 schema(《统一方案》字段表)渲染表头,实例间仅两处可变——目标台账(决定表头 schema)、预填内容(决定行集)。
|
||||
|
||||
---
|
||||
|
||||
## 附:v1.2 待确认 4 项 — 落地状态
|
||||
|
||||
素材第六节列出 4 项待确认问题。按《SS02-SS03-SS04_Demo台账字段细化与统一方案》(2026-08),本套工单的落地状态如下:
|
||||
|
||||
| # | 待确认问题 | 落地状态 |
|
||||
|---|---|---|
|
||||
| 1 | 差异明细正式归属是什么?(P0) | **已落地**:差异明细 = P-11 差异明细子表(difference_item_id,一条参数一条记录);验收工单 01 的 `out.difference_items` 行集即其草稿视图,含 parameter_name / original_value / candidate_value / parameter_unit / constraint_type / difference_status / evidence_ref 等字段。 |
|
||||
| 2 | P-11 何时允许写入?(P0) | **已落地**:以 analysis_status 枚举区分写入时机(AI草稿→FAE确认),客户批准独立为 customer_approval_status 字段,由审批流程维护,不由差异评估产生;验收工单 02 只写分析层(analysis_status=FAE确认),customer_approval_status 保持「未提交」。 |
|
||||
| 3 | 硬约束规则由哪个 GOV 对象维护?(P0) | rule_version 字段已落在差异明细子表(记录评估时采用的硬/软约束规则版本,本单锁 R-2026.07);维护方归属仍待 GOV 确认(P0 遗留)。 |
|
||||
| 4 | 是否按 BOM 位独立评估?(P1) | bom_position 字段已落在 P-01 与 P-11 主记录(执行单评估输入清单以「BOM 位 bom_position」待补可编辑行承载,启动后补录,示例 U3 电源位);多 BOM 位边界不混用,不做按位独立评估。 |
|
||||
| 5 | 冷启动与数据工单 | **已落地**:新增第三类工单「数据工单」——PN 不在库(完全没有,系统硬查询)时派发,人创建整条主数据记录,**确认即入库**(无草稿态、无审核环节),确认人=数据责任人并留痕;与执行工单「有而不全」严格区分、互不嵌套。 |
|
||||
@@ -0,0 +1,212 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>验收工单 1 · 差异评估结论审核 · SS02 ACT-WORK-05 差异评估</title>
|
||||
<style>
|
||||
: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;
|
||||
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
||||
.criteria li{margin:4px 0}
|
||||
.criteria b{color:var(--ink)}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
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)}
|
||||
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
||||
.cust{font-weight:700}
|
||||
.prio{font-family:var(--mono);font-weight:800}
|
||||
.prio.hi{color:var(--danger)}
|
||||
.prio.mid{color:var(--ink-2)}
|
||||
.reason{font-size:12.5px;color:var(--ink-2)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:四态结论 pill 与风险等级(仅追加,不改动基件样式) */
|
||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);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:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
||||
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||
.risk{font-weight:800;font-size:12.5px}
|
||||
.risk.low{color:var(--ok)}
|
||||
.risk.mid{color:#8f6a12}
|
||||
.risk.hi{color:var(--danger)}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<header class="card-head">
|
||||
<div class="badge">验收</div>
|
||||
<div class="tit">
|
||||
<div class="kick">SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2</div>
|
||||
<h1>差异评估结论审核</h1>
|
||||
<div class="sub">收件人:<span data-slot="meta.task.owner">FAE · 李工</span> · 关联任务:<span data-slot="meta.task.id">TASK-BT02-20260818-007</span></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<span class="tag">验收工单</span>
|
||||
<span class="step" data-slot="meta.phase">步骤 3~4/5 · 逐项差异评估 + FAE/RI 审核</span>
|
||||
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card-bd">
|
||||
|
||||
<p class="zone"><b>【AI 结论说明】</b>—— 结论是怎么算出来的</p>
|
||||
<div class="note">
|
||||
<p><b>AI 已完成的计算</b>:按 GOV 评估规则(锁定版本 <b>R-2026.07</b>,rule_version),对 Original=<b>LM2596S-5.0(TI)</b> / Candidate=<b>XL2596S-5.0(XLSEMI)</b> 逐项比对,产出四态结论——<b>MATCH / MISMATCH / UNKNOWN / CONDITIONAL</b>,并给出风险等级、证据引用与置信度。差异明细 = <b>P-11 差异明细子表</b>(一条参数差异一条记录,difference_item_id);当前为 AI 草稿,<b>FAE 审核通过前不落账</b>。评估参数可能含本次数据工单创建的主数据(确认人背书;未附 datasheet 的参数项会显式标注「人工录入」)。</p>
|
||||
</div>
|
||||
|
||||
<p class="zone"><b>【验收判据】</b>—— 你据此判断「通过 / 驳回」</p>
|
||||
<div class="criteria">
|
||||
<div class="c-title">判断这份差异评估结论是否可信,请逐项核对</div>
|
||||
<ul>
|
||||
<li><b>关键参数差异均有明确状态</b>:逐项结论无漏项,每个参数都有四态之一的判定;</li>
|
||||
<li><b>UNKNOWN 项均有去向</b>:每个 UNKNOWN 都挂了验证任务(如 VAL-2026-0188)或补来源动作,不允许悬空;</li>
|
||||
<li><b>证据链完整</b>:每项结论可追溯至 P-08 / P-10 / P-06 或 EXT-02 的具体记录,或追溯至数据工单创建记录(标注:人工录入 · 数据工单号 · 确认人);</li>
|
||||
<li><b>硬约束项未被自动判 MATCH</b>:封装 / 工艺等硬约束未经证据支持不得判 MATCH。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="zone"><b>【AI 产出的差异明细】</b>—— 只读展示(P-11 差异明细子表草稿 · 一条参数一条记录 difference_item_id)</p>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:40px">#</th><th data-h="参数项 parameter_name">参数项 parameter_name</th><th data-h="原PN值 original_value">原 PN 值 original_value</th><th data-h="候选PN值 candidate_value">候选 PN 值 candidate_value</th><th data-h="单位 parameter_unit">单位 parameter_unit</th><th data-h="约束类型 constraint_type">约束类型 constraint_type</th><th data-h="结论 difference_status">结论 difference_status</th><th data-h="风险等级">风险等级</th><th data-h="证据引用 evidence_ref">证据引用 evidence_ref</th><th data-h="AI置信度">AI 置信度</th></tr></thead>
|
||||
<tbody data-rows="out.difference_items">
|
||||
<tr>
|
||||
<td class="num">1</td>
|
||||
<td data-field="parameter_name" class="cust">输出电压</td>
|
||||
<td data-field="original_value" class="mono">5.0</td>
|
||||
<td data-field="candidate_value" class="mono">5.0</td>
|
||||
<td data-field="parameter_unit">V</td>
|
||||
<td data-field="constraint_type">硬约束</td>
|
||||
<td data-field="difference_status"><span class="st ok">MATCH</span></td>
|
||||
<td data-field="risk_level"><span class="risk low">低</span></td>
|
||||
<td data-field="evidence_ref" class="mono">P-08 · P-10</td>
|
||||
<td data-field="confidence" class="mono">0.98</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">2</td>
|
||||
<td data-field="parameter_name" class="cust">输出电流</td>
|
||||
<td data-field="original_value" class="mono">3</td>
|
||||
<td data-field="candidate_value" class="mono">3</td>
|
||||
<td data-field="parameter_unit">A</td>
|
||||
<td data-field="constraint_type">硬约束</td>
|
||||
<td data-field="difference_status"><span class="st ok">MATCH</span></td>
|
||||
<td data-field="risk_level"><span class="risk low">低</span></td>
|
||||
<td data-field="evidence_ref" class="mono">P-08 · P-10</td>
|
||||
<td data-field="confidence" class="mono">0.97</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">3</td>
|
||||
<td data-field="parameter_name" class="cust">开关频率</td>
|
||||
<td data-field="original_value" class="mono">150</td>
|
||||
<td data-field="candidate_value" class="mono">150</td>
|
||||
<td data-field="parameter_unit">kHz</td>
|
||||
<td data-field="constraint_type">硬约束</td>
|
||||
<td data-field="difference_status"><span class="st ok">MATCH</span></td>
|
||||
<td data-field="risk_level"><span class="risk low">低</span></td>
|
||||
<td data-field="evidence_ref" class="mono">P-08 · P-10</td>
|
||||
<td data-field="confidence" class="mono">0.95</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">4</td>
|
||||
<td data-field="parameter_name" class="cust">封装</td>
|
||||
<td data-field="original_value" class="mono">TO-263-5</td>
|
||||
<td data-field="candidate_value" class="mono">TO-263-5</td>
|
||||
<td data-field="parameter_unit">—</td>
|
||||
<td data-field="constraint_type">硬约束</td>
|
||||
<td data-field="difference_status"><span class="st ok">MATCH</span></td>
|
||||
<td data-field="risk_level"><span class="risk low">低</span></td>
|
||||
<td data-field="evidence_ref" class="mono">P-08 · P-10</td>
|
||||
<td data-field="confidence" class="mono">0.99</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">5</td>
|
||||
<td data-field="parameter_name" class="cust">工作温度</td>
|
||||
<td data-field="original_value" class="mono">-40~+85</td>
|
||||
<td data-field="candidate_value" class="mono">-40~+125</td>
|
||||
<td data-field="parameter_unit">℃</td>
|
||||
<td data-field="constraint_type">硬约束</td>
|
||||
<td data-field="difference_status"><span class="st miss">MISMATCH</span></td>
|
||||
<td data-field="risk_level"><span class="risk hi">高</span></td>
|
||||
<td data-field="evidence_ref" class="mono">P-10</td>
|
||||
<td data-field="confidence" class="mono">0.93</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">6</td>
|
||||
<td data-field="parameter_name" class="cust">使能阈值</td>
|
||||
<td data-field="original_value" class="mono">1.4</td>
|
||||
<td data-field="candidate_value" class="mono">未披露</td>
|
||||
<td data-field="parameter_unit">V</td>
|
||||
<td data-field="constraint_type">硬约束</td>
|
||||
<td data-field="difference_status"><span class="st unknown">UNKNOWN</span></td>
|
||||
<td data-field="risk_level"><span class="risk mid">中</span></td>
|
||||
<td data-field="evidence_ref" class="mono">挂 VAL-2026-0188</td>
|
||||
<td data-field="confidence" class="mono">—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">7</td>
|
||||
<td data-field="parameter_name" class="cust">生命周期</td>
|
||||
<td data-field="original_value" class="mono">Active</td>
|
||||
<td data-field="candidate_value" class="mono">Active</td>
|
||||
<td data-field="parameter_unit">—</td>
|
||||
<td data-field="constraint_type">软约束</td>
|
||||
<td data-field="difference_status"><span class="st cond">CONDITIONAL</span></td>
|
||||
<td data-field="risk_level"><span class="risk mid">中</span></td>
|
||||
<td data-field="evidence_ref" class="mono">P-10(候选供货承诺函)</td>
|
||||
<td data-field="confidence" class="mono">0.90</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flow"><b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。审核意见与补充条件写入 P-06(验证要求 verification_requirement);<b>通过后去向:</b>进入步骤 5 · 评审放行与落账确认。</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
<span>SS02 · ACT-WORK-05 差异评估 · 验收工单 1/2 · 差异评估结论审核</span>
|
||||
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:验收 · <span data-slot="card.id">WO-ACT-WORK-05-02</span></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,226 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>验收工单 2 · 评审放行与落账确认 · SS02 ACT-WORK-05 差异评估</title>
|
||||
<style>
|
||||
: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;
|
||||
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--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:#eef1f6;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;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
||||
.criteria li{margin:4px 0}
|
||||
.criteria b{color:var(--ink)}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
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)}
|
||||
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
|
||||
.cust{font-weight:700}
|
||||
.prio{font-family:var(--mono);font-weight:800}
|
||||
.prio.hi{color:var(--danger)}
|
||||
.prio.mid{color:var(--ink-2)}
|
||||
.reason{font-size:12.5px;color:var(--ink-2)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:推荐等级 pill 与状态强调(仅追加,不改动基件样式) */
|
||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);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:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
|
||||
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||
.risk{font-weight:800;font-size:12.5px}
|
||||
.risk.mid{color:#8f6a12}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.tbl-gap{margin-top:18px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<header class="card-head">
|
||||
<div class="badge">验收</div>
|
||||
<div class="tit">
|
||||
<div class="kick">SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2</div>
|
||||
<h1>评审放行与落账确认</h1>
|
||||
<div class="sub">收件人:<span data-slot="meta.task.owner">FAE · 李工</span> · 关联任务:<span data-slot="meta.task.id">TASK-BT02-20260818-007</span></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<span class="tag">验收工单</span>
|
||||
<span class="step" data-slot="meta.phase">步骤 5/5 · 提交 ACT-GATE-03</span>
|
||||
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card-bd">
|
||||
|
||||
<p class="zone"><b>【AI 结论说明】</b>—— 本单确认什么</p>
|
||||
<div class="note">
|
||||
<p><b>本活动以 ACT-GATE-03 评审放行为终点。</b>本单确认拟落账内容:评估结论层写 <b>P-11 替代关系主记录</b>(替代类型 substitution_type / 原内部料号 original_internal_pn / 候选内部料号 candidate_internal_pn / 推荐等级 recommendation_level / 推荐理由 / 差异摘要 / 替代分析报告 / 分析状态 analysis_status / 风险等级 / 验证要求 / 证据可信度 / 差异规则版本),需求状态回写 <b>P-01</b>(风险等级 / 验证要求 / 输出链接 / 下一步动作 / 当前阶段)。<b>客户批准不属于本活动</b>——客户批准状态 customer_approval_status 由审批流程维护,本单只写分析层(analysis_status=FAE确认),customer_approval_status 保持「未提交」,不由差异评估产生。</p>
|
||||
</div>
|
||||
|
||||
<p class="zone"><b>【验收判据】</b>—— 你据此判断「通过 / 驳回」</p>
|
||||
<div class="criteria">
|
||||
<div class="c-title">放行与落账前,请逐项核对</div>
|
||||
<ul>
|
||||
<li><b>推荐等级与差异明细一致</b>:差异明细中存在 UNKNOWN / CONDITIONAL 时,推荐等级 recommendation_level 不得给「推荐」;</li>
|
||||
<li><b>分析状态与客户批准状态分离</b>:本单只写分析层(analysis_status=FAE确认),customer_approval_status 保持「未提交」,不由差异评估产生;</li>
|
||||
<li><b>GOV 规则版本已锁定</b>:本次评估按 R-2026.07 执行,rule_version 随结论一并落账;</li>
|
||||
<li><b>回写 P-01 字段完整</b>:风险等级、验证要求、输出链接、下一步动作、当前阶段五项齐全。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="zone"><b>【拟写 P-11 · 替代关系主记录】</b>—— 只读展示</p>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
||||
<tbody data-rows="out.ledger_p11">
|
||||
<tr>
|
||||
<td class="num">1</td>
|
||||
<td data-field="field" class="cust">替代类型 substitution_type</td>
|
||||
<td data-field="value">Pin-to-Pin(BT-02 跨品牌)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">2</td>
|
||||
<td data-field="field" class="cust">原内部料号 original_internal_pn</td>
|
||||
<td data-field="value" class="mono">LM2596S-5.0(TI)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">3</td>
|
||||
<td data-field="field" class="cust">候选内部料号 candidate_internal_pn</td>
|
||||
<td data-field="value" class="mono">XL2596S-5.0(XLSEMI)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">4</td>
|
||||
<td data-field="field" class="cust">推荐等级 recommendation_level</td>
|
||||
<td data-field="value"><span class="st cond">条件推荐</span> <span class="reason">(枚举:推荐 / 条件推荐 / 备选 / 不推荐,四态之一)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">5</td>
|
||||
<td data-field="field" class="cust">推荐理由 recommendation_reason</td>
|
||||
<td data-field="value">温度范围候选更优,使能阈值待 VAL-2026-0188 闭环</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">6</td>
|
||||
<td data-field="field" class="cust">差异摘要 difference_summary</td>
|
||||
<td data-field="value">7 项比对:4 MATCH / 1 MISMATCH / 1 UNKNOWN / 1 CONDITIONAL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">7</td>
|
||||
<td data-field="field" class="cust">替代分析报告 analysis_report_ref</td>
|
||||
<td data-field="value" class="mono">RPT-2026-0188</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">8</td>
|
||||
<td data-field="field" class="cust">分析状态 analysis_status</td>
|
||||
<td data-field="value"><span class="st ok">FAE确认</span> <span class="reason">(枚举:AI草稿 / FAE确认 / 待客户验证 / 客户已批准 / 不批准 / 作废)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">9</td>
|
||||
<td data-field="field" class="cust">客户批准状态 customer_approval_status</td>
|
||||
<td data-field="value">未提交 <span class="reason">(由审批流程维护,本活动不产生)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">10</td>
|
||||
<td data-field="field" class="cust">风险等级 risk_level</td>
|
||||
<td data-field="value"><span class="risk mid">中</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">11</td>
|
||||
<td data-field="field" class="cust">验证要求 verification_requirement</td>
|
||||
<td data-field="value" class="mono">VAL-2026-0188(使能阈值实测)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">12</td>
|
||||
<td data-field="field" class="cust">证据可信度 confidence_score</td>
|
||||
<td data-field="value" class="mono">0.93</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">13</td>
|
||||
<td data-field="field" class="cust">差异规则版本 rule_version</td>
|
||||
<td data-field="value" class="mono">R-2026.07</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="zone tbl-gap"><b>【拟回写 P-01 · 需求状态】</b>—— 只读展示</p>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:40px">#</th><th style="width:180px" data-h="字段">字段</th><th data-h="拟写入值">拟写入值</th></tr></thead>
|
||||
<tbody data-rows="out.writeback_p01">
|
||||
<tr>
|
||||
<td class="num">1</td>
|
||||
<td data-field="field" class="cust">风险等级 risk_level</td>
|
||||
<td data-field="value"><span class="risk mid">中</span> <span class="reason">(2 项待闭环)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">2</td>
|
||||
<td data-field="field" class="cust">验证要求 verification_requirement</td>
|
||||
<td data-field="value" class="mono">VAL-2026-0188(使能阈值实测)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">3</td>
|
||||
<td data-field="field" class="cust">输出链接 output_ref</td>
|
||||
<td data-field="value" class="mono">RPT-2026-0188</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">4</td>
|
||||
<td data-field="field" class="cust">下一步动作 next_action</td>
|
||||
<td data-field="value">提交 ACT-GATE-03</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">5</td>
|
||||
<td data-field="field" class="cust">当前阶段 current_stage</td>
|
||||
<td data-field="value">提交 ACT-GATE-03</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flow"><b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。<b>通过后去向:</b>在对话区卡提交 ACT-GATE-03 评审放行;放行后结论供 ACT-WORK-08 证据归集消费。</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
<span>SS02 · ACT-WORK-05 差异评估 · 验收工单 2/2 · 评审放行与落账确认</span>
|
||||
<span style="font-family:'SF Mono',Menlo,Consolas,monospace">工单类型:验收 · <span data-slot="card.id">WO-ACT-WORK-05-03</span></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,632 @@
|
||||
# SS02—SS03—SS04 Demo台账字段细化与统一方案
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
本成果以《SS01-02-03-04-台账体系全景-18Activity关联矩阵》的台账编号为唯一编号标准,对六组Demo Activity进行字段级反推和补齐:[1] [2] [3] [4] [5] [6]。结论是:**当前全景体系的台账编号总体可承载Demo,但核心内容描述仍不足以直接支撑执行,必须补充字段级定义、条件必填规则和主记录/子表结构。**
|
||||
|
||||
本方案将Demo中的细分编号按全景标准归并,例如P-01-01→P-01、P-08-01→P-08、P-11-01/P-11-02→P-11的不同关系状态或子对象;不新增与全景体系冲突的台账编号。[1] [2] [3] 对于逐参数差异、逐报价阶梯和逐份单证等重复明细,采用子表承载,避免在单一文本字段中堆叠数据。
|
||||
|
||||
## 二、编号归并与建模原则
|
||||
|
||||
> **编号原则:**全景矩阵的44个台账编号是主键标准;Demo中的-01/-02属于局部架构或对象层标识,除非全景矩阵明确存在对应台账,否则不作为新的全景台账编号。
|
||||
|
||||
|Demo原编号/写法|统一全景编号|统一后的对象/说明|
|
||||
|---|---|---|
|
||||
|P-01-01|P-01|客户具体需求与项目任务主台账;任务、需求版本、报价上下文和Activity回链统一归入。|
|
||||
|P-06-01|P-06|样品交付与芯片验证记录台账;验证事实与样品追溯统一归入。|
|
||||
|P-08-01|P-08|芯片主数据台账;全链路只认internal_pn。|
|
||||
|P-09-02|P-09|客户技术与机会档案;技术档案字段统一挂客户ID。|
|
||||
|P-10-01/02/03|P-10|原厂技术资料库;资料类型由document_type区分。|
|
||||
|P-11-01/P-11-02|P-11|替代选型与批准关系库;analysis_status与customer_approval_status分离,差异明细设子表。|
|
||||
|P-04-01/P-04-02|P-04|交易框架与承诺台账;框架主记录与承诺子记录分层。|
|
||||
|D-01物流F组/交接E组|D-01|交易与执行台账;交接事实与物流节点作为不同对象层,但共享order_id。|
|
||||
|
||||
## 三、Activity覆盖校验
|
||||
|
||||
下表说明每个Demo的关键读写台账、字段补足点和覆盖结论。[1] [2] [3] [4] [5] [6]
|
||||
|
||||
|Activity|原矩阵台账编号|统一台账编号|主写入台账|关键新增/细化字段|覆盖结论|
|
||||
|---|---|---|---|---|---|
|
||||
|ACT-WORK-05 差异评估|P-01-01、P-06-01、P-08-01、P-09-02、P-10-01/02/03、P-11-01、P-11-02、GOV|P-01、P-06、P-08、P-09、P-10、P-11、GOV|P-01、P-11;条件写P-06|requirement_version、input_snapshot_ref、bom_position、normalized_parameter_set、difference_item_id、difference_status、impact_statement、constraint_type、rule_version、confidence_score|已覆盖;差异明细由P-11差异明细子表承载,客户批准状态与差异分析状态分离。|
|
||||
|ACT-WORK-24 报价方案制定|P-01-01、P-02-01、P-03-01、P-04-01/02、P-06-01、P-08-01、P-11-02、GOV|P-01、P-02、P-03、P-04、P-06、P-08、P-11、GOV|P-02;回写P-01|quote_version、quote_type、quote_valid_until、delivery_commitment、payment_terms、tax_terms、incoterms、exchange_rate、gross_margin_status、rule_version、QuoteLine、blocking_reason、substitution_approval_status|已覆盖;P-02采用主记录+QuoteLine明细结构,正式发布与AI草稿以状态和版本区分。|
|
||||
|SS03 Activity 1 发起询价|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 RFQ层;条件写S-03|source_type/source_id、rfq_batch_id、rfq_line_id、quote_due_at、outbound_content_ref、sent_evidence_ref、reminder_count、last_reminded_at、overdue_flag、temporary_supplier_flag|已覆盖;最小粒度固定为询价批次×供应商×内部料号。|
|
||||
|SS03 Activity 2 回填报价/比价/定点|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 Quote/Decision层|supplier_quote_id、quote_revision_no、comparability_status、comparison_basis_snapshot_ref、baseline_match_status、baseline_unit_price、comparison_currency_code、conversion_rate、price_variance_pct、selected_supplier_id、selected_quote_id、unselected_reason、notification_recipient_ref、score_rule_version|已覆盖;跨币种、无基准、不可比、涨幅>10%、代理证及临时供应商门禁均有字段承载。|
|
||||
|ACT-11-A2 提货交接与风险转移|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03;触发D-04相关门禁|allocation_ref、handover_no、receiver_name、sign_time、risk_transfer_time、handover_diff、handover_file_ref、handover_doc_status、handover_notes、document_set_complete、consistency_check_status、allocation_status、last_ship_date|已覆盖;交接事实、风险转移派生、文件状态、单证门禁和库存同步均已落台账。|
|
||||
|ACT-11-A3 物流执行信息跟踪|D-01、D-10|D-01、D-10|D-01物流节点事实层|logi_pickup/logi_clearance/logi_release/logi_departure(统一节点结构)、track_note、track_visible、logistics_confirmation_channel、logistics_confirmation_interval_days、logistics_max_reminder_count、alert_thresholds|已覆盖;每个节点包含预计时间、实际时间、来源、采集方式、确认状态、异常标记和证据引用。|
|
||||
|
||||
## 四、字段统一总表
|
||||
|
||||
### 4.1 通用治理字段
|
||||
|
||||
以下字段建议作为所有涉及Demo台账的通用治理字段继承,但不要求在每张业务台账中重复手工维护。`record_status`只管理记录生命周期;`*_status`管理具体业务过程;`evidence_ref`用于AI和人工结果的可审计回链。
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|维护/来源|Demo使用|定义/取值/校验规则|
|
||||
|---|---|---|---|---|---|---|
|
||||
|record_id|记录ID|Text/AutoNumber|是|系统|所有Demo|每条台账记录的唯一主键;业务编号不能替代数据库主键。|
|
||||
|record_status|记录状态|Enum|是|台账Owner|所有Demo|有效/草稿/归档/作废;不得用业务过程状态替代记录生命周期状态。|
|
||||
|record_version|记录版本|Text/Integer|是|系统/Owner|W05/W24/SS03/11-A2/11-A3|每次影响业务判断的修改递增;报价、价格、规则、客户映射必须可回溯。|
|
||||
|source_type|来源类型|Enum|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|来源对象的类型,如客户需求、采购计划、价格维护、异常补货、订单、外部回复。|
|
||||
|source_id|来源对象ID|Text/Link|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|与source_type成对出现;不得把说明文字写入ID字段。|
|
||||
|data_owner|数据Owner|Link|是|台账Owner|所有Demo|负责定义、质量和变更审批的角色或人员。|
|
||||
|evidence_ref|证据引用|URL/Attachment/Structure|条件必填|AI/PW/Owner|W05/W24/SS03/11-A2/11-A3|指向原始邮件、聊天记录、签署件、数据表、资料页码或其他证据;可多值。|
|
||||
|created_at|创建时间|DateTime|系统|系统|所有Demo|系统生成。|
|
||||
|updated_at|更新时间|DateTime|系统|系统|所有Demo|系统生成。|
|
||||
|last_verified_at|最后核验时间|DateTime|条件必填|Owner/PW/FAE/采购|所有Demo|对影响下游决策的事实标记最近一次人工或规则核验时间。|
|
||||
|
||||
### 4.2 按全景台账编号展开的字段
|
||||
|
||||
#### P-01 客户具体需求与项目任务主台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|request_id|需求编号|Text/AutoNumber|是|主记录|SS02|W05/W24/SS03-1/SS03-2/D-01|客户需求与项目任务主记录的统一业务键;SS03写入S-04.source_id。|重命名/统一|任务ID、request_id、需求编号|
|
||||
|customer_id|客户ID|Link|是|主记录|L-02/SS02|W05/W24/SS03-1/SS03-2|统一关联L-02;需求没有客户时需明确内部需求标识。|保留/细化|customer_id、客户|
|
||||
|project_id|项目ID|Link|条件必填|主记录|P-05/SS02|W05/W24/SS03-1/SS03-2|有项目场景必填;与客户ID共同确定需求归属和结果通知对象。|新增/细化|project_id、客户/项目|
|
||||
|requirement_version|需求版本|Text|是|主记录|SS02/系统|W05/W24|需求基线版本;差异评估与报价均按版本回溯。|新增/统一|任务描述/需求版本、版本|
|
||||
|request_type|需求类型|Enum|是|主记录|SS02/系统|W05/W24/SS03|客户需求/RFQ/询价/技术需求/价格维护/其他;采购来源可由source_type补充。|新增/统一|需求类型、RFQ类型|
|
||||
|request_description|需求描述|LongText|是|主记录|SS02/需求Owner|W05/W24|保留原始需求语义,不能只保留结构化字段。|新增/细化|任务描述|
|
||||
|input_snapshot_ref|输入快照|URL/Attachment/Structure|W05必填;W24条件|主记录|系统/AI|W05/W24|记录本次评估或报价运行时的输入快照,支持版本回溯。|新增|输入快照|
|
||||
|bom_id|BOM ID|Link|条件必填|主记录|SS02|W05/W24|有BOM场景必填;与bom_position一并确定物料位置。|新增|BOM范围|
|
||||
|bom_position|BOM位|Text|条件必填|主记录|SS02/FAE|W05/W24|按BOM位记录原物料、替代物料和报价明细;不同BOM位批准边界不得混用。|新增|BOM位、PN/BOM范围|
|
||||
|original_internal_pn|原内部料号|Link(P-08)|W05必填;其他条件|主记录|P-08/FAE|W05/W24|差异评估基准;引用P-08.internal_pn。|重命名/统一|原型号/原PN、原PN|
|
||||
|candidate_internal_pn|候选内部料号|Link(P-08)|W05条件;W24条件|主记录|P-08/FAE|W05/W24|候选替代物料;多个候选应进入P-11子记录而非逗号拼接。|重命名/统一|候选PN、Candidate_PN|
|
||||
|customer_pn|客户料号|Text|条件必填|主记录|L-02/D-10映射|W24/SS03/D-01|若客户使用客户料号,保留并与internal_pn分开。|新增/统一|customer_mpn、客户料号|
|
||||
|application_scenario|应用场景|LongText/Enum|W05必填;W24条件|主记录|FAE/客户|W05/W24|解释需求约束、验证条件与报价适用场景。|保留/细化|应用场景、适配场景|
|
||||
|target_qty|目标数量|Number/Structure|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的数量基准;阶梯放入数量阶梯子表。|重命名/统一|数量、target_qty、报价类型/数量|
|
||||
|target_delivery_date|目标交期|Date|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的日期基准。|重命名/统一|target_delivery_date、目标交期|
|
||||
|target_price_internal|内部目标价|Currency|否|主记录|SS02|W24/SS03|仅内部判断;默认不对外发送;币种必须同时记录。|保留/细化|target_price_internal、目标价|
|
||||
|target_price_currency_code|内部目标价币种|ISO 4217 Enum|target_price有值时必填|主记录|SS02|W24/SS03|与target_price_internal成对出现。|新增/统一|currency|
|
||||
|requested_price_types|所需价格类型|MultiEnum|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|参考价/量产价/现货价/样品价;上游默认值可补充,删除或替换需留痕。|重命名/统一|price_types_default、price_types_requested、报价类型|
|
||||
|quote_due_at|期望报价返回时间|DateTime|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|客户需求来源从P-01读取;内部或维护型询价由采购填写。|重命名/统一|quote_return_due_at、quote_due_at、截止时间|
|
||||
|technical_constraints|技术约束|Structure|W05/W24条件|主记录|FAE/AI|W05/W24|包含参数、单位、版本、硬约束/软约束,供差异评估和报价前提检查。|新增|参数边界、硬/软约束|
|
||||
|risk_level|风险等级|Enum|W05/W24条件|主记录|FAE/BDPM|W05/W24|低/中/高/关键;必须记录确认人和时间。|统一|FAE人工判断/风险等级、风险等级|
|
||||
|verification_requirement|验证/复测/确认要求|LongText/Structure|W05条件;W24条件|主记录|FAE|W05/W24|用于关闭UNKNOWN或CONDITIONAL,并可回链P-06验证记录。|重命名/统一|验证/复测/确认要求、验证要求|
|
||||
|latest_quote_id|最新报价ID|Link(P-02)|W24/SS03条件|主记录|系统|W24/SS03-2|聚合P-02最新有效报价,不直接复制价格。|新增|关联报价、最新报价状态|
|
||||
|latest_quote_status|最新报价状态|Lookup/Enum|W24/SS03条件|主记录|系统|W24/SS03-2|由P-02.quote_status/publish_status聚合。|重命名/统一|最新报价状态、报价状态|
|
||||
|request_status|需求状态|Enum|是|主记录|SS02|W05/W24/SS03|待处理/已响应/已成交/已放弃/关闭;不得与Activity运行状态混用。|重命名/统一|状态机、需求状态|
|
||||
|current_activity|当前Activity|Link/Enum|W05/W24条件|主记录|系统|W05/W24|如ACT-WORK-05、ACT-WORK-24;用于流程位置显示。|重命名/统一|当前Activity|
|
||||
|current_stage|当前阶段|Enum/Text|W05/W24条件|主记录|系统/Owner|W05/W24|评估、报价、审批、发布等阶段。|新增/统一|当前阶段|
|
||||
|next_action|下一步动作|LongText|W05/W24条件|主记录|FAE/BDPM/系统|W05/W24|明确责任人、动作和截止时间。|统一|下一步动作|
|
||||
|output_ref|标准输出链接|URL/Attachment|W05/W24条件|主记录|系统/FAE/BDPM|W05/W24|差异报告或报价文件的正式输出链接。|重命名/统一|标准输出类型/输出文件链接、输出链接|
|
||||
|
||||
#### P-02 报价与商务记录台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|quote_id|报价ID|Text/AutoNumber|是|报价主记录|系统|W24/SS03-2|报价主记录唯一标识。|保留/细化|报价ID|
|
||||
|request_id|关联需求编号|Link(P-01)|是|报价主记录|系统|W24/SS03-2|回链P-01.request_id。|重命名/统一|关联任务、关联需求号|
|
||||
|quote_version|报价版本|Text|是|报价主记录|系统/BDPM|W24|同一需求条件变化创建新版本;旧版本保留不可覆盖。|新增/统一|报价版本、版本|
|
||||
|quote_type|报价类型|Enum|是|报价主记录|BDPM|W24|正式报价/参考价/项目报价/其他;与price_type(明细价格类型)区分。|新增/统一|报价类型|
|
||||
|quote_status|报价状态|Enum|是|报价主记录|系统/BDPM|W24/SS03-2|草稿/待审核/已批准/已发布/已过期/已撤回。|重命名/统一|报价发布状态、报价状态|
|
||||
|quote_currency_code|报价币种|ISO 4217 Enum|是|报价主记录|系统/BDPM|W24|报价金额的统一币种;明细价格若币种不同必须换算并留痕。|重命名/统一|报价价格/币种、currency|
|
||||
|valid_from|报价生效时间|Date/DateTime|条件必填|报价主记录|BDPM|W24|正式发布报价必须有生效时间。|新增/统一|生效期|
|
||||
|quote_valid_until|报价有效期至|Date|条件必填|报价主记录|BDPM|W24|不得与P-03价格来源有效期混用。|重命名/统一|报价有效期、valid_until|
|
||||
|delivery_commitment|报价交期承诺|Date/Text/Structure|是|报价主记录|PW/BDPM|W24|记录交期数值、计算口径、来源和是否为承诺。|重命名/统一|报价交期、交期|
|
||||
|payment_terms|付款条件|Text/Structure|条件必填|报价主记录|BDPM/P-04|W24|来源于P-04或本次商务确认。|新增/统一|付款|
|
||||
|tax_terms|税费条件|Text/Structure|条件必填|报价主记录|BDPM|W24|税率、含税/未税口径必须明确。|新增|税费|
|
||||
|incoterms|贸易术语|Enum|条件必填|报价主记录|BDPM/P-04|W24|使用标准Incoterms枚举并记录版本/地点。|新增/统一|Incoterms、交货条款|
|
||||
|quote_assumptions|报价前提条件|LongText/Structure|是|报价主记录|BDPM|W24|含付款、税费、Incoterms、MOQ/SPQ、供货边界等。|重命名/细化|报价前提条件|
|
||||
|technical_boundary|技术边界|LongText/Structure|条件必填|报价主记录|FAE/BDPM|W24|记录替代、验证、规格限制与客户批准状态。|新增|技术边界|
|
||||
|cost_basis|成本基准|Currency/Structure|条件必填|报价主记录|BDPM/采购|W24|记录成本来源、币种、数量口径和时点。|新增|成本|
|
||||
|exchange_rate|报价汇率|Number|跨币种时必填|报价主记录|系统/BDPM|W24|统一比较币种时使用;必须关联exchange_rate_source与as_of。|新增|汇率|
|
||||
|exchange_rate_source|汇率来源|Text/URL|exchange_rate有值时必填|报价主记录|系统/BDPM|W24|记录汇率来源。|新增|汇率|
|
||||
|exchange_rate_as_of|汇率时点|Date|exchange_rate有值时必填|报价主记录|系统|W24|统一币种计算基准日。|新增|汇率|
|
||||
|gross_margin_status|毛利状态|Enum/Structure|条件必填|报价主记录|系统/BDPM|W24|未计算/通过/低于阈值/缺成本;口径由GOV规则维护。|新增|毛利状态|
|
||||
|rule_version|报价规则版本|Text/Link|是|报价主记录|GOV/系统|W24|记录计算、毛利、价格优先级使用的规则版本。|新增/统一|规则版本|
|
||||
|approval_status|商务审批状态|Enum|是|报价主记录|Gate/审批人|W24|未提交/待审批/已通过/已驳回;通过前不得发布。|重命名/统一|商务联审结论/审批记录|
|
||||
|approval_comment|商务审批意见|LongText|条件必填|报价主记录|审批人|W24|驳回或有条件通过时必须填写。|新增/统一|审批记录、审批意见|
|
||||
|publish_status|报价发布状态|Enum|是|报价主记录|系统/Gate|W24|草稿/待审批/已批准/已发布/过期/撤回。|重命名/统一|报价发布状态|
|
||||
|quote_document_ref|正式报价文件链接|URL/Attachment|已发布时必填|报价主记录|系统/BDPM|W24|只指向正式发布版本。|重命名/统一|报价文件、台账链接|
|
||||
|blocking_reason|阻断原因|LongText/Structure|条件必填|报价主记录|AI/BDPM|W24|价格过期、冲突、阶梯缺失、技术批准缺失等逐项记录。|新增|阻断原因|
|
||||
|quote_line_id|报价明细ID|Text/AutoNumber|是|报价明细QuoteLine|系统|W24|最小粒度为报价版本×PN×BOM位×数量阶梯。|新增|QuoteLine|
|
||||
|internal_pn|内部料号|Link(P-08)|是|报价明细QuoteLine|P-08/FAE|W24|报价物料统一引用P-08。|统一|PN|
|
||||
|bom_position|BOM位|Text|条件必填|报价明细QuoteLine|FAE|W24|同一报价中不同BOM位不能混淆。|统一|BOM位|
|
||||
|quantity_tier|数量阶梯|Structure|是|报价明细QuoteLine|AI/BDPM|W24|含min_qty、max_qty或阶梯描述。|拆分/结构化|数量阶梯|
|
||||
|price_type|价格类型|Enum|是|报价明细QuoteLine|BDPM|W24|参考价/量产价/现货价/样品价等。|新增/统一|price_type|
|
||||
|unit_price|报价单价|Currency|是|报价明细QuoteLine|系统/BDPM|W24|与currency_code成对存在。|重命名/统一|报价价格、单价|
|
||||
|currency_code|明细币种|ISO 4217 Enum|是|报价明细QuoteLine|系统/BDPM|W24|明细币种;若与主报价币种不同必须记录换算。|重命名/统一|币种|
|
||||
|lead_time_days|供货交期天数|Number|条件必填|报价明细QuoteLine|供应商/采购/BDPM|W24|统一为自然日或工作日一种口径,口径写入字段说明。|重命名/统一|LT、报价交期|
|
||||
|moq_qty|最小起订量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与前提条件校验。|拆分/统一|MOQ|
|
||||
|spq_qty|标准包装量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与包装约束校验。|拆分/统一|SPQ|
|
||||
|price_source_ref|价格来源引用|Link(P-03/S-05/S-04)|是|报价明细QuoteLine|系统|W24|指向Price List、采购基准或专项询价记录。|新增/统一|来源报价、价格来源|
|
||||
|substitution_approval_status|替代批准状态|Enum|条件必填|报价明细QuoteLine|P-11|W24|未检查/待验证/已批准/不批准;未满足时阻断发布。|新增|技术替代批准状态|
|
||||
|
||||
#### P-03 Price List台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|price_list_id|PriceList ID|Text/AutoNumber|是|价格记录|系统/BDPM|W24|价格来源唯一标识。|重命名/统一|PriceListID|
|
||||
|internal_pn|内部料号|Link(P-08)|是|价格记录|P-08/价格Owner|W24|统一引用P-08。|重命名/统一|PN/料号|
|
||||
|customer_id|适用客户ID|Link(L-02)|条件必填|价格记录|价格Owner|W24|客户专属价必填;公共渠道价可用scope_type标识。|新增|客户或渠道|
|
||||
|price_scope_type|价格适用范围|Enum|是|价格记录|BDPM|W24|客户专属/渠道/公共/项目。|新增|客户或渠道|
|
||||
|price_type|价格类型|Enum|是|价格记录|BDPM|W24|与报价所需价格类型对齐。|新增/统一|价格类型|
|
||||
|list_unit_price|Price List单价|Currency|是|价格记录|价格Owner|W24|与currency_code成对。|重命名/统一|客户专属价/单价|
|
||||
|currency_code|价格币种|ISO 4217 Enum|是|价格记录|价格Owner|W24|统一币种编码。|重命名/统一|币种|
|
||||
|release_version|Release版本|Text|是|价格记录|价格Owner|W24|报价时按as_of选取有效版本。|重命名/统一|Release版本、版本|
|
||||
|valid_from|生效时间|Date|是|价格记录|价格Owner|W24|与valid_to组成有效区间。|保留/细化|生效期|
|
||||
|valid_to|失效时间|Date|是|价格记录|价格Owner|W24|报价时点必须落在有效区间内。|重命名/统一|失效期、有效期|
|
||||
|quantity_tier|数量阶梯|Structure|条件必填|价格记录|价格Owner|W24|含适用数量和折扣条件。|拆分/结构化|数量阶梯/折扣条款|
|
||||
|discount_terms|折扣条款|LongText/Structure|条件必填|价格记录|价格Owner|W24|结构化记录折扣条件和例外。|细化|折扣条款|
|
||||
|source_quote_id|来源报价ID|Link(P-02/S-04)|条件必填|价格记录|系统/BDPM|W24|能追溯价格来源。|重命名/统一|来源报价|
|
||||
|price_status|价格状态|Enum|是|价格记录|价格Owner|W24|草稿/有效/暂停/过期/替代。|新增/统一|状态|
|
||||
|
||||
#### P-04 交易框架与承诺台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|framework_id|交易框架ID|Text/AutoNumber|是|框架主记录|商务Owner|W24|统一框架合同主键。|重命名/统一|框架ID|
|
||||
|customer_id|客户ID|Link(L-02)|是|框架主记录|商务Owner|W24|交易框架归属客户。|保留/统一|客户名称|
|
||||
|contract_version|合同版本|Text|是|框架主记录|商务Owner|W24|报价采用的合同/框架版本。|重命名/统一|合同版本|
|
||||
|contract_status|合同状态|Enum|是|框架主记录|商务Owner|W24|草稿/有效/到期/终止。|保留/细化|合同状态|
|
||||
|account_status|交易账户状态|Enum|是|框架主记录|商务Owner|W24|正常/冻结/待准入/关闭。|重命名/统一|交易账户状态|
|
||||
|payment_terms|账期与付款条款|Text/Structure|条件必填|框架主记录|商务Owner|W24|供报价继承;与D-04信用台账区分。|重命名/统一|账期条款、付款条件|
|
||||
|credit_limit|信用额度|Currency|条件必填|框架主记录|商务Owner|W24/11-A2条件|可供信用门禁使用。|保留/细化|信用额度|
|
||||
|cooperation_scope|合作范围|LongText/Structure|条件必填|框架主记录|商务Owner|W24|定义品牌、产品线、区域等边界。|保留/细化|合作范围|
|
||||
|incoterms|默认贸易术语|Enum|条件必填|框架主记录|商务Owner|W24|报价若覆盖本条件,回链本字段。|新增/统一|交易条款、Incoterms|
|
||||
|effective_from|框架生效时间|Date|是|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期|
|
||||
|effective_to|框架失效时间|Date|条件必填|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期|
|
||||
|commitment_id|承诺ID|Text/AutoNumber|条件必填|承诺子记录|系统/商务Owner|W24|每条价格、交期、数量或条件承诺一个ID。|重命名/统一|承诺ID|
|
||||
|commitment_party|承诺方|Enum/Link|是|承诺子记录|商务Owner|W24|我方/客户/供应商/其他。|统一|承诺方|
|
||||
|commitment_type|承诺类型|Enum|是|承诺子记录|商务Owner|W24|价格/交期/数量/质量/付款/其他。|新增/统一|承诺内容|
|
||||
|commitment_content|承诺内容|LongText/Structure|是|承诺子记录|商务Owner|W24|结构化值+说明,避免只写自由文本。|细化|承诺内容|
|
||||
|commitment_due_date|承诺期限|Date|条件必填|承诺子记录|商务Owner|W24|到期前可触发提醒。|重命名/统一|期限|
|
||||
|commitment_status|承诺状态|Enum|是|承诺子记录|商务Owner/系统|W24|未生效/生效/已兑现/逾期/取消。|重命名/统一|状态|
|
||||
|fulfillment_record_ref|兑现记录引用|Link/URL|条件必填|承诺子记录|系统/履约|W24|回链D-01/D-09等执行事实。|重命名/统一|兑现记录|
|
||||
|unresolved_risk_list|未决商务风险清单|LongText/Structure|条件必填|主记录|商务Owner|W24|报价前置风险;未关闭时可阻断审批。|重命名/细化|未决风险清单、商务风险|
|
||||
|
||||
#### P-06 样品交付与芯片验证记录台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|validation_id|验证ID|Text/AutoNumber|是|验证主记录|系统|W05/W24|验证记录唯一标识。|保留/统一|Validation_ID、验证编号|
|
||||
|request_id|关联需求编号|Link(P-01)|条件必填|验证主记录|系统/FAE|W05/W24|回链需求与验证目的。|新增/统一|需求号|
|
||||
|project_id|关联项目ID|Link(P-05)|条件必填|验证主记录|系统/FAE|W05/W24|项目场景验证必填。|新增/统一|项目|
|
||||
|customer_id|客户ID|Link(L-02)|条件必填|验证主记录|系统/FAE|W05/W24|客户验证必填。|新增/统一|客户ID|
|
||||
|validation_internal_pn|验证内部料号|Link(P-08)|是|验证主记录|FAE|W05/W24|统一引用P-08.internal_pn。|重命名/统一|Validation_PN、验证PN|
|
||||
|validation_type|验证类型|Enum|是|验证主记录|FAE|W05/W24|Pin-to-Pin/功能/可靠性/认证/复测。|新增/统一|验证类型|
|
||||
|test_condition|测试条件|LongText/Structure|是|验证主记录|FAE/验证Owner|W05|环境、输入、负载、样品状态等。|重命名/统一|测试条件|
|
||||
|test_parameters|测试参数|Structure|是|验证主记录|FAE/验证Owner|W05|参数名、值、单位、版本必须结构化。|重命名/统一|测试参数|
|
||||
|test_result|测试结果|LongText/Structure|是|验证主记录|验证Owner|W05|实测数据与结论,必要时附原始数据。|重命名/统一|测试结果|
|
||||
|validation_status|验证状态|Enum|是|验证主记录|FAE/验证Owner|W05/W24|未开始/进行中/通过/不通过/条件通过/待补证。|新增/统一|通过状态、验证状态|
|
||||
|issue_risk|问题与风险|LongText/Structure|条件必填|验证主记录|AI/FAE|W05/W24|记录未关闭问题、风险等级、责任人和关闭条件。|重命名/统一|问题与风险|
|
||||
|sample_version|样品版本|Text|条件必填|验证主记录|验证Owner|W05|样品追溯。|重命名/统一|样品版本|
|
||||
|sample_lot|样品批次|Text|条件必填|验证主记录|验证Owner|W05|与D-07或样品出库记录回链。|重命名/统一|样品批次、批次|
|
||||
|evidence_ref|验证证据引用|URL/Attachment/Structure|是|验证主记录|验证Owner/FAE|W05/W24|原始数据、照片、报告、资料页码等。|统一|证据引用|
|
||||
|next_validation_action|后续验证动作|LongText/Structure|条件必填|验证主记录|FAE|W05/W24|UNKNOWN/CONDITIONAL时必须有关闭动作。|新增|验证建议/缺口|
|
||||
|
||||
#### P-08 芯片主数据台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|internal_pn|内部料号|Text|是|物料主记录|SS02/FAE|W05/W24/SS03/11-A2|全链路料号统一基准;不可使用自由文本替代。|重命名/统一|标准型号PN、pn、mpn|
|
||||
|customer_pn|客户料号|Text|条件必填|物料主记录|SS02/D-10映射|W24/SS03/11-A2|客户料号映射优先维护在D-10,P-08可作为别名索引。|新增/统一|customer_mpn|
|
||||
|brand_name|品牌名称|Text/Link|是|物料主记录|FAE/数据Owner|W05/W24/SS03/11-A2|品牌统一引用;外部供应商品牌与原厂制造商分开。|重命名/统一|brand、manufacturer_brand、原厂或品牌|
|
||||
|manufacturer_name|制造商名称|Text/Link|条件必填|物料主记录|数据Owner|W05/W24|品牌与制造商不一致时分开记录。|新增/统一|原厂|
|
||||
|specification_description|规格描述|LongText|是|物料主记录|数据Owner/原厂资料|W05/W24|标准规格文本。|重命名/统一|Specification Description、产品描述|
|
||||
|normalized_parameter_set|标准化参数集|Structure|W05必填;W24条件|物料主记录|FAE/数据Owner|W05/W24|参数名、值、单位、精度、版本,用于硬/软约束比较。|新增|参数名称、单位、版本|
|
||||
|package|封装|Enum/Text|条件必填|物料主记录|数据Owner|W05/W24/SS03|影响硬约束和报价识别。|保留/细化|封装|
|
||||
|process_technology|工艺|Text/Enum|条件必填|物料主记录|数据Owner|W05|硬约束比较项。|重命名/统一|工艺|
|
||||
|lifecycle_status|生命周期状态|Enum|是|物料主记录|数据Owner|W05/W24/SS03|Active/NRND/EOL/停产/风险。|重命名/统一|生命周期状态、lifecycle_status、产品状态|
|
||||
|eol_pcn_status|EOL/PCN状态|Enum/Structure|条件必填|物料主记录|数据Owner/原厂|W05/W24|记录通知状态、影响和生效时间。|新增/统一|EoL/PCN状态|
|
||||
|datasheet_ref|Datasheet引用|URL/Link(P-10)|条件必填|物料主记录|P-10/原厂|W05/W24|官方资料链接。|重命名/统一|Datasheet链接|
|
||||
|datasheet_version|Datasheet版本|Text|datasheet_ref有值时必填|物料主记录|P-10/原厂|W05/W24|用于证据版本回链。|重命名/统一|Datasheet版本/版本|
|
||||
|application_scenarios|适配应用场景|MultiEnum/Structure|条件必填|物料主记录|FAE/数据Owner|W05/W24|判断物料实际适用性。|重命名/统一|应用场景、适配场景|
|
||||
|pin_to_pin_relation_ref|Pin-to-Pin关系引用|Link(P-11)|条件必填|物料主记录|FAE|W05/W24|引用P-11关系记录,避免在P-08复制批准结论。|新增/统一|Pin-to-Pin替代关系|
|
||||
|
||||
#### P-09 客户技术与机会档案
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|customer_id|客户ID|Link(L-02)|是|客户技术档案|SS02/FAE|W05/W24|区别于L-02的商务画像,技术档案以客户ID为主键。|保留/统一|客户 ID|
|
||||
|technical_stack|客户技术栈|Structure|条件必填|客户技术档案|FAE|W05/W24|记录平台、架构、关键技术约束。|重命名/统一|技术栈|
|
||||
|product_line_direction|产品线技术方向|Text/Structure|条件必填|客户技术档案|FAE/BDPM|W24|报价上下文与应用场景。|重命名/统一|产品线方向|
|
||||
|bom_brand_usage|BOM品牌使用情况|Structure|条件必填|客户技术档案|FAE|W05/W24|记录客户当前BOM品牌、料号及替代空间。|重命名/统一|BOM品牌|
|
||||
|validation_process|客户验证流程|LongText/Structure|条件必填|客户技术档案|FAE/客户|W05/W24|支持验证要求和报价前提判断。|重命名/统一|验证流程|
|
||||
|technical_decision_maker_ref|技术决策人引用|Link(L-08)|条件必填|客户技术档案|FAE|W05/W24|引用联系人,不复制姓名。|重命名/统一|技术决策人|
|
||||
|rd_capability_assessment|研发能力评估|Enum/LongText|条件必填|客户技术档案|FAE|W05/W24|用于技术支持与验证路径判断。|重命名/统一|研发能力|
|
||||
|technical_preference_tags|技术偏好标签|MultiEnum|条件必填|客户技术档案|FAE|W05/W24|用于推荐和方案匹配。|重命名/统一|技术偏好标签|
|
||||
|opportunity_id|机会ID|Text/AutoNumber|条件必填|机会子记录|系统/BDPM|W05/W24|需求或报价对应的机会对象。|新增/统一|机会记录|
|
||||
|
||||
#### P-10 原厂技术资料库
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|document_id|资料ID|Text/AutoNumber|是|资料主记录|系统|W05/W24|资料唯一标识。|新增/统一|资料记录|
|
||||
|document_type|资料类型|Enum|是|资料主记录|FAE|W05/W24|Datasheet/应用笔记/参考设计/白皮书/样片资料/其他。|新增/统一|资料类型|
|
||||
|internal_pn|关联内部料号|Link(P-08)|条件必填|资料主记录|P-08/FAE|W05/W24|料号相关资料必须回链P-08。|新增/统一|PN|
|
||||
|brand_name|品牌名称|Text/Link|是|资料主记录|数据Owner|W05/W24|统一引用P-08.brand_name。|重命名/统一|原厂|
|
||||
|document_title|资料标题|Text|是|资料主记录|资料Owner|W05/W24|资料可识别名称。|新增|资料|
|
||||
|document_version|资料版本|Text|是|资料主记录|资料Owner|W05/W24|评估必须回链版本。|重命名/统一|版本|
|
||||
|document_date|资料发布日期|Date|条件必填|资料主记录|资料Owner|W05/W24|用于时效判断。|新增|更新记录|
|
||||
|document_ref|资料文件链接|URL/Attachment|是|资料主记录|资料Owner|W05/W24|官方资料文件。|重命名/统一|Datasheet链接、资料|
|
||||
|citation_locator|证据定位|Text/Structure|W05必填;W24条件|资料主记录|AI/FAE|W05/W24|页码、章节、表格或段落,支持参数证据引用。|新增|证据引用、页码或证据段落|
|
||||
|source_contact_ref|原厂支持联系人|Link(L-08)/Text|条件必填|资料主记录|FAE|W05/W24|需要补证时的联系人。|重命名/统一|原厂支持联系人|
|
||||
|document_status|资料状态|Enum|是|资料主记录|资料Owner|W05/W24|有效/过期/待核实/作废。|新增/统一|状态|
|
||||
|
||||
#### P-11 替代选型与批准关系库
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|substitution_relation_id|替代关系ID|Text/AutoNumber|是|替代关系主记录|系统|W05/W24|原P-11-01与P-11-02统一后的关系主键。|新增/统一|P-11-01/P-11-02记录ID|
|
||||
|original_internal_pn|原内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|评估基准物料。|重命名/统一|Original_PN、原PN、目标替代料号|
|
||||
|candidate_internal_pn|候选内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|被评估物料。|重命名/统一|Candidate_PN、候选PN|
|
||||
|substitution_type|替代类型|Enum|是|替代关系主记录|FAE|W05/W24|Pin-to-Pin/功能替代/参数替代/其他。|重命名/统一|替代类型、关系类型|
|
||||
|recommendation_level|推荐等级|Enum|是|替代关系主记录|AI/FAE|W05/W24|推荐/条件推荐/备选/不推荐。|重命名/统一|替代等级、推荐|
|
||||
|recommendation_reason|推荐理由|LongText|是|替代关系主记录|AI/FAE|W05/W24|说明技术和商务依据。|新增/统一|推荐理由|
|
||||
|difference_summary|差异摘要|LongText|是|替代关系主记录|AI/FAE|W05|供P-01和后续流程消费。|新增/统一|评论、差异摘要|
|
||||
|difference_item_id|差异明细ID|Text/AutoNumber|是|差异明细子表|系统|W05|一条参数差异一条记录。|新增|逐参数差异明细|
|
||||
|parameter_name|标准参数名|Text/Link(GOV)|是|差异明细子表|AI/FAE|W05|参数名称标准化。|新增/统一|参数名称|
|
||||
|original_value|原物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数A|
|
||||
|candidate_value|候选物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数B|
|
||||
|parameter_unit|参数单位|Text/Link(GOV)|条件必填|差异明细子表|AI/FAE|W05|统一单位后比较。|新增/统一|单位|
|
||||
|constraint_type|约束类型|Enum|是|差异明细子表|GOV/AI|W05|硬约束/软约束。|新增/统一|硬/软约束标识|
|
||||
|difference_status|差异状态|Enum|是|差异明细子表|AI/FAE|W05|MATCH/MISMATCH/UNKNOWN/CONDITIONAL。|新增/统一|Status、差异状态|
|
||||
|impact_statement|影响说明|LongText|MISMATCH/CONDITIONAL必填|差异明细子表|AI/FAE|W05|说明对应用、验证、报价的影响。|新增|Impact、影响说明|
|
||||
|evidence_ref|差异证据引用|URL/Attachment/Structure|是|差异明细子表|AI/FAE|W05|具体资料、页码、测试记录。|统一|证据引用|
|
||||
|rule_version|差异规则版本|Text/Link(GOV)|是|差异明细子表|GOV/系统|W05|记录评估时采用的硬/软约束规则版本。|新增/统一|GOV规则版本|
|
||||
|risk_level|替代风险等级|Enum|是|替代关系主记录|FAE/RI|W05/W24|低/中/高/关键。|统一|风险等级|
|
||||
|verification_requirement|验证要求|LongText/Structure|CONDITIONAL/UNKNOWN必填|替代关系主记录|FAE|W05/W24|与P-06.validation_id或待验证任务回链。|统一|验证要求|
|
||||
|validation_id|关联验证ID|Link(P-06)|条件必填|替代关系主记录|系统/FAE|W05/W24|验证完成后回链事实。|新增/统一|验证编号、验证结果|
|
||||
|analysis_status|分析状态|Enum|是|替代关系主记录|系统/FAE|W05/W24|AI草稿/FAE确认/待客户验证/客户已批准/不批准/作废。|新增/统一|写入权限与时机、批准状态|
|
||||
|customer_approval_status|客户批准状态|Enum|是|替代关系主记录|审批流程|W05/W24|未提交/待批准/已批准/不批准/有条件批准;不由差异评估自动产生。|重命名/统一|批准状态、客户批准|
|
||||
|customer_approval_at|客户批准时间|DateTime|客户已批准时必填|替代关系主记录|审批流程|W05/W24|客户批准事实时间。|新增/统一|批准时间|
|
||||
|customer_approval_ref|客户批准凭证|URL/Attachment|客户已批准时必填|替代关系主记录|审批流程|W05/W24|签核邮件、系统审批单或客户文件。|新增/统一|批准凭证|
|
||||
|customer_feedback|客户反馈|LongText|条件必填|替代关系主记录|FAE/BDPM|W05/W24|记录客户对候选物料的反馈。|保留/细化|客户反馈|
|
||||
|request_id|关联需求编号|Link(P-01)|条件必填|替代关系主记录|系统|W05/W24|明确候选关系适用的需求。|新增/统一|关联需求|
|
||||
|project_id|关联项目ID|Link(P-05)|条件必填|替代关系主记录|系统|W05/W24|不同项目批准边界可不同。|新增/统一|关联项目|
|
||||
|bom_position|关联BOM位|Text|条件必填|替代关系主记录|FAE|W05/W24|批准边界定位。|新增/统一|BOM位|
|
||||
|analysis_report_ref|替代分析报告|URL/Attachment|条件必填|替代关系主记录|系统/FAE|W05|正式结构化结果或附件回链。|重命名/统一|替代分析报告、报告链接|
|
||||
|confidence_score|证据可信度|Percent/Enum|条件必填|替代关系主记录|AI/FAE|W05|AI输出可信度,需与人工确认区分。|新增/统一|证据可信度|
|
||||
|
||||
#### S-03 供应商主台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|supplier_id|供应商ID|Text/AutoNumber|是|供应商主记录|SS03|SS03-1/SS03-2|统一供应商主体键;临时供应商定点前必须转正式ID。|保留/统一|supplier_id、Supplier ID|
|
||||
|supplier_name|供应商名称|Text|是|供应商主记录|采购/SS03|SS03-1/SS03-2|临时供应商可先占位,定点前补齐正式名称。|保留/统一|supplier_name、供应商名称|
|
||||
|supplier_type|供应商分类|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|原厂/授权代理/贸易/分销/其他。|保留/细化|supplier_type、供应商分类|
|
||||
|supplier_status|供应商状态|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|临时/正式/停用;临时状态不可定点。|新增/统一|supplier_status|
|
||||
|temporary_supplier_flag|临时供应商标记|Boolean|是|供应商主记录|采购|SS03-1/SS03-2|临时新增立即可询价,但必须受定点门禁约束。|新增|临时供应商|
|
||||
|sales_contact_ref|业务联系人引用|Link/Text|SS03-1必填|供应商主记录|采购/供应商Owner|SS03-1|至少一名联系人,用于邮件或即时通讯外发。|重命名/统一|sales_contact、联系人|
|
||||
|contact_channel|首选联系渠道|Enum|条件必填|供应商主记录|供应商Owner|SS03-1|邮件/即时通讯/电话;需与外发记录一致。|新增/统一|send_channel、确认通道|
|
||||
|rfq_product_scope|当前询价品牌/料号范围|Structure|临时供应商定点前必填|供应商主记录|采购|SS03-1/SS03-2|允许不预建完整品牌覆盖,但必须覆盖当前询价。|新增/统一|rfq_product_scope|
|
||||
|authorized_brand_scope|授权品牌范围|Structure|代理/原厂条件必填|供应商主记录|SS03|SS03-1/SS03-2|判断品牌授权和可供范围。|新增/统一|品牌授权|
|
||||
|agency_certificate_ref|代理证凭证|URL/Attachment|supplier_type=授权代理时必填|供应商主记录|供应商Owner/采购|SS03-2|无有效代理证不得提交定点审批。|保留/细化|agency_certificate_ref、代理证|
|
||||
|agency_certificate_valid_to|代理证有效期至|Date|代理证有值时必填|供应商主记录|供应商Owner|SS03-2|定点前检查有效期。|新增|代理证|
|
||||
|qualification_status|准入资质状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|待准入/合格/限制/不合格。|新增/统一|资质/准入|
|
||||
|compliance_status|合规状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|通过/待核查/不通过。|新增/统一|合规|
|
||||
|commercial_terms_ref|长期商务规则引用|Link/Structure|条件必填|供应商主记录|SS03|SS03-2|长期条款不在本次询价Activity维护,但应有引用。|新增/统一|商务规则、长期条款|
|
||||
|supplier_health_score|供应商健康度|Percent/Enum|条件必填|供应商主记录|SS03/SS04|SS03-2|由履约数据计算,不作为单次询价事实覆盖。|新增/统一|供应商绩效/健康度|
|
||||
|
||||
#### S-04 询价、报价、比价与采购决策记录台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|rfq_batch_id|询价批次号|Text/AutoNumber|是|RFQ层|系统/采购|SS03-1/SS03-2|同一批次可含多料号、多供应商;品牌或料号变化必须新开批次。|保留/统一|rfq_batch_id、询价批次号|
|
||||
|rfq_line_id|询价行ID|Text/AutoNumber|是|RFQ层|系统|SS03-1/SS03-2|最小粒度为询价批次×供应商×内部料号。|保留/统一|rfq_line_id、询价行ID|
|
||||
|source_type|需求来源类型|Enum|是|RFQ层|采购/上游台账|SS03-1/SS03-2|P-01客户需求/D-15采购计划/价格维护/异常补货。|统一|source_type|
|
||||
|source_id|来源对象ID|Text/Link|条件必填|RFQ层|系统/上游台账|SS03-1/SS03-2|与source_type成对;P-01写request_id,D-15写purchase_plan_id。|统一|source_id|
|
||||
|customer_id|客户ID|Link(L-02)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|customer_id|
|
||||
|project_id|项目ID|Link(P-05)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|project_id|
|
||||
|brand_name|询价品牌|Text/Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|与internal_pn精确匹配;品牌变化新开批次。|重命名/统一|brand、manufacturer_brand|
|
||||
|internal_pn|询价内部料号|Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|统一引用P-08.internal_pn。|重命名/统一|pn、料号|
|
||||
|target_qty|目标数量|Number|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01.target_qty或D-15.trigger_qty。|统一|target_qty、purchase_trigger_qty|
|
||||
|target_delivery_date|目标交期|Date|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01或D-15.requirement_date。|统一|target_delivery_date、requirement_date|
|
||||
|requested_price_types|所需价格类型|MultiEnum|是|RFQ层|P-01/采购|SS03-1|参考价/量产价/现货价/样品价。|重命名/统一|price_types_requested|
|
||||
|quote_due_at|报价回复截止时间|DateTime|是|RFQ层|P-01/采购|SS03-1|供应商回复时效基准。|重命名/统一|quote_due_at、期望报价返回时间|
|
||||
|target_price_internal|内部目标价|Currency|否|RFQ层|P-01|SS03-1/SS03-2|仅内部判断;如实际披露需留痕。|统一|target_price_internal|
|
||||
|supplier_id|供应商ID|Link(S-03)|是|RFQ层|S-03/采购|SS03-1/SS03-2|临时供应商定点前转正式ID。|统一|supplier_id|
|
||||
|send_channel|询价发送渠道|Enum|是|RFQ层|采购/系统|SS03-1|邮件/即时通讯/电话。|统一|send_channel|
|
||||
|sent_at|询价发送时间|DateTime|是|RFQ层|系统|SS03-1|系统生成;未发送不得标记已发送。|保留/细化|sent_at|
|
||||
|outbound_content_ref|外发内容引用|URL/Attachment|是|RFQ层|系统/采购|SS03-1|记录对外披露内容和附件。|新增/统一|对外披露内容、附件|
|
||||
|sent_evidence_ref|外发凭证|URL/Attachment|是|RFQ层|系统|SS03-1|原始邮件、聊天发送记录等。|新增/统一|发送凭证、evidence|
|
||||
|rfq_status|询价状态|Enum|是|RFQ层|系统/采购|SS03-1|草稿/已发送/部分回复/全部回复/已到期未回复/催询中/关闭。|保留/细化|rfq_status|
|
||||
|reminder_count|催询次数|Integer|是|RFQ层|系统|SS03-1|自动提醒与人工催询均计数。|新增|跟进回复时效|
|
||||
|last_reminded_at|最后催询时间|DateTime|条件必填|RFQ层|系统/采购|SS03-1|有催询时必填。|新增|催询|
|
||||
|overdue_flag|询价超期标记|Boolean|是|RFQ层|系统|SS03-1|quote_due_at已过且未完成回复或未发出时触发。|新增|已到期未回复、发起超期|
|
||||
|supplier_quote_id|供应商报价ID|Text/AutoNumber|是|Quote层|系统|SS03-2|同一询价行可有报价修订历史。|新增/统一|报价记录ID|
|
||||
|quote_status|供应商报价状态|Enum|是|Quote层|采购|SS03-2|已报价/拒绝报价/未回复/待确认。|保留/细化|quote_status|
|
||||
|price_type|报价价格类型|Enum|条件必填|Quote层|采购|SS03-2|必须与requested_price_types匹配。|保留/统一|price_type|
|
||||
|unit_price|报价单价|Currency|已报价时必填|Quote层|采购|SS03-2|与currency_code成对。|重命名/统一|unit_price、价格|
|
||||
|currency_code|报价币种|ISO 4217 Enum|已报价时必填|Quote层|采购|SS03-2|统一币种比较前保留原币种。|重命名/统一|currency|
|
||||
|actual_qty|报价适用数量|Number|已报价时条件必填|Quote层|采购|SS03-2|判断与target_qty可比性。|重命名/统一|actual_qty|
|
||||
|lead_time_days|报价交期天数|Number|已报价时条件必填|Quote层|采购|SS03-2|统一工作日/自然日口径;原始文字可放lead_time_note。|重命名/统一|lead_time、LT|
|
||||
|lead_time_note|交期说明|LongText|条件必填|Quote层|采购|SS03-2|无法标准化时保留供应商原文。|新增|lead_time|
|
||||
|moq_qty|MOQ|Number|条件必填|Quote层|采购|SS03-2|最小起订量。|拆分/统一|MOQ-SPQ、MOQ|
|
||||
|spq_qty|SPQ|Number|条件必填|Quote层|采购|SS03-2|标准包装量。|拆分/统一|MOQ-SPQ、SPQ|
|
||||
|quote_valid_until|报价有效期至|Date|条件必填|Quote层|采购|SS03-2|报价是否仍可用。|重命名/统一|valid_until、报价有效期|
|
||||
|special_terms|本次特殊商务条款|LongText/Structure|否|Quote层|采购|SS03-2|一次性条款;长期供应商条款回链S-03。|重命名/细化|special_terms、特殊条款|
|
||||
|response_at|实际回复时间|DateTime|已回复时必填|Quote层|系统/采购|SS03-2|回复时间事实。|保留/细化|response_at|
|
||||
|no_quote_reason|未报价原因|LongText|拒绝/未回复时必填|Quote层|采购|SS03-2|拒绝报价或未回复必须填写。|保留/细化|no_quote_reason|
|
||||
|quote_evidence_ref|原始报价凭证|URL/Attachment|报价/拒绝均必填|Quote层|系统/采购|SS03-2|邮件、聊天截图、供应商报价单。|重命名/统一|evidence、原始沟通凭证|
|
||||
|quote_revision_no|同轮报价修订号|Integer|是|Quote层|系统/采购|SS03-2|同轮修改保留修订历史,当前报价可聚合展示。|新增|报价与版本|
|
||||
|comparison_group_id|比价组ID|Text/AutoNumber|是|Decision层|系统|SS03-2|由rfq_batch_id+internal_pn+target_qty+target_delivery_date组成。|重命名/统一|comparison_group|
|
||||
|comparability_status|可比性状态|Enum|是|Decision层|AI/采购|SS03-2|可直接比较/条件可比/不可比;不可比必须说明原因。|新增|正式比价组、不可直接比较|
|
||||
|comparison_basis_snapshot_ref|比价基准快照|Structure/URL|是|Decision层|系统|SS03-2|固化目标数量、目标交期、价格类型、汇率和基准版本。|新增|比价组|
|
||||
|system_score|系统评分|Number|是|Decision层|系统|SS03-2|按询价类型权重计算,0-100。|重命名/统一|score|
|
||||
|system_recommendation|系统推荐|Enum|是|Decision层|系统|SS03-2|推荐/备选/不推荐。|重命名/统一|recommendation|
|
||||
|procurement_confirm|采购确认意见|Enum|意见确认时必填|Decision层|采购|SS03-2|认可/调整/否决。|保留/细化|procurement_confirm|
|
||||
|confirm_reason|确认或调整理由|LongText|与系统推荐不一致时必填|Decision层|采购|SS03-2|采购调整或否决系统建议时必填。|重命名/统一|confirm_reason|
|
||||
|baseline_id|采购基准ID|Link(S-05)|有基准时必填|Decision层|系统|SS03-2|与品牌、料号、价格类型、数量和有效期匹配。|新增/统一|baseline_id|
|
||||
|baseline_match_status|基准匹配状态|Enum|是|Decision层|系统|SS03-2|已匹配/无基准/条件匹配/不匹配。|新增|无基准可比、基准比较|
|
||||
|baseline_unit_price|比较基准单价|Currency|有基准时必填|Decision层|系统|SS03-2|复制当次采用的S-05基准快照,避免历史重算漂移。|新增/统一|benchmark_unit_price|
|
||||
|comparison_currency_code|比价统一币种|ISO 4217 Enum|跨币种时必填|Decision层|系统|SS03-2|统一币种并回链汇率。|新增|汇率/统一币种|
|
||||
|conversion_rate|币种换算汇率|Number|跨币种时必填|Decision层|系统|SS03-2|记录换算基准和来源。|新增|汇率|
|
||||
|price_variance_pct|相对基准涨幅|Percent|有基准时派生|Decision层|系统|SS03-2|(报价-基准)/基准;涨幅>10%标红。|重命名/统一|price_variance_pct、涨幅|
|
||||
|variance_reason|涨价原因|LongText|涨幅>10%时必填|Decision层|采购|SS03-2|说明供应商涨价、市场、交期或其他原因。|保留/细化|variance_reason、涨价原因|
|
||||
|selected_supplier_id|中选供应商ID|Link(S-03)|定点通过时必填|Decision层|系统/采购|SS03-2|审批通过的定点对象。|新增/统一|推荐供应商、中选供应商|
|
||||
|selected_quote_id|中选报价ID|Link(S-04 Quote)|定点通过时必填|Decision层|系统|SS03-2|关联被选中的供应商报价。|新增/统一|定点结论|
|
||||
|approval_status|定点审批状态|Enum|是|Decision层|审批流|SS03-2|待审批/已通过/已驳回。|重命名/统一|approval_status|
|
||||
|approval_comment|定点审批意见|LongText|驳回/条件通过时必填|Decision层|审批人|SS03-2|审批意见。|重命名/统一|approval_comment|
|
||||
|decision_status|定点结论状态|Enum|是|Decision层|系统/采购|SS03-2|待定点/已定点/无法定点/建议关闭/已变更。|重命名/统一|decision_status、定点结论|
|
||||
|unselected_reason|未选/无法定点原因|LongText|无法定点或未选时必填|Decision层|采购|SS03-2|完整说明比较、准入或供应条件原因。|新增/统一|未选原因、无法定点原因|
|
||||
|notification_status|结果通知状态|Enum|是|Decision层|系统|SS03-2|待通知/已通知/通知失败。|重命名/统一|notification_status|
|
||||
|notification_recipient_ref|结果通知对象|Link/Text|定点结论产生时必填|Decision层|系统|SS03-2|按source_type通知P-01负责人、D-15负责人或其他发起人。|新增/统一|通知需求发起人|
|
||||
|score_rule_version|评分规则版本|Text/Link(GOV)|是|Decision层|GOV/系统|SS03-2|记录不同询价类型的权重版本。|新增/统一|规则版本|
|
||||
|
||||
#### S-05 采购基准与供应能力台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|baseline_id|采购基准ID|Text/AutoNumber|是|基准主记录|系统|SS03-1/SS03-2|基准唯一标识。|保留/统一|baseline_id|
|
||||
|brand_name|基准品牌|Text/Link(P-08)|是|基准主记录|采购/SS03|SS03-1/SS03-2|与internal_pn精确匹配。|重命名/统一|brand|
|
||||
|internal_pn|基准内部料号|Link(P-08)|是|基准主记录|P-08/采购|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn|
|
||||
|supplier_id|基准供应商ID|Link(S-03)|是|基准主记录|S-03/采购|SS03-1/SS03-2|当前采购基准的供应商。|统一|supplier_id|
|
||||
|price_type|基准价格类型|Enum|是|基准主记录|采购|SS03-2|必须与本次报价价格类型一致后比较。|统一|price_type|
|
||||
|baseline_unit_price|采购基准单价|Currency|是|基准主记录|采购|SS03-2|历史/当前有效采购基准,不被单次询价直接覆盖。|重命名/统一|benchmark_unit_price|
|
||||
|currency_code|基准币种|ISO 4217 Enum|是|基准主记录|采购|SS03-2|与单价成对。|重命名/统一|currency|
|
||||
|quantity_tier|适用数量阶梯|Structure|是|基准主记录|采购|SS03-2|匹配目标数量。|拆分/结构化|applicable_qty、数量阶梯|
|
||||
|valid_from|基准生效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_from|
|
||||
|valid_to|基准失效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_to|
|
||||
|lead_time_days|基准交期天数|Number|条件必填|基准主记录|采购|SS03-1/SS03-2|供应能力比较。|重命名/统一|lead_time、LT|
|
||||
|moq_qty|基准MOQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|MOQ|
|
||||
|spq_qty|基准SPQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|SPQ|
|
||||
|availability_status|可供状态|Enum|是|基准主记录|采购|SS03-1/SS03-2|可供/部分可供/不可供/待确认。|重命名/统一|availability_status、可供状态|
|
||||
|supply_risk_level|供应风险等级|Enum|条件必填|基准主记录|采购|SS03-2|低/中/高/关键。|新增/统一|供应风险|
|
||||
|baseline_status|基准状态|Enum|是|基准主记录|采购|SS03-2|Active/暂停/过期/替代。|保留/细化|baseline_status|
|
||||
|source_quote_ref|基准来源报价引用|Link(S-04/P-02)|条件必填|基准主记录|系统/采购|SS03-2|记录基准形成依据。|重命名/统一|来源报价|
|
||||
|locked_at|基准锁定时间|DateTime|条件必填|基准主记录|采购|SS03-2|锁定后单次询价不得直接覆盖。|新增|报价锁定基准|
|
||||
|
||||
#### D-15 采购计划与执行需求台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|purchase_plan_id|采购计划ID|Text/AutoNumber|是|采购计划主记录|SS04|SS03-1/SS03-2|内部采购需求唯一引用;写入S-04.source_id。|保留/统一|purchase_plan_id|
|
||||
|customer_id|客户ID|Link(L-02)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户需求型采购必填。|统一|customer_id|
|
||||
|project_id|项目ID|Link(P-05)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|项目型采购必填。|统一|project_id|
|
||||
|internal_pn|内部料号|Link(P-08)|是|采购计划主记录|P-08/SS04|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn|
|
||||
|forecast_version|Forecast版本|Text/Link(D-02)|条件必填|采购计划主记录|D-02/SS04|SS03-1/SS03-2|Forecast来源时必填。|统一|Forecast版本、版本号|
|
||||
|customer_pickup_demand|客户提货需求|Number/Date/Structure|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户提货需求的数量和时间。|重命名/统一|提货需求|
|
||||
|requirement_date|内部需求日期|Date|是|采购计划主记录|SS04|SS03-1/SS03-2|作为询价目标交期或内部需求时点。|重命名/统一|requirement_date、需求日期|
|
||||
|inventory_ref|库存引用|Link(D-03)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|库存引用|
|
||||
|in_transit_ref|在途引用|Link(D-03/D-16)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|在途引用|
|
||||
|recommended_purchase_qty|建议采购量|Number|是|采购计划主记录|系统/采购|SS03-1/SS03-2|形成询价目标数量的依据。|新增/统一|建议采购量|
|
||||
|trigger_qty|采购触发数量|Number|是|采购计划主记录|SS04|SS03-1/SS03-2|进入询价的正式数量输入。|重命名/统一|purchase_trigger_qty、触发数量|
|
||||
|purchase_type|采购类型|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|量产/样品/现货/异常补货。|保留/细化|purchase_type|
|
||||
|urgency_flag|紧急程度|Enum/Boolean|是|采购计划主记录|SS04|SS03-1/SS03-2|普通/紧急/特急;影响评分权重。|保留/细化|urgency_flag|
|
||||
|plan_status|采购计划状态|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|有效/待执行/询价中/已满足/关闭。|保留/细化|plan_status|
|
||||
|triggered_at|触发询价时间|DateTime|条件必填|采购计划主记录|系统|SS03-1|记录进入询价流程的时间。|新增/统一|触发时间|
|
||||
|po_ref|关联采购订单引用|Link(D-16)|条件必填|采购计划主记录|系统|SS03-2|定点/执行后回链D-16。|重命名/统一|关联后续PO|
|
||||
|
||||
#### L-02 客户主台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|customer_id|客户ID|Text/AutoNumber|是|客户主记录|SS01|W24/SS03/11-A2|所有客户侧台账统一锚点。|保留/统一|唯一ID、customer_id|
|
||||
|customer_name|客户名称|Text|是|客户主记录|SS01|W05/W24/11-A2|展示名称;法律主体与展示名称如不同需分开。|新增/统一|客户名称、客户|
|
||||
|legal_entity_name|客户法律主体|Text|条件必填|客户主记录|SS01|11-A2条件|单证、合同或制裁筛查需要时必填。|新增|主体信息|
|
||||
|customer_status|客户状态|Enum|是|客户主记录|SS01|W24/11-A2|有效/暂停/终止。|新增/统一|状态机|
|
||||
|default_contact_ref|默认联系人引用|Link(L-08)|条件必填|客户主记录|SS01|11-A2|与D-10提货联系人区分;D-10可覆盖为履约联系人。|新增/统一|联系人|
|
||||
|
||||
#### D-01 交易与执行台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|order_id|订单号|Text/AutoNumber|是|订单主记录|SS04|11-A2/11-A3|订单执行主键。|保留/统一|order_id、订单号|
|
||||
|request_id|需求编号|Link(P-01)|条件必填|订单主记录|系统|11-A2|回链P-01需求。|统一|需求编号|
|
||||
|customer_id|客户ID|Link(L-02)|是|订单主记录|系统|11-A2/11-A3|客户名称通过L-02展示。|统一|customer_id|
|
||||
|project_id|项目ID|Link(P-05)|条件必填|订单主记录|系统|11-A2|项目追溯。|统一|项目|
|
||||
|internal_pn|内部料号|Link(P-08)|是|订单主记录|P-08/SS04|11-A2|内部料号统一引用P-08。|重命名/统一|mpn、料号|
|
||||
|customer_pn|客户料号|Text/Link(D-10)|条件必填|订单主记录|D-10/SS04|11-A2|通过D-10映射获得。|重命名/统一|customer_mpn|
|
||||
|order_qty|订单数量|Number|是|订单主记录|订单/系统|11-A2|交接实发数量核对基准。|保留/统一|order_qty|
|
||||
|allocation_ref|分配批次引用|Link(D-03)|条件必填|订单主记录|D-03/SS04|11-A2|指向D-03批次分配记录。|新增/统一|inventory_lot|
|
||||
|promised_delivery_date|承诺交期|Date|条件必填|订单主记录|P-04/SS04|11-A2|订单承诺交期。|重命名/统一|承诺交期|
|
||||
|trade_term|贸易术语|Enum|条件必填|订单主记录|D-10/P-04|11-A2|EXW等;风险转移逻辑依赖。|新增/统一|BT类型、贸易术语|
|
||||
|milestones|履约里程碑|Structure|是|订单主记录|系统/SS04|11-A2/11-A3|至少含交接;每个节点有状态、时间、来源、证据。|细化|milestones、里程碑|
|
||||
|pickup_status|提货状态|Enum|是|提货交接事实层|PW/系统|11-A2|待提货/已预约/已提货/待重新预约/取消。|保留/细化|pickup_status|
|
||||
|ship_date|发货日期|Date|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;由PW唯一录入。|保留/细化|ship_date|
|
||||
|shipped_qty|实发数量|Number|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;与order_qty及D-03同步校验。|保留/细化|shipped_qty|
|
||||
|handover_no|交接单号|Text|交接完成时必填|提货交接事实层|系统|11-A2|格式HD-YYYYMMDD-NNN。|保留/细化|handover_no|
|
||||
|receiver_name|签收人姓名|Text|交接完成时必填|提货交接事实层|PW|11-A2|双方签署事实。|重命名/统一|receiver、签收人姓名|
|
||||
|sign_time|交接签署时间|DateTime|交接完成时必填|提货交接事实层|PW|11-A2|EXW风险转移时点的事实基础。|保留/细化|sign_time、签署时间|
|
||||
|risk_transfer_time|风险转移时点|DateTime|签署后派生|提货交接事实层|系统/AI/PW确认|11-A2/11-A3|EXW场景默认等于sign_time;作为开票和后续追踪锚点。|保留/细化|risk_transfer_time|
|
||||
|handover_diff|交接差异|Enum/Structure|条件必填|提货交接事实层|PW/RI|11-A2|无差异/数量差异/型号差异/批次差异/包装差异/其他;关联差异单。|保留/细化|handover_diff|
|
||||
|handover_file_ref|交接单影像/文件|URL/Attachment|交接完成时必填|提货交接事实层|PW|11-A2|必须区分草稿、已签署、已归档。|重命名/统一|handover_file|
|
||||
|handover_doc_status|交接单文件状态|Enum|有handover_file_ref时必填|提货交接事实层|PW/系统|11-A2|草稿/已签署/已归档/作废;下游只消费已签署或已归档。|新增/统一|文档状态|
|
||||
|handover_notes|交接注意事项|LongText|条件必填|提货交接事实层|D-10/PW|11-A2|模板或现场特殊注意事项。|新增|交接注意事项|
|
||||
|logi_pickup|提货节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|保留/结构化|logi_pickup|
|
||||
|logi_clearance|报关节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如报关受阻、查验。|保留/结构化|logi_clearance|
|
||||
|logi_release|放行节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上。|保留/结构化|logi_release|
|
||||
|logi_departure|离港节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如离港延误。|保留/结构化|logi_departure|
|
||||
|track_note|物流跟踪/决策备注|LongText|条件必填|物流节点事实层|PW/RI|11-A3|货代不配合时记录降级为到货确认或销售协调等决策。|重命名/统一|track_note、决策记录|
|
||||
|track_visible|物流信息可查状态|Boolean|四节点完成后必填|物流节点事实层|系统|11-A3|四节点实际事实齐备后为True;不是人工主观勾选。|保留/细化|track_visible|
|
||||
|invoice_status|开票状态|Enum|是|订单状态层|系统/财务|11-A2|待开票/已触发/已开票/异常;risk_transfer_time为触发锚点。|新增/统一|开票状态|
|
||||
|acceptance_status|验收状态|Enum|是|订单状态层|履约Owner|11-A2|待验收/已确认/争议/不适用。|新增/统一|验收状态|
|
||||
|order_status|订单状态|Enum|是|订单状态层|系统/SS04|11-A2/11-A3|已放行/已交接/物流跟踪中/已完成/异常/关闭。|重命名/统一|订单状态机|
|
||||
|closed_flag|关闭标记|Boolean|是|订单状态层|系统/SS04|11-A2/11-A3|所有必需节点或异常处置完结后关闭。|新增/统一|关闭状态|
|
||||
|close_reason|关闭原因|LongText|关闭时必填|订单状态层|SS04|11-A2/11-A3|正常完成/取消/转异常流程等。|新增|关闭状态|
|
||||
|pickup_appointment_time|预约提货时间|DateTime|分支2场景必填|提货交接事实层|D-10/PW|11-A2|用于预约提醒、未按预约提货判断及重新预约。|新增/统一|提货预约时间|
|
||||
|physical_check_status|实物核对状态|Enum|交接前必填|提货交接事实层|PW|11-A2|未核对/已核对/不一致;核对数量、型号、批次标签和包装。|新增|实物一致性核对|
|
||||
|diff_handling_result|差异处理结果|Enum/Structure|有差异时必填|提货交接事实层|RI/PW|11-A2|补齐后交接/按实发量交接并标注/暂停并转后续Activity。|新增/统一|差异处理结果|
|
||||
|ri_gate_decision|RI门禁判断|Enum/LongText|差异或超时升级时必填|提货交接事实层|RI|11-A2/11-A3|放行/补齐后放行/暂停/转异常/降级追踪/联系客户核实。|新增/统一|RI门禁判断、决策记录|
|
||||
|ri_escalated_at|RI升级时间|DateTime|发生升级时必填|提货交接事实层|系统|11-A2/11-A3|差异、预约超时或节点反馈超时的升级事实。|新增|升级时限|
|
||||
|node_type|物流节点类型|Enum|是|物流节点子表(适用于logi_*)|系统|11-A3|提货/报关/放行/离港;对应logi_*字段。|新增/统一|四个关键节点|
|
||||
|estimated_time|节点预计时间|DateTime|是|物流节点子表(适用于logi_*)|系统/AI|11-A3|到达预计时间后触发确认请求。|新增/统一|节点预计时间|
|
||||
|actual_time|节点实际时间|DateTime|收到事实时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|收到反馈或人工电话确认的实际时间。|新增/统一|时间|
|
||||
|source|节点事实来源|Text/Link|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复单位、系统或具体联系人;不得只写“外部”。|重命名/统一|来源、Source|
|
||||
|collection_method|节点采集方式|Enum|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|邮件/微信/即时通讯/电话/人工录入。|重命名/统一|采集方式、Method|
|
||||
|confirmation_status|节点确认状态|Enum|是|物流节点子表(适用于logi_*)|系统/PW|11-A3|待确认/已确认/超时/异常/已升级。|新增/统一|确认状态|
|
||||
|anomaly_flag|节点异常标记|Boolean|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻、查验、离港延误等为True。|新增/统一|Anomaly Flag、异常节点标记|
|
||||
|anomaly_type|节点异常类型|Enum/Text|anomaly_flag=True时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻/查验/离港延误/其他。|新增/统一|异常节点|
|
||||
|node_evidence_ref|节点事实证据|URL/Attachment|已确认或异常时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复原文、电话记录或其他节点证据。|新增/统一|证据引用|
|
||||
|reminder_count|节点提醒次数|Integer|是|物流节点子表(适用于logi_*)|系统|11-A3|按D-10最大提醒次数控制。|新增/统一|最大提醒次数|
|
||||
|last_request_at|最后请求时间|DateTime|有请求时必填|物流节点子表(适用于logi_*)|系统|11-A3|支持间隔天数和超时判断。|新增|确认请求|
|
||||
|node_ri_escalation_status|节点RI升级状态|Enum|超时/异常时必填|物流节点子表(适用于logi_*)|系统/RI|11-A3|未升级/已升级/已核实/已降级。|新增/统一|升级RI门禁|
|
||||
|
||||
#### D-04 回款与信用台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|credit_record_id|信用记录ID|Text/AutoNumber|是|信用门禁主记录|系统|11-A2|回款信用门禁记录主键。|新增/统一|信用记录|
|
||||
|customer_id|客户ID|Link(L-02)|是|信用门禁主记录|SS04|11-A2|关联客户信用主体。|统一|客户ID|
|
||||
|payment_terms|账期条款|Text/Structure|是|信用门禁主记录|P-04/SS04|11-A2|从交易框架读取,作为信用判断依据。|重命名/统一|账期条款|
|
||||
|credit_limit|信用额度|Currency|条件必填|信用门禁主记录|商务/财务|11-A2|与currency_code成对。|统一|信用额度|
|
||||
|outstanding_amount|应收金额|Currency|是|信用门禁主记录|财务|11-A2|当前应收,需记录币种。|新增/统一|应收|
|
||||
|paid_amount|已回款金额|Currency|是|信用门禁主记录|财务|11-A2|当前已回款,需记录币种。|新增/统一|已回款|
|
||||
|overdue_days|逾期天数|Integer|是|信用门禁主记录|系统/财务|11-A2|信用预警触发依据。|保留/细化|逾期天数|
|
||||
|credit_rating|信用评级|Enum|是|信用门禁主记录|财务/商务|11-A2|正常/关注/高风险/冻结。|保留/细化|信用评级|
|
||||
|warning_status|信用预警状态|Enum|是|信用门禁主记录|系统/财务|11-A2|无预警/预警/拦截/已解除。|保留/细化|预警状态|
|
||||
|
||||
#### D-03 库存与在途台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|inventory_record_id|库存记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|库存事实主键。|新增/统一|库存记录|
|
||||
|internal_pn|内部料号|Link(P-08)|是|库存批次记录|P-08/SS04|11-A2|统一引用P-08。|重命名/统一|料号|
|
||||
|manufacturer_lot|原厂批次|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|mfr_lot、lot|
|
||||
|date_code|日期码DC|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|dc、Date Code|
|
||||
|available_qty|可用数量|Number|是|库存批次记录|仓库|11-A2|可出库库存。|新增/统一|可用|
|
||||
|inspection_qty|待检数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|待检|
|
||||
|frozen_qty|冻结数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|冻结|
|
||||
|in_transit_qty|在途数量|Number|是|库存批次记录|系统|11-A2|与D-16履约关联。|新增/统一|在途|
|
||||
|allocation_id|分配记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|批次占用与订单绑定。|新增/统一|子表2|
|
||||
|allocated_qty|已分配数量|Number|是|库存批次记录|系统|11-A2|与allocation_status配套。|新增/统一|分配数量|
|
||||
|allocation_status|分配状态|Enum|是|库存批次记录|系统/PW确认|11-A2|已分配/部分出库/已出库/释放。|重命名/统一|alloc_status|
|
||||
|shipped_qty|已出库数量|Number|是|库存批次记录|系统/PW确认|11-A2|与D-01.shipped_qty同步。|保留/细化|shipped_qty|
|
||||
|last_ship_date|最后出库日期|Date|条件必填|库存批次记录|系统/PW确认|11-A2|该批次最后一次出库事实。|保留/细化|last_ship_date|
|
||||
|moq_qty|库存批次MOQ|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货决策参考。|新增/统一|MOQ|
|
||||
|lead_time_days|库存批次交期天数|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货与在途判断。|新增/统一|交期|
|
||||
|stocking_decision|备货决策|Enum/Structure|条件必填|库存批次记录|履约Owner|11-A2|维持/补货/冻结/退出。|新增/统一|备货决策|
|
||||
|exit_plan|退出方案|LongText/Structure|stocking_decision=退出时必填|库存批次记录|履约Owner|11-A2|异常PN或库存退出动作。|新增/统一|退出方案|
|
||||
|
||||
#### D-10 客户履约主数据台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|execution_profile_id|履约参数档案ID|Text/AutoNumber|是|执行参数主记录|SS04|11-A2/11-A3|客户履约参数的版本化主键。|新增/统一|执行参数|
|
||||
|customer_id|客户ID|Link(L-02)|是|执行参数主记录|SS04|11-A2/11-A3|履约参数按客户维护。|统一|客户|
|
||||
|incoterms|贸易术语|Enum|是|执行参数主记录|履约Owner|11-A2|EXW等;影响风险转移规则。|新增/统一|贸易术语|
|
||||
|packaging_requirements|包装要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|生成交接单和实物核对依据。|重命名/统一|包装|
|
||||
|label_requirements|标签要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|实物核对依据。|重命名/统一|标签|
|
||||
|document_requirements|单证要求|Structure|条件必填|执行参数主记录|履约Owner|11-A2|客户格式与单证清单要求。|重命名/统一|单证要求|
|
||||
|logistics_preference|物流偏好|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点采集偏好。|重命名/统一|物流偏好|
|
||||
|service_level|服务等级|Enum/Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点可见性和响应要求。|新增/统一|服务等级|
|
||||
|alert_thresholds|预警阈值|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点超时、异常延误等阈值。|新增/统一|预警阈值|
|
||||
|pickup_contact_ref|提货联系人引用|Link(L-08)/Text|是|执行参数主记录|履约Owner|11-A2/11-A3|用于交接单和未按预约提货提醒。|重命名/统一|pickup_contact|
|
||||
|pickup_appointment_mode|预约提货方式|Enum|是|执行参数主记录|履约Owner|11-A2|预约/临时/固定窗口等。|重命名/统一|pickup_appt_mode|
|
||||
|logistics_confirmation_channel|物流节点确认渠道|Enum|是|执行参数主记录|履约Owner|11-A3|邮件/微信/即时通讯/电话。|重命名/统一|logi_channel、确认通道|
|
||||
|logistics_confirmation_interval_days|节点确认间隔天数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3天;可按客户配置。|重命名/统一|logi_interval|
|
||||
|logistics_max_reminder_count|最大提醒次数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3次;达到后升级RI。|重命名/统一|logi_max_retry|
|
||||
|handover_template_ref|交接单模板引用|URL/Attachment|是|执行参数主记录|履约Owner|11-A2|生成交接单草稿。|重命名/统一|handover_tpl|
|
||||
|handover_notes|交接注意事项|LongText|条件必填|执行参数主记录|履约Owner|11-A2|客户特殊注意事项;生成交接单。|新增/统一|交接注意事项|
|
||||
|mapping_id|料号映射ID|Text/AutoNumber|是|料号映射子表|系统|11-A2|客户料号与内部料号一对一版本记录。|新增|料号映射|
|
||||
|customer_pn|客户料号|Text|是|料号映射子表|履约Owner|11-A2|客户侧料号。|重命名/统一|客户料号、customer_mpn|
|
||||
|internal_pn|内部料号|Link(P-08)|是|料号映射子表|履约Owner|11-A2|内部统一料号。|重命名/统一|internal_pn|
|
||||
|mapping_version|映射版本|Text|是|料号映射子表|履约Owner|11-A2|料号映射版本受控。|新增/统一|版本|
|
||||
|mapping_status|映射状态|Enum|是|料号映射子表|履约Owner|11-A2|有效/待确认/过期/作废。|新增/统一|状态|
|
||||
|difference_flag|映射差异标记|Boolean/Structure|是|料号映射子表|系统/履约Owner|11-A2|客户料号与内部料号无法一一对应时标记并说明。|新增/统一|差异标注|
|
||||
|
||||
#### D-11 出口单证台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|document_set_id|单证包ID|Text/AutoNumber|是|单证包主记录|系统|11-A2|订单级单证包唯一标识。|新增/统一|证据包|
|
||||
|order_id|订单号|Link(D-01)|是|单证包主记录|系统|11-A2|回链D-01订单。|新增/统一|订单号|
|
||||
|invoice_ref|发票文件引用|URL/Attachment|条件必填|单证包主记录|履约/财务|11-A2|随货单证之一。|重命名/统一|发票|
|
||||
|packing_list_ref|箱单文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|箱单|
|
||||
|coc_ref|COC文件引用|URL/Attachment|条件必填|单证包主记录|履约/质量|11-A2|随货单证之一。|重命名/统一|COC|
|
||||
|certificate_of_origin_ref|产地证文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|按客户或贸易场景要求。|重命名/统一|产地证|
|
||||
|warranty_certificate_ref|质保书文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|质保书|
|
||||
|required_document_types|应提供单证类型|MultiEnum|是|单证包主记录|D-10/履约|11-A2|根据D-10.document_requirements生成。|新增/统一|单证清单、客户格式要求|
|
||||
|document_set_complete|单证包齐套状态|Enum|是|单证包主记录|系统/PW|11-A2|未齐套/已齐套;已齐套至少满足本订单required_document_types。|保留/细化|doc_set_complete|
|
||||
|consistency_check_status|单证一致性审核状态|Enum|是|单证包主记录|系统/PW|11-A2|未审核/通过/不通过/待补正;核对订单、料号、数量、批次、客户信息。|新增/统一|一致性审核|
|
||||
|consistency_check_result|单证一致性审核结果|LongText/Structure|审核后必填|单证包主记录|系统/PW|11-A2|记录差异项、责任人和补正动作。|新增/统一|一致性审核记录|
|
||||
|document_archive_ref|单证包归档引用|URL/Attachment|归档时必填|单证包主记录|系统/履约|11-A2|正式证据包统一归档。|新增/统一|证据包|
|
||||
|archive_due_date|归档期限|Date|条件必填|单证包主记录|履约Owner|11-A2|依据客户或合规要求。|新增/统一|归档期限|
|
||||
|document_type|单证类型|Enum|是|单证明细子表|系统|11-A2|发票/箱单/COC/产地证/质保书/其他。|新增/统一|doc_list|
|
||||
|document_ref|单证文件引用|URL/Attachment|是|单证明细子表|履约|11-A2|单证文件。|重命名/统一|doc_list|
|
||||
|document_status|单证文件状态|Enum|是|单证明细子表|系统/履约|11-A2|草稿/已核验/已归档/作废。|新增/统一|文档状态|
|
||||
|document_version|单证版本|Text|是|单证明细子表|系统/履约|11-A2|单证修改必须递增。|新增/统一|版本|
|
||||
|
||||
|
||||
## 五、重叠字段与统一命名清单
|
||||
|
||||
下表列出不同Activity或台账中“含义相同但写法不同”“层级不同却使用同名”“同一字段承载多个语义”的主要问题,以及本次建议的统一口径。
|
||||
|
||||
|冲突主题|涉及台账/Activity|原有写法|统一字段名|统一口径|
|
||||
|---|---|---|---|---|
|
||||
|客户ID|P-01/P-02/P-04/P-06/P-09/S-04/D-01/D-10/L-02;W05/W24/SS03/11-A2|customer_id、客户ID、客户/项目、客户名称|customer_id;展示字段customer_name|ID只存L-02.customer_id;名称通过关联展示,不在业务台账重复维护。|
|
||||
|项目关联|P-01/P-02/P-04/P-06/P-09/S-04/D-15/D-01;W05/W24/SS03/11-A2|project_id、客户/项目、项目|project_id|项目对象只保留ID关联;项目名称由P-05展示。|
|
||||
|内部料号|P-01/P-02/P-03/P-06/P-08/P-11/S-04/S-05/D-15/D-01/D-03/D-10;W05/W24/SS03/11-A2|pn、mpn、标准型号PN、Validation_PN、Original_PN、Candidate_PN|internal_pn;原物料用original_internal_pn,候选物料用candidate_internal_pn|全链路只认P-08.internal_pn;不得将mpn、pn、型号混作同一字段。|
|
||||
|客户料号|P-01/P-08/D-01/D-10;W05/W24/11-A2|customer_mpn、客户料号|customer_pn|客户料号与internal_pn分开;正式映射落D-10料号映射子表。|
|
||||
|品牌/原厂|P-08/P-10/S-04/S-05/SS03|brand、manufacturer_brand、原厂或品牌|brand_name;制造商另用manufacturer_name|品牌、制造商、供应商不是同一对象;S-04/S-05引用brand_name与supplier_id。|
|
||||
|数量|P-01/S-04/D-15/D-01/D-03/W24/SS03/11-A2|数量、target_qty、purchase_trigger_qty、order_qty、shipped_qty、actual_qty|target_qty、trigger_qty、order_qty、actual_qty、shipped_qty、allocated_qty|按业务语义分开;数量阶梯统一用quantity_tier子表,不用文本拼接。|
|
||||
|交期/时间窗|P-01/S-04/D-15/P-02/S-05/W24/SS03|target_delivery_date、requirement_date、quote_return_due_at、quote_due_at、lead_time/LT|target_delivery_date、requirement_date、quote_due_at、lead_time_days|目标日期、需求日期、报价截止时间、供货时长四类概念分开;原始说明写lead_time_note。|
|
||||
|价格类型|P-01/P-02/P-03/S-04/S-05/W24/SS03|price_types_default、price_types_requested、price_type、报价类型|requested_price_types(需求层);price_type(价格明细层);quote_type(报价主记录层)|需求要什么价格、这一行是什么价格、这份报价属于什么业务类型分别建字段。|
|
||||
|价格/币种|P-01/P-02/P-03/S-04/S-05/W24/SS03|报价价格/币种、unit_price、benchmark_unit_price、currency|金额字段按对象命名,币种统一currency_code;跨币种另记comparison_currency_code/conversion_rate|金额与币种成对出现;currency不再作为模糊字段名。|
|
||||
|有效期|P-02/P-03/S-04/S-05/W24/SS03|生效期、失效期、报价有效期、valid_until、effective_from/to|valid_from、valid_to;报价主记录用quote_valid_until|Price List/采购基准的有效区间用valid_from/valid_to;报价对外有效期用quote_valid_until。|
|
||||
|状态|P-01/P-02/P-03/P-06/P-11/S-04/S-05/D-01/D-03/D-10/D-11/W05/W24/SS03/11-A2/11-A3|状态机、状态、approval_status、publish_status、doc_set_complete|对象业务状态与record_status分离;各对象保留*_status命名|record_status只管记录生命周期;quote_status、approval_status等只管业务流程。|
|
||||
|版本|P-01/P-02/P-03/P-06/P-08/P-10/P-11/D-10/S-04/W05/W24/SS03/11-A2|版本、需求版本、Release版本、报价版本、Datasheet版本、规则版本|对象字段用requirement_version/quote_version/release_version/document_version/mapping_version;通用用record_version;规则用rule_version|版本必须绑定对象和用途,不再使用裸字段version。|
|
||||
|证据/附件/链接|P-01/P-02/P-06/P-10/P-11/S-04/D-01/D-11/W05/W24/SS03/11-A2/11-A3|evidence、证据引用、输出文件链接、handover_file、doc_list|evidence_ref;对象文件用*_ref;输出用output_ref;交接单用handover_file_ref|证据引用可多值且必须可定位;业务对象文件链接按对象命名。|
|
||||
|供应商主体/联系人|S-03/S-04/S-05/SS03|supplier_id、供应商ID、sales_contact、联系人|supplier_id;sales_contact_ref;contact_channel|供应商主体与联系人分离;联系渠道记录在供应商主数据,实际发送渠道记录在S-04.send_channel。|
|
||||
|询价批次/比价组|S-04/SS03|rfq_batch_id、comparison_group|rfq_batch_id;comparison_group_id|批次是询价过程容器;比价组是同条件可比集合,两者不可互代。|
|
||||
|MOQ/SPQ|P-02/S-04/S-05/D-03/W24/SS03/11-A2|MOQ-SPQ、MOQ、SPQ|moq_qty、spq_qty|拆分为两个数值字段,适用对象前缀由台账区分。|
|
||||
|交期LT|S-04/S-05/P-02/SS03/W24|LT、lead_time、交期|lead_time_days;lead_time_note|标准化时长与原始说明分开;统一日历口径由GOV定义。|
|
||||
|替代关系与批准|P-01/P-02/P-06/P-08/P-11/W05/W24|替代等级、推荐、批准状态、验证结果、P-11-01/P-11-02|recommendation_level、analysis_status、customer_approval_status、validation_status|推荐、分析、验证、客户批准四个阶段分开,P-11作为统一关系主对象。|
|
||||
|风险等级|P-01/P-02/P-06/P-11/S-05/D-18/W05/W24/SS03|风险等级、供应风险、问题与风险|risk_level(通用决策风险);supply_risk_level(供应能力风险);issue_risk(问题明细)|不同风险对象不共用一个自由文本字段。|
|
||||
|交接/物流节点|D-01/D-03/D-10/D-11/11-A2/11-A3|milestones、ship_date、logi_pickup、track_note、交接|milestones;交接事实字段;logi_*节点结构;track_note|交接是法律/责任事实;物流节点是后续过程事实,统一回链同一order_id但不混成一个字段。|
|
||||
|
||||
## 六、必须落地的结构化规则
|
||||
|
||||
|标准项|标准定义|示例|
|
||||
|---|---|---|
|
||||
|ID命名|业务对象主键使用<对象>_id;跨台账引用保留同名ID并采用Link。|request_id、supplier_id、order_id|
|
||||
|料号命名|internal_pn表示内部标准料号;customer_pn表示客户料号;original_internal_pn/candidate_internal_pn只用于替代关系上下文。|P-08.internal_pn、D-10.customer_pn|
|
||||
|金额与币种|所有金额字段必须与currency_code成对;跨币种比较在决策记录中另记comparison_currency_code与conversion_rate。|unit_price + currency_code|
|
||||
|数量阶梯|数量阶梯使用结构化子表,至少包含min_qty、max_qty、price或discount;禁止把多个阶梯拼接为自由文本。|P-02 QuoteLine.quantity_tier|
|
||||
|时间字段|发生时点用*_at(DateTime),业务日期用*_date(Date),持续时长用*_days(Number)。|quote_due_at、target_delivery_date、lead_time_days|
|
||||
|版本字段|不使用裸version;必须指明对象:requirement_version、quote_version、release_version、document_version、record_version、rule_version。|P-02.quote_version|
|
||||
|状态字段|record_status管记录生命周期;对象业务流程用*_status;派生可见性用*_flag或*_visible。|record_status、approval_status、track_visible|
|
||||
|证据字段|evidence_ref表示证据集合;对象文件按对象命名为*_ref;证据必须支持URL/附件/定位信息。|quote_evidence_ref、handover_file_ref、citation_locator|
|
||||
|节点结构|物流节点统一包含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|D-01.logi_pickup|
|
||||
|主记录与子表|主记录保存上下文、状态和汇总;逐PN/BOM位/阶梯/参数/单证的重复明细使用子表。|P-02 QuoteLine、P-11差异明细、D-11单证明细|
|
||||
|
||||
## 七、实施优先级与待确认事项
|
||||
|
||||
**P0:**先落实P-01、P-02、P-11、S-04、D-01的主记录与子表结构,并建立统一ID、料号、版本、状态和证据字段。P-02如果不建立QuoteLine,PN×BOM位×数量阶梯无法稳定支撑报价和版本管理;P-11如果不建立差异明细,差异评估只能保留摘要,无法被AI复核或下游消费;S-04如果不拆RFQ/Quote/Decision三层,询价事实、供应商回复和定点结论会互相覆盖。
|
||||
|
||||
**P1:**落实条件必填与校验规则,包括代理证、涨价原因、跨币种换算、报价/基准有效期、风险转移时点、交接文件状态、物流节点异常及提醒次数。
|
||||
|
||||
**P1:**明确以下业务口径后固化到GOV规则:lead_time_days按自然日还是工作日;毛利计算公式和阈值;价格来源优先级;汇率来源与as_of;Incoterms及地点字段;客户批准与FAE分析结论的权限边界;D-10履约参数的客户级/物流公司级配置粒度。
|
||||
|
||||
**P2:**将S-05基准、D-03库存与D-16采购履约的数据同步关系做成系统接口;将D-01物流节点结构接入客户可查询视图,并将异常节点回链D-18或既有异常归集Activity。
|
||||
|
||||
|
||||
## 八、交付物说明
|
||||
|
||||
本Markdown用于评审和阅读;配套Excel提供可直接继续维护的“字段总表”“Activity覆盖矩阵”“重叠字段统一”“字段标准”四张工作表,并保留“说明”页作为使用口径。
|
||||
|
||||
|
||||
## 参考附件
|
||||
|
||||
[1]: /home/ubuntu/upload/SS01-02-03-04-台账体系全景-18Activity关联矩阵(2).html "SS01-02-03-04-台账体系全景:18 Activity关联矩阵"
|
||||
|
||||
[2]: /home/ubuntu/upload/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-05 差异评估"
|
||||
|
||||
[3]: /home/ubuntu/upload/SS02_ACT-WORK-24_报价方案制定_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-24 报价方案制定"
|
||||
|
||||
[4]: /home/ubuntu/upload/SS03询价ActivityDemo(1).html "SS03 询价 Activity Demo"
|
||||
|
||||
[5]: /home/ubuntu/upload/20260826-11-A2架构版(E04-I01v1.2).html "SS04 ACT-11-A2 提货交接与风险转移"
|
||||
|
||||
[6]: /home/ubuntu/upload/20260826-SS04-SOP-11活动描述样板(11-A3).html "SS04 11-A3 物流执行信息跟踪活动描述样板"
|
||||
@@ -55,3 +55,26 @@
|
||||
- Keycloak 侧前置(管理员):本服务 client(建议复用 `api-gateway`)、`web-console` 补 audience mapper、
|
||||
provisioning 服务账号、e2e 服务账号 client(`bpos-e2e`,realm 角色 admin,firstName「AI」)。
|
||||
- 欠账见 `docs/debt.md`「鉴权」段。
|
||||
|
||||
## 补充(2026-08-26):成员与组织三页签
|
||||
|
||||
运营中心「员工」那一屏按目标件重做成三个页签,一一对着 Keycloak 的三样东西:
|
||||
|
||||
| 页签 | 数据来自 | 写去哪 |
|
||||
|---|---|---|
|
||||
| **成员** | Keycloak users(合本地的岗位与「几时来过」) | 穿透 Keycloak:新建 / 改资料 / 启停 / 重置口令 / 删除 |
|
||||
| **权限** | Keycloak realm roles × 本地 `sys.role_access` | 端配置写本地;角色授收穿透 Keycloak |
|
||||
| **组织** | Keycloak groups | 穿透:建组 / 改名 / 删组 / 成员进出组 |
|
||||
|
||||
`/api/gov/org/*` 十五条,`access` 一律 `ops`。三处口径值得记:
|
||||
|
||||
1. **写是穿透的,没有撤销** —— 这里建的人立刻能登录、停的人立刻登不上。界面上凡写必二次确认
|
||||
(三必答:谁受影响 / 能不能撤 / 几时生效)。
|
||||
2. **服务账号是身份不是人** —— 目录读一律剔除、写面一律 403。停掉它的后果不显眼却很重:
|
||||
试跑与 e2e 第二天全趴下。
|
||||
3. **两种 502 分开说**:`keycloak-misconfigured`(服务账号缺角色,重试无益,去补权限)与
|
||||
`keycloak-unavailable`(暂时够不着,可退避重试)。**重试有没有意义**是分界 —— 混成一个 code,
|
||||
界面只能说一句含糊的「出错了」。
|
||||
|
||||
同批拿掉的:「岗位授权」与「工作组」两个页签(用户定)。**后端数据与接口原样留着** ——
|
||||
岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流;拿掉的只是入口。
|
||||
|
||||
@@ -1181,3 +1181,94 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
||||
㉔ **`claimForResend` 会把抢到的那一行的 `created_at` 推到当下**:这是 CAS 判据的一部分
|
||||
(抢到即离开可抢集合),代价是**这一列不再是「这一行何时建的」** —— 谁要拿它当来源凭据都会读错。
|
||||
要么另立一列记真正的创建时刻,要么在读它的地方写明。目前无人拿它做凭据。
|
||||
|
||||
## 成员目录冷启 8 秒(2026-08-26,ADR-012)
|
||||
|
||||
`GET /api/gov/org/users` 第一次要 8 秒:Keycloak **没有「一次拿回全部人的生效角色」的接口**
|
||||
(手册 §14.4 是逐人 `GET /users/{id}/role-mappings/realm/composite`),8 个人就是 8 趟远程往返。
|
||||
并发从 8 提到 24 实测无改善(9.3 秒)—— 卡的是那台 Keycloak 每个请求自身的时延,不是我们的并发。
|
||||
|
||||
现状:usecase 层 60 秒读缓存(`DIRECTORY_TTL_MS`),任一写成功即整体清空 ——
|
||||
**冷启 8 秒、60 秒内 0 毫秒**;写完立刻重拉一定看得到新结果。
|
||||
|
||||
结构性治法(没做):按**角色**问而不是按人问 —— `GET /roles/{name}/users` 拿直接持有者
|
||||
(每个角色一趟)+ 展开 `default-roles-<realm>` 的组合(一趟),合起来就是全员的生效角色,
|
||||
调用次数从 O(人数) 降到 O(角色数)。当时试过这条的半截(只用 `/roles/{name}/users`),
|
||||
**数错了**:`user` 是经 `default-roles-corp` 继承的,那条路报「持有 0 人」而实际人人都有 ——
|
||||
所以要做就得连组合展开一起做,不能只做一半。realm 长到几十上百人时再动它。
|
||||
|
||||
## 成员与组织:写完那几秒屏上还是旧的(2026-08-26,ADR-012)
|
||||
|
||||
组树那一趟读要 5-6 秒(逐组数成员 + 服务端 60 秒缓存被写清掉),于是**删一个组之后**
|
||||
到画面更新之间有一段真空。实测三轮里有一轮**根本没刷过来** —— 网络记录里露了马脚:
|
||||
中间夹着一趟 `GET /groups → 401`(token 到期,typed-fetch 强刷重试),那一次重取就此失败,
|
||||
而 react-query 拿不到新数据就继续摆旧的。
|
||||
|
||||
已做的两刀:
|
||||
- 服务端缓存**按写碰到的东西分开清**(`'users' | 'groups' | 'all'`)—— 建个组不再把
|
||||
「全量成员的角色」那趟 N+1 拖下水;前端的失效键同批收窄(`ORG_KEYS`)。
|
||||
- 删组成功后**先把已知事实落进缓存**(`setQueryData`,按路径前缀连子组一起摘),
|
||||
再照常发重取 —— 与 `mirrorTwins` 同一条道理:值本来就在手上,不必等服务端再说一遍。
|
||||
|
||||
**还欠**:①**重取失败时界面继续摆旧数据,一声不吭** —— 这不是这一屏的毛病,
|
||||
治理台每一屏都这样(`QueryGate` 只管首次载入,不管重取失败);要治得在共享层做
|
||||
「这份数据是几时的 / 刚才那次刷新失败了」。②组树的成员数仍是逐组一趟,
|
||||
组多了会线性变慢 —— 与「成员目录冷启 8 秒」是同一族问题(见上一条),
|
||||
真治法都是把「按人问」换成「按组/按角色问」。
|
||||
|
||||
## 推送有多密请求就有多密:广播没有合并层(2026-08-26)
|
||||
|
||||
08-26 的「工作台疯狂轮询三个接口」查到底是两台后端互桥回弹(`broadcast-bridge.ts`,已修,
|
||||
2 测钉死)。风暴掐断之后,**同一条放大链还在**,只是没了永动机:
|
||||
|
||||
- `task-service.tick` **每拍无条件** `setPhase` → `control` 信封 → `task` touch(不看 phase 有没有变);
|
||||
每拍推帧 `view-html` 一条;推进器 `advanced` 一条、在流程里再加一条 `workflow` ——
|
||||
一拍 3-4 条 `task` touch 在几十毫秒内到达;
|
||||
- 前端 `invalidateFor` 逐条失效、不合并:每条 touch 各让 tasks / approvals / task 详情重取一次,
|
||||
TanStack 默认还会取消在途请求重发(`cancelRefetch`)—— 一拍 9-12 趟请求,一条案例活动跑十几拍
|
||||
就是上百趟,而画面只需要每拍刷一次。
|
||||
|
||||
治法两层:①源头 `setPhase` 只在 phase 真变时广播(与 `worthBroadcasting` 同一条道理);
|
||||
②`invalidateFor` 加一个 50-100ms 的合并窗,同一批 touch 落地后按键去重只失效一次
|
||||
(纯函数、可测)。两层都做,任一层失守另一层兜着。
|
||||
|
||||
## ACT-WORK-05 走查逼出来的三条平台欠账(2026-08-27)
|
||||
|
||||
差异评估活动(`part-substitution-assessment`)从零到 released 的浏览器走查里,
|
||||
有三处**病在平台、这一轮只在活动侧绕过去了**——绕法是措辞,治法在共享面:
|
||||
|
||||
- **`disable` 这条路从来没通过**:`lifecycle.ts` 里 `Disabled` 只能从 `Enabled` 来,
|
||||
而 `Enabled` 只由 `plan` 动作写(裁量项入场);普通计划项一直停在 `Available`,
|
||||
于是任何 `disable` 都撞「生命周期迁移非法:Available → Disabled」。
|
||||
全仓只有本活动用过它 —— E04/I01 一次都没用,所以没人发现。实测代价:连拒五拍熔断。
|
||||
两条路二选一:要么让 `disable` 对 `Available` 的项也成立(语义:这一单用不上它),
|
||||
要么在拒回里明说「本平台没有弃用计划项这回事,不需要办的项什么都不用做」。
|
||||
**现状**:活动侧全包清了 `disable`,并在哨兵上加了「真有料号不在库」才开门。
|
||||
|
||||
- **前一项不收掉,后一项的件就推不出来**:引擎挑画面按「当下还没干完的第一步」取 view
|
||||
(`turn-runner.ts` 2058-2064 附近)。模型把「人交回 → 发下一张卡」压成一拍、忘了 `finish`
|
||||
上一项时,卡是新的、件还是旧的 —— **人拿到的卡写着「把资料填到件上」,屏上一个格子都没有**,
|
||||
提交闸如实拦死,整单挂在那儿。E04 的 S4→S5 撞过同一堵墙(2026-08-23 已按 `currentRuntime`
|
||||
重算在办那一步治过一次),这次是**两个人环项前后相邻**的形状,重算也救不了 ——
|
||||
两项都还 Active,第一步仍是上一项。
|
||||
真治法:发人卡那一刻,若卡绑的项与当前推的 view 所属项不是同一项,要么按卡所属项推件,
|
||||
要么把这件事如实说给模型(现在它一点回声都收不到)。
|
||||
**现状**:活动侧把动作串写死「①先 finish ②再发卡」,配测试钉措辞。
|
||||
|
||||
- **拒回只谈行与列,数不出「缺的是一次工具调用」**:`S3_assess` 的 doneWhen 里有
|
||||
`tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条),
|
||||
模型跳过工具、自己把推荐等级填了,于是 finish 恒被拒;而拒回文案只说
|
||||
「是不是该写 N 行只写了 M 行 / 某一行缺了必填列」—— 把注意力全导向行与列,
|
||||
模型连着五拍重写同样的明细,熔断。
|
||||
真治法:收步判据里那半句若是 `tool_*_calls`,拒回就该直说「这一步的收步条件里还数着
|
||||
某某工具的调用次数,当下是 0 —— 先去调它」。这是设计律「拒回的话是模型下一拍的行动指南」
|
||||
在工具这一类上的缺口。
|
||||
**现状**:活动侧在 guide 里写了写盘前自检。
|
||||
|
||||
- **执行工单绑的必交付台账一行都没有时,提交闸无牙**:卡不绑表时闸回落到
|
||||
必交付声明的台账(`gateAddressesOf`),而 `missingReadings` 只数「已有行缺不缺必填」——
|
||||
**零行 = 零缺**,闸开着。实测:模型压拍把卡发出去、清单一行没写,人对着空表,
|
||||
按得下提交、按下去什么也没发生。
|
||||
真治法待议:若一张必交付台账本单一行都没写,闸该拦并说「这张表还没有内容」;
|
||||
但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。
|
||||
**现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,472 @@
|
||||
# 数据域字段说明 —— ACT-WORK-05 差异评估
|
||||
|
||||
> 对应设计中心 → 数据域「ACT-WORK-05 · 差异评估」分组。字段以活动清单的台账声明为准
|
||||
> (`activities/part-substitution-assessment/src/manifest.ts` 的 `declarations.ledger`);
|
||||
> 主数据六张(`customer.profile` / `parts.profile` / `parts.document` / `demand.request` /
|
||||
> `substitution.relation` + `difference_item` / `validation.record`)的**物理结构**由平台基线
|
||||
> `apps/api/src/app/seed-mvp-data.ts` 的 `MASTER_OBJECTS` 物化(字段名与《SS02-SS03-SS04_Demo
|
||||
> 台账字段细化与统一方案》§4.2 一字不差),声明合并对基线是**纯 add-column**:
|
||||
> 活动只追加自己要用的列与标记(required / tally / refersTo / requiredWhen / distinct),
|
||||
> 不改基线已有列的语义。那份声明同时是**授权面**:没声明的列写不进去,也不会出现在件上、
|
||||
> 判据里、数据域的活动分组里。
|
||||
|
||||
## 怎么读这份文档
|
||||
|
||||
- **★ = 重点字段**。每张表挑 5–8 个,在「重点字段」小节里**按重要程度从高到低**排;
|
||||
数据结构表里在列名后打 ★。
|
||||
- **【人填】= 人在活动过程中亲手填、提交后入库的字段**(执行工单 / 数据工单的件上编辑,
|
||||
或数据录入卡)。这类字段**一律进重点**:它们是这一单里唯一不能靠 AI 补出来的事实。
|
||||
- 「写入者」一栏的口径与「约束」一栏的词表,与 `docs/数据域字段说明-E04-I01.md`
|
||||
「怎么读这份文档」是**同一套**,不重复抄一遍:人(件上)/ 人(录入卡)/ AI / 工具 / 基座 /
|
||||
承继;主键 / 自增 / 必填 / 建议填 / 取值域 / 引用 / 去重计数 / 脱敏。
|
||||
requiredWhen 类字段(某一列到某个值才必填)统一写「条件必填 · <字段>=<值> 时」——
|
||||
与 E04-I01 的「发布时必填」是同一种约束机制,ACT-WORK-05 的触发条件不止「发布」一种,
|
||||
所以逐列写清条件,不套用那个专用词。
|
||||
- ACT-WORK-05 比 E04/I01 多两种「写入者」写法,单列在这里:
|
||||
- **AI(据人的意见改)** —— S4 审核决策卡驳回、T_release 打回重做时,人写的是自然语言意见,
|
||||
不是结构化字段值;基座按字面比对定位到哪一行,AI 照意见改那一行的具体列(同 PK 覆盖写),
|
||||
意见本身不落进任何一张台账的某一列。
|
||||
- **AI(抄自 input_gap.supplement)** —— `demand.request` 的 `application_scenario` /
|
||||
`bom_position` 两列,人是在 `sub_eval.input_gap` 的「补充内容」列作答,S1 收口那一拍才由
|
||||
AI 抄进 P-01;人没有直接编辑过 `demand.request` 本身。
|
||||
|
||||
### 一览
|
||||
|
||||
| 全景 | 台账地址 | 人读名 | 性质 | 本活动用到的列 | 人填列 |
|
||||
|---|---|---|---|---|---|
|
||||
| L-02 | `customer.profile` | 客户档案 | 主数据 · 共享 | 4 | 3(录入卡,仅新客户) |
|
||||
| P-08 | `parts.profile` | 芯片主数据 | 主数据 · 共享 | 12 | 12(数据工单件上) |
|
||||
| P-10 | `parts.document` | 原厂技术资料库 | 主数据 · 共享 | 9 | 9(数据工单件上) |
|
||||
| P-01 | `demand.request` | 客户需求主记录 | 主数据 · 共享 | 17 | 0(应用场景 / BOM 位经执行单由 AI 抄入) |
|
||||
| P-11 | `substitution.relation` | 替代关系主记录 | 主数据 · 共享 | 17 | 0(只审不改) |
|
||||
| P-11 子 | `substitution.difference_item` | 差异明细 | 主数据 · 共享 | 15 | 0 |
|
||||
| P-06 | `validation.record` | 芯片验证记录 | 主数据 · 共享 | 8 | 0(只写验证要求) |
|
||||
| — | `sub_eval.pn_check` | 料号存在性核对 | 本活动工作台账 | 6 | 0(决策卡定放弃) |
|
||||
| — | `sub_eval.input_gap` | 评估输入清单 | 本活动工作台账 | 8 | 3(补充内容 / 加行) |
|
||||
| — | `sub_eval.verdict` | 本次评估结论 | 本活动工作台账 | 14 | 0 |
|
||||
|
||||
九步(0.5 拆两项数据工单):绑定与存在性核对(S0_bind)→(等待数据:S0d_p08_master →
|
||||
S0d_p10_document,人)→ 人补评估输入(S1_input_confirm)→ 标准化对照表(S2_normalize)→
|
||||
逐项评估(S3_assess)→ 人审结论(S4_review,可循环)→ 拟落账(S5_prepare_landing)→
|
||||
人放行(T_release)。四道人环里 S0d_p08_master / S0d_p10_document / S1_input_confirm 三道
|
||||
往台账里写下人亲手填的事实,S4_review / T_release 两道是审核与签字(人写意见,AI 据意见改台账)。
|
||||
|
||||
---
|
||||
|
||||
## 一、`customer.profile` 客户档案(L-02 · 共享主数据)
|
||||
|
||||
本活动平时**不建这张表**,只在客户不在库(启动入参 `customerCode` 在 `customer.profile`
|
||||
里查无记录)那一刻代行建档职责:S0_bind 发一张数据录入卡,人核对名称与行业区域后确认。
|
||||
除此之外整个活动流程里再不碰这张表。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `customer_code` ★ 主键 —— 值取自启动入参 `customerCode`,AI 不许自编;人在录入卡上核对确认,不是自由填写
|
||||
2. `name` ★【人填】必填 —— 无名档案进不了正式表(与 E04 同一条墙);`customerName` 有给就 AI 预填,人核对
|
||||
3. `industry` ★【人填】建议填 —— 所属行业,录入卡上从零填
|
||||
4. `region` ★【人填】建议填 —— 区域,录入卡上从零填
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `customer_code` ★ | 客户编码 | text | 主键 | AI(抄入参 customerCode)/ 人(录入卡确认) | 老客户沿用,不经这张卡 |
|
||||
| `name` ★ | 客户名称 | text | 必填 | AI 预填(`customerName`)/ 人(录入卡) | 没传 `customerName` 就整格空着让人填 |
|
||||
| `industry` ★ | 所属行业 | text | 建议填 | 人(录入卡) | |
|
||||
| `region` ★ | 区域 | text | 建议填 | 人(录入卡) | |
|
||||
|
||||
---
|
||||
|
||||
## 二、`parts.profile` 芯片主数据(P-08 · 共享主数据)
|
||||
|
||||
一张表:料号一行,标准化参数集是它的一个字段(一行一参数
|
||||
`参数名 | 值 | 单位 | hard/soft | 说明`,值是「未披露」的进 UNKNOWN)。库里按 `internal_pn`
|
||||
查无记录 → 发**数据工单**,人在工作区的件上从零建整条(可加行);有上传 datasheet 就
|
||||
AI 先按文本预填、人核对,没有就人全填;确认落草稿层,T_release 放行时才与其余表一起并入
|
||||
正式版。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `internal_pn` ★【人填】主键 —— 全链路只认它,不可自由文本
|
||||
2. `normalized_parameter_set` ★【人填】必填 —— 差异评估逐项比对的唯一输入;「未披露」进 UNKNOWN
|
||||
3. `lifecycle_status` ★【人填】必填 · 取值域 `active/nrnd/eol/discontinued/risk`
|
||||
4. `package` ★【人填】必填 —— 硬约束
|
||||
5. `specification_description` ★【人填】必填
|
||||
6. `brand_name` ★【人填】必填
|
||||
7. `datasheet_ref` / `datasheet_version` ★【人填】建议填(成对)—— 证据回链
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `internal_pn` ★ | 内部料号 | text | 主键 | 人(件上)/ 种子 | 已在库的候选(如样例里预播的 LM2596S-5.0)不经这张单 |
|
||||
| `brand_name` ★ | 品牌名称 | text | 必填 | 人(件上)/ AI 预填 | |
|
||||
| `manufacturer_name` | 制造商名称 | text | 建议填 | 人(件上) | 与品牌不一致时分开记 |
|
||||
| `specification_description` ★ | 规格描述 | text | 必填 | 人(件上)/ AI 预填 | |
|
||||
| `normalized_parameter_set` ★ | 标准化参数集 | text | 必填 | 人(件上)/ AI 预填 | 一行一参数,不建子表 |
|
||||
| `package` ★ | 封装 | text | 必填 | 人(件上)/ AI 预填 | 硬约束 |
|
||||
| `process_technology` | 工艺 | text | 建议填 | 人(件上) | 硬约束 |
|
||||
| `lifecycle_status` ★ | 生命周期状态 | text | 必填 · 取值域 | 人(件上,下拉) | |
|
||||
| `eol_pcn_status` | EOL/PCN 状态 | text | 建议填 | 人(件上) | |
|
||||
| `datasheet_ref` ★ | Datasheet 引用 | text | 建议填 | 人(件上)/ AI 预填 | |
|
||||
| `datasheet_version` ★ | Datasheet 版本 | text | 建议填 | 人(件上)/ AI 预填 | 有引用就该有版本 |
|
||||
| `application_scenarios` | 适配应用场景 | text | 建议填 | 人(件上) | |
|
||||
|
||||
---
|
||||
|
||||
## 三、`parts.document` 原厂技术资料库(P-10 · 共享主数据)
|
||||
|
||||
按 `internal_pn` 查无资料记录 → 发**数据工单**(与 P-08 那张分开,一张表一张单,
|
||||
在 P-08 那张确认之后再发);S0d_p08_master 那一步先写骨架(`document_type` /
|
||||
`document_status` / `brand_name` 三格能填就填),人在 P-10 数据工单的件上补齐其余列。
|
||||
同一料号多份资料就多加几行,序号 `01`/`02`… 人自己续(不是自增列)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `document_ref` ★【人填】必填 —— 资料文件链接,差异评估的证据引用要指到它
|
||||
2. `internal_pn` ★【人填】主键之一 —— 关联料号,与 P-08 同一个内部料号
|
||||
3. `doc_seq` ★【人填】主键之一 —— 同一料号下 01/02… 人自己续号
|
||||
4. `document_type` ★【人填】必填 · 取值域 —— `datasheet/app_note/reference_design/white_paper/sample_doc/commitment_letter/other`
|
||||
5. `document_title` ★【人填】必填
|
||||
6. `document_version` ★【人填】必填
|
||||
7. `document_status` ★【人填】必填 · 取值域 `valid/expired/pending/void`
|
||||
8. `citation_locator` ★【人填】建议填 —— 页码 / 章节 / 表格,证据定位精确到这里
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `internal_pn` ★ | 关联内部料号 | text | 主键之一 | 人(件上) | 与 P-08 那一行同一个键 |
|
||||
| `doc_seq` ★ | 资料序号(同一料号下 01/02…) | text | 主键之一 | 人(件上) | 不是自增列,人自己续号 |
|
||||
| `document_type` ★ | 资料类型 | text | 必填 · 取值域 | AI 预填骨架(S0d_p08_master)/ 人(件上,下拉) | |
|
||||
| `brand_name` | 品牌名称 | text | 建议填 | AI 预填骨架 / 人(件上) | |
|
||||
| `document_title` ★ | 资料标题 | text | 必填 | 人(件上) | |
|
||||
| `document_version` ★ | 资料版本 | text | 必填 | 人(件上) | |
|
||||
| `document_ref` ★ | 资料文件链接 | text | 必填 | 人(件上) | |
|
||||
| `citation_locator` ★ | 证据定位(页码/章节/表格) | text | 建议填 | 人(件上) | |
|
||||
| `document_status` ★ | 资料状态 | text | 必填 · 取值域 | AI 预填骨架(pending)/ 人(件上,下拉) | |
|
||||
|
||||
---
|
||||
|
||||
## 四、`demand.request` 客户需求主记录(P-01 · 共享主数据)
|
||||
|
||||
S0_bind 新建 V1(客户 / 需求描述 / 原 PN 三项照入参原样落,启动时未收的 `application_scenario`
|
||||
/ `bom_position` 先不写);S1_input_confirm 人在评估输入清单上把这两项的「补充内容」补上,
|
||||
AI 收口那一拍原样抄回本表;S5_prepare_landing 放行前回写风险等级 / 验证要求 / 输出链接 /
|
||||
下一步动作 / 当前阶段五项(`current_stage = "gate03"` 那一刻这五项转为必填)。**本表没有一列
|
||||
是人直接编辑的**——人的手笔经由 `sub_eval.input_gap.supplement` 间接进来。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `request_id` ★ 主键 —— 需求编号,与 `eval_ref` 用同一串时刻数字铸出
|
||||
2. `customer_code` ★ 必填 · 引用 `customer.profile.customer_code`
|
||||
3. `request_description` ★ 必填 —— 客户需求原话,S0 落一次,不许改写
|
||||
4. `original_internal_pn` ★ 必填
|
||||
5. `application_scenario` ★ 建议填 —— 启动时未收,人在评估输入清单补的「补充内容」由 AI 抄进这一格(本表唯一带着人手笔的两列之一)
|
||||
6. `bom_position` ★ 建议填 —— 同上
|
||||
7. `risk_level` ★ 条件必填 · `current_stage=gate03` 时 —— 放行回写五项之一,取各候选风险最高档
|
||||
8. `verification_requirement` ★ 条件必填 · `current_stage=gate03` 时 —— 列出挂的验证任务号
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `request_id` ★ | 需求编号 | text | 主键 | AI | `REQ-` + 案号数字串 |
|
||||
| `customer_code` ★ | 客户编码 | text | 必填 · 引用 `customer.profile.customer_code` | AI | |
|
||||
| `requirement_version` | 需求版本 | text | 必填 | AI | 恒 `V1` |
|
||||
| `request_type` | 需求类型 | text | 必填 | AI | 恒「技术需求」 |
|
||||
| `request_description` ★ | 需求描述 | text | 必填 | AI | 入参原话,不改写 |
|
||||
| `input_snapshot_ref` | 输入快照 | text | 必填 | AI | `SNAP-` + 案号数字串 |
|
||||
| `original_internal_pn` ★ | 原内部料号 | text | 必填 | AI | |
|
||||
| `bom_position` ★ | BOM 位 | text | 建议填 | AI(抄自 `sub_eval.input_gap.supplement`) | 启动时未收 |
|
||||
| `application_scenario` ★ | 应用场景 | text | 建议填 | AI(抄自 `sub_eval.input_gap.supplement`) | 启动时未收 |
|
||||
| `technical_constraints` | 技术约束 | text | — | AI | 人加的 input_gap 行汇总成一句 |
|
||||
| `risk_level` ★ | 风险等级 | text | 条件必填 · `current_stage=gate03` | AI | 取各候选 `risk_level` 最高档 |
|
||||
| `verification_requirement` ★ | 验证/复测/确认要求 | text | 条件必填 · `current_stage=gate03` | AI | 列出所有挂的 VAL 号 |
|
||||
| `request_status` | 需求状态 | text | 必填 · 取值域 | AI | `pending → responded` |
|
||||
| `current_activity` | 当前 Activity | text | 条件必填 · `current_stage=gate03` | AI | 恒 `ACT-WORK-05` |
|
||||
| `current_stage` | 当前阶段 | text | — | AI | `assessing → gate03` |
|
||||
| `next_action` | 下一步动作 | text | 条件必填 · `current_stage=gate03` | AI | 恒「提交 ACT-GATE-03 评审放行」 |
|
||||
| `output_ref` | 标准输出链接 | text | 条件必填 · `current_stage=gate03` | AI | `RPT-` + 案号数字串 |
|
||||
|
||||
---
|
||||
|
||||
## 五、`substitution.relation` 替代关系主记录(P-11 · 共享主数据)
|
||||
|
||||
一个未放弃的候选一条:S2_normalize 写骨架(不含推荐/风险/摘要)、S3_assess 用两枚工具的
|
||||
出参回填推荐等级与风险(**AI 原样抄,不改一字**)、S4_review 只审不改(人写意见,AI 据
|
||||
意见改具体列)、S5_prepare_landing 把 `analysis_status` 推到 `fae_confirmed`。**本活动没有
|
||||
一列是人直接编辑的** —— 人的动作是通过 S4/T_release 的决策卡意见间接改台账。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `substitution_relation_id` ★ 主键
|
||||
2. `eval_ref` ★ 必填 —— **本活动新增列(基线没有)**,评估案号,与 `pn_check` / `verdict` 同一个 `eval_ref` 串联全单
|
||||
3. `candidate_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn`
|
||||
4. `original_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn`
|
||||
5. `recommendation_level` ★ 必填 · 取值域 `recommended/conditional/alternate/not_recommended` —— 工具算,AI 照抄不改一字
|
||||
6. `analysis_status` ★ 必填 · 取值域 —— 分析层状态,S5 推到 `fae_confirmed`
|
||||
7. `risk_level` ★ 必填 · 取值域 `low/medium/high/critical` —— 工具算
|
||||
8. `rule_version` ★ 必填 —— 随结论落账,锁定 `R-2026.07`
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `substitution_relation_id` ★ | 替代关系ID | text | 主键 | AI | `REL-<案号数字串>-01/02…` |
|
||||
| `eval_ref` ★ | 评估案号 | text | 必填 | AI | 本活动新增列(基线没有) |
|
||||
| `request_id` | 关联需求编号 | text | 必填 | AI | |
|
||||
| `bom_position` | 关联 BOM 位 | text | 建议填 | AI | 照 `demand.request` 现值抄 |
|
||||
| `original_internal_pn` ★ | 原内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | AI | |
|
||||
| `candidate_internal_pn` ★ | 候选内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | AI | |
|
||||
| `substitution_type` | 替代类型 | text | 必填 · 取值域 | AI | `pin_to_pin/functional/parametric/other` |
|
||||
| `recommendation_level` ★ | 推荐等级 | text | 必填 · 取值域 | 工具(`recommend`)→ AI 照抄 | |
|
||||
| `recommendation_reason` | 推荐理由 | text | 必填 | AI(可按人的驳回意见改) | 工具 reason 扩写成人话 |
|
||||
| `difference_summary` | 差异摘要 | text | 必填 | AI | 「7 项比对:4 MATCH / 1 MISMATCH / 1 UNKNOWN / 1 CONDITIONAL」句式 |
|
||||
| `risk_level` ★ | 替代风险等级 | text | 必填 · 取值域 | 工具(`recommend`)→ AI 照抄 | |
|
||||
| `verification_requirement` | 验证要求 | text | 条件必填 · `recommendation_level=conditional` | AI | 列出 VAL 号 |
|
||||
| `validation_id` | 关联验证ID | text | — | AI | |
|
||||
| `analysis_status` ★ | 分析状态 | text | 必填 · 取值域 | AI | `ai_draft → fae_confirmed`(其余三档本活动不写) |
|
||||
| `analysis_report_ref` | 替代分析报告 | text | 建议填 | AI | `RPT-<案号数字串>` |
|
||||
| `confidence_score` | 证据可信度 | number | 建议填 | AI | 取各行 `confidence` 最小值 |
|
||||
| `rule_version` ★ | 差异规则版本 | text | 必填 | AI | 锁定 `R-2026.07` |
|
||||
|
||||
> **只读边界**:基线在这张表上还建了 `customer_approval_status` / `customer_approval_at` /
|
||||
> `customer_approval_ref` 三列,由**独立审批流**维护;本活动的声明里没有这三列的写权
|
||||
> (禁令 `no-customer-approval-write`)。落账确认那一屏那一格是静态文案
|
||||
> 「未提交 · 由审批流程维护,本活动不产生」,不是可取值的 `data-slot`
|
||||
> ——审批流做起来后才改成真读数(见 `docs/debt.md`)。
|
||||
|
||||
---
|
||||
|
||||
## 六、`substitution.difference_item` 差异明细(P-11 子表 · 共享主数据)
|
||||
|
||||
一条参数一条记录。S2_normalize 按原 PN 的参数集写骨架(不含四态/影响)、S3_assess 第一拍
|
||||
调 `compare-parameters` 拿到四态与风险后**原样抄回** `difference_status` / `compare_result` /
|
||||
`risk_level`,AI 自己只补写证据引用 / 影响说明 / UNKNOWN 去向;S4_review 驳回时人写的意见由
|
||||
AI 定位到具体行后改 `impact_statement` / `unknown_disposition` / `evidence_ref`。
|
||||
`substitution_relation_id` 标了 `distinct`,「几条关系已经有明细」就是数它的不同值数
|
||||
(S2 收步、S3 收步都读这个数)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `substitution_relation_id` ★ 主键之一 · 去重计数 —— 「几条关系已有明细」就是数它的不同值
|
||||
2. `difference_item_id` ★ 主键之一
|
||||
3. `difference_status` ★ 必填 · 取值域 `match/mismatch/unknown/conditional` —— 工具算,AI 原样抄
|
||||
4. `compare_result` ★ 建议填 —— **本活动新增列(基线没有)**,工具出参原样存底,供复核
|
||||
5. `constraint_type` ★ 必填 · 取值域 `hard/soft` —— 决定这一项差异判得严不严
|
||||
6. `evidence_ref` ★ 必填 —— 证据引用,验收判据「证据链可信」逐行核对它
|
||||
7. `impact_statement` ★ 必填 —— MATCH 写「无」,其余写清对应用 / 验证 / 报价的影响
|
||||
8. `unknown_disposition` ★ 条件必填 · `difference_status=unknown` 时 —— **本活动新增列(基线没有)**,UNKNOWN 的去向(挂验证任务号 / 补来源动作)
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `substitution_relation_id` ★ | 替代关系ID | text | 主键之一 · 去重计数 | AI | |
|
||||
| `difference_item_id` ★ | 差异明细ID | text | 主键之一 | AI | `<关系id>-P01/P02…` |
|
||||
| `parameter_name` | 标准参数名 | text | 必填 | AI | |
|
||||
| `original_value` | 原物料参数值 | text | 必填 | AI | |
|
||||
| `candidate_value` | 候选物料参数值 | text | 必填 | AI | 候选没有这个参数写「未披露」 |
|
||||
| `parameter_unit` | 参数单位 | text | 建议填 | AI | 以原 PN 为准 |
|
||||
| `constraint_type` ★ | 约束类型 | text | 必填 · 取值域 | AI | 原 PN 那一行的 hard/soft |
|
||||
| `compare_result` ★ | 比对结果(工具出参) | text | 建议填 | 工具(`compare-parameters`)→ AI 照抄 | 本活动新增列 |
|
||||
| `difference_status` ★ | 差异状态 | text | 必填 · 取值域 | 工具(`compare-parameters`)→ AI 照抄 | |
|
||||
| `impact_statement` ★ | 影响说明(MATCH 写「无」) | text | 必填 | AI | |
|
||||
| `unknown_disposition` ★ | UNKNOWN 去向(验证任务号 / 补来源动作) | text | 条件必填 · `difference_status=unknown` | AI(可按人的驳回意见改) | 本活动新增列 |
|
||||
| `risk_level` | 风险等级 | text | 必填 · 取值域 `low/medium/high` | 工具(`compare-parameters`)→ AI 照抄 | |
|
||||
| `evidence_ref` ★ | 差异证据引用 | text | 必填 | AI(可按人的驳回意见改) | |
|
||||
| `confidence` | AI 置信度 | number | 建议填 | AI | 0~1 |
|
||||
| `rule_version` | 差异规则版本 | text | 必填 | AI | 锁定 `R-2026.07` |
|
||||
|
||||
---
|
||||
|
||||
## 七、`validation.record` 芯片验证记录(P-06 · 共享主数据)
|
||||
|
||||
事实类表:本活动只在 S3_assess 给每个 UNKNOWN 差异项挂一行「验证要求」占位
|
||||
(`validation_status` 恒写 `not_started`),不经数据工单建 —— 真正测完、写测试结果是
|
||||
另一件事(下游活动或线下流程的事)。S4_review 驳回若要补验证任务,同样由 AI 加一行。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `validation_id` ★ 主键
|
||||
2. `validation_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn`
|
||||
3. `validation_status` ★ 必填 · 取值域 —— 本活动只写 `not_started`(占位待测)
|
||||
4. `next_validation_action` ★ 必填 —— 要测什么、什么条件
|
||||
5. `validation_type` ★ 必填 · 取值域
|
||||
6. `request_id` ★ 必填 —— 关联需求编号
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `validation_id` ★ | 验证ID | text | 主键 | AI | `VAL-<案号数字串>-01/02…` |
|
||||
| `request_id` ★ | 关联需求编号 | text | 必填 | AI | |
|
||||
| `validation_internal_pn` ★ | 验证内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | AI | 候选 |
|
||||
| `validation_type` ★ | 验证类型 | text | 必填 · 取值域 | AI | 恒 `pin_to_pin`(本活动只挂这一种) |
|
||||
| `validation_status` ★ | 验证状态 | text | 必填 · 取值域 | AI | 恒 `not_started` |
|
||||
| `test_condition` | 测试条件 | text | 建议填 | AI | |
|
||||
| `next_validation_action` ★ | 后续验证动作 | text | 必填 | AI(可按人的驳回意见改) | 要测什么、什么条件 |
|
||||
| `evidence_ref` | 验证证据引用 | text | 建议填 | AI | |
|
||||
|
||||
---
|
||||
|
||||
## 八、`sub_eval.pn_check` 料号存在性核对(本活动工作台账)
|
||||
|
||||
S0_bind 逐个料号(原 PN + 每个候选)在 `parts.profile` 里核对一遍写下的表:原 PN 一行、
|
||||
候选各一行。`exists_status` 是这张表的核心结论 —— 两项数据工单的入场哨兵、S1 的入场哨兵
|
||||
都读它。候选 ≥2 且不在库时,S0d_p08_master 发决策卡问放弃还是创建,人选的答案决定这一行
|
||||
`exists_status` 改成 `abandoned` 还是等数据工单确认后改成 `created`。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `exists_status` ★ 必填 · 取值域 `found/absent/created/abandoned` —— 存在性检查的核心结论,S0d 两项与 S1 的入场哨兵都读它
|
||||
2. `internal_pn` ★ 主键之一
|
||||
3. `eval_ref` ★ 主键之一 —— 串联本单十张表的案号
|
||||
4. `role` ★ 必填 · 取值域 `original/candidate`
|
||||
5. `abandon_reason` ★ 条件必填 · `exists_status=abandoned` 时 —— 人在决策卡选放弃、没写理由时,AI 补写「负责人本轮放弃(未注明原因)」
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `eval_ref` ★ | 评估案号 | text | 主键之一 | AI | |
|
||||
| `internal_pn` ★ | 内部料号 | text | 主键之一 | AI | |
|
||||
| `role` ★ | 角色 | text | 必填 · 取值域 | AI | `original`(恰一行)/ `candidate` |
|
||||
| `exists_status` ★ | 在库状态 | text | 必填 · 取值域 | AI | `found`(在库)→ 或 `absent` → `created`/`abandoned`(数据工单/决策卡确认后改) |
|
||||
| `abandon_reason` | 放弃原因 | text | 条件必填 · `exists_status=abandoned` | AI(据人的决策卡选项改) | 人选放弃未写理由时 AI 补写默认句 |
|
||||
| `note` | 备注 | text | — | AI | 如「PCN 转 NRND」 |
|
||||
|
||||
---
|
||||
|
||||
## 九、`sub_eval.input_gap` 评估输入清单(本活动工作台账)
|
||||
|
||||
S1_input_confirm 一拍写全:已绑定四项(status=ok)、待补两项(应用场景/BOM 位,status=gap)、
|
||||
默认可改三项(status=default)、逐颗候选「未披露」参数各一行(status=gap)、齐备项(原 PN 资料
|
||||
版本,status=ok)。**这张表是本活动唯一让人直接在件上补内容的执行工单载体**:人在
|
||||
「补充内容」列把 status=gap 的行填上答案,也可以加一行补充 AI 没列出的资料项、删一行标
|
||||
「不适用」。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `supplement` ★【人填】条件必填 · `status=gap` 时 —— 缺口行的答案,这一单里唯一不能靠 AI 补出来的事实
|
||||
2. `status` ★ 必填 · 取值域 `gap/default/ok` —— 决定哪一行必须有 `supplement`
|
||||
3. `item_key` ★【人填,加行时】主键之一 —— 英文短键;已绑定 / 待补 / 默认 / 待确认各占一批行
|
||||
4. `input_item` ★【人填,加行时】必填 —— 人读名,加一行 = 补充 AI 没列出的资料项
|
||||
5. `origin` ★ —— `ai` / `human-added`,标出哪几行是人加的(与 E04 `candidate.origin` 同一口径)
|
||||
6. `source_ledger` ★ 建议填 —— 缺口来自哪张台账(P-01/P-08/P-09/P-10)
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `eval_ref` | 评估案号 | text | 主键之一 | AI | |
|
||||
| `item_key` ★ | 输入项键 | text | 主键之一 | AI / **人(件上,加行时)** | 英文短键,如 `application_scenario` |
|
||||
| `input_item` ★ | 输入项 | text | 必填 | AI / **人(件上,加行时)** | 人读名 |
|
||||
| `source_ledger` ★ | 来源台账 | text | 建议填 | AI | |
|
||||
| `status` ★ | 状态 | text | 必填 · 取值域 | AI | `gap`(待补)/`default`(默认·可改)/`ok`(齐备) |
|
||||
| `detail` | 绑定值 / 缺口说明 | text | 建议填 | AI | |
|
||||
| `supplement` ★ | 补充内容 | text | 条件必填 · `status=gap` | **人(件上)** | 唯一的人手笔答案 |
|
||||
| `origin` ★ | 来源(ai/human-added) | text | — | AI | |
|
||||
|
||||
---
|
||||
|
||||
## 十、`sub_eval.verdict` 本次评估结论(本活动工作台账)
|
||||
|
||||
一单一行的收口结论,挂着几面「人环走到哪儿了」的记账旗 —— 它们是下一步能不能开工的哨兵
|
||||
(与 E04 `crm_coverage.verdict` 同一种设计)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `input_confirmed_total` ★ —— 评估输入已确认(人环凭据),S2 标准化那一步的入场哨兵之一
|
||||
2. `review_approved_total` ★ —— 差异结论已审核通过(仅通过时置一),S5 拟落账那一步的入场哨兵
|
||||
3. `landing_prepared_total` ★ —— 拟落账已备好,T_release 放行门的入场哨兵
|
||||
4. `summary` ★ —— 本次评估结论,归档件上的那段话
|
||||
5. `relation_total` ★ —— 替代关系条数
|
||||
6. `recommended_total` / `conditional_total` / `alternate_total` / `not_recommended_total` ★ —— 四档分布,归档件摘要与放行口 `assessment-released` 载荷都用它
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `eval_ref` | 评估案号 | text | 主键 | AI | |
|
||||
| `request_id` | 需求编号 | text | — | AI | |
|
||||
| `rule_version` | 规则版本 | text | — | AI | 锁定 `R-2026.07` |
|
||||
| `original_internal_pn` | 原内部料号 | text | — | AI | |
|
||||
| `candidate_total` | 候选数 | integer | — | AI | 候选数组长度 |
|
||||
| `input_confirmed_total` ★ | 评估输入已确认 | integer | — | AI | 人环凭据 |
|
||||
| `review_approved_total` ★ | 差异结论已审核通过 | integer | — | AI | 人环凭据(仅通过时置一) |
|
||||
| `landing_prepared_total` ★ | 拟落账已备好 | integer | — | AI | S5 写完时置一 |
|
||||
| `relation_total` ★ | 替代关系条数 | integer | — | AI | |
|
||||
| `recommended_total` ★ | 推荐 | integer | — | AI | |
|
||||
| `conditional_total` ★ | 条件推荐 | integer | — | AI | |
|
||||
| `alternate_total` ★ | 备选 | integer | — | AI | |
|
||||
| `not_recommended_total` ★ | 不推荐 | integer | — | AI | |
|
||||
| `summary` ★ | 本次评估结论 | text | — | AI | 归档件上的那段话 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、人填字段总表(按人办事的那一屏)
|
||||
|
||||
| 活动 · 屏 | 卡的形式 | 落进哪张表 | 人亲手填的列 |
|
||||
|---|---|---|---|
|
||||
| S0_bind · 新客户建档(仅客户不在库) | 数据录入卡(entry) | `customer.profile` | `name` · `industry` · `region`(`customer_code` 由入参给,人核对) |
|
||||
| S0d_p08_master · 候选放弃/创建抉择(候选≥2 且部分缺库) | 决策卡(decide) | 间接改 `sub_eval.pn_check.exists_status` | 选项值(`create`/`abandon`/`custom:`,不直接写字段) |
|
||||
| S0d_p08_master · 数据工单·芯片主数据创建(P-08) | 执行工单(件上编辑 + 增删行) | `parts.profile` | 全部 12 列(`internal_pn` / `brand_name` / `manufacturer_name` / `specification_description` / `normalized_parameter_set` / `package` / `process_technology` / `lifecycle_status` / `eol_pcn_status` / `datasheet_ref` / `datasheet_version` / `application_scenarios`) |
|
||||
| S0d_p10_document · 数据工单·原厂技术资料创建(P-10) | 执行工单(件上编辑 + 增删行) | `parts.document` | 全部 9 列(`internal_pn` / `doc_seq` / `document_type` / `brand_name` / `document_title` / `document_version` / `document_ref` / `citation_locator` / `document_status`) |
|
||||
| S1_input_confirm · 评估输入与资料补齐确认 | 执行工单(件上编辑 + 增删行) | `sub_eval.input_gap` | `supplement`(缺口行必填);加一行时另填 `item_key` / `input_item` |
|
||||
| S4_review · 差异评估结论审核 | 决策卡循环(通过 / 驳回 + 意见) | 间接改 `substitution.difference_item` / `substitution.relation` / `validation.record` | 意见文本(AI 据此改 `evidence_ref` / `impact_statement` / `unknown_disposition` / `recommendation_reason`;推荐等级不许改) |
|
||||
| T_release · 评审放行与落账确认 | 验收工单(签字,可打回重做) | `parts.profile` / `parts.document` / `demand.request` / `substitution.relation` + `difference_item` / `validation.record` 并入正式版 | 裁决与意见(打回后 AI 按意见改台账本身,不开新行) |
|
||||
|
||||
---
|
||||
|
||||
## 十二、在数据域里看这些表时的几条提醒
|
||||
|
||||
1. **主数据是共享的,物理表里还有别的用途留下的列,本活动不写它们**:
|
||||
- `customer.profile` 还有 `grade` / `owner` / `payment_terms` / `credit_limit` /
|
||||
`profile_tags` / `status`(报价链早先声明的客户主台账列)
|
||||
- `parts.profile` 还有 `customer_pn` / `pin_to_pin_relation_ref`
|
||||
- `parts.document` 还有 `document_date` / `source_contact_ref`
|
||||
- `demand.request` 还有 `project_code` / `bom_id` / `customer_pn`
|
||||
- `substitution.relation` 还有 `project_code`,以及客户批准三列 + `customer_feedback`
|
||||
(见下条,单独说明只读边界)
|
||||
- `validation.record` 还有 `customer_code` / `test_parameters` / `test_result` /
|
||||
`issue_risk` / `sample_version` / `sample_lot` —— 本活动只写「验证要求」占位阶段,
|
||||
不写实测结果(那是真验证发生后才有的事实)。
|
||||
|
||||
记录浏览里看到它们空着是正常的;判断一列还算不算数,以**活动清单的声明**为准,
|
||||
不以物理表结构为准。
|
||||
|
||||
2. **本活动往两张主数据表新增了列(基线没有,声明里 add-column 长出来的)**:
|
||||
`substitution.relation.eval_ref`(评估案号,串联本单十张表)与
|
||||
`substitution.difference_item.compare_result` / `unknown_disposition`
|
||||
(工具出参原样存底、UNKNOWN 去向)。这三列只对 ACT-WORK-05 生效,不影响其它活动
|
||||
对这两张表的读写。
|
||||
|
||||
3. **`customer_approval_status` 是只读边界,不是本活动没做完**:基线在
|
||||
`substitution.relation` 上建了 `customer_approval_status` / `customer_approval_at` /
|
||||
`customer_approval_ref` 三列,由**独立审批流**维护;本活动的声明里没有这三列的写权
|
||||
(禁令 `no-customer-approval-write`)。放行前那一屏那一格是静态文案
|
||||
「未提交 · 由审批流程维护,本活动不产生」,不是取值的 `data-slot`(见 `docs/debt.md`)。
|
||||
|
||||
4. **`parts.profile` 的标准化参数集是一个 text 列,不是子表**:`normalized_parameter_set`
|
||||
存的是一行一参数的行语法「参数名 | 值 | 单位 | hard/soft | 说明」,四态比对工具的入参
|
||||
都靠**解析这段文本**,不是关系型的多行记录 —— 用户当场定的口径,《SS02-SS03-SS04》
|
||||
字面上写的 Structure / 子表在本平台落成这样(见 `docs/debt.md`「参数集是 text 列的行语法」)。
|
||||
|
||||
5. **数据工单是零输入执行工单 + 工作区可加行的件**:料号在 `parts.profile` /
|
||||
`parts.document` 里查无记录时,人从零建整条记录;确认落草稿层,T_release 放行那一刻
|
||||
才与其余表一起并入正式版(平台铁律②:写只有草稿与意向,终局唯放行)。一张表一张单:
|
||||
P-08 一张、P-10 一张。
|
||||
|
||||
6. **试跑单默认也落真库**:与 E04 / I01 同一个开关(`sys.task.land_master`,测试台起单
|
||||
默认打开)—— 跑一遍活动,新建的客户档案、料号、资料记录都会真的进正式表;要它不落,
|
||||
起单时显式关掉。
|
||||
|
||||
---
|
||||
|
||||
*字段口径以 `activities/part-substitution-assessment/src/manifest.ts` 的
|
||||
`declarations.ledger` 为准;六张主数据的物理结构见 `apps/api/src/app/seed-mvp-data.ts` 的
|
||||
`MASTER_OBJECTS`;计划项与卡骨架见 `activities/part-substitution-assessment/src/case-model.ts`
|
||||
的 `ASSESSMENT_STEPS` 与 `NARRATIVE`;业务设计源见 `docs/SS02-ACT-WORK-05-html/`
|
||||
(活动流程与工单说明 / 一键启动入参说明)与《SS02-SS03-SS04_Demo台账字段细化与统一方案》。*
|
||||
+2
-2
@@ -165,7 +165,7 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
||||
| 门 | 在哪 | 规则 |
|
||||
|---|---|---|
|
||||
| **起单宽进** | `process.start` | 不按 Schema 拒缺项。三活动 `input.required` 一律空(宽进严出),起单补全卡那条老路实际不再触发(彻底移除记 debt) |
|
||||
| **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表:卡上绑了 blocks 看绑的;**没绑(执行卡零输入后是常态)回落到必交付物声明的 `ledgers`/`ledger`**(`gateAddressesOf`,有测钉着)。两边都空才不给闸 —— 没有依据的时候不拦人 |
|
||||
| **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表**三级取一**(`gateAddressesOf`,有测钉着):①卡上绑了 blocks 看绑的;②**卡自己声明的 `gateLedgers`**(零输入卡该给的就是它 —— 只有发卡的人知道这一张要人补哪几张表);③回落到必交付物声明的 `ledgers`/`ledger`。三级全空才不给闸 —— 没有依据的时候不拦人。**主数据类的数据工单必须给 `gateLedgers`**:它要补的表(`parts.*`)不在必交付物名单里,不给就回落到一堆无关的表、一格不缺,件上红带点着缺项而提交钮照样绿(2026-08-27 W05 e2e 实测) |
|
||||
| **提交门**(`submit` 预检) | engines | 机检(L1/L2)**必须为真**;裁量(L3/L4)**裁过即可**(裁成「不满足」照样能提交,裁断连理由随卡交给人 —— 一条只接受「是」的判据是一句教模型说谎的提示);**L4 带谓词时谓词说了算**(谓词真 = 已达标,模型那句相反的理由一并丢掉;谓词假才轮到裁量);必交付物按 ledger 地址数本单草稿行 |
|
||||
| **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) |
|
||||
| **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) |
|
||||
@@ -252,7 +252,7 @@ activities/<id>/src/ ──(pnpm activities:build)──> dist/{manifest.json,in
|
||||
| `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 |
|
||||
| `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 |
|
||||
| `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 |
|
||||
| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住(闸回落到必交付物的台账声明) |
|
||||
| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住 —— **卡上加 `gateLedgers:["域.对象"]` 告诉闸这一张要拦哪几张表**,不给才回落到必交付物声明 |
|
||||
| `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 |
|
||||
| `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 |
|
||||
| `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 |
|
||||
|
||||
@@ -64,6 +64,13 @@ export const API_ERROR_CODES = [
|
||||
'precondition-failed',
|
||||
'internal',
|
||||
'gone',
|
||||
/*
|
||||
* 成员目录专用的两档 502(ADR-012)—— **重试有没有意义**是它们的分界,
|
||||
* 前端据此说两句不同的话:一句「找管理员补权限」,一句「过会儿再点」。
|
||||
* 混成一个 code 的话,界面只能说一句含糊的「出错了」。
|
||||
*/
|
||||
'keycloak-misconfigured',
|
||||
'keycloak-unavailable',
|
||||
] as const;
|
||||
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './http.ts';
|
||||
export * from './workbench.ts';
|
||||
export * from './governance.ts';
|
||||
export * from './org-directory.ts';
|
||||
export * from './ingest.ts';
|
||||
export * from './mail.ts';
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 成员与组织(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';
|
||||
import type { RouteContract } from './http.ts';
|
||||
import { surfaceSchema } from './http.ts';
|
||||
|
||||
/* ── 成员 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const orgUserSchema = z.object({
|
||||
/** Keycloak 用户 id(= `sub`,业务外键用它,不用用户名) */
|
||||
sub: z.string().min(1),
|
||||
/** 登录名,建后不可改 */
|
||||
username: z.string().min(1),
|
||||
/** `firstName lastName` 直拼;皆空回落 username —— 不会是空串 */
|
||||
displayName: z.string().min(1),
|
||||
email: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
/** realm 角色(家具角色已滤) */
|
||||
roles: z.array(z.string()),
|
||||
/** 这些角色合起来能进哪几个端 —— 由 `sys.role_access` 算,不是 Keycloak 的字段 */
|
||||
ends: z.array(surfaceSchema),
|
||||
/** 本地名册记的「几时来过」;没有 = 还没拿这枚身份访问过本系统 */
|
||||
lastSeenAt: z.string().optional(),
|
||||
/** 本地岗位(哨兵岗 NONE = 未分配)—— 目录里只读,改岗位走 `setStaffPositionRoute` */
|
||||
positionCode: z.string().optional(),
|
||||
});
|
||||
export type OrgUser = z.infer<typeof orgUserSchema>;
|
||||
|
||||
export const orgUserDetailSchema = orgUserSchema.extend({
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
emailVerified: z.boolean(),
|
||||
/** 直属组;`path` 形如 `/技术部/平台组` */
|
||||
groups: z.array(z.object({ id: z.string(), path: z.string() })),
|
||||
});
|
||||
export type OrgUserDetail = z.infer<typeof orgUserDetailSchema>;
|
||||
|
||||
export const listOrgUsersRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/org/users',
|
||||
access: 'ops',
|
||||
output: z.object({ users: z.array(orgUserSchema) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const getOrgUserRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/org/users/:sub',
|
||||
access: 'ops',
|
||||
params: z.object({ sub: z.string().min(1) }),
|
||||
output: orgUserDetailSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 新建成员 —— 建完立刻能登录。口令是**临时**的:本人首登必须改
|
||||
* (`temporary:true`,手册 §14.2);我们不存它,也不回显。
|
||||
*/
|
||||
export const createOrgUserRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/org/users',
|
||||
access: 'ops',
|
||||
body: z.object({
|
||||
username: z.string().min(1),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
/** 临时口令;不给就建一个登不进来的壳(人自己去改密码那条路) */
|
||||
password: z.string().min(1).optional(),
|
||||
/** 一并授的 realm 角色;不给就只有 Keycloak 的默认角色 */
|
||||
roles: z.array(z.string()).optional(),
|
||||
}),
|
||||
output: orgUserSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 改资料(姓名 / 邮箱)—— 用户名建后不可改,不在这里 */
|
||||
export const patchOrgUserRoute = {
|
||||
method: 'PATCH',
|
||||
path: '/api/gov/org/users/:sub',
|
||||
access: 'ops',
|
||||
params: z.object({ sub: z.string().min(1) }),
|
||||
body: z.object({
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
}),
|
||||
output: orgUserSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 启用 / 停用。停用**立刻踢掉该用户全部会话** —— 不然他攥着的 token
|
||||
* 还能用到过期为止,而「已停用」这三个字在界面上已经写出去了(界面不撒谎)。
|
||||
*/
|
||||
export const setOrgUserEnabledRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/org/users/:sub/enabled',
|
||||
access: 'ops',
|
||||
params: z.object({ sub: z.string().min(1) }),
|
||||
body: z.object({ enabled: z.boolean() }),
|
||||
output: orgUserSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 重置口令 —— 一律临时口令,本人首登必须改;服务端不回显、不留痕 */
|
||||
export const resetOrgUserPasswordRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/org/users/:sub/reset-password',
|
||||
access: 'ops',
|
||||
params: z.object({ sub: z.string().min(1) }),
|
||||
body: z.object({ password: z.string().min(1) }),
|
||||
output: z.object({ ok: z.literal(true) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 删人 —— 从 Keycloak 消失,不可撤销;本地名册那一行同批停用(留痕与在途还挂着他) */
|
||||
export const deleteOrgUserRoute = {
|
||||
method: 'DELETE',
|
||||
path: '/api/gov/org/users/:sub',
|
||||
access: 'ops',
|
||||
params: z.object({ sub: z.string().min(1) }),
|
||||
output: z.object({ ok: z.literal(true) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ─────────────────────────── */
|
||||
|
||||
export const orgRoleSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
/** Keycloak 的组合角色(展开后会带出别的角色) */
|
||||
composite: z.boolean(),
|
||||
/** 这个角色能进哪几个端(`sys.role_access`;没配过 = 空 = 哪儿都进不去) */
|
||||
ends: z.array(surfaceSchema),
|
||||
/** 持有它的成员数(已剔服务账号) */
|
||||
holders: z.number().int().nonnegative(),
|
||||
});
|
||||
export type OrgRole = z.infer<typeof orgRoleSchema>;
|
||||
|
||||
export const listOrgRolesRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/org/roles',
|
||||
access: 'ops',
|
||||
output: z.object({ roles: z.array(orgRoleSchema) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 授 / 收一个 realm 角色。收权**立刻踢掉该用户全部会话**(手册 §17.3)——
|
||||
* 否则他手上那枚 token 还带着旧角色,收权要等到 token 过期才生效。
|
||||
* 不能收自己的最后一条进运营中心的路(409)—— 收完没人进得来,这扇门就锁死了。
|
||||
*/
|
||||
export const setOrgUserRoleRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/org/users/:sub/roles/:role',
|
||||
access: 'ops',
|
||||
params: z.object({ sub: z.string().min(1), role: z.string().min(1) }),
|
||||
body: z.object({ granted: z.boolean() }),
|
||||
output: orgUserSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/* ── 组织:Keycloak 组 ─────────────────────────────────────────────────── */
|
||||
|
||||
export const orgGroupSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
/** 从根拼下来,如 `/技术部/平台组` */
|
||||
path: z.string().min(1),
|
||||
/** 顶层组为空 */
|
||||
parentId: z.string().optional(),
|
||||
/** 0 = 顶层 */
|
||||
depth: z.number().int().nonnegative(),
|
||||
/** 本组**直属**成员数(已剔服务账号,不含子组) */
|
||||
memberCount: z.number().int().nonnegative(),
|
||||
});
|
||||
export type OrgGroup = z.infer<typeof orgGroupSchema>;
|
||||
|
||||
/** 组树平表,按 `path` 升序 —— 层级看 `parentId` / `depth` */
|
||||
export const listOrgGroupsRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/org/groups',
|
||||
access: 'ops',
|
||||
output: z.object({ groups: z.array(orgGroupSchema) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 组的直属成员(含被停用的人 —— 这一屏管的是归属,不是能不能登录) */
|
||||
export const listOrgGroupMembersRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/org/groups/:id/members',
|
||||
access: 'ops',
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
output: z.object({ members: z.array(orgUserSchema) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 建组:`parentId` 给了就是子组,不给就是顶层组。同级不得重名(Keycloak 那一侧拦,409) */
|
||||
export const createOrgGroupRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/org/groups',
|
||||
access: 'ops',
|
||||
body: z.object({ name: z.string().min(1), parentId: z.string().optional() }),
|
||||
output: orgGroupSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const renameOrgGroupRoute = {
|
||||
method: 'PATCH',
|
||||
path: '/api/gov/org/groups/:id',
|
||||
access: 'ops',
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
body: z.object({ name: z.string().min(1) }),
|
||||
output: orgGroupSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 删组 —— 连同子组一起没(Keycloak 语义);人不会被删,只是不再属于这个组 */
|
||||
export const deleteOrgGroupRoute = {
|
||||
method: 'DELETE',
|
||||
path: '/api/gov/org/groups/:id',
|
||||
access: 'ops',
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
output: z.object({ ok: z.literal(true) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 成员进 / 出组 */
|
||||
export const setOrgUserGroupRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/org/users/:sub/groups/:groupId',
|
||||
access: 'ops',
|
||||
params: z.object({ sub: z.string().min(1), groupId: z.string().min(1) }),
|
||||
body: z.object({ member: z.boolean() }),
|
||||
output: z.object({ ok: z.literal(true) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 这一屏的全部路由 —— 归属一处列全,测试拿它逐条断言。
|
||||
* 逐个 import 去断言的写法,新增一条只要忘了加进名单就悄悄漏过去了。
|
||||
*/
|
||||
export const ORG_DIRECTORY_ROUTES = [
|
||||
listOrgUsersRoute,
|
||||
getOrgUserRoute,
|
||||
createOrgUserRoute,
|
||||
patchOrgUserRoute,
|
||||
setOrgUserEnabledRoute,
|
||||
resetOrgUserPasswordRoute,
|
||||
deleteOrgUserRoute,
|
||||
listOrgRolesRoute,
|
||||
setOrgUserRoleRoute,
|
||||
listOrgGroupsRoute,
|
||||
listOrgGroupMembersRoute,
|
||||
createOrgGroupRoute,
|
||||
renameOrgGroupRoute,
|
||||
deleteOrgGroupRoute,
|
||||
setOrgUserGroupRoute,
|
||||
] as const satisfies readonly RouteContract[];
|
||||
@@ -356,6 +356,18 @@ export const executionWorkOrderSchema = z.object({
|
||||
* 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格,
|
||||
* 按钮照常能按:**没有依据的时候不拦人**。
|
||||
*/
|
||||
/**
|
||||
* **这张卡该拦哪几张台账**(发卡的人说了算)—— 不给就照旧回落到必交付物声明。
|
||||
*
|
||||
* 起因(2026-08-27 W05 实测):数据工单是零输入卡(没有 `blocks`),闸于是回落到
|
||||
* **必交付物**的那几张表 —— 而数据工单要人补的是 `parts.profile` / `parts.parameter`
|
||||
* 这类**主数据**,压根不在交付物名单里。结果:件上红带如实点着「缺品牌名称、缺封装」,
|
||||
* 提交钮却是绿的,空手交卷一路放行。**闸看的表和件上摆的表不是同一张,闸就等于没有。**
|
||||
*
|
||||
* 声明的是台账地址(`域.对象`),基座据此现算缺几格 —— 与红带、放行门 L1 同一个数同一套算法。
|
||||
* 优先级:卡上绑了表(`blocks`)> 这一项 > 必交付物声明。
|
||||
*/
|
||||
gateLedgers: z.array(z.string()).optional(),
|
||||
gate: z
|
||||
.object({
|
||||
/** 能交了吗 */
|
||||
|
||||
@@ -21,6 +21,20 @@ export const STAFF_STATES = ['active', 'disabled'] as const;
|
||||
export const staffStateSchema = z.enum(STAFF_STATES);
|
||||
export type StaffState = z.infer<typeof staffStateSchema>;
|
||||
|
||||
/**
|
||||
* 人,还是服务账号。
|
||||
*
|
||||
* Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为
|
||||
* `service-account-<clientId>` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在
|
||||
* 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。
|
||||
*
|
||||
* 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**:
|
||||
* 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。
|
||||
* 这条判断归后端 —— 界面不该去猜账号名长什么样。
|
||||
*/
|
||||
export const staffKindSchema = z.enum(['person', 'service']);
|
||||
export type StaffKind = z.infer<typeof staffKindSchema>;
|
||||
|
||||
export const staffSchema = z.object({
|
||||
/** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */
|
||||
id: staffIdSchema,
|
||||
@@ -35,6 +49,8 @@ export const staffSchema = z.object({
|
||||
state: staffStateSchema.default('active'),
|
||||
/** realm 角色快照(token 签发时;噪声角色已滤) */
|
||||
roles: z.array(z.string()).default([]),
|
||||
/** 人 / 服务账号 —— 由账号名判(见 `staffKindSchema`),不落库 */
|
||||
kind: staffKindSchema.default('person'),
|
||||
});
|
||||
export type Staff = z.infer<typeof staffSchema>;
|
||||
|
||||
|
||||
Generated
+2
@@ -55,6 +55,8 @@ importers:
|
||||
|
||||
activities/crm-relationship-coverage: {}
|
||||
|
||||
activities/part-substitution-assessment: {}
|
||||
|
||||
activities/quote-alternative: {}
|
||||
|
||||
activities/quote-archive: {}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/bin/bash
|
||||
# 稳定版 —— 把**当前代码**构建成一份快照,在另一组端口上常驻,给外部访问。
|
||||
# 与 ./start.sh 起的开发服务(:8787 / :5173,tsup --watch + vite HMR)互不相干:
|
||||
# 开发那边改代码、重启、崩掉,这边一个字都不动;要发新版,显式跑一次 update。
|
||||
#
|
||||
# ./stable.sh 起(没有快照就先构建;已在跑的不动)
|
||||
# ./stable.sh update 用当前代码重建快照,然后重启两个 —— 这才是「发一版」
|
||||
# ./stable.sh restart 不重建,只重启
|
||||
# ./stable.sh stop 停掉两个
|
||||
# ./stable.sh status 只看状态,一个进程都不动
|
||||
# ./stable.sh logs [api|web] 看日志(tail -f)
|
||||
# ./stable.sh env-from-dev 把开发服务(:8787)进程里的环境变量抄成 .stable/env
|
||||
#
|
||||
# 落盘位置(全部 gitignore):
|
||||
# apps/api/.build/stable/ 后端快照(tsup 一次性构建,不 watch)—— 必须住在 apps/api 下,
|
||||
# 产物里 `import "hono"` 这类裸名要从 apps/api/node_modules 解析
|
||||
# apps/web/.build/stable/ 前端快照(vite build),由 vite preview 托静态 + /api 反代到稳定版后端
|
||||
# .stable/env 环境变量(KEY=VALUE,600 权限)。缺它起不来 —— Keycloak 三样是硬门
|
||||
# .stable/logs/ api.log / web.log / build.log
|
||||
# .stable/RELEASE 这份快照是哪个 commit、什么时候构的
|
||||
#
|
||||
# .stable/env 长这样(env-from-dev 会自动照开发进程抄一份;要单独一套自己改):
|
||||
# KEYCLOAK_URL=https://… KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=task-dispatcher
|
||||
# KEYCLOAK_PROVISIONING_CLIENT_ID=… KEYCLOAK_PROVISIONING_CLIENT_SECRET=…(可选,名册同步)
|
||||
# JCAI_TEST_ACCOUNTS=… JCAI_SECRET_KEY=…(开发没设的话这里也别设 —— 库里的密文
|
||||
# 是用哪把钥匙封的就得用哪把开)
|
||||
# DATABASE_URL=… (不填 = 与开发共用一个库,见下)
|
||||
# STABLE_API_PORT=8790 STABLE_WEB_PORT=5180
|
||||
# STABLE_ALLOWED_HOSTS=jcai-bpos.akhr-ai.com 经域名/反代访问时要列在这儿(逗号分隔,`*` 全放)——
|
||||
# vite preview 有宿主校验,不列会 403「This host is not allowed」
|
||||
#
|
||||
# ./stable.sh stop web / restart api —— 第二个参数可只动一个
|
||||
#
|
||||
# 关于数据库:默认与开发共用同一个库(两台后端连一个库是本仓库既有的跑法,跨进程租约与
|
||||
# LISTEN/NOTIFY 桥都为此做过)。代价要知道:开发那边 `reset-runs` 会把稳定版上外部人跑出来的
|
||||
# 任务一起清掉;开发那边跑了新迁移,稳定版的旧代码就在改过的表结构上跑。真要隔离,
|
||||
# 在 .stable/env 里把 DATABASE_URL 指到另一个库,并自己跑一遍迁移 + 播种。
|
||||
#
|
||||
# 沿用 start.sh 踩过的坑:端口属主只认 LISTEN(不然会把浏览器的连接当成占用者);
|
||||
# 起完轮询到 /api/health 的 body 里 db:"up" 才算数(它在库挂了时照样返 200)。
|
||||
set -u
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
REPO=$(pwd)
|
||||
STABLE="$REPO/.stable"
|
||||
ENV_FILE="$STABLE/env"
|
||||
LOGDIR="$STABLE/logs"
|
||||
API_DIR="$REPO/apps/api"
|
||||
WEB_DIR="$REPO/apps/web"
|
||||
API_OUT="$API_DIR/.build/stable"
|
||||
WEB_OUT="$WEB_DIR/.build/stable"
|
||||
DEV_API_PORT=8787
|
||||
mkdir -p "$LOGDIR"
|
||||
WHAT=${1:-start}
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { say " ✗ $*"; exit 1; }
|
||||
|
||||
# ── 环境 ────────────────────────────────────────────────────────────────
|
||||
ENV_KEYS='KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET JCAI_TEST_ACCOUNTS JCAI_SECRET_KEY JCAI_DEEPSEEK_API_KEY JCAI_LLM_API_KEY JCAI_LLM_BASE_URL DATABASE_URL JCAI_ATTACHMENT_ROOT JCAI_DISPATCH_INTERVAL_MS JCAI_MAIL_POLL_TICK_MS NODE_ENV'
|
||||
|
||||
# 端口的真属主 —— 只认 LISTEN
|
||||
owner_of() { lsof -ti:"$1" -sTCP:LISTEN 2>/dev/null | head -1; }
|
||||
|
||||
env_from_dev() {
|
||||
local pid envs k v n=0
|
||||
pid=$(owner_of "$DEV_API_PORT")
|
||||
[ -n "$pid" ] || die "开发后端(:$DEV_API_PORT)没在跑,抄不到环境 —— 先 ./start.sh api,或照文件头的模版手写 $ENV_FILE"
|
||||
# macOS 的 ps -E 把环境变量追在命令后面,空格分隔;这几个键的值都不含空格
|
||||
envs=$(ps -E -p "$pid" -o command= 2>/dev/null | tr ' ' '\n')
|
||||
[ -n "$envs" ] || die "读不到 pid $pid 的环境(不是同一个用户起的?)"
|
||||
: > "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
for k in $ENV_KEYS; do
|
||||
v=$(printf '%s\n' "$envs" | grep -m1 "^$k=" | cut -d= -f2-)
|
||||
if [ -n "$v" ]; then
|
||||
printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE"
|
||||
n=$((n + 1))
|
||||
fi
|
||||
done
|
||||
printf '%s=%s\n%s=%s\n%s=%s\n' STABLE_API_PORT "${STABLE_API_PORT:-8790}" STABLE_WEB_PORT "${STABLE_WEB_PORT:-5180}" \
|
||||
STABLE_ALLOWED_HOSTS "${STABLE_ALLOWED_HOSTS:-}" >> "$ENV_FILE"
|
||||
say " ✓ 从开发后端(pid $pid)抄了 $n 个变量 → $ENV_FILE(600)"
|
||||
say " $(cut -d= -f1 "$ENV_FILE" | tr '\n' ' ')"
|
||||
}
|
||||
|
||||
load_env() {
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
say " $ENV_FILE 还没有,先照开发后端抄一份:"
|
||||
env_from_dev
|
||||
fi
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
API_PORT=${STABLE_API_PORT:-8790}
|
||||
WEB_PORT=${STABLE_WEB_PORT:-5180}
|
||||
for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID; do
|
||||
eval "v=\${$k:-}"
|
||||
# shellcheck disable=SC2154
|
||||
[ -n "$v" ] || die "$ENV_FILE 里缺 $k —— 统一鉴权中心是唯一的登录途径,缺了后端拒绝启动(ADR-012)"
|
||||
done
|
||||
}
|
||||
|
||||
# ── 构建快照 ────────────────────────────────────────────────────────────
|
||||
build() {
|
||||
local sha dirty at
|
||||
say '构建快照:'
|
||||
say " api → apps/api/.build/stable …"
|
||||
# --out-dir 盖过 tsup.config 的 outDir;config 里的 clean:true 清的就是这个目录,碰不到开发的 dist/
|
||||
if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) > "$LOGDIR/build.log" 2>&1; then
|
||||
tail -20 "$LOGDIR/build.log"
|
||||
die 'api 构建失败(全文 .stable/logs/build.log)'
|
||||
fi
|
||||
[ -f "$API_OUT/main.js" ] || die "构建说成功,可 $API_OUT/main.js 不在"
|
||||
say " web → apps/web/.build/stable …"
|
||||
if ! (cd "$WEB_DIR" && pnpm exec vite build --outDir .build/stable --emptyOutDir) >> "$LOGDIR/build.log" 2>&1; then
|
||||
tail -20 "$LOGDIR/build.log"
|
||||
die 'web 构建失败(全文 .stable/logs/build.log)'
|
||||
fi
|
||||
[ -f "$WEB_OUT/index.html" ] || die "构建说成功,可 $WEB_OUT/index.html 不在"
|
||||
sha=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
dirty=$([ -n "$(git status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '')
|
||||
at=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
printf 'commit=%s%s\nbuilt_at=%s\n' "$sha" "$dirty" "$at" > "$STABLE/RELEASE"
|
||||
say " ✓ 快照:$sha$dirty · $at"
|
||||
}
|
||||
|
||||
have_snapshot() { [ -f "$API_OUT/main.js" ] && [ -f "$WEB_OUT/index.html" ]; }
|
||||
|
||||
# ── 起停 ────────────────────────────────────────────────────────────────
|
||||
# 直接 exec node,进程链上不留 pnpm/bash:pid 文件里记的就是真正听端口的那个进程
|
||||
start_api() {
|
||||
if [ -n "$(owner_of "$API_PORT")" ]; then
|
||||
say " api :$API_PORT 已在跑,不动它"
|
||||
return 0
|
||||
fi
|
||||
# cwd 必须是 apps/api:附件根目录默认 cwd/.data/attachments,与开发服务同一处 ——
|
||||
# 共用一个库时,库里存的 file:// 指针两边都得指得到
|
||||
( cd "$API_DIR" && PORT="$API_PORT" exec nohup node .build/stable/main.js < /dev/null >> "$LOGDIR/api.log" 2>&1 ) &
|
||||
echo $! > "$STABLE/api.pid"
|
||||
disown 2>/dev/null
|
||||
say " api :$API_PORT 启动中… (pid $(cat "$STABLE/api.pid"),日志 .stable/logs/api.log)"
|
||||
}
|
||||
|
||||
start_web() {
|
||||
if [ -n "$(owner_of "$WEB_PORT")" ]; then
|
||||
say " web :$WEB_PORT 已在跑,不动它"
|
||||
return 0
|
||||
fi
|
||||
# vite preview:托 .build/stable 的静态文件,/api 反代沿用 vite.config 的 server.proxy(preview 默认继承),
|
||||
# 目标由 API_PROXY_TARGET 指到稳定版后端;--host 绑全部网卡,外部才连得进来
|
||||
( cd "$WEB_DIR" && API_PROXY_TARGET="http://127.0.0.1:$API_PORT" PREVIEW_ALLOWED_HOSTS="${STABLE_ALLOWED_HOSTS:-}" \
|
||||
exec nohup node node_modules/vite/bin/vite.js preview \
|
||||
--outDir .build/stable --host --port "$WEB_PORT" --strictPort < /dev/null >> "$LOGDIR/web.log" 2>&1 ) &
|
||||
echo $! > "$STABLE/web.pid"
|
||||
disown 2>/dev/null
|
||||
say " web :$WEB_PORT 启动中… (pid $(cat "$STABLE/web.pid"),日志 .stable/logs/web.log)"
|
||||
}
|
||||
|
||||
# 只杀「确实是我们起的那个」:pid 文件里的 pid 还活着且命令里带 .build/stable,或端口属主命令里带 .build/stable
|
||||
# (dist-stable 是 08-26 之前的旧落点,留着认一阵子,免得换目录那一刻停不掉老进程)
|
||||
is_ours() { ps -o command= -p "$1" 2>/dev/null | grep -qE '\.build/stable|dist-stable'; }
|
||||
|
||||
stop_one() {
|
||||
local label=$1 port=$2 pidfile=$3 pid owner i
|
||||
pid=$(cat "$pidfile" 2>/dev/null || true)
|
||||
owner=$(owner_of "$port")
|
||||
if [ -z "$owner" ] && { [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; }; then
|
||||
say " $label 本来就没在跑"
|
||||
rm -f "$pidfile"
|
||||
return 0
|
||||
fi
|
||||
# pid 文件里的与端口属主多半是同一个进程,别对它说两遍话
|
||||
[ "$owner" = "$pid" ] && owner=''
|
||||
for i in $pid $owner; do
|
||||
if is_ours "$i"; then
|
||||
kill "$i" 2>/dev/null
|
||||
elif [ -n "$(ps -o command= -p "$i" 2>/dev/null)" ]; then
|
||||
say " ! :$port 的属主 pid $i 不是稳定版的进程,不动它:$(ps -o command= -p "$i" | cut -c1-80)"
|
||||
fi
|
||||
done
|
||||
for i in 1 2 3 4 5 6 7 8; do
|
||||
[ -z "$(owner_of "$port")" ] && { say " $label 已停"; rm -f "$pidfile"; return 0; }
|
||||
sleep 1
|
||||
done
|
||||
owner=$(owner_of "$port")
|
||||
if is_ours "$owner"; then
|
||||
kill -9 "$owner" 2>/dev/null
|
||||
sleep 1
|
||||
[ -z "$(owner_of "$port")" ] && { say " $label 已停(-9)"; rm -f "$pidfile"; return 0; }
|
||||
fi
|
||||
say " ! $label :$port 还被占着 —— 手工看看 lsof -i:$port"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 核实 ────────────────────────────────────────────────────────────────
|
||||
http_code() { curl -s -o /dev/null -m 3 -w '%{http_code}' "$1" 2>/dev/null; }
|
||||
|
||||
# /api/health 在库挂了时照样返 200,得看 body 里的 db
|
||||
wait_api() {
|
||||
local i body
|
||||
for i in $(seq 1 45); do
|
||||
body=$(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null)
|
||||
case "$body" in
|
||||
*'"db":"up"'*) say " ✓ api 就绪 (${i}s) $body"; return 0 ;;
|
||||
*'"db":"down"'*) say " ✗ api 起来了但连不上库:$body"; return 1 ;;
|
||||
esac
|
||||
# 进程已经死了就别等满 45 秒
|
||||
if [ -f "$STABLE/api.pid" ] && ! kill -0 "$(cat "$STABLE/api.pid")" 2>/dev/null; then
|
||||
say ' ✗ api 进程已退出 —— 日志末尾:'
|
||||
tail -5 "$LOGDIR/api.log" | sed 's/^/ /'
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
say ' ✗ api 45 秒没有应答 —— 看 .stable/logs/api.log'
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_web() {
|
||||
local i code
|
||||
for i in $(seq 1 30); do
|
||||
code=$(http_code "http://127.0.0.1:$WEB_PORT/")
|
||||
[ "$code" = '200' ] && { say " ✓ web 就绪 (${i}s)"; return 0; }
|
||||
if [ -f "$STABLE/web.pid" ] && ! kill -0 "$(cat "$STABLE/web.pid")" 2>/dev/null; then
|
||||
say ' ✗ web 进程已退出 —— 日志末尾:'
|
||||
tail -5 "$LOGDIR/web.log" | sed 's/^/ /'
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
say ' ✗ web 30 秒没有应答 —— 看 .stable/logs/web.log'
|
||||
return 1
|
||||
}
|
||||
|
||||
verify() {
|
||||
local rc=0 proxy spa
|
||||
wait_api || rc=1
|
||||
wait_web || rc=1
|
||||
proxy=$(http_code "http://127.0.0.1:$WEB_PORT/api/health")
|
||||
[ "$proxy" = '200' ] && say ' ✓ 反代 /api → 200' || { say " ✗ 反代 /api → ${proxy:-连不上}"; rc=1; }
|
||||
# 三个台面是同一个 SPA,刷新 /design /ops 要吐 index.html
|
||||
spa=$(http_code "http://127.0.0.1:$WEB_PORT/design")
|
||||
[ "$spa" = '200' ] && say ' ✓ SPA 回退 /design → 200' || { say " ✗ SPA 回退 /design → ${spa:-连不上}"; rc=1; }
|
||||
return $rc
|
||||
}
|
||||
|
||||
lan_ip() { ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true; }
|
||||
|
||||
# 登录能不能跳回来,Keycloak 那一侧说了算:web-portal 客户端没登记这个来源的 redirect URI,
|
||||
# 页面开得了、点「前往统一登录」就吃一句 Invalid parameter: redirect_uri。
|
||||
# 服务起好了不等于能用 —— 这里替人先问一次 Keycloak(只看它对 redirect_uri 的裁决,不登录)。
|
||||
WEB_CLIENT_ID=$(grep -m1 '^VITE_KEYCLOAK_CLIENT_ID=' "$WEB_DIR/.env.production" 2>/dev/null | cut -d= -f2-)
|
||||
kc_redirect_ok() {
|
||||
local origin=$1 enc body
|
||||
enc=$(printf '%s/' "$origin" | sed 's,/,%2F,g; s,:,%3A,g')
|
||||
body=$(curl -s -m 10 "$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=${WEB_CLIENT_ID:-web-portal}&response_type=code&scope=openid&redirect_uri=$enc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256" 2>/dev/null)
|
||||
case "$body" in
|
||||
'') return 2 ;; # Keycloak 连不上,说不出
|
||||
*'Invalid parameter: redirect_uri'*) return 1 ;; # 明确拒了
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 列进 STABLE_ALLOWED_HOSTS 的域名,外部多半是 https 进来的,一并问 Keycloak(通配与整子域问不了,跳过)
|
||||
allowed_origins() {
|
||||
local h
|
||||
for h in $(printf '%s' "${STABLE_ALLOWED_HOSTS:-}" | tr ',' ' '); do
|
||||
case "$h" in '*' | .*) ;; *) printf 'https://%s\n' "$h" ;; esac
|
||||
done
|
||||
}
|
||||
|
||||
report_kc() {
|
||||
local origin
|
||||
set -- "$@" $(allowed_origins)
|
||||
for origin in "$@"; do
|
||||
kc_redirect_ok "$origin"
|
||||
case $? in
|
||||
0) say " ✓ Keycloak 接受 $origin 的 redirect_uri" ;;
|
||||
1) say " ✗ Keycloak 拒绝 $origin 的 redirect_uri —— 去 realm $KEYCLOAK_REALM 的客户端 ${WEB_CLIENT_ID:-web-portal} 把 $origin/* 加进 Valid redirect URIs 与 Web origins,不然这个入口登不进" ;;
|
||||
*) say " ? Keycloak($KEYCLOAK_URL)没应答,登录能不能跳回来说不准" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
finish() {
|
||||
local ip
|
||||
say ''
|
||||
if [ "$1" = '0' ]; then
|
||||
ip=$(lan_ip)
|
||||
say " 稳定版都好了 → http://localhost:$WEB_PORT/"
|
||||
[ -n "$ip" ] && say " 局域网 → http://$ip:$WEB_PORT/"
|
||||
report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"}
|
||||
else
|
||||
say ' 有起不来的,日志在 .stable/logs/'
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
local ip
|
||||
say '稳定版状态:'
|
||||
[ -f "$STABLE/RELEASE" ] && sed 's/^/ 快照 /' "$STABLE/RELEASE" || say ' 快照 (还没构建过)'
|
||||
say " api :$API_PORT $(http_code "http://127.0.0.1:$API_PORT/api/health") $(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) pid $(owner_of "$API_PORT")"
|
||||
say " web :$WEB_PORT $(http_code "http://127.0.0.1:$WEB_PORT/") pid $(owner_of "$WEB_PORT")"
|
||||
say " 反代 /api $(http_code "http://127.0.0.1:$WEB_PORT/api/health")"
|
||||
ip=$(lan_ip)
|
||||
[ -n "$ip" ] && say " 入口 http://localhost:$WEB_PORT/ · http://$ip:$WEB_PORT/"
|
||||
report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"}
|
||||
}
|
||||
|
||||
# ── 分派 ────────────────────────────────────────────────────────────────
|
||||
case "$WHAT" in
|
||||
env-from-dev)
|
||||
env_from_dev
|
||||
exit 0
|
||||
;;
|
||||
logs)
|
||||
exec tail -n 50 -f "$LOGDIR/${2:-api}.log"
|
||||
;;
|
||||
status)
|
||||
load_env
|
||||
status
|
||||
exit 0
|
||||
;;
|
||||
stop)
|
||||
load_env
|
||||
say '停止稳定版:'
|
||||
[ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid"
|
||||
[ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid"
|
||||
exit 0
|
||||
;;
|
||||
start)
|
||||
load_env
|
||||
have_snapshot || build
|
||||
say "起稳定版(api :$API_PORT · web :$WEB_PORT):"
|
||||
start_api
|
||||
start_web
|
||||
;;
|
||||
update)
|
||||
load_env
|
||||
build
|
||||
say '重启稳定版:'
|
||||
stop_one api "$API_PORT" "$STABLE/api.pid"
|
||||
stop_one web "$WEB_PORT" "$STABLE/web.pid"
|
||||
start_api
|
||||
start_web
|
||||
;;
|
||||
restart)
|
||||
load_env
|
||||
have_snapshot || die '还没有快照,先 ./stable.sh update'
|
||||
say "重启稳定版${2:+($2)}:"
|
||||
[ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid"
|
||||
[ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid"
|
||||
start_api
|
||||
start_web
|
||||
;;
|
||||
*)
|
||||
say "不认识的参数:$WHAT(可用:start / update / restart / stop / status / logs [api|web] / env-from-dev)"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
RC=0
|
||||
verify || RC=1
|
||||
finish $RC
|
||||
exit $RC
|
||||
@@ -19,6 +19,7 @@ import { manifest as quoteSourcing } from '../activities/quote-sourcing/src/mani
|
||||
import { manifest as quoteAlternative } from '../activities/quote-alternative/src/manifest.ts';
|
||||
import { manifest as crmRelationshipCoverage } from '../activities/crm-relationship-coverage/src/manifest.ts';
|
||||
import { manifest as brandContentToolkit } from '../activities/brand-content-toolkit/src/manifest.ts';
|
||||
import { manifest as partSubstitutionAssessment } from '../activities/part-substitution-assessment/src/manifest.ts';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
@@ -31,6 +32,7 @@ const PACKAGES = [
|
||||
{ dir: 'quote-alternative', manifest: quoteAlternative },
|
||||
{ dir: 'crm-relationship-coverage', manifest: crmRelationshipCoverage },
|
||||
{ dir: 'brand-content-toolkit', manifest: brandContentToolkit },
|
||||
{ dir: 'part-substitution-assessment', manifest: partSubstitutionAssessment },
|
||||
] as const;
|
||||
|
||||
for (const pkg of PACKAGES) {
|
||||
|
||||
+388
-21
@@ -9,6 +9,7 @@
|
||||
* node tools/e2e-a2.mjs a1 # 只跑 A1 回归(执行卡零输入 + 提交闸回落)
|
||||
* node tools/e2e-a2.mjs e04 # 只跑 E04 三案例(两张执行单 + 决策卡审批循环)
|
||||
* node tools/e2e-a2.mjs i01 # 只跑 I01 三案例(一张执行单 + 初稿审核决策卡 + 发布放行)
|
||||
* node tools/e2e-a2.mjs w05 # 只跑 W05 五案例(ACT-WORK-05 差异评估:条件数据工单 P-08/P-10 + 执行单 + 决策卡审核循环 + 放行)
|
||||
* node tools/e2e-a2.mjs # 全跑
|
||||
* node tools/e2e-a2.mjs a2 1 # 只跑 A2 的第 1 条(0 起)
|
||||
*
|
||||
@@ -30,9 +31,36 @@ const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, '');
|
||||
const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp';
|
||||
const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? '';
|
||||
const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? '';
|
||||
/**
|
||||
* **直供 token 的旁路**(`JCAI_E2E_BEARER`)—— 手边没有服务账号客户端时的临时通道:
|
||||
* 直接给一枚已经拿到手的 access token(比如从浏览器会话里取的、有效期调长过的)。
|
||||
* 两处代价说在前头,别不知不觉踩上:
|
||||
* ①**卡会落到那个人的真工作台上**(服务账号那条路才是「绝不派到用户工作台」的);
|
||||
* ②**不会自动续期** —— 过期了整套当场 401,得换一枚重跑。
|
||||
* **给了就用它** —— 显式设了这个变量就是要走旁路,哪怕环境里还留着一对
|
||||
* `KEYCLOAK_E2E_CLIENT_ID/SECRET`(常见于 `.stable/env` 里的历史残留,还未必有效);
|
||||
* 不设它才走服务账号那条正路(ADR-012)。
|
||||
*/
|
||||
const DIRECT_BEARER = (process.env.JCAI_E2E_BEARER ?? '').replace(/^Bearer\s+/i, '').trim();
|
||||
/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */
|
||||
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 标准化参数子表 `parts.parameter` 的行(w05 专用)—— 按 [参数名, 值, 单位, 约束, 说明]
|
||||
* 五元组批量转成保存载荷的行对象;数值照抄各案例原先写在 `normalized_parameter_set`
|
||||
* 那个文本列里的那几行,一个数都不改(2026-08-27 结构改:参数集拆成独立子表)。
|
||||
*/
|
||||
function paramRows(internalPn, rows) {
|
||||
return rows.map(([param_name, param_value, param_unit, constraint_type, param_note]) => ({
|
||||
internal_pn: internalPn,
|
||||
param_name,
|
||||
param_value,
|
||||
param_unit,
|
||||
constraint_type,
|
||||
param_note,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */
|
||||
const SUITES = {
|
||||
a1: {
|
||||
@@ -507,13 +535,244 @@ const SUITES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
* ACT-WORK-05 差异评估 —— 1 数据(条件,P-08 / P-10 各一张)+ 1 执行 + 2 验收(审核走决策卡循环、放行终局)。
|
||||
* 库里只播了 LM2596S-5.0 / LM2596S-ADJ:XL2596S-5.0、CN-DCDC-32V、LM2596S-3.3 刻意不在,
|
||||
* 「候选缺库 / 原 PN 缺库 / 硬约束不推荐」三条路靠它们。e2e 关落真库,主数据不落正式表,案例可反复跑。
|
||||
*/
|
||||
w05: {
|
||||
activityId: 'part-substitution-assessment',
|
||||
address: 'sub_eval.input_gap',
|
||||
blankCol: 'supplement',
|
||||
stages: [
|
||||
{
|
||||
// 这张卡摆两张表(表 1 主记录 + 表 2 标准化参数子表)—— 主表照旧,子表挂在 extraTables 里
|
||||
// 加性表达(见 buildEdits 顶注),保存时两张表的行一次并进同一个 `tables` 载荷。
|
||||
match: /数据工单 · 芯片主数据创建/,
|
||||
address: 'parts.profile',
|
||||
rowKey: 'internal_pn',
|
||||
blankCol: 'brand_name',
|
||||
expectGateBlock: true,
|
||||
edits: [],
|
||||
// 案例没点名的料号兜底(不该走到这里 —— 每个案例都在 stageEdits 里逐颗给了)
|
||||
fillAllBlank: {
|
||||
brand_name: '兜底品牌',
|
||||
specification_description: '兜底规格(案例没给参数集)',
|
||||
package: 'TO-263-5',
|
||||
lifecycle_status: 'active',
|
||||
},
|
||||
extraTables: [
|
||||
{
|
||||
// 主键是 (internal_pn, param_name) 复合列 —— 两颗料同名参数(如都有「工作温度」)
|
||||
// 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。
|
||||
address: 'parts.parameter',
|
||||
rowKey: ['internal_pn', 'param_name'],
|
||||
blankCol: 'param_value',
|
||||
edits: [],
|
||||
// 案例没点名的参数行兜底(模型骨架只给了 internal_pn+param_name、案例没接住时才会用到)
|
||||
fillAllBlank: { param_value: '未披露', constraint_type: 'hard', param_note: '兜底(案例没给这一条)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
match: /数据工单 · 原厂技术资料创建/,
|
||||
address: 'parts.document',
|
||||
rowKey: 'internal_pn',
|
||||
blankCol: 'document_title',
|
||||
expectGateBlock: true,
|
||||
edits: [],
|
||||
fillAllBlank: {
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
document_title: 'Datasheet(e2e 兜底)',
|
||||
document_version: 'Rev A',
|
||||
document_ref: 'https://example.com/datasheet.pdf',
|
||||
citation_locator: 'p.1',
|
||||
document_status: 'valid',
|
||||
},
|
||||
},
|
||||
{
|
||||
match: /评估输入与资料补齐确认/,
|
||||
address: 'sub_eval.input_gap',
|
||||
rowKey: 'item_key',
|
||||
blankCol: 'supplement',
|
||||
expectGateBlock: true,
|
||||
edits: [
|
||||
{ item_key: 'application_scenario', supplement: '12V→5V、工业温域(-40~85℃)、输入上限 40V' },
|
||||
{ item_key: 'bom_position', supplement: 'U3 电源位' },
|
||||
],
|
||||
// 其余 gap 行(原厂未披露的参数)统一「留 UNKNOWN」—— 补不了也是合法答案
|
||||
fillWhere: { column: 'status', equals: 'gap', values: { supplement: '原厂未披露,留 UNKNOWN 并挂验证任务' } },
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
{
|
||||
sample: 0,
|
||||
name: '候选缺库 · XL2596S-5.0 派数据工单(P-08 + P-10)· 审核先点名驳回再通过',
|
||||
// 决策卡序:候选 ≥2 缺库先问 → create;审核第一张点名意见驳回;第二张通过
|
||||
decideSeq: ['create', 'custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
|
||||
expectDecision: true,
|
||||
expectPort: 'assessment-released',
|
||||
stageEdits: {
|
||||
'parts.profile': [
|
||||
{
|
||||
internal_pn: 'XL2596S-5.0',
|
||||
brand_name: 'XLSEMI',
|
||||
manufacturer_name: 'XLSEMI',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||
package: 'TO-263-5',
|
||||
process_technology: 'BCD',
|
||||
lifecycle_status: 'active',
|
||||
datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
|
||||
datasheet_version: 'Rev C',
|
||||
application_scenarios: '工业电源;消费电源',
|
||||
},
|
||||
],
|
||||
'parts.parameter': paramRows('XL2596S-5.0', [
|
||||
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
|
||||
['输出电压', '5.0', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
|
||||
]),
|
||||
'parts.document': [
|
||||
{
|
||||
internal_pn: 'XL2596S-5.0',
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
brand_name: 'XLSEMI',
|
||||
document_title: 'XL2596S 150KHz 3A Buck DC/DC Converter',
|
||||
document_version: 'Rev C',
|
||||
document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
|
||||
citation_locator: 'p.3 Electrical Characteristics',
|
||||
document_status: 'valid',
|
||||
},
|
||||
],
|
||||
},
|
||||
// 使能阈值未披露 → UNKNOWN → 条件推荐(工具算的,不是模型说的)
|
||||
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' },
|
||||
},
|
||||
{
|
||||
sample: 1,
|
||||
name: '都在库 · 常路 · 可调版 → 备选 · 审核一次通过',
|
||||
decide: 'approve',
|
||||
expectPort: 'assessment-released',
|
||||
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'LM2596S-ADJ', column: 'recommendation_level', contains: 'alternate' },
|
||||
},
|
||||
{
|
||||
sample: 2,
|
||||
name: '原 PN 缺库 · 硬阻塞建档(不问人)· 审核通过',
|
||||
decide: 'approve',
|
||||
expectPort: 'assessment-released',
|
||||
stageEdits: {
|
||||
'parts.profile': [
|
||||
{
|
||||
internal_pn: 'LM2596S-3.3',
|
||||
brand_name: 'TI',
|
||||
manufacturer_name: 'Texas Instruments',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 3.3V,150kHz',
|
||||
package: 'TO-263-5',
|
||||
process_technology: 'BCD',
|
||||
lifecycle_status: 'nrnd',
|
||||
datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
datasheet_version: 'Rev H (2023-09)',
|
||||
},
|
||||
],
|
||||
'parts.parameter': paramRows('LM2596S-3.3', [
|
||||
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
|
||||
['输出电压', '3.3', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '5', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '1.4', 'V', 'hard', 'ON/OFF 阈值'],
|
||||
]),
|
||||
'parts.document': [
|
||||
{
|
||||
internal_pn: 'LM2596S-3.3',
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
brand_name: 'TI',
|
||||
document_title: 'LM2596 SIMPLE SWITCHER Power Converter',
|
||||
document_version: 'Rev H (2023-09)',
|
||||
document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
citation_locator: '§6.5 Electrical Characteristics(3.3V 列)',
|
||||
document_status: 'valid',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
sample: 3,
|
||||
name: '三候选 · 两颗缺库各问一次 · 国产 32V 硬约束不推荐',
|
||||
decideSeq: ['create', 'create', 'approve'],
|
||||
expectDecision: true,
|
||||
// 两颗候选缺库 → 按规格各问一次(审核卡也是决策卡,所以要按问法数,不能只看张数)
|
||||
expectAsks: [{ re: /查无记录/, count: 2 }],
|
||||
expectPort: 'assessment-released',
|
||||
stageEdits: {
|
||||
'parts.profile': [
|
||||
{
|
||||
internal_pn: 'XL2596S-5.0',
|
||||
brand_name: 'XLSEMI',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||
package: 'TO-263-5',
|
||||
lifecycle_status: 'active',
|
||||
},
|
||||
{
|
||||
internal_pn: 'CN-DCDC-32V',
|
||||
brand_name: '国产某厂',
|
||||
specification_description: '降压型 DCDC,3A,固定 5.0V,输入上限 32V',
|
||||
package: 'TO-263-5',
|
||||
lifecycle_status: 'active',
|
||||
},
|
||||
],
|
||||
'parts.parameter': [
|
||||
...paramRows('XL2596S-5.0', [
|
||||
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
|
||||
['输出电压', '5.0', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
|
||||
]),
|
||||
...paramRows('CN-DCDC-32V', [
|
||||
['输入电压范围', '4.5~32', 'V', 'hard', '上限 32V'],
|
||||
['输出电压', '5.0', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '6', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '1.3', 'V', 'hard', 'ON/OFF 阈值'],
|
||||
]),
|
||||
],
|
||||
'parts.document': [
|
||||
{ internal_pn: 'XL2596S-5.0', doc_seq: '01', document_type: 'datasheet', document_title: 'XL2596S Datasheet', document_version: 'Rev C', document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', citation_locator: 'p.3', document_status: 'valid' },
|
||||
{ internal_pn: 'CN-DCDC-32V', doc_seq: '01', document_type: 'datasheet', document_title: 'CN-DCDC-32V Datasheet', document_version: 'V1.0', document_ref: 'https://example.com/cn-dcdc-32v.pdf', citation_locator: 'p.2', document_status: 'valid' },
|
||||
],
|
||||
},
|
||||
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'CN-DCDC-32V', column: 'recommendation_level', contains: 'not_recommended' },
|
||||
},
|
||||
{
|
||||
sample: 4,
|
||||
name: '新客户 · 录入卡先建档 · 审核通过',
|
||||
entryValues: { name: '深圳恒星电源有限公司', industry: '工业电源', region: '广东' },
|
||||
decide: 'approve',
|
||||
expectPort: 'assessment-released',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
let bearer = '';
|
||||
let bearerAt = 0;
|
||||
|
||||
async function api(path, init = {}) {
|
||||
if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
|
||||
/* 直供的那枚续不了期(没有 refresh 凭据),只有服务账号那条路才换 */
|
||||
if (bearer !== '' && DIRECT_BEARER === '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) },
|
||||
@@ -534,8 +793,14 @@ async function api(path, init = {}) {
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (DIRECT_BEARER !== '') {
|
||||
if (bearer === '') console.log(' 身份:JCAI_E2E_BEARER 直供的 token(不续期;卡会落到这枚 token 主人的工作台)');
|
||||
bearer = `Bearer ${DIRECT_BEARER}`;
|
||||
bearerAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') {
|
||||
throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012)');
|
||||
throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012);临时可用 JCAI_E2E_BEARER 直供一枚');
|
||||
}
|
||||
const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
@@ -608,7 +873,9 @@ async function expectGateBlocks(cardId, taskId, stage) {
|
||||
if (column === undefined) return 'nothing-missing';
|
||||
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
|
||||
const rows = draftRows(body.drafts ?? [], stage.address);
|
||||
const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length;
|
||||
// 有 fillWhere 的段只数该档的行:default / ok 行空着是本分,不是「闸没拦」
|
||||
const scoped = stage.fillWhere ? rows.filter((r) => String(r[stage.fillWhere.column] ?? '') === stage.fillWhere.equals) : rows;
|
||||
const blank = scoped.filter((r) => String(r[column] ?? '').trim() === '').length;
|
||||
return blank > 0 ? 'leaked' : 'nothing-missing';
|
||||
}
|
||||
|
||||
@@ -641,6 +908,7 @@ async function answerDecisions(taskId, kase, answered) {
|
||||
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' } }),
|
||||
});
|
||||
answered.add(card.id);
|
||||
asked.push(String(card.ask ?? card.title ?? ''));
|
||||
count += 1;
|
||||
console.log(` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}`);
|
||||
}
|
||||
@@ -672,36 +940,89 @@ async function answerEntries(taskId, kase, answered) {
|
||||
* 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
|
||||
* 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
|
||||
* `fillAllBlank` 整行兜底扫空(回执这类「几行由模型定、每行要填一把」的表用它)。
|
||||
*
|
||||
* **复合主键**(2026-08-27,w05 的 `parts.parameter` 主键是 `(internal_pn, param_name)`)——
|
||||
* `rowKey` 除了旧的单列字符串形态,也接受一个列名数组:两颗料同名参数(如都有「工作温度」)
|
||||
* 若只按 `param_name` 单列匹配,`rows.find` 会永远命中第一条同名行、把不同料的值互相顶掉;
|
||||
* 数组形态按**全部列同时相等**才算命中同一行,单列字符串形态原样保留旧行为(不影响既有八套件)。
|
||||
*/
|
||||
function buildEdits(stage, drafts) {
|
||||
function buildEdits(stage, drafts, kase = {}) {
|
||||
const rows = draftRows(drafts, stage.address);
|
||||
const rowKey = stage.rowKey ?? 'pn';
|
||||
const keyCols = Array.isArray(stage.rowKey) ? stage.rowKey : [stage.rowKey ?? 'pn'];
|
||||
const blankCol = stage.blankCol ?? 'base_price';
|
||||
const pending = rows.filter((r) => String(r[blankCol] ?? '') === '');
|
||||
const lines = [];
|
||||
const taken = new Set();
|
||||
for (const [i, edit] of (stage.edits ?? []).entries()) {
|
||||
const { [rowKey]: keyed, ...cells } = edit;
|
||||
const target = keyed !== '' ? rows.find((r) => String(r[rowKey]) === keyed) : pending[i];
|
||||
const sig = (obj) => keyCols.map((k) => String(obj[k] ?? '')).join('');
|
||||
const keyOf = (obj) => Object.fromEntries(keyCols.map((k) => [k, String(obj[k] ?? '')]));
|
||||
// 案例级按表的编辑叠在套件级之上(同一套件不同案例要建的料号不同 —— 参数集不能是同一份)
|
||||
const edits = [...(stage.edits ?? []), ...((kase.stageEdits ?? {})[stage.address] ?? [])];
|
||||
for (const [i, edit] of edits.entries()) {
|
||||
const cells = { ...edit };
|
||||
const keyVals = {};
|
||||
for (const k of keyCols) {
|
||||
keyVals[k] = cells[k];
|
||||
delete cells[k];
|
||||
}
|
||||
const allGiven = keyCols.every((k) => keyVals[k] !== undefined && keyVals[k] !== '');
|
||||
const target = allGiven ? rows.find((r) => keyCols.every((k) => String(r[k]) === String(keyVals[k]))) : pending[i];
|
||||
if (!target) {
|
||||
console.log(` ⚠️ 找不到要填的行(${rowKey}=${keyed || `待填第${i}行`});现有行:${rows.map((r) => r[rowKey]).join(', ')}`);
|
||||
const wanted = allGiven ? keyCols.map((k) => keyVals[k]).join('/') : `待填第${i}行`;
|
||||
const have = rows.map((r) => keyCols.map((k) => r[k]).join('/')).join(', ');
|
||||
/*
|
||||
* **点名的行找不到 = 判失败,不是提个醒**(2026-08-27 实测:模型把参数名写成英文 key
|
||||
* `input_voltage_range`,而库里既有行是中文「输入电压范围」—— 参数名是主键的一半,
|
||||
* 两套名字对不上,比对时 27 条里 14 条判成 UNKNOWN。而这一例照样报「1/1 通过」:
|
||||
* 断言报的通过是假的,比断言报的失败是假的更糟 —— 后者还有人去查。)
|
||||
*/
|
||||
/*
|
||||
* **件上没有这一行就「加一行」** —— 人在浏览器里就是这么做的(闸的原话:
|
||||
* 「先在件上加一行填好再提交」),脚本从前只会填已有行,于是模型漏写骨架时
|
||||
* 这一例直接卡死(2026-08-27:P-10 零行,连吃三次 400 抛出)。
|
||||
* 主键齐了才加(凑不出钥匙的行基座也认不出),加完从「缺」的名单里划掉。
|
||||
*/
|
||||
if (allGiven) {
|
||||
lines.push({ ...keyVals, ...cells });
|
||||
missedRows.delete(`${keyCols.join('+')}=${wanted}`);
|
||||
console.log(` 件上加一行(${keyCols.join('+')}=${wanted})—— 模型没铺这行骨架,照闸的指示补`);
|
||||
continue;
|
||||
}
|
||||
missedRows.add(`${keyCols.join('+')}=${wanted}`);
|
||||
console.log(` ⚠️ 找不到要填的行(${keyCols.join('+')}=${wanted});现有行:${have}`);
|
||||
continue;
|
||||
}
|
||||
taken.add(String(target[rowKey]));
|
||||
lines.push({ [rowKey]: String(target[rowKey]), ...cells });
|
||||
taken.add(sig(target));
|
||||
/*
|
||||
* **填上了就从「缺」的名单里划掉** —— 同一类卡一例里可能发好几张(两颗缺库各一张 P-08),
|
||||
* 第一张上第二颗的行还不存在是**正常的**,不是缺;整例跑完仍没填上才是真缺。
|
||||
*/
|
||||
if (allGiven) missedRows.delete(`${keyCols.join('+')}=${keyCols.map((k) => keyVals[k]).join('/')}`);
|
||||
lines.push({ ...keyOf(target), ...cells });
|
||||
}
|
||||
if (stage.fillMissing) {
|
||||
const { column, value } = stage.fillMissing;
|
||||
for (const r of rows) {
|
||||
if (String(r[column] ?? '') === '' && !taken.has(String(r[rowKey]))) {
|
||||
lines.push({ [rowKey]: String(r[rowKey]), [column]: value });
|
||||
if (String(r[column] ?? '') === '' && !taken.has(sig(r))) {
|
||||
lines.push({ ...keyOf(r), [column]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stage.fillAllBlank) {
|
||||
for (const r of rows) {
|
||||
if (String(r[blankCol] ?? '') === '' && !taken.has(String(r[rowKey]))) {
|
||||
lines.push({ [rowKey]: String(r[rowKey]), ...stage.fillAllBlank });
|
||||
if (String(r[blankCol] ?? '') === '' && !taken.has(sig(r))) {
|
||||
lines.push({ ...keyOf(r), ...stage.fillAllBlank });
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 按条件整行扫空:只给某一档状态的行填(评估输入清单里只有 status = gap 的行要补充内容,
|
||||
* default / ok 的行本就不该填 —— fillAllBlank 会把它们也填上,那是替人说了他没说的话)。
|
||||
*/
|
||||
if (stage.fillWhere) {
|
||||
const { column, equals, values } = stage.fillWhere;
|
||||
for (const r of rows) {
|
||||
if (String(r[column] ?? '') === equals && !taken.has(sig(r))) {
|
||||
lines.push({ ...keyOf(r), ...values });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -831,6 +1152,8 @@ async function runCase(suite, kase) {
|
||||
let gateLeaked = false;
|
||||
const gateChecked = new Set();
|
||||
let decisions = 0;
|
||||
missedRows.clear();
|
||||
asked.length = 0;
|
||||
/** 人加行只加一次;加的那家进没进计划,在办中盯(放行会把草稿消费掉,收工再看就晚了) */
|
||||
let addedRows = false;
|
||||
let planned = false;
|
||||
@@ -958,19 +1281,32 @@ async function runCase(suite, kase) {
|
||||
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
|
||||
);
|
||||
}
|
||||
const lines = buildEdits(stage, body.drafts ?? []);
|
||||
const lines = buildEdits(stage, body.drafts ?? [], kase);
|
||||
// 人加行:与真人在件上「加一行」同一条链 —— 新主键的行落进保存载荷即是新增
|
||||
if (kase.adds && kase.adds.address === stage.address && !addedRows) {
|
||||
lines.push(...kase.adds.rows);
|
||||
addedRows = true;
|
||||
console.log(` 件上加 ${kase.adds.rows.length} 行(${kase.adds.rows.map((r) => Object.values(r)[0]).join('、')})`);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
/*
|
||||
* **一张卡填两张表**(w05:P-08 数据工单同一张卡上摆着表 1 主记录 + 表 2 标准化参数子表)——
|
||||
* 加性写法:`stage.extraTables`(可选)是同形状的段描述数组,各自按自己的 address 走
|
||||
* 同一套 `buildEdits`;保存载荷 `{tables:{地址:行[]}}` 本就支持多地址一次发,不必分两次保存、
|
||||
* 也不影响没有 `extraTables` 的老套件(a1/a2/a3/a4/a6/a7/e04/i01 的 stage 都没有这个键)。
|
||||
*/
|
||||
const tables = {};
|
||||
if (lines.length > 0) tables[stage.address] = lines;
|
||||
for (const extra of stage.extraTables ?? []) {
|
||||
const extraLines = buildEdits(extra, body.drafts ?? [], kase);
|
||||
if (extraLines.length > 0) tables[extra.address] = extraLines;
|
||||
}
|
||||
const tableAddresses = Object.keys(tables);
|
||||
if (tableAddresses.length > 0) {
|
||||
await api(`/api/desk/tasks/${taskId}/table`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tables: { [stage.address]: lines } }),
|
||||
body: JSON.stringify({ tables }),
|
||||
});
|
||||
console.log(` 件上保存 ${lines.length} 行(${card.title})`);
|
||||
console.log(` 件上保存 ${tableAddresses.map((a) => `${a}:${tables[a].length}行`).join(' + ')}(${card.title})`);
|
||||
}
|
||||
try {
|
||||
await submitExecution(card.id);
|
||||
@@ -1044,6 +1380,23 @@ async function runCase(suite, kase) {
|
||||
ok = false;
|
||||
}
|
||||
// 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设
|
||||
if (missedRows.size > 0) {
|
||||
console.log(` ⚠️ 案例点名要填的行,整例跑完也没在件上出现过(${missedRows.size} 处:${[...missedRows].slice(0, 3).join('、')}…)—— 判失败`);
|
||||
ok = false;
|
||||
}
|
||||
/*
|
||||
* **决策卡要数「问的是哪件事」,不是笼统数张数**(2026-08-27:两颗缺库一张创建卡都没问,
|
||||
* 而审核卡也是决策卡 —— `decisions > 0` 于是恒真,该问没问照样绿)。
|
||||
*/
|
||||
if (kase.expectAsks !== undefined) {
|
||||
for (const { re, count } of kase.expectAsks) {
|
||||
const got = asked.filter((a) => re.test(a)).length;
|
||||
if (got < count) {
|
||||
console.log(` ⚠️ 该问的没问够:${re} 期望 ≥${count} 张,实际 ${got} 张 —— 判失败`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kase.expectDecision && decisions === 0) {
|
||||
console.log(' ⚠️ 超限条目没走决策卡 —— 判失败');
|
||||
ok = false;
|
||||
@@ -1094,11 +1447,16 @@ async function runCase(suite, kase) {
|
||||
return ok && (!kase.expectPort || ports.includes(kase.expectPort));
|
||||
}
|
||||
|
||||
/** 这一例里「案例点名要填、而件上找不到」的行 —— 每例开头清空,收尾判失败 */
|
||||
const missedRows = new Set();
|
||||
/** 这一例里模型问过的每一句(决策卡的 ask)—— 收尾按「问的是哪件事」核,不是笼统数张数 */
|
||||
const asked = [];
|
||||
|
||||
async function main() {
|
||||
const which = process.argv[2] ?? 'all';
|
||||
const only = process.argv[3] === undefined ? undefined : Number(process.argv[3]);
|
||||
await login();
|
||||
const suites = which === 'all' || which === 'both' ? ['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01'] : [which];
|
||||
const suites = which === 'all' || which === 'both' ? ['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01', 'w05'] : [which];
|
||||
let pass = 0;
|
||||
let total = 0;
|
||||
for (const key of suites) {
|
||||
@@ -1107,7 +1465,16 @@ async function main() {
|
||||
for (const [i, kase] of suite.cases.entries()) {
|
||||
if (only !== undefined && i !== only) continue;
|
||||
total += 1;
|
||||
if (await runCase(suite, kase)) pass += 1;
|
||||
/*
|
||||
* **一例翻车不该拖垮整套**(2026-08-27 实测:P-10 那张卡零行、闸如实拦着,
|
||||
* 脚本没东西可填、抛出去,后面两例连跑都没跑)。判失败、记下来、接着跑下一例 ——
|
||||
* 一套五例的价值就在于「哪几例过、哪几例没过」,死在半路等于什么都没测出来。
|
||||
*/
|
||||
try {
|
||||
if (await runCase(suite, kase)) pass += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ 这一例中途出错,判失败,接着跑下一例:${String(error.message ?? error).slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\n${pass}/${total} 通过;记得 pnpm --filter @app/api reset-runs 清运行数据`);
|
||||
|
||||
Reference in New Issue
Block a user