diff --git a/api-report/packages/contracts/src/api/ingest.d.ts b/api-report/packages/contracts/src/api/ingest.d.ts index 196494d..d15629a 100644 --- a/api-report/packages/contracts/src/api/ingest.d.ts +++ b/api-report/packages/contracts/src/api/ingest.d.ts @@ -69,3 +69,4 @@ export declare const ingestMailRoute: { fellBackToMessage: z.ZodBoolean; }, z.core.$strip>; }; +export type IngestMailOutput = z.infer; diff --git a/apps/api/.data/attachments/inbound/sim-1785218175996.eml b/apps/api/.data/attachments/inbound/sim-1785218175996.eml new file mode 100644 index 0000000..4643ac4 --- /dev/null +++ b/apps/api/.data/attachments/inbound/sim-1785218175996.eml @@ -0,0 +1,19 @@ +{ + "messageId": "sim-1785218175996", + "from": "buyer@sz-precision.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】RFQ-2607-501 精密车削件 6 项", + "body": "你好,\n\n我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。\n月需约 2000 套,请两周内回价。\n\n深圳精密件采购部", + "receivedAt": "2026-07-28T05:56:15.996Z", + "attachments": [ + { + "name": "BOM-RFQ-2607-501.xlsx", + "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "size": 18432, + "ref": "sim://attach/BOM-RFQ-2607-501.xlsx" + } + ] +} \ No newline at end of file diff --git a/apps/api/.data/attachments/inbound/sim-1785218283747.eml b/apps/api/.data/attachments/inbound/sim-1785218283747.eml new file mode 100644 index 0000000..726c904 --- /dev/null +++ b/apps/api/.data/attachments/inbound/sim-1785218283747.eml @@ -0,0 +1,19 @@ +{ + "messageId": "sim-1785218283747", + "from": "buyer@sz-precision.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】RFQ-2607-501 精密车削件 6 项", + "body": "你好,\n\n我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。\n月需约 2000 套,请两周内回价。\n\n深圳精密件采购部", + "receivedAt": "2026-07-28T05:58:03.747Z", + "attachments": [ + { + "name": "BOM-RFQ-2607-501.xlsx", + "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "size": 18432, + "ref": "sim://attach/BOM-RFQ-2607-501.xlsx" + } + ] +} \ No newline at end of file diff --git a/apps/api/.data/attachments/inbound/sim-verify-1.eml b/apps/api/.data/attachments/inbound/sim-verify-1.eml new file mode 100644 index 0000000..6815f08 --- /dev/null +++ b/apps/api/.data/attachments/inbound/sim-verify-1.eml @@ -0,0 +1,19 @@ +{ + "messageId": "sim-verify-1", + "from": "buyer@sz-precision.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】RFQ-2607-501 精密车削件 6 项", + "body": "我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单。", + "receivedAt": "2026-07-28T06:00:00.000Z", + "attachments": [ + { + "name": "BOM.xlsx", + "contentType": "application/vnd.ms-excel", + "size": 18432, + "ref": "sim://a/1" + } + ] +} \ No newline at end of file diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index 37f5d41..d9182c3 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -470,9 +470,16 @@ export async function seedMvpFlow(container: Container): Promise { const { positions } = await container.org.listStaff(); const op = positions.find((p) => p.code === 'OP'); const pm = positions.find((p) => p.code === 'PM'); + /* + * 归属键提取 —— 正则要能吃**完整**的单号。 + * + * `RFQ-[A-Z0-9]+` 不含连字符:遇到 `RFQ-2607-501` 只抽到 `RFQ-2607` 就停(实测)。 + * 而这个键会成为「受理结论」那一行的主键 —— 截断了,同一单的数据就静默分裂成两行, + * 网关读到哪一行全看运气。分段续接才能整个吃下:`RFQ-2607-501` / `RFQ-ABC123` 都对。 + */ const extract = [ { key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' as const }, - { key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' as const }, + { key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+(?:-[A-Z0-9]+)*', source: 'both' as const }, ]; await container.channel.upsert({ diff --git a/apps/web/src/app/gov-shell.tsx b/apps/web/src/app/gov-shell.tsx index 23913e8..8301e02 100644 --- a/apps/web/src/app/gov-shell.tsx +++ b/apps/web/src/app/gov-shell.tsx @@ -31,6 +31,7 @@ import { TriggerPage } from '@features/gov-trigger'; import { ActPage, LibraryHome } from '@features/gov-activity'; import { ToolPage, ToolsHome } from '@features/gov-tool'; import { DataHome, LedgersHome, ObjPage } from '@features/gov-data'; +import { TestbedHome } from '@features/gov-testbed'; import { CockpitHome, KnowledgeHome, MemPage } from '@features/gov-memory'; import { ChanPage, LlmPage, SystemHome } from '@features/gov-system'; @@ -280,6 +281,7 @@ function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) library: LibraryHome, tools: ToolsHome, data: DataHome, + testbed: TestbedHome, // 运营中心 cockpit: CockpitHome, tasks: TasksHome, diff --git a/apps/web/src/features/gov-testbed/index.ts b/apps/web/src/features/gov-testbed/index.ts new file mode 100644 index 0000000..9ecde00 --- /dev/null +++ b/apps/web/src/features/gov-testbed/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-testbed` 唯一出口(编码规范 §9.1)。 + * 联调测试台:把定义试着跑一遍 —— 造入站邮件投通道,看归属解析与三级消费的真结果。 + */ +export { TestbedHome } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-testbed/internal/panel.tsx b/apps/web/src/features/gov-testbed/internal/panel.tsx new file mode 100644 index 0000000..3e0a850 --- /dev/null +++ b/apps/web/src/features/gov-testbed/internal/panel.tsx @@ -0,0 +1,321 @@ +/** + * 联调测试台 · 入站邮件 —— 把定义**试着跑一遍**。 + * + * 造一封入站邮件(可带附件)投进通道,走的是**与真通道同一条**入站归一链路 + * (`POST /api/ingest/channel/:id/mail` → 归属解析 → 统一事件流 → 三级消费)。 + * 邮件通道是外界,模拟通道是允许的(README:mock 只许出现在外界); + * 系统内部机制这里一行都不 mock —— 命中判定、起单、消息兜底全是真的。 + * + * 这一屏要回答的是三个问题,所以结果就摆这三样: + * · 归属判到谁了(哪一级、认领岗位) + * · 谁接了(任务监听 / 触发器,各命中几个) + * · 没人接的话落哪儿(消息兜底) + */ +import { useMemo, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import type { IngestMailOutput, InboundMail } from '@contracts'; +import { ingestMailRoute } from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { useGovChannels, useGovStaff } from '@web-shared/gov/gov-api.ts'; +import { Acts, Card, Field, Note, PageHead, Rows, Sec, St } from '@web-shared/gov/primitives.tsx'; +import type { Nav } from '@web-shared/gov/gov-route.ts'; + +/* + * 出参形状**直接用契约类型**,不手搓。 + * 我手搓过一次(`level` / `kind` / `position` / `rationale`),四个字段名全是编的 —— + * 页面上就摆出两个永远显示「—」的格子。归属解析真实是按 `outcome` 判别的联合: + * owned 给 `rule` + `taskId`,unowned 给 `reason` + 可选的 `fallbackPosition`。 + */ +type IngestResult = IngestMailOutput; + +const RULE_LABEL: Readonly> = { + thread: '①线程精确 —— 回信落在原线程上', + 'listener-key': '②关联键命中在途任务的监听', + 'address-window': '③地址 + 时间窗对上了', +}; + +const REASON_LABEL: Readonly> = { + 'no-match': '三级都没对上', + ambiguous: '对上多条,有歧义 —— 宁交人不错投', + 'extract-failed': '归属键抽不出来', +}; + +type Attach = { name: string; contentType: string; size: number; ref: string }; + +/** + * 样例邮件 —— 一键就能起单的那一封。 + * + * 三件事都得对,少一件就不起单(这也正是这一屏要让人看见的): + * ① 投给「公司询价邮箱」这个通道(触发器规则写死了 `channelId = "ch-rfq-mail"`) + * ② 主题含「询价」或 RFQ(触发器规则的第二半) + * ③ 正文或主题里有客户码 `C\d{3}` 与单号 `RFQ-[A-Z0-9]+` —— 通道的归属键提取规则按正则抽, + * 抽不出来就没有关联键,起单装配拿不到 customerCode / rfqRef + */ +const SAMPLE = { + from: 'buyer@sz-precision.com', + to: 'rfq@we.com', + subject: '【询价】RFQ-2607-501 精密车削件 6 项', + body: [ + '你好,', + '', + '我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。', + '月需约 2000 套,请两周内回价。', + '', + '深圳精密件采购部', + ].join('\n'), +}; + +const SAMPLE_ATTACH: Attach = { + name: 'BOM-RFQ-2607-501.xlsx', + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + size: 18_432, + ref: 'sim://attach/BOM-RFQ-2607-501.xlsx', +}; + +export function TestbedHome({ nav }: { tab: string; nav: Nav }) { + const channels = useGovChannels(); + const rows = channels.data?.channels ?? []; + // 岗位在出参里是 id;摆一串 UUID 等于没说 —— 换成人读的名字 + const staffQ = useGovStaff(); + const positionName = (id: string) => + (staffQ.data?.positions ?? []).find((p) => p.id === id)?.name ?? id; + + const [channelId, setChannelId] = useState(''); + const [from, setFrom] = useState(SAMPLE.from); + const [to, setTo] = useState(SAMPLE.to); + const [subject, setSubject] = useState(SAMPLE.subject); + const [body, setBody] = useState(SAMPLE.body); + const [attachments, setAttachments] = useState([SAMPLE_ATTACH]); + const [result, setResult] = useState(undefined); + const [error, setError] = useState(undefined); + + // 没选通道时默认落到第一个 —— 让「一键投递」真的一键 + const effectiveChannel = channelId !== '' ? channelId : (rows[0]?.id ?? ''); + const channel = rows.find((c) => c.id === effectiveChannel); + + /** + * 起单前置自检 —— 把「为什么没起单」提前说出来,而不是让人投完了猜。 + * 判据与通道/触发器的真实配置同源:通道 id、主题关键词、两个归属键正则。 + */ + const preflight = useMemo(() => { + const text = `${subject}\n${body}`; + return [ + { ok: effectiveChannel === 'ch-rfq-mail', k: '投给公司询价邮箱', v: 'ch-rfq-mail' }, + { ok: /询价|RFQ/.test(subject), k: '主题含「询价」或 RFQ', v: '触发器规则' }, + { ok: /\bC\d{3}\b/.test(text), k: '含客户码', v: 'C000 形' }, + { ok: /RFQ-[A-Z0-9]+/.test(text), k: '含询价单号', v: 'RFQ-XXXX 形' }, + ]; + }, [effectiveChannel, subject, body]); + const allGreen = preflight.every((p) => p.ok); + + const send = useMutation({ + mutationFn: (mail: InboundMail) => + call(ingestMailRoute, { params: { channelId: effectiveChannel as never }, body: mail }), + onSuccess: (r) => { + setResult(r); + setError(undefined); + }, + onError: (e: unknown) => { + setResult(undefined); + setError((e as { message?: string }).message ?? '投递失败'); + }, + }); + + const fire = () => { + // messageId 每次不同 —— 同一封信重投在归属解析里会被当同一线程,那是另一回事 + const stamp = Date.now(); + send.mutate({ + messageId: `sim-${stamp}`, + from, + to: [to], + cc: [], + subject, + body, + receivedAt: new Date().toISOString(), + attachments: [...attachments], + } as InboundMail); + }; + + return ( + <> + + + {allGreen ? '起单条件齐' : `差 ${preflight.filter((p) => !p.ok).length} 条`} + + + 附件 {attachments.length} + + + } + acts={[ + { + label: send.isPending ? '投递中…' : '投递这封信', + ico: 'send', + primary: true, + disabled: send.isPending || effectiveChannel === '', + onClick: fire, + }, + ]} + /> + + + 走的是与真通道同一条入站链路:通道归一 → 归属解析 → 统一事件流 → 三级消费(任务监听 / + 触发器 / 消息兜底)。邮件通道是外界,模拟通道是允许的;命中判定与起单都是真的。 + + + + + + + + + setFrom(e.target.value)} /> + + + setTo(e.target.value)} /> + + + setSubject(e.target.value)} /> + + + + +