فهرست منبع

feat(s8): 主动提报支持点对点指派与创建通知

提报人在提交时直接选处理人与复检人,异常建出来就是 ASSIGNED,
处理人立刻收到通知并可直接处理或转交 —— 不再需要有人先去"认领"。

为什么不走认领:主动提报的人通常已经知道该找谁。让它先落到无主状态、
等人来捞,既拖慢响应,也让"谁负责"在一段时间内没有答案。

后端建单契约:
- Status 由 NEW 改为 ASSIGNED,同时写入 assignee / verifier / assigned_at
- assigned_at 与 created_at 复用同一个 now:差几毫秒会让"建单即指派"
  在数据上看起来像"建完之后又被指派了一次"
- 新增必填:处理人、复检人、详细说明
- 处理人与复检人不得为同一账号 —— 自己处理完自己复检,复检只是个仪式
- 明确允许提报人把自己选为处理人或复检人
- 两个账号一律走 _userScope.EnsureActiveUserAsync 校验,不手写 SysUser 查询:
  第二套身份判据迟早与主判据分叉,而分叉只表现为"某些人莫名其妙选不到"

时间线记两条:CREATE 与 MANUAL_ASSIGN。不复用 CLAIM ——
CLAIM 是"处理人自己把单子捞走",这里是"提报人把单子指给别人",责任主体正好相反。
同时把 CREATE 的 ToStatus 从 NEW 改为 ASSIGNED:单据从未处于 NEW,
而 from/to 是经 S8DecisionService 对外暴露的字段,记错会误导事后复盘。

通知:新增显式收件人投递入口 DispatchToExplicitUsersAsync,复用既有
DispatchToUsersAsync(站内信 + push + 日志逐字一致),不另起第二套发送实现。
走这条路而不是配置路径,是因为配置按 (租户, rule_code, 事件) 取收件人,
主动提报的 rule_code 恒为 NULL、兜底键 '*' 至今没有写入方也没有配置界面 ——
用它等于永远发不出去。收件人在这里本就是业务动作的一部分,不该再去问配置。
通知在事务提交后发起且只吞异常记 Warning:异常已经建成,通知发不出去是通知的问题。

前端:删除提报模板(纯 UX 预填、后端无 TemplateId、无其它页面消费);
新增处理人 / 复检人下拉,复用既有 master-data/users 接口按 EXCEPTION_START /
EXCEPTION_VERIFY 收窄候选,让页面上能选到的人与后端会放行的人一致;
详细说明改必填;严重度收进高级选项;去掉发生/责任部门静默选中第一项的回落 ——
静默选中比不填更危险,用户不会注意到自己提交了一个错误归属。

未新增:责任配置页、Scene 责任池、'*' 通知配置、legacy 通知、EmployeeMaster 桥。
无 migration、无 schema 变更(assignee_user_id / verifier_user_id / assigned_at 及索引均已存在)。

已知边界(本批不修,非本次引入):StartProgress 与 Transfer 都不校验调用者
是否为 Assignee,因此"点对点"目前等于"派给谁 + 通知谁",不等于"只有他能操作"。

测试:新增 29 条契约;S8 全量 194/194;插件 2056/2058(2 项既有 skip);
三个后端项目 build 0 Error;vue-tsc 583(与改动前持平,本批文件净增 0)。
本地 1.0.510 运行态实测:提交后 status=ASSIGNED、两人写入正确、
assigned_at 与 created_at 完全相同、认领按钮禁用、开始处理可执行、
创建通知 source=EXPLICIT recipients=1 且处理人本人收件箱确认收到。
YY968XX 1 ماه پیش
والد
کامیت
26afe15474

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.391",
+	"version": "2.4.392",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 6 - 0
Web/src/views/aidop/s8/api/s8ReportApi.ts

@@ -16,7 +16,13 @@ export interface S8ReportCreate {
 	responsibleDeptId: number;
 	// S8-SYSUSER-ONLY-1:提报人不再由前端传入,服务端一律取登录账号(后端 DTO 已删除该字段)。
 	relatedObjectCode?: string;
+	// S8-MANUAL-DIRECT-ASSIGN-1:点对点派单。值域是 SysUser.Id。
+	// 契约上可选、业务上必填 —— 后端 CreateAsync 有独立 guard,
+	// 这里跟着写成必选会让「谁来兜底」这件事出现两个答案。
+	assigneeUserId?: number;
+	verifierUserId?: number;
 	// TASK-022-MANUAL-REPORT-TEMPLATE-1:与后端 AdoS8ManualReportCreateDto 对齐,支持模板透传维度。
+	// 提报模板退役后前端不再携带这两个值,后端仍保留字段并回落 module_code。
 	stageCode?: string;
 	orderFlowCode?: string;
 }

+ 155 - 95
Web/src/views/aidop/s8/report/S8ManualReportPage.vue

@@ -7,27 +7,46 @@ import { getAPI } from '/@/utils/axios-utils';
 import { SysFileApi } from '/@/api-services';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import { s8ReportApi } from '../api/s8ReportApi';
-// TASK-022-MANUAL-REPORT-TEMPLATE-1:拉取「主动提报模板」。
-// S8-RULE-GOVERNANCE-BATCH4:数据源从已退役的 wizard-drafts 改为只读的 report-templates 端点
-// (后端已按 mechanism=MANUAL_REPORT 过滤,前端不再自行筛选)。
-import { s8ConfigApi, type S8ReportTemplateRow } from '../api/s8ConfigApi';
+// S8-MANUAL-REPORT-P2P-DISPATCH-1:主动提报改「点对点派单」——提报时直接定人。
+// 人员下拉复用异常域已有封装 s8ExceptionApi.users(requiredAction),
+// 它打的是 S8 人员选择器的唯一数据源 GET /api/aidop/s8/master-data/users,
+// 返回的都是「启用系统账号」而非员工主数据,不存在"选了却没绑账号、用不了"的选项。
+import { s8ExceptionApi } from '../api/s8ExceptionApi';
 
 const router = useRouter();
 const saving = ref(false);
 const uploadRef = ref<UploadInstance>();
 const attachmentFiles = ref<File[]>([]);
+
+/** master-data/users 的行结构:{ id, name, account }(见 S8MasterDataAdapter.GetOperatorUsersAsync)。 */
+interface S8OperatorUserOption {
+	id: number;
+	name: string;
+	account?: string | null;
+}
+
+// S8-MANUAL-REPORT-DEAD-LOAD-1:删除 lines —— 页面从未渲染产线,
+// 留着只会让人以为「产线」是这张表单的一个维度。后端 DTO 仍返回该字段,不动。
 const formOptions = reactive<{
 	scenes: { value: string; label: string }[];
 	severities: { value: string; label: string }[];
 	departments: { value: number; label: string }[];
-	lines: { value: number; label: string }[];
 }>({
 	scenes: [],
 	severities: [],
 	departments: [],
-	lines: [],
 });
 
+// S8-MANUAL-REPORT-P2P-DISPATCH-1:处理人 / 复检人两份候选刻意分开取数。
+// 两者要求的动作权限不同(EXCEPTION_START vs EXCEPTION_VERIFY),
+// 共用一份全租户列表会让用户选中「页面上能选、后端必拒」的账号。
+const assigneeUsers = ref<S8OperatorUserOption[]>([]);
+const verifierUsers = ref<S8OperatorUserOption[]>([]);
+const assigneeLoading = ref(false);
+const verifierLoading = ref(false);
+const assigneeLoaded = ref(false);
+const verifierLoaded = ref(false);
+
 const form = reactive({
 	title: '',
 	sceneCode: '',
@@ -36,15 +55,13 @@ const form = reactive({
 	occurrenceDeptId: undefined as number | undefined,
 	responsibleDeptId: undefined as number | undefined,
 	relatedObjectCode: '',
-	// TASK-022-MANUAL-REPORT-TEMPLATE-1:模板带入维度归属,提交时透传后端落 ado_s8_exception。
-	stageCode: '',
-	orderFlowCode: '',
+	// S8-MANUAL-REPORT-P2P-DISPATCH-1:提报即派单,两者均必填;提交后后端直接把异常置 ASSIGNED。
+	assigneeUserId: undefined as number | undefined,
+	verifierUserId: undefined as number | undefined,
 });
 
-// TASK-022-MANUAL-REPORT-TEMPLATE-1:模板下拉状态。
-const templates = ref<S8ReportTemplateRow[]>([]);
-const selectedTemplateId = ref<number | string | null>(null);
-const templateLoading = ref(false);
+// 高级选项默认折叠:严重度有合理默认值(FOLLOW),不该和必填项抢注意力。
+const advancedPanels = ref<string[]>([]);
 
 async function loadOptions() {
 	const result = (await s8ReportApi.formOptions()) as Record<string, any> & {
@@ -54,64 +71,47 @@ async function loadOptions() {
 	formOptions.scenes = (result.scenes ?? []).map((item: any) => ({ value: item.value, label: item.label }));
 	formOptions.severities = (result.severities ?? []).map((item: any) => ({ value: item.value, label: item.label }));
 	formOptions.departments = (result.departments ?? []).map((item: any) => ({ value: item.value, label: item.label }));
-	formOptions.lines = (result.lines ?? []).map((item: any) => ({ value: item.value, label: item.label }));
 	if (!form.sceneCode && formOptions.scenes[0]) form.sceneCode = formOptions.scenes[0].value;
 	// S8-MANUAL-REPORT-DEFAULT-OCCURRENCE-DEPT-1(P1):发生部门默认 = 当前登录员工部门(后端解析)。
-	// 仅当后端给出 defaultOccurrenceDeptId 且该 ID 存在于 departments 选项中时采用;否则继续兜底首项。
-	// 用户仍可改;不联动 responsibleDeptId 的默认值。
+	// 仅当后端给出 defaultOccurrenceDeptId 且该 ID 存在于 departments 选项中时采用。
+	//
+	// S8-MANUAL-REPORT-NO-SILENT-DEPT-FALLBACK-1:这里**不再**回落到 departments[0]。
+	// 原实现在拿不到后端默认值时静默选中列表首项,责任部门更是无条件填首项——
+	// 部门决定异常派给谁、算谁的账,一个按字典序排在最前的部门被默默写进单据,
+	// 用户看到框里有值就直接提交,错归属没有任何提示。宁可留空、逼用户明确选。
 	if (!form.occurrenceDeptId) {
 		const defaultId = result.defaultOccurrenceDeptId ?? null;
 		if (defaultId != null && formOptions.departments.some((d) => d.value === defaultId)) {
 			form.occurrenceDeptId = defaultId;
-		} else if (formOptions.departments[0]) {
-			form.occurrenceDeptId = formOptions.departments[0].value;
 		}
 	}
-	if (!form.responsibleDeptId && formOptions.departments[0]) form.responsibleDeptId = formOptions.departments[0].value;
 }
 
-// TASK-022-MANUAL-REPORT-TEMPLATE-1:拉取主动提报模板列表(前端按 mechanism 过滤)。
-async function loadTemplates() {
-	templateLoading.value = true;
+/** 处理人候选:具备 EXCEPTION_START(受理/开始处理)权限的启用账号。 */
+async function loadAssignees() {
+	assigneeLoading.value = true;
 	try {
-		templates.value = (await s8ConfigApi.reportTemplates.list()) ?? [];
-	} catch {
-		// 静默:模板拉取失败不影响手填路径
-		templates.value = [];
+		assigneeUsers.value = ((await s8ExceptionApi.users('EXCEPTION_START')) ?? []) as S8OperatorUserOption[];
 	} finally {
-		templateLoading.value = false;
+		assigneeLoading.value = false;
+		assigneeLoaded.value = true;
 	}
 }
 
-// 选择模板后载入详情,解析 wizardJson 并应用到 form。
-async function applyTemplate(id: number | string | null) {
-	selectedTemplateId.value = id;
-	if (id == null) return;
-	let detail;
+/** 复检人候选:具备 EXCEPTION_VERIFY(复核)权限的启用账号。 */
+async function loadVerifiers() {
+	verifierLoading.value = true;
 	try {
-		detail = await s8ConfigApi.reportTemplates.detail(id);
-	} catch {
-		ElMessage.warning('主动提报模板加载失败,已保持当前表单内容');
-		return;
-	}
-	let parsed: any;
-	try {
-		parsed = JSON.parse(detail.templateJson);
-	} catch {
-		parsed = null;
-	}
-	if (!parsed || typeof parsed !== 'object' || parsed.templateKind !== 'MANUAL_REPORT') {
-		ElMessage.warning('主动提报模板格式异常,已保持当前表单内容');
-		return;
+		verifierUsers.value = ((await s8ExceptionApi.users('EXCEPTION_VERIFY')) ?? []) as S8OperatorUserOption[];
+	} finally {
+		verifierLoading.value = false;
+		verifierLoaded.value = true;
 	}
-	if (typeof parsed.title === 'string' && parsed.title) form.title = parsed.title;
-	if (typeof parsed.sceneCode === 'string' && parsed.sceneCode) form.sceneCode = parsed.sceneCode;
-	if (parsed.severity === 'FOLLOW' || parsed.severity === 'SERIOUS') form.severity = parsed.severity;
-	if (typeof parsed.description === 'string') form.description = parsed.description;
-	if (typeof parsed.relatedObjectCode === 'string') form.relatedObjectCode = parsed.relatedObjectCode;
-	if (typeof parsed.stageCode === 'string') form.stageCode = parsed.stageCode;
-	if (typeof parsed.orderFlowCode === 'string') form.orderFlowCode = parsed.orderFlowCode;
-	ElMessage.success(`已应用模板:${parsed.templateName ?? detail.templateName ?? ''}`);
+}
+
+/** 下拉展示文案:姓名 + 账号。账号一并进 label,是为了让 filterable 也能按账号搜。 */
+function userLabel(item: S8OperatorUserOption) {
+	return item.account ? `${item.name}(${item.account})` : item.name;
 }
 
 function onFileChange(uploadFile: UploadFile) {
@@ -137,26 +137,59 @@ async function uploadAttachments(exceptionId: number) {
 }
 
 async function submit() {
-	if (!form.title.trim() || !form.sceneCode.trim()) {
-		ElMessage.warning('请填写标题并选择场景');
+	// 校验顺序与表单视觉顺序一致,用户按提示自上而下补就能一次填完。
+	if (!form.title.trim()) {
+		ElMessage.warning('请填写标题');
+		return;
+	}
+	if (!form.sceneCode.trim()) {
+		ElMessage.warning('请选择场景');
+		return;
+	}
+	const assigneeUserId = form.assigneeUserId;
+	const verifierUserId = form.verifierUserId;
+	if (!assigneeUserId) {
+		ElMessage.warning('请选择处理人');
+		return;
+	}
+	if (!verifierUserId) {
+		ElMessage.warning('请选择复检人');
+		return;
+	}
+	// S8-MANUAL-REPORT-P2P-DISPATCH-1:处理人 ≠ 复检人,否则复检退化成自己检查自己。
+	// 注意这里**只**禁同一人,不禁「提报人把自己选成处理人或复检人」——
+	// 一线发现问题顺手自己修、自己复检别人的单,都是正常路径。
+	if (assigneeUserId === verifierUserId) {
+		ElMessage.warning('处理人与复检人不能为同一账号');
 		return;
 	}
 	const occurrenceDeptId = form.occurrenceDeptId;
+	if (!occurrenceDeptId) {
+		ElMessage.warning('请选择发生部门');
+		return;
+	}
 	const responsibleDeptId = form.responsibleDeptId;
-	if (!occurrenceDeptId || !responsibleDeptId) {
-		ElMessage.warning('请选择发生部门和责任部门');
+	if (!responsibleDeptId) {
+		ElMessage.warning('请选择责任部门');
+		return;
+	}
+	// S8-MANUAL-REPORT-DESCRIPTION-REQUIRED-1:详细说明改必填。
+	// 处理人接到的是一条点对点派单,只有标题的话他得回头找提报人问一遍才能动手。
+	if (!form.description.trim()) {
+		ElMessage.warning('请填写详细说明:异常现象、发生时间、影响范围');
 		return;
 	}
 
 	saving.value = true;
 	try {
+		// form 的字段已与请求体一一对应(stageCode / orderFlowCode 随模板一并删除),故可直接展开。
+		// 两个人员字段已补进 S8ReportCreate 类型,走正常的类型检查,不再需要绕过 excess property check。
 		const res = (await s8ReportApi.create({
 			...form,
 			occurrenceDeptId,
 			responsibleDeptId,
-			// TASK-022-MANUAL-REPORT-TEMPLATE-1:维度三字段空串归一为 undefined,避免后端把 '' 当合法 stage_code。
-			stageCode: form.stageCode || undefined,
-			orderFlowCode: form.orderFlowCode || undefined,
+			assigneeUserId,
+			verifierUserId,
 		})) as { exceptionId: number };
 		if (attachmentFiles.value.length > 0) {
 			await uploadAttachments(res.exceptionId);
@@ -172,32 +205,14 @@ async function submit() {
 
 onMounted(() => {
 	void loadOptions();
-	void loadTemplates();
+	void loadAssignees();
+	void loadVerifiers();
 });
 </script>
 
 <template>
 	<AidopDemoShell title="主动提报" subtitle="S8 异常协同 / 人工上报">
 		<el-form :model="form" label-width="120px" style="max-width: 760px">
-			<!-- TASK-022-MANUAL-REPORT-TEMPLATE-1:提报模板下拉,可选;不选则继续手填路径。 -->
-			<el-form-item label="提报模板">
-				<el-select
-					:model-value="selectedTemplateId"
-					placeholder="可选:选择主动提报模板自动带入业务参数"
-					style="width: 100%"
-					clearable
-					filterable
-					:loading="templateLoading"
-					@update:model-value="(v: number | string | null) => applyTemplate(v)"
-				>
-					<el-option
-						v-for="t in templates"
-						:key="t.id"
-						:label="t.templateName ?? t.templateCode"
-						:value="t.id"
-					/>
-				</el-select>
-			</el-form-item>
 			<el-form-item label="标题" required>
 				<el-input v-model="form.title" placeholder="简要描述异常" />
 			</el-form-item>
@@ -206,33 +221,78 @@ onMounted(() => {
 					<el-option v-for="item in formOptions.scenes" :key="item.value" :label="item.label" :value="item.value" />
 				</el-select>
 			</el-form-item>
-			<el-form-item label="关联对象编码">
-				<el-input
-					v-model="form.relatedObjectCode"
-					placeholder="如订单项/订单/工单编码,可留空"
-					maxlength="64"
-					show-word-limit
+			<!-- S8-MANUAL-REPORT-P2P-DISPATCH-1:提报时直接指派,提交后异常即为已派单状态。 -->
+			<el-form-item label="处理人" required>
+				<el-select
+					v-model="form.assigneeUserId"
+					style="width: 100%"
 					clearable
-				/>
+					filterable
+					:loading="assigneeLoading"
+					placeholder="选择由谁处理这条异常"
+				>
+					<el-option v-for="item in assigneeUsers" :key="item.id" :label="userLabel(item)" :value="item.id">
+						<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.account ? '(' + item.account + ')' : '' }}</span></span>
+					</el-option>
+				</el-select>
+				<div v-if="assigneeLoaded && assigneeUsers.length === 0" style="color:#e6a23c;font-size:12px;line-height:1.9">
+					当前租户没有具备「处理异常」权限的账号,无法提报。请让管理员先为相关账号授予该操作权限。
+				</div>
 			</el-form-item>
-			<el-form-item label="严重度">
-				<el-select v-model="form.severity" style="width: 240px">
-					<el-option v-for="item in formOptions.severities" :key="item.value" :label="item.label" :value="item.value" />
+			<el-form-item label="复检人" required>
+				<el-select
+					v-model="form.verifierUserId"
+					style="width: 100%"
+					clearable
+					filterable
+					:loading="verifierLoading"
+					placeholder="选择处理完成后由谁复检"
+				>
+					<el-option v-for="item in verifierUsers" :key="item.id" :label="userLabel(item)" :value="item.id">
+						<span>{{ item.name }} <span style="color:#999;font-size:12px">{{ item.account ? '(' + item.account + ')' : '' }}</span></span>
+					</el-option>
 				</el-select>
+				<div v-if="verifierLoaded && verifierUsers.length === 0" style="color:#e6a23c;font-size:12px;line-height:1.9">
+					当前租户没有具备「复核异常」权限的账号,无法提报。请让管理员先为相关账号授予该操作权限。
+				</div>
 			</el-form-item>
 			<el-form-item label="发生部门" required>
-				<el-select v-model="form.occurrenceDeptId" style="width: 100%" clearable filterable>
+				<el-select v-model="form.occurrenceDeptId" style="width: 100%" clearable filterable placeholder="请选择异常发生在哪个部门">
 					<el-option v-for="item in formOptions.departments" :key="item.value" :label="item.label" :value="item.value" />
 				</el-select>
 			</el-form-item>
 			<el-form-item label="责任部门" required>
-				<el-select v-model="form.responsibleDeptId" style="width: 100%" clearable filterable>
+				<el-select v-model="form.responsibleDeptId" style="width: 100%" clearable filterable placeholder="请选择由哪个部门担责">
 					<el-option v-for="item in formOptions.departments" :key="item.value" :label="item.label" :value="item.value" />
 				</el-select>
 			</el-form-item>
-			<el-form-item label="详细说明">
-				<el-input v-model="form.description" type="textarea" :rows="4" />
+			<el-form-item label="关联对象编码">
+				<el-input
+					v-model="form.relatedObjectCode"
+					placeholder="如订单项/订单/工单编码,可留空"
+					maxlength="64"
+					show-word-limit
+					clearable
+				/>
+			</el-form-item>
+			<el-form-item label="详细说明" required>
+				<el-input
+					v-model="form.description"
+					type="textarea"
+					:rows="4"
+					placeholder="必填:异常现象、发生时间、影响范围,便于处理人直接开工"
+				/>
 			</el-form-item>
+			<!-- S8-MANUAL-REPORT-ADVANCED-SEVERITY-1:严重度有默认值 FOLLOW,收进折叠区,主表单只留必填项。 -->
+			<el-collapse v-model="advancedPanels" style="margin: 0 0 18px 120px">
+				<el-collapse-item title="高级选项" name="advanced">
+					<el-form-item label="严重度" label-width="80px" style="margin-bottom: 0">
+						<el-select v-model="form.severity" style="width: 240px">
+							<el-option v-for="item in formOptions.severities" :key="item.value" :label="item.label" :value="item.value" />
+						</el-select>
+					</el-form-item>
+				</el-collapse-item>
+			</el-collapse>
 			<el-form-item label="附件">
 				<el-upload
 					ref="uploadRef"

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.509</AssemblyVersion>
-    <FileVersion>1.0.509</FileVersion>
-    <Version>1.0.509</Version>
+    <AssemblyVersion>1.0.510</AssemblyVersion>
+    <FileVersion>1.0.510</FileVersion>
+    <Version>1.0.510</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 440 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ManualDirectAssignTests.cs

@@ -0,0 +1,440 @@
+using System.Reflection;
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-MANUAL-DIRECT-ASSIGN-1:主动提报「点对点派单」契约。
+///
+/// <para><b>一期业务口径</b>:提报人填异常时直接选处理人与复检人,提交后异常
+/// <b>直接进入 ASSIGNED</b>,不经认领;处理人收到创建通知后可直接处理或转交。</para>
+///
+/// <para><b>本文件守护的是三类最容易悄悄退化的东西</b>:
+/// ① 建单写入契约(状态 / 两个人 / 指派时间)——写错了不会报错,只会让单子躺在那里没人管;
+/// ② 身份边界(跨租户 / 停用账号 / 处理人与复检人同一人)——错了要么泄漏、要么复检形同虚设;
+/// ③ 创建通知<b>不依赖任何配置行</b>——这条一旦被"顺手改成走配置路径",
+///    主动提报会立刻回到"建单成功但没有任何人被通知"的状态,而且全链路无一处报错。</para>
+///
+/// <para><b>为什么大量采用源码文本断言</b>:<c>CreateAsync</c> 依赖 10+ 个仓储与
+/// <c>UserManager</c>/<c>FlowEngine</c>,在单测里构造真实调用不现实;本仓对这类
+/// service 内部契约的既有守护方式即为源码断言(见 <c>S8TenantOnlyExceptionScopeTests</c> /
+/// <c>S8SysUserOnlyIdentityTests</c>)。能纯函数验证的(状态机)一律用真实调用。</para>
+/// </summary>
+public class S8ManualDirectAssignTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static readonly string WebRoot =
+        Path.GetFullPath(Path.Combine(PluginRoot, "../../../Web"));
+
+    /// <summary>只取可执行代码行:注释里会复述旧写法与反面教材,不应算命中。</summary>
+    private static string CodeOnly(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+
+    private static string WebFile(string relativePath)
+    {
+        var full = Path.Combine(WebRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"前端文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    private static string ManualReport() => CodeOnly("Service/S8/S8ManualReportService.cs");
+    private static string Resolver() => CodeOnly("Service/S8/S8NotificationLayerResolver.cs");
+    private static string ReportPage() => WebFile("src/views/aidop/s8/report/S8ManualReportPage.vue");
+
+    /// <summary><c>CreateAsync</c> 方法体(到下一个 public 方法为止),避免断言误命中自动建单分支。</summary>
+    private static string CreateAsyncBody()
+    {
+        var code = ManualReport();
+        var start = code.IndexOf("public async Task<AdoS8ManualReportResultDto> CreateAsync", StringComparison.Ordinal);
+        Assert.True(start > 0, "未找到 CreateAsync,方法签名可能已变更");
+        var next = code.IndexOf("public async Task<AdoS8Exception> CreateFromWatchAsync", start, StringComparison.Ordinal);
+        Assert.True(next > start, "未找到 CreateFromWatchAsync 作为下界");
+        return code[start..next];
+    }
+
+    // ============================================================
+    // A. 建单写入契约(T1–T5)
+    // ============================================================
+
+    [Fact]
+    public void T1_ManualCreate_LandsDirectlyInAssigned()
+    {
+        var body = CreateAsyncBody();
+
+        Assert.Contains("Status = \"ASSIGNED\"", body, StringComparison.Ordinal);
+        // 不得再落 NEW —— 落 NEW 就意味着又要有人去认领,点对点派单当场失效。
+        Assert.DoesNotContain("Status = \"NEW\"", body, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T2_AssigneeUserId_IsWrittenFromValidatedUser()
+    {
+        // 写的是校验后返回的 UserId,而不是 dto 原值 —— 后者绕过了租户/启用校验。
+        Assert.Contains("AssigneeUserId = assignee.UserId", CreateAsyncBody(), StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T3_VerifierUserId_IsWrittenFromValidatedUser()
+    {
+        Assert.Contains("VerifierUserId = verifier.UserId", CreateAsyncBody(), StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T4_AssignedAt_SharesTheSameNowAsCreatedAt()
+    {
+        var body = CreateAsyncBody();
+
+        Assert.Contains("AssignedAt = now", body, StringComparison.Ordinal);
+        Assert.Contains("CreatedAt = now", body, StringComparison.Ordinal);
+
+        // 复用同一个 now 而不是各自 DateTime.Now:两列相差几毫秒会让
+        //「建单即指派」在数据上看起来像「建完之后又被指派了一次」。
+        Assert.Contains("var now = DateTime.Now;", body, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T5_ReporterUserId_StaysCurrentSysUser()
+    {
+        var body = CreateAsyncBody();
+
+        Assert.Contains("ReporterUserId = currentUserId", body, StringComparison.Ordinal);
+        Assert.Contains("_userManager.UserId", body, StringComparison.Ordinal);
+    }
+
+    // ============================================================
+    // B. 必填与身份规则(T6–T11)
+    // ============================================================
+
+    [Theory]
+    [InlineData("dto.AssigneeUserId is not > 0", "处理人")]   // T6
+    [InlineData("dto.VerifierUserId is not > 0", "复检人")]   // T7
+    public void T6_T7_AssigneeAndVerifier_AreRequired(string condition, string subject)
+    {
+        var body = CreateAsyncBody();
+
+        Assert.Contains(condition, body, StringComparison.Ordinal);
+        Assert.Contains(subject, body, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T8_Description_IsRequiredOnTheServerToo()
+    {
+        // 只在前端拦是不够的:直连 API 仍可提交空描述,
+        // 而"哪里出了什么问题"正是主动提报唯一不可替代的信息。
+        Assert.Contains("string.IsNullOrWhiteSpace(dto.Description)", CreateAsyncBody(), StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T9_AssigneeEqualsVerifier_IsRejected()
+    {
+        var body = CreateAsyncBody();
+
+        Assert.Contains("dto.AssigneeUserId == dto.VerifierUserId", body, StringComparison.Ordinal);
+        // 自己处理完自己复检,复检就只是个仪式。
+        Assert.Contains("复检失去意义", body, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T10_T11_ReporterMayBeAssigneeOrVerifier()
+    {
+        var body = CreateAsyncBody();
+
+        // 一期明确允许自报自处理 / 自报自复检,不得出现针对提报人的比较。
+        // 若哪天要禁止,必须是显式业务决定,而不是顺手加进来的一行。
+        Assert.DoesNotContain("currentUserId == dto.AssigneeUserId", body, StringComparison.Ordinal);
+        Assert.DoesNotContain("currentUserId == dto.VerifierUserId", body, StringComparison.Ordinal);
+        Assert.DoesNotContain("dto.AssigneeUserId == currentUserId", body, StringComparison.Ordinal);
+        Assert.DoesNotContain("dto.VerifierUserId == currentUserId", body, StringComparison.Ordinal);
+    }
+
+    // ============================================================
+    // C. 账号有效性(T12–T16)
+    //
+    // 跨租户 / 停用 / 不存在三种情形共用同一个校验器:
+    // S8UserScopeValidator.EnsureActiveUserAsync 内部按
+    // (TenantId == tenantId && Status == Enable) 取人,取不到即抛。
+    // 因此这里守护的是「确实委派给了它」,而不是重新实现一遍判据。
+    // ============================================================
+
+    [Fact]
+    public void T12_T13_T14_T15_T16_BothUsers_GoThroughSharedScopeValidator()
+    {
+        var body = CreateAsyncBody();
+
+        Assert.Contains("_userScope.EnsureActiveUserAsync", body, StringComparison.Ordinal);
+        Assert.Contains("\"处理人\"", body, StringComparison.Ordinal);
+        Assert.Contains("\"复检人\"", body, StringComparison.Ordinal);
+
+        // 两次调用,一人一次 —— 只校验其中一个是本类问题里最容易漏的那种。
+        var occurrences = body.Split("_userScope.EnsureActiveUserAsync").Length - 1;
+        Assert.True(occurrences >= 2, $"处理人与复检人都必须校验,当前只有 {occurrences} 次调用");
+    }
+
+    [Fact]
+    public void T12b_ManualReport_NeverHandRollsItsOwnUserQuery()
+    {
+        var code = ManualReport();
+
+        // 手写「本租户 + 启用」的 SysUser 查询 = 第二套身份 Authority,
+        // 两套判据迟早分叉,且分叉只表现为"某些人莫名其妙选不到/收不到"。
+        // 该约束同时被 S8SysUserOnlyIdentityTests 守护,这里再钉一次是因为
+        // 本批次正是最有动机去手写这条查询的时刻。
+        Assert.DoesNotContain("Status == StatusEnum.Enable", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("AdoS0EmployeeMaster", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("GetEmployeeSysUserId", code, StringComparison.Ordinal);
+    }
+
+    // ============================================================
+    // D. 状态机(T17–T19)——这三条是真实调用,不是文本断言
+    // ============================================================
+
+    [Fact]
+    public void T17_OnceAssigned_ClaimIsNoLongerPossible()
+    {
+        // 建单即 ASSIGNED 之后,认领这个动作在状态机层面就不成立了:
+        // 边表里没有 ASSIGNED → ASSIGNED,ClaimAsync 会在状态守卫处直接拒绝,
+        // 根本走不到那条「AssigneeUserId == null」的原子 UPDATE。
+        Assert.False(S8StatusRules.IsAllowedTransition("ASSIGNED", "ASSIGNED"));
+
+        // 自动异常仍然从 NEW 开始,认领路径必须完好。
+        Assert.True(S8StatusRules.IsAllowedTransition("NEW", "ASSIGNED"));
+    }
+
+    [Fact]
+    public void T18_Assigned_CanStartProgress()
+    {
+        Assert.True(S8StatusRules.IsAllowedTransition("ASSIGNED", "IN_PROGRESS"));
+    }
+
+    [Fact]
+    public void T19_Assigned_IsInTransferWhitelist()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+
+        // 转派白名单必须仍含 ASSIGNED,否则「派错了人可以转交」这条一期承诺当场落空。
+        var idx = code.IndexOf("allowedStatuses", StringComparison.Ordinal);
+        Assert.True(idx > 0, "未找到 TransferAsync 的状态白名单");
+        var window = code.Substring(idx, Math.Min(200, code.Length - idx));
+        Assert.Contains("\"ASSIGNED\"", window, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T20_PrewrittenVerifier_DoesNotShortCircuitSubmitVerification()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+
+        // 提交复检仍必须走资格校验:建单时预写的 verifier 只是默认值,
+        // 不是"已经批准过"的凭据。绕过它等于让提报人单方面决定谁能复核。
+        Assert.Contains("_verifierEligibility.CheckAsync", code, StringComparison.Ordinal);
+        Assert.Contains("只有当前处理人才能提交复检", code, StringComparison.Ordinal);
+    }
+
+    // ============================================================
+    // E. 创建通知(T21–T25)
+    // ============================================================
+
+    [Fact]
+    public void T21_CreatedNotification_TargetsTheAssigneeOnly()
+    {
+        var body = CreateAsyncBody();
+        var code = ManualReport();
+
+        Assert.Contains("TryDispatchCreatedToAssigneeAsync(entity, assignee)", body, StringComparison.Ordinal);
+        Assert.Contains("new[] { assignee.UserId }", code, StringComparison.Ordinal);
+
+        // 复检人此刻还轮不到动手,通知他只会制造一条无法行动的消息。
+        Assert.DoesNotContain("verifier.UserId }", code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T22_T23_CreatedNotification_NeedsNoConfigRowAndNoSourceRule()
+    {
+        var code = ManualReport();
+
+        // 走显式收件人入口。配置路径 DispatchByLayerAsync 会按
+        // (租户, rule_code, 事件) 查配置行,而主动提报的 rule_code 恒为 NULL、
+        // 兜底键 '*' 至今没有写入方 —— 用它等于永远发不出去。
+        Assert.Contains("DispatchToExplicitUsersAsync", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("DispatchByLayerAsync", code, StringComparison.Ordinal);
+
+        var resolver = Resolver();
+        // 显式路径不得回头去解析配置,否则又会被 Source == "NONE" 挡掉。
+        var start = resolver.IndexOf("DispatchToExplicitUsersAsync", StringComparison.Ordinal);
+        Assert.True(start > 0);
+        var end = resolver.IndexOf("private async Task DispatchToUsersAsync", start, StringComparison.Ordinal);
+        Assert.True(end > start);
+        var method = resolver[start..end];
+        Assert.DoesNotContain("_recipientResolver.ResolveAsync", method, StringComparison.Ordinal);
+        Assert.Contains("\"EXPLICIT\"", method, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T24_NoLegacyNotificationRevived()
+    {
+        var code = ManualReport();
+
+        Assert.DoesNotContain("ado_s8_notification_layer", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("AdoS8NotificationLayer", code, StringComparison.Ordinal);
+        // 也不得偷偷引入租户级通配配置。
+        Assert.DoesNotContain("TenantDefaultRuleCode", code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T25_NotificationFailure_NeverRollsBackTheException()
+    {
+        var code = ManualReport();
+        var body = CreateAsyncBody();
+
+        // 通知必须在事务之后发:异常已经建成,通知发不出去是通知的问题。
+        var tranEnd = body.IndexOf("}, ex => throw ex);", StringComparison.Ordinal);
+        var dispatchAt = body.IndexOf("TryDispatchCreatedToAssigneeAsync", StringComparison.Ordinal);
+        Assert.True(tranEnd > 0 && dispatchAt > tranEnd,
+            "创建通知必须在事务提交之后发起,否则通知失败会连带回滚已经成立的异常");
+
+        // 且自身吞异常只记 Warning。
+        var m = code.IndexOf("private async Task TryDispatchCreatedToAssigneeAsync", StringComparison.Ordinal);
+        Assert.True(m > 0);
+        var method = code.Substring(m, Math.Min(2000, code.Length - m));
+        Assert.Contains("catch (Exception ex)", method, StringComparison.Ordinal);
+        Assert.Contains("_logger.LogWarning", method, StringComparison.Ordinal);
+    }
+
+    // ============================================================
+    // F. 前端契约(T26–T29)
+    // ============================================================
+
+    [Fact]
+    public void T26_ReportPage_NoLongerOffersTemplates()
+    {
+        var page = ReportPage();
+
+        Assert.DoesNotContain("提报模板", page, StringComparison.Ordinal);
+        Assert.DoesNotContain("reportTemplates", page, StringComparison.Ordinal);
+        Assert.DoesNotContain("applyTemplate", page, StringComparison.Ordinal);
+        Assert.DoesNotContain("loadTemplates", page, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T27_DepartmentsNoLongerSilentlyDefaultToFirstOption()
+    {
+        var page = ReportPage();
+
+        // 静默选中第一个部门,比不填更危险:用户不会注意到自己提交了一个错误归属。
+        //
+        // 断言的是**赋值形状**而不是裸 token「departments[0]」:源码里留了一条
+        // 「这里不再回落到 departments[0]」的说明性注释,用裸 token 会命中它自己 ——
+        // 那样这条测试就变成了「注释里不许提这件事」,而不是「代码里不许这么做」。
+        Assert.DoesNotContain("departments[0].value", page, StringComparison.Ordinal);
+        Assert.Contains("请选择发生部门", page, StringComparison.Ordinal);
+        Assert.Contains("请选择责任部门", page, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T28_T29_CandidatesAreNarrowedByAction()
+    {
+        var page = ReportPage();
+
+        // 页面上能选到的人必须与后端会放行的人一致,否则用户会选中一个
+        // 看起来正常、提交却被拒的账号。
+        Assert.Contains("EXCEPTION_START", page, StringComparison.Ordinal);
+        Assert.Contains("EXCEPTION_VERIFY", page, StringComparison.Ordinal);
+        Assert.Contains("assigneeUserId", page, StringComparison.Ordinal);
+        Assert.Contains("verifierUserId", page, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T29b_FrontendBlocksSameAssigneeAndVerifier()
+    {
+        Assert.Contains("处理人与复检人不能为同一账号", ReportPage(), StringComparison.Ordinal);
+    }
+
+    // ============================================================
+    // G. 自动 Rule 零回归(T30)
+    // ============================================================
+
+    [Fact]
+    public void T30_AutoCreationPaths_StillStartAtNew()
+    {
+        var code = ManualReport();
+
+        // 自动建单没有"提报人指定处理人"这回事,必须仍从 NEW 起、仍走认领。
+        var watchAt = code.IndexOf("public async Task<AdoS8Exception> CreateFromWatchAsync", StringComparison.Ordinal);
+        Assert.True(watchAt > 0);
+        var autoPart = code[watchAt..];
+
+        Assert.Contains("Status = \"NEW\"", autoPart, StringComparison.Ordinal);
+        // 自动路径不得被顺手改成点对点派单。
+        Assert.DoesNotContain("AssigneeUserId = assignee.UserId", autoPart, StringComparison.Ordinal);
+        Assert.DoesNotContain("MANUAL_ASSIGN", autoPart, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T30b_ClaimPathIsUntouched()
+    {
+        var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
+
+        // 一期不废弃自助认领:自动异常仍靠它流转。
+        Assert.Contains("_claimEligibility.CheckAsync", code, StringComparison.Ordinal);
+        Assert.Contains("x.AssigneeUserId == null", code, StringComparison.Ordinal);
+    }
+
+    // ============================================================
+    // H. DTO / 时间线
+    // ============================================================
+
+    [Fact]
+    public void Dto_CarriesBothPeopleAsNullableLongs()
+    {
+        var t = typeof(AdoS8ManualReportCreateDto);
+
+        foreach (var name in new[] { "AssigneeUserId", "VerifierUserId" })
+        {
+            var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
+            Assert.NotNull(p);
+            // 可空是为了契约兼容(老调用方不传不会 400 在模型绑定层),
+            // 业务必填由 CreateAsync 的 guard 兜底 —— 见 T6/T7。
+            Assert.Equal(typeof(long?), p!.PropertyType);
+        }
+    }
+
+    [Fact]
+    public void Timeline_RecordsAssignmentSeparatelyFromCreate()
+    {
+        var body = CreateAsyncBody();
+
+        Assert.Contains("\"MANUAL_ASSIGN\"", body, StringComparison.Ordinal);
+        // 不得复用 CLAIM:CLAIM 是「处理人自己把单子捞走」,
+        // 这里是「提报人把单子指给别人」,责任主体正好相反。
+        Assert.DoesNotContain("\"CLAIM\"", body, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void Timeline_CreateNoLongerClaimsTheRowWasEverNew()
+    {
+        var body = CreateAsyncBody();
+
+        // 单据从未处于 NEW —— CREATE 那条若仍写 ToStatus="NEW",
+        // 时间线就记录了一个真实世界不存在的状态,而 from/to 是对外暴露的字段。
+        var createAt = body.IndexOf("ActionCode = \"CREATE\"", StringComparison.Ordinal);
+        Assert.True(createAt > 0);
+        var window = body.Substring(createAt, Math.Min(400, body.Length - createAt));
+        Assert.Contains("ToStatus = \"ASSIGNED\"", window, StringComparison.Ordinal);
+        Assert.DoesNotContain("ToStatus = \"NEW\"", window, StringComparison.Ordinal);
+    }
+}

+ 20 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -154,6 +154,26 @@ public class AdoS8ManualReportCreateDto
     /// <summary>关联对象编码:当前按订单项 / 类订单项对象理解,允许重复,可为空。最长 64 字符(与 ado_s8_exception.related_object_code 一致)。</summary>
     public string? RelatedObjectCode { get; set; }
 
+    // 主动提报点对点派单 V1:提报人在提报表单上直接指定处理人与复检人,建单即 ASSIGNED,不再经「认领」抢单。
+    // 之所以点对点:主动提报的异常提报人通常已经知道该找谁,走认领池只会让单子在 NEW 里悬着等人主动捞。
+    //
+    /// <summary>
+    /// 处理人账号 Id。<b>值域是 <c>SysUser.Id</c></b>(与 <c>ado_s8_exception.assignee_user_id</c> 同一 ID 空间),
+    /// 不是员工主数据的 <c>EmployeeMaster.RecID</c>、也不是员工工号。
+    ///
+    /// <para>声明为可空只为保持 DTO 契约 additive(旧调用方不传不至于反序列化失败);
+    /// 业务上必填,缺失由 <c>CreateAsync</c> 的 guard 明确拒绝,而不是静默建成一张无人负责的单。</para>
+    /// </summary>
+    public long? AssigneeUserId { get; set; }
+
+    /// <summary>
+    /// 复检人账号 Id。<b>值域同样是 <c>SysUser.Id</c></b>(对应 <c>ado_s8_exception.verifier_user_id</c>)。
+    ///
+    /// <para>与 <see cref="AssigneeUserId"/> 一样:可空是为了契约兼容,业务上必填,
+    /// 且必须与处理人不同 —— 自己处理自己复检等于没有复检。</para>
+    /// </summary>
+    public long? VerifierUserId { get; set; }
+
     // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:手工提报维度选择(前端首版可不传,按 module_code 兜底;rule_mechanism 固定 MANUAL_REPORT)。
     public string? StageCode { get; set; }
     public string? OrderFlowCode { get; set; }

+ 118 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
@@ -89,6 +90,18 @@ public class S8ManualReportService : ITransient
     private readonly FlowEngineService _flowEngine;
     // S8-RULE-READINESS-1:租户部门合法性的唯一实现,与启用前的就绪门禁同源。
     private readonly IS8DepartmentScopeValidator _deptValidator;
+    // S8-SYSUSER-ONLY-1:「这个账号在本租户能不能作为 S8 的人」只有这一份判据。
+    // 主动提报选处理人/复检人必须复用它,而不是在本文件里另写一套 SysUser 查询 ——
+    // 认领 / 转派 / 提交复检已经踩过一次「几处写成一样」最终分叉的坑。
+    private readonly IS8UserScopeValidator _userScope;
+    /// <summary>
+    /// S8-MANUAL-DIRECT-ASSIGN-1:建单后通知处理人。
+    /// 只用它的<b>显式收件人</b>入口 —— 主动提报没有来源规则,配置路径按
+    /// (租户, rule_code, 事件) 取收件人,对 rule_code 为空的单据永远查不到行,
+    /// 会在 <c>Source == "NONE"</c> 处直接返回。收件人在这里本就是业务动作的一部分
+    /// (提报人当场指定的),不该再去问配置。
+    /// </summary>
+    private readonly S8NotificationLayerResolver _notificationLayerResolver;
     private readonly ILogger<S8ManualReportService> _logger;
 
     public S8ManualReportService(
@@ -104,6 +117,8 @@ public class S8ManualReportService : ITransient
         UserManager userManager,
         FlowEngineService flowEngine,
         IS8DepartmentScopeValidator deptValidator,
+        IS8UserScopeValidator userScope,
+        S8NotificationLayerResolver notificationLayerResolver,
         ILogger<S8ManualReportService> logger)
     {
         _rep = rep;
@@ -118,6 +133,8 @@ public class S8ManualReportService : ITransient
         _userManager = userManager;
         _flowEngine = flowEngine;
         _deptValidator = deptValidator;
+        _userScope = userScope;
+        _notificationLayerResolver = notificationLayerResolver;
         _logger = logger;
     }
 
@@ -439,6 +456,54 @@ public class S8ManualReportService : ITransient
         }
     }
 
+    /// <summary>
+    /// S8-MANUAL-DIRECT-ASSIGN-1:主动提报建单后,通知被指定的处理人。
+    ///
+    /// <para><b>为什么走显式收件人、而不是配置路径</b>:配置路径按
+    /// <c>(租户, rule_code, 事件)</c> 查 <c>ado_s8_notification_recipient</c>,
+    /// 主动提报的 <c>source_rule_code</c> 恒为 NULL,兜底键 <c>'*'</c> 至今没有写入方也没有配置界面 ——
+    /// 于是 <c>DispatchByLayerAsync</c> 会在 <c>Source == "NONE"</c> 处直接返回,一个字都发不出去。
+    /// 而这里的收件人根本不需要"查":它就是提报人刚刚在页面上指定的那个人,
+    /// 是业务动作的一部分,不是一项配置。</para>
+    ///
+    /// <para><b>为什么放在事务之后</b>:与自动建单侧
+    /// (<c>S8WatchSchedulerService.TryDispatchLayerNotificationAsync</c>)同一条纪律 ——
+    /// 异常已经建成,通知发不出去是通知的问题,不该把已经成立的业务事实回滚掉。
+    /// 因此全程 try/catch 只记 Warning,绝不外抛。</para>
+    ///
+    /// <para><b>刻意只发处理人</b>:复检人此刻还轮不到他动手(要等处理人提交复检),
+    /// 现在通知他只会制造一条无法行动的消息。VERIFICATION_SUBMITTED 事件本身已在
+    /// <c>S8TaskFlowService</c> 里接好线,待其收件人配置补齐后自然生效。</para>
+    /// </summary>
+    private async Task TryDispatchCreatedToAssigneeAsync(AdoS8Exception entity, S8OperatorUser assignee)
+    {
+        try
+        {
+            await _notificationLayerResolver.DispatchToExplicitUsersAsync(
+                new S8NotificationLayerResolver.DispatchByLayerInput
+                {
+                    TenantId = entity.TenantId,
+                    ExceptionId = entity.Id,
+                    ExceptionNo = entity.ExceptionCode,
+                    SceneCode = entity.SceneCode ?? string.Empty,
+                    Severity = entity.Severity ?? string.Empty,
+                    Status = entity.Status,
+                    SourceRuleCode = entity.SourceRuleCode,
+                    EventCode = S8NotifyEventCode.ExceptionCreated,
+                    ExceptionRef = entity,
+                    Title = entity.Title ?? string.Empty,
+                    Content = $"异常 {entity.ExceptionCode}:{entity.Title}(主动提报,已指派给你处理)",
+                },
+                new[] { assignee.UserId });
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "s8_manual_created_notify_failed exceptionId={Id} exceptionCode={Code} assignee={Assignee}",
+                entity.Id, entity.ExceptionCode, assignee.UserId);
+        }
+    }
+
     public async Task<object> GetFormOptionsAsync()
     {
         var tenantId = ResolveTrustedTenantId();
@@ -506,6 +571,26 @@ public class S8ManualReportService : ITransient
         if (dto.OccurrenceDeptId <= 0) throw new S8BizException("发生部门不能为空,请选择有效发生部门");
         if (dto.ResponsibleDeptId <= 0) throw new S8BizException("处理部门不能为空,请选择有效处理部门");
 
+        // ── 主动提报点对点派单 V1:提报即定人,不进认领池 ──
+        //
+        // 三条必填放在这里(而不是靠前端约束)的原因:这三样一旦缺失,单子仍然能建成,
+        // 只是变成一张「没人负责、没人复检、没说清发生了什么」的空壳挂在列表里,
+        // 而调用方看到的是 200 成功。缺失必须在入口就变成可读的 400,不能顺延到运营侧才发现。
+        if (dto.AssigneeUserId is not > 0)
+            throw new S8BizException("请选择处理人,主动提报需明确由谁处理");
+        if (dto.VerifierUserId is not > 0)
+            throw new S8BizException("请选择复检人,主动提报需明确由谁复检");
+        if (string.IsNullOrWhiteSpace(dto.Description))
+            throw new S8BizException("详细说明不能为空,请描述异常的具体情况");
+        // 同一个人既处理又复检,复检就只是走个形式;这是业务规则,不是数据完整性问题,故单独成条并给出理由。
+        if (dto.AssigneeUserId == dto.VerifierUserId)
+            throw new S8BizException("处理人与复检人不能是同一账号,否则复检失去意义");
+
+        // 账号合法性统一委派给 IS8UserScopeValidator(租户内 + 启用),与认领 / 转派 / 提交复检同源判据。
+        // 不在本文件里手写 SysUser 查询:判据一旦有第二份,迟早分叉成「提报能选、认领校验不过」这种最难查的形态。
+        var assignee = await _userScope.EnsureActiveUserAsync(tenantId, dto.AssigneeUserId!.Value, "处理人");
+        var verifier = await _userScope.EnsureActiveUserAsync(tenantId, dto.VerifierUserId!.Value, "复检人");
+
         // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:白名单接受新值(FOLLOW/SERIOUS)+ 旧值兼容;
         // 通过 Normalize 写入 DB 一律为 FOLLOW/SERIOUS。
         var rawSeverity = string.IsNullOrWhiteSpace(dto.Severity) ? S8SeverityCode.Follow : dto.Severity.Trim();
@@ -549,7 +634,10 @@ public class S8ManualReportService : ITransient
             Description = dto.Description,
             SceneCode = dto.SceneCode.Trim(),
             SourceType = "MANUAL",
-            Status = "NEW",
+            // 主动提报点对点派单 V1:处理人在提报时就已确定,没有「待认领」这个中间态可言,
+            // 建单直接落 ASSIGNED。留 NEW 只会制造一个谁都不会去认领的空窗口 ——
+            // 单子已经有主,却在列表里显示为待认领,反而误导运营。
+            Status = "ASSIGNED",
             Severity = severity,
             PriorityScore = 0,
             PriorityLevel = "P3",
@@ -557,6 +645,13 @@ public class S8ManualReportService : ITransient
             ResponsibleDeptId = dto.ResponsibleDeptId,
             // S8-SYSUSER-ONLY-1:reporter_user_id idspace = SysUser.Id(与 assignee/verifier/timeline 同)。
             ReporterUserId = currentUserId,
+            // 三个字段来自同一次提报动作,必须同批写入:只写 Status 不写人,
+            // 单子会以「已分派但无处理人」的形态存在,而这正是列表 / 通知 / 工作台都解释不了的那种脏状态。
+            AssigneeUserId = assignee.UserId,
+            VerifierUserId = verifier.UserId,
+            // 与 CreatedAt 共用同一个 now:点对点提报的「建单」与「派单」是同一瞬间发生的事,
+            // 两个时间戳若各取一次 DateTime.Now,事后按时间排序会出现毫秒级倒挂。
+            AssignedAt = now,
             ExceptionTypeCode = inferredType,
             ModuleCode = resolvedModule,
             // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
@@ -584,14 +679,35 @@ public class S8ManualReportService : ITransient
                 ActionCode = "CREATE",
                 ActionLabel = "创建",
                 FromStatus = null,
-                ToStatus = "NEW",
+                // 点对点派单后单据**从未处于 NEW**:它在同一条 INSERT 里就直接落到 ASSIGNED。
+                // 这里若沿用旧的 "NEW",时间线就会记录一个真实世界不存在的状态,
+                // 而 from/to 是经 S8DecisionService 对外暴露的字段 —— 事后复盘会被它误导。
+                // 自动建单路径(CreateFromWatchAsync / CreateFromHitAsync)确实是 NEW,那两处不动。
+                ToStatus = "ASSIGNED",
                 OperatorUserId = currentUserId,
                 ActionRemark = "主动提报",
                 CreatedAt = DateTime.Now
             });
+            // 派单单独记一条,不与 CREATE 合并、也不复用 CLAIM:
+            // CLAIM 的语义是「处理人自己把单子捞走」,这里是「提报人把单子指给别人」,
+            // 责任主体正好相反。共用一个 ActionCode 会让时间线再也分不清是谁做的决定。
+            // FromStatus 留 null:这一步没有前置状态可言——单据是在同一事务里刚刚诞生的。
+            await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
+            {
+                ExceptionId = entity.Id,
+                ActionCode = "MANUAL_ASSIGN",
+                ActionLabel = "指派处理人",
+                FromStatus = null,
+                ToStatus = "ASSIGNED",
+                OperatorUserId = currentUserId,
+                // 记显示名而非裸 ID:时间线是给人看的,事后追责时没人愿意再去反查一串数字。
+                ActionRemark = $"主动提报指派:处理人 {assignee.DisplayName},复检人 {verifier.DisplayName}",
+                CreatedAt = DateTime.Now
+            });
         }, ex => throw ex);
 
         await TryStartIntakeFlowAsync(entity);
+        await TryDispatchCreatedToAssigneeAsync(entity, assignee);
 
         return new AdoS8ManualReportResultDto
         {

+ 79 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8NotificationLayerResolver.cs

@@ -152,6 +152,85 @@ public class S8NotificationLayerResolver : ITransient
 		await DispatchToUsersAsync(input, resolution);
 	}
 
+	/// <summary>
+	/// S8-NOTIFY-EXPLICIT-1:把通知投递给<b>调用方显式给定</b>的账号,**跳过收件人配置解析**。
+	///
+	/// <para><b>为什么这条路径不需要配置行</b>:<see cref="DispatchByLayerAsync"/> 解决的是
+	/// 「这类事件该通知谁」——答案要从 <c>ado_s8_notification_recipient</c> 的
+	/// (租户, 规则, 事件) 配置推导,所以没配置就必须停在 <c>Source == "NONE"</c> 的门上。
+	/// 本方法解决的是另一个问题:收件人在调用现场<b>已经是确定的具体账号</b>
+	/// (由业务动作本身决定,例如建单时手选的对象),不存在"该通知谁"的推导空间。
+	/// 对这种输入再去查一遍配置表只有一种结果:配置缺失时把一条本该送达的通知丢掉——
+	/// 即"人已经指名道姓选好了,却因为管理员没配规则而没收到"。
+	/// 因此这里刻意<b>不经过</b> <c>_recipientResolver.ResolveAsync</c>,
+	/// 也就不会被 <c>Source == "NONE"</c> 提前拦截。</para>
+	///
+	/// <para><b>为什么不在通知层重做身份校验</b>:账号是否存在、是否启用、是否属于本租户,
+	/// 是<b>调用方</b>的前置(<c>IS8UserScopeValidator.EnsureActiveUserAsync</c>)——
+	/// 收件人本来就是它校验通过后才交到这里的。通知层若再造一套判断,就等于在系统里
+	/// 存在第二个身份 Authority:两处规则迟早不一致,而不一致的那一刻没有任何报错,
+	/// 只表现为"某些人莫名其妙收不到通知"。所以本方法<b>只负责投递</b>,
+	/// 不做账号有效性 / 租户归属判断,也不因此放宽任何边界——
+	/// 边界仍在调用方,且调用方必须先校验再调本方法。</para>
+	///
+	/// <para>本批只提供能力,<b>不接任何具体事件</b>;
+	/// <c>EXCEPTION_TRANSFERRED</c> / <c>VERIFICATION_SUBMITTED</c> 等接线登记为 Deferred。</para>
+	/// </summary>
+	/// <param name="input">通知载荷,与配置路径同一个入参类型,保证两条路径构造出的消息完全一致。</param>
+	/// <param name="userIds">显式收件账号(<c>SysUser.Id</c>)。允许重复,内部去重。</param>
+	public async Task DispatchToExplicitUsersAsync(DispatchByLayerInput input, IReadOnlyCollection<long> userIds)
+	{
+		// 前置与 DispatchByLayerAsync 保持一致:入参本身缺失属于调用方 bug,落 Warning。
+		if (input == null)
+		{
+			_logger.LogWarning("S8ExplicitDispatch: input null");
+			return;
+		}
+		// ExceptionRef 同样沿用配置路径的前置。本方法虽不靠它解析收件人,
+		// 但它是"这条通知挂在哪张单据上"的唯一凭据,缺了就无法在排障时定位,
+		// 也说明调用现场根本没拿到单据本体——那种状态下不该发通知。
+		if (input.ExceptionRef == null)
+		{
+			_logger.LogInformation(
+				"S8ExplicitDispatch skip: 缺少 exception 引用,不发送 (exceptionId={ExceptionId} event={Event})",
+				input.ExceptionId, input.EventCode);
+			return;
+		}
+		// 刻意**不**沿用 sceneCode / severity 的前置:那一条挡的是配置路径的
+		// **路由键**(要靠它们选人)。本路径收件人已给定,两者只是载荷字段,
+		// 缺失不该让一条指名道姓的通知静默消失。
+
+		// 去重;同时剔除非正数(0 / 负数不是合法 SysUser.Id,多半是调用方没取到值)。
+		var distinct = (userIds ?? Array.Empty<long>())
+			.Where(id => id > 0)
+			.Distinct()
+			.ToList();
+
+		if (distinct.Count == 0)
+		{
+			// 明确 no-op 而不是静默返回:调用方以为发出去了、实际一个人都没发,
+			// 是本模块反复出现的失败形态,必须留下可检索的记录。
+			_logger.LogInformation(
+				"S8ExplicitDispatch skip: 显式收件人为空(原始 {Raw} 个,去重且过滤非法 Id 后为 0),不发送 (tenant={Tenant} event={Event} exceptionId={ExceptionId})",
+				userIds?.Count ?? 0, input.TenantId, input.EventCode, input.ExceptionId);
+			return;
+		}
+
+		// 复用同一个 Resolution 结构交给同一个投递实现,确保站内信 / push / 日志
+		// 与配置路径逐字一致——绝不在此另起第二套发送代码。
+		var resolution = new S8RecipientResolution
+		{
+			// 新来源标识,与既有的 RECIPIENT_MODEL / LEGACY_LAYER / NONE 均不冲突,
+			// 日志里一眼能看出这批人不是配置解析来的。
+			Source = "EXPLICIT",
+			UserIds = distinct,
+			// PerType 让既有日志与 ConfiguredButEmpty 语义仍然成立且可读。
+			PerType = new Dictionary<string, int> { ["EXPLICIT"] = distinct.Count },
+		};
+
+		await DispatchToUsersAsync(input, resolution);
+	}
+
 	/// <summary>
 	/// 新收件人模型的投递。复用与 LEGACY 完全相同的 <see cref="BuildNotification"/> 与
 	/// PushAdapter —— 两条路径只在"收件人怎么来"上不同,通知内容与投递方式必须一致,