forked from ChaiHao/BPOS-MVP
你要的「模拟输入邮件+附件触发起单」。后端**本来就齐了** —— `ingestMailRoute` 契约里早有 attachments,出参也早就回归属解析与三级消费结果。 所以这是纯前端加一个区:设计中心新增 `testbed`,一个页签「入站邮件」。 走的是与真通道**同一条**入站链路(通道归一 → 归属解析 → 统一事件流 → 三级消费)。 邮件通道是外界,模拟通道是 README 明许的;系统内部机制这里一行都不 mock —— 命中判定、起单、消息兜底全是真的。 ## 这一屏回答三个问题 投出去之后就摆三样:归属判到谁(哪一级命中 / 无主的原因 / 默认认领岗位)、 谁接了(任务监听 vs 触发器各命中几个)、没人接落哪儿(消息兜底)。 还有一格**起单前置自检**:投之前就告诉你会不会命中,不用投完了猜。 判据与真实配置同源 —— 通道 id、主题关键词、两个归属键正则,四条逐条给绿灯或说差什么。 预填的样例是一键能起单的那一封;把它改坏,自检立刻指出差哪条。 附件如实说明「只走**登记**」:名字/类型/大小/存证指针进事件信封,内容不解析 —— 解析 Excel/PDF 是活动的事,活动还没实现。不含糊过去。 ## 顺手修一个会静默分裂数据的 bug 通道的归属键正则 `RFQ-[A-Z0-9]+` 不含连字符,`RFQ-2607-501` 只抽到 `RFQ-2607` 就停。 而这个键会成为「受理结论」那一行的**主键** —— 截断了,同一单的数据就静默分裂成两行, 网关读到哪一行全看运气。改成分段续接 `RFQ-[A-Z0-9]+(?:-[A-Z0-9]+)*`, 实测 `RFQ-2607-501` / `RFQ-ABC123` 都整个吃下。 ## 我自己踩的两个 ① 出参类型手搓,`level`/`kind`/`position`/`rationale` **四个字段名全是编的** —— 页面上就摆出两个永远显示「—」的格子。改为直接用契约类型:归属解析真实是按 `outcome` 判别的联合(owned 给 rule+taskId,unowned 给 reason+可选 fallbackPosition)。 为此给契约补了 `IngestMailOutput` 导出(加法)。 ② JSX 正文里写 markdown 的 `**` 会显示成字面星号,换 `<b>`。这坑本轮踩过第二次。 另补两个缺失图标(flask-conical / split),不补就是空 SVG。 ## 实测 界面上点「投递这封信」→ 归属判**无主 · 三级都没对上 · 默认认领岗位 运营** (对新询价完全正确:没有在途任务可归)→ 触发器命中 1 个 → 起单。 起单入参 `customerCode: C014` / `rfqRef: RFQ-2607-501` 都抽对了。 一个值得记的观察:事件起单**没有「来源人」**,所以不装载工作组,A1 的 BD 岗位解析到 真正的 BD(冯兵),任务落他工位而不是我的 —— 这是对的,不是 bug。 要在自己工位上看,用「人工登记起单」那条路(登记人即来源)。 typecheck ✓ lint ✓ depcruise 376 模块 ✓ 498 测试 ✓ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
/**
|
|
* 入站路由契约(编码规范 §8.1)。
|
|
*
|
|
* 通道入站 → 归属解析 → 统一事件流(OS 06)。MVP 的邮件通道以**模拟通道**接入:
|
|
* mock 只允许出现在**外界**(邮件通道与外部系统),系统内部机制不得 mock(README MVP 完成判据)。
|
|
*/
|
|
import { z } from 'zod';
|
|
/** 一封入站邮件的归一形状 —— 真实邮件通道与模拟通道共用 */
|
|
export declare const inboundMailSchema: z.ZodObject<{
|
|
messageId: z.ZodString;
|
|
threadId: z.ZodOptional<z.ZodString>;
|
|
from: z.ZodString;
|
|
to: z.ZodArray<z.ZodString>;
|
|
cc: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
subject: z.ZodString;
|
|
body: z.ZodString;
|
|
receivedAt: z.ZodISODateTime;
|
|
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
name: z.ZodString;
|
|
contentType: z.ZodString;
|
|
size: z.ZodNumber;
|
|
ref: z.ZodString;
|
|
}, z.core.$strip>>>;
|
|
}, z.core.$strip>;
|
|
export type InboundMail = z.infer<typeof inboundMailSchema>;
|
|
export declare const ingestMailRoute: {
|
|
readonly method: "POST";
|
|
readonly path: "/api/ingest/channel/:channelId/mail";
|
|
readonly params: z.ZodObject<{
|
|
channelId: z.core.$ZodBranded<z.ZodString, "ChannelId", "out">;
|
|
}, z.core.$strip>;
|
|
readonly body: z.ZodObject<{
|
|
messageId: z.ZodString;
|
|
threadId: z.ZodOptional<z.ZodString>;
|
|
from: z.ZodString;
|
|
to: z.ZodArray<z.ZodString>;
|
|
cc: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
subject: z.ZodString;
|
|
body: z.ZodString;
|
|
receivedAt: z.ZodISODateTime;
|
|
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
name: z.ZodString;
|
|
contentType: z.ZodString;
|
|
size: z.ZodNumber;
|
|
ref: z.ZodString;
|
|
}, z.core.$strip>>>;
|
|
}, z.core.$strip>;
|
|
readonly output: z.ZodObject<{
|
|
eventId: z.core.$ZodBranded<z.ZodString, "EventId", "out">;
|
|
resolution: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
outcome: z.ZodLiteral<"owned">;
|
|
rule: z.ZodEnum<{
|
|
thread: "thread";
|
|
"listener-key": "listener-key";
|
|
"address-window": "address-window";
|
|
}>;
|
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
|
}, z.core.$strip>, z.ZodObject<{
|
|
outcome: z.ZodLiteral<"unowned">;
|
|
reason: z.ZodEnum<{
|
|
"no-match": "no-match";
|
|
ambiguous: "ambiguous";
|
|
"extract-failed": "extract-failed";
|
|
}>;
|
|
fallbackPosition: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "PositionId", "out">>;
|
|
}, z.core.$strip>], "outcome">;
|
|
listenersHit: z.ZodNumber;
|
|
triggersHit: z.ZodNumber;
|
|
fellBackToMessage: z.ZodBoolean;
|
|
}, z.core.$strip>;
|
|
};
|
|
export type IngestMailOutput = z.infer<typeof ingestMailRoute.output>;
|