Bläddra i källkod

feat(aidop): complete S3 delivery rule pilot

Implement the S3 delivery generation pilot with configurable rules, execution logging, PR/DO follow-up flows, and supporting docs/tests.

chore: bump version Web 2.4.142 / server 1.0.107
Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 3 månader sedan
förälder
incheckning
36cc491b90
28 ändrade filer med 5217 tillägg och 100 borttagningar
  1. 1 1
      Web/package.json
  2. 52 32
      Web/src/views/aidop/s3/api/deliverySchedule.ts
  3. 399 48
      Web/src/views/aidop/s3/supply/deliveryScheduleList.vue
  4. 68 3
      Web/src/views/system/log/oplog/index.vue
  5. 129 0
      doc/db/rule/01_ado_rule_config.sql
  6. 50 0
      doc/db/rule/02_enable_rule_repository_checklist.md
  7. 115 0
      doc/db/rule/README.md
  8. 1153 0
      doc/plan/S3调用旧系统Business API解析.md
  9. 566 0
      doc/plan/全模块规则配置表设计草案.md
  10. 123 12
      doc/plan/数据中台模块扩展开发指南-S3范式.md
  11. 113 0
      doc/plan/旧Business API迁移映射清单.md
  12. 137 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Rule/RuleConfigResolverTests.cs
  13. 182 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Rule/RuleConfigServiceTests.cs
  14. 99 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S3/Supply/PurchaseRequestMigrationFlowTests.cs
  15. 47 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleItem.cs
  16. 56 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleProfile.cs
  17. 62 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleSchema.cs
  18. 184 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigQueryService.cs
  19. 152 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigRepository.cs
  20. 205 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigResolver.cs
  21. 83 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigService.cs
  22. 18 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigSnapshot.cs
  23. 18 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Rule/S3DeliveryGenerateRuleOptions.cs
  24. 766 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/DeliveryScheduleService.cs
  25. 225 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseOrderTransferService.cs
  26. 82 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestExternalPushService.cs
  27. 70 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestMergeService.cs
  28. 62 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3OperationLogService.cs

+ 1 - 1
Web/package.json

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

+ 52 - 32
Web/src/views/aidop/s3/api/deliverySchedule.ts

@@ -1,5 +1,4 @@
 import service from '/@/utils/request';
-import { EXTERNAL_RESOURCE_BASE } from '/@/views/aidop/api/workOrderScheduling';
 
 export interface Paged<T> {
 	total: number;
@@ -43,12 +42,58 @@ export interface PurchaseOrderRow {
 }
 
 export interface DeliveryScheduleGenerateResult {
+	demandCount?: number;
 	candidateCount: number;
 	createdCount: number;
+	exceptionCount?: number;
+	purchaseRequestCount?: number;
+	purchaseRequestMergeReducedCount?: number;
+	purchaseOrderCount?: number;
+	purchaseOrderLineCount?: number;
+	qadTrackingCount?: number;
+	skippedCount?: number;
+	ruleSnapshot?: Record<string, unknown>;
 	dsNums: string[];
 	message: string;
 }
 
+export interface S3DeliveryGenerateRuleOptions {
+	quotaRequiredTotal?: number;
+	quotaTolerance?: number;
+	poBuyerCodes?: string[];
+	doUsages?: string[];
+	defaultLeadDays?: number;
+	enablePackagingRoundUp?: boolean;
+	shortageCreatePr?: boolean;
+	enablePrMerge?: boolean;
+	enableRequireGoodsToPo?: boolean;
+	enableExternalPushTracking?: boolean;
+}
+
+export interface RuleConfigAppliedItem {
+	scope?: string;
+	ruleCode?: string;
+	ruleValue?: string | null;
+}
+
+export interface RuleConfigSnapshot<TOptions = S3DeliveryGenerateRuleOptions> {
+	moduleCode?: string;
+	scenarioCode?: string;
+	source?: string;
+	resolvedAt?: string;
+	tenantId?: number | string;
+	factoryId?: number | string | null;
+	options?: TOptions;
+	appliedItems?: RuleConfigAppliedItem[];
+	warnings?: string[];
+}
+
+export interface SaveS3DeliveryGenerateRuleInput {
+	tenantId?: number | string;
+	factoryId?: number | string | null;
+	options: S3DeliveryGenerateRuleOptions;
+}
+
 export function fetchDeliveryScheduleList(params: Record<string, unknown>) {
 	return service.get<Paged<DeliveryScheduleRow>>('/api/Supply/delivery-schedule/list', { params }).then((r) => r.data);
 }
@@ -65,41 +110,16 @@ export function cancelDeliverySchedule(id: number) {
 	return service.post(`/api/Supply/delivery-schedule/cancel/${id}`).then((r) => r.data);
 }
 
-/** 本系统内生成(不经外部 replenishment);列表「生成交货单」已改为前端直连外部链式接口。 */
 export function generateDeliverySchedule() {
 	return service.post<DeliveryScheduleGenerateResult>('/api/Supply/delivery-schedule/generate').then((r) => r.data);
 }
 
-async function postExternalJson(url: string, body: Record<string, unknown>): Promise<string> {
-	const r = await fetch(url, {
-		method: 'POST',
-		headers: { 'Content-Type': 'application/json;charset=UTF-8' },
-		body: JSON.stringify(body),
-	});
-	const text = String((await r.text()) ?? '').trim();
-	if (!r.ok) {
-		throw new Error(`${r.status}(${r.statusText})${text ? `:${text}` : ''}`);
-	}
-	return text;
-}
-
-/**
- * 外部 replenishment:生成交货单(与旧版 $.ajax 一致:query + JSON body 均带 domain、user)。
- * 使用 fetch,避免全局 axios 向外部地址附带 Authorization。
- */
-export function createDeliveryScheduleExternal(domain: string, user: string) {
-	const qs = new URLSearchParams({ domain, user }).toString();
-	const url = `${EXTERNAL_RESOURCE_BASE}/api/business/replenishment/CreateDeliverySchedule?${qs}`;
-	return postExternalJson(url, { domain, user });
-}
-
-/**
- * 外部 resource-examine:自动合并 PR / 转 DO 等(第二步;响应体为 `ok` 表示成功,忽略大小写)。
- */
-export function autoTransferDoOrPoExternal(domain: string) {
-	const qs = new URLSearchParams({ domain }).toString();
-	const url = `${EXTERNAL_RESOURCE_BASE}/api/business/resource-examine/AutoTransferDoOrPo?${qs}`;
-	return postExternalJson(url, { domain });
+export function fetchEffectiveRuleOptions(params?: Record<string, unknown>) {
+	return service.get<RuleConfigSnapshot>('/api/RuleConfig/effective-options', { params }).then((r) => r.data);
+}
+
+export function saveS3DeliveryGenerateRule(data: SaveS3DeliveryGenerateRuleInput) {
+	return service.post<RuleConfigSnapshot>('/api/RuleConfig/s3-delivery-generate/save', data).then((r) => r.data);
 }
 
 export function batchGenerateDeliverySchedule(poNumber: string) {

+ 399 - 48
Web/src/views/aidop/s3/supply/deliveryScheduleList.vue

@@ -28,6 +28,8 @@
 
 		<div class="toolbar">
 			<el-button type="success" :loading="generating" @click="onGenerate">生成交货单</el-button>
+			<el-button :loading="viewingRules" @click="onViewRules">查看当前规则</el-button>
+			<el-button type="primary" plain :loading="viewingRules" @click="onEditRules">编辑当前规则</el-button>
 			<el-button type="primary" :disabled="!selectedPublishableIds.length" @click="onPublish">发布</el-button>
 			<el-button @click="onExport">导出</el-button>
 			<el-button type="warning" @click="openBatchAdd">批量添加</el-button>
@@ -84,41 +86,85 @@
 		<el-dialog v-model="batchVisible" title="批量添加交货单" width="680px" destroy-on-close>
 			<DeliveryScheduleBatchForm @cancel="batchVisible = false" @saved="onBatchSaved" />
 		</el-dialog>
+
+		<el-dialog v-model="ruleEditVisible" title="编辑当前规则" width="760px" destroy-on-close>
+			<el-alert
+				class="rule-edit-alert"
+				type="warning"
+				show-icon
+				:closable="false"
+				title="研发阶段配置入口:保存后会直接更新 ado_rule_item,并立即影响后续 S3 交货单生成。"
+			/>
+			<el-form label-width="150px" :model="ruleForm" class="rule-edit-form">
+				<el-form-item label="配额合计要求">
+					<el-input-number v-model="ruleForm.quotaRequiredTotal" :min="0" :max="100" :precision="4" />
+					<span class="rule-edit-unit">%</span>
+				</el-form-item>
+				<el-form-item label="配额误差容忍">
+					<el-input-number v-model="ruleForm.quotaTolerance" :min="0" :precision="8" :step="0.0001" />
+				</el-form-item>
+				<el-form-item label="普通 PO 采购组">
+					<el-select v-model="ruleForm.poBuyerCodes" multiple filterable allow-create default-first-option style="width: 100%">
+						<el-option v-for="item in buyerCodeOptions" :key="item" :label="item" :value="item" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="DO 可用供应类别">
+					<el-select v-model="ruleForm.doUsages" multiple filterable allow-create default-first-option style="width: 100%">
+						<el-option v-for="item in doUsageOptions" :key="item" :label="item" :value="item" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="默认提前期">
+					<el-input-number v-model="ruleForm.defaultLeadDays" :min="0" :max="365" />
+					<span class="rule-edit-unit">天</span>
+				</el-form-item>
+				<el-form-item label="包装量向上取整">
+					<el-switch v-model="ruleForm.enablePackagingRoundUp" />
+				</el-form-item>
+				<el-form-item label="PO 不足生成 PR">
+					<el-switch v-model="ruleForm.shortageCreatePr" />
+				</el-form-item>
+				<el-form-item label="启用 PR 合并">
+					<el-switch v-model="ruleForm.enablePrMerge" />
+				</el-form-item>
+				<el-form-item label="要货令转 DO/PO">
+					<el-switch v-model="ruleForm.enableRequireGoodsToPo" />
+				</el-form-item>
+				<el-form-item label="写 QadTracking">
+					<el-switch v-model="ruleForm.enableExternalPushTracking" />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="ruleEditVisible = false">取消</el-button>
+				<el-button type="primary" :loading="savingRules" @click="onSaveRules">保存</el-button>
+			</template>
+		</el-dialog>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS3SupplyDeliveryScheduleList">
-import { computed, onMounted, reactive, ref } from 'vue';
-import { useRoute } from 'vue-router';
-import { ElMessage, ElMessageBox } from 'element-plus';
+import { computed, h, onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import DeliveryScheduleBatchForm from './deliveryScheduleBatchForm.vue';
 import {
-	autoTransferDoOrPoExternal,
 	cancelDeliverySchedule,
-	createDeliveryScheduleExternal,
 	fetchDeliveryScheduleList,
+	fetchEffectiveRuleOptions,
+	generateDeliverySchedule,
 	publishDeliverySchedule,
+	saveS3DeliveryGenerateRule,
+	type DeliveryScheduleGenerateResult,
 	type DeliveryScheduleRow,
+	type RuleConfigSnapshot,
+	type S3DeliveryGenerateRuleOptions,
 	unpublishDeliverySchedule,
 } from '../api/deliverySchedule';
-import { useUserInfo } from '/@/stores/userInfo';
 
 const route = useRoute();
-const userInfoStore = useUserInfo();
+const router = useRouter();
 const pageTitle = computed(() => (route.meta?.title as string) || '物料交货计划');
 
-/** 外部接口 `domain`:与工单池「生成物料需求」等一致,传当前用户租户 ID(旧系统 UserFactoryNum / 租户 id)。 */
-async function resolveExternalDomain(): Promise<string | null> {
-	if (!userInfoStore.userInfos?.tenantId && !userInfoStore.userInfos?.currentTenantId) {
-		await userInfoStore.setUserInfos();
-	}
-	const u = userInfoStore.userInfos;
-	const tid = u?.currentTenantId ?? u?.tenantId;
-	if (tid === undefined || tid === null || String(tid).trim() === '') return null;
-	return String(tid);
-}
-
 const query = reactive({
 	poNumber: '',
 	dsNum: '',
@@ -132,10 +178,28 @@ const query = reactive({
 });
 const loading = ref(false);
 const generating = ref(false);
+const viewingRules = ref(false);
+const savingRules = ref(false);
 const rows = ref<DeliveryScheduleRow[]>([]);
 const total = ref(0);
 const selectedRows = ref<DeliveryScheduleRow[]>([]);
 const batchVisible = ref(false);
+const ruleEditVisible = ref(false);
+const buyerCodeOptions = ['110', '160', '170'];
+const doUsageOptions = ['标准', 'VMI', '委外加工'];
+
+const ruleForm = reactive<Required<S3DeliveryGenerateRuleOptions>>({
+	quotaRequiredTotal: 100,
+	quotaTolerance: 0.0001,
+	poBuyerCodes: [],
+	doUsages: [],
+	defaultLeadDays: 1,
+	enablePackagingRoundUp: true,
+	shortageCreatePr: true,
+	enablePrMerge: true,
+	enableRequireGoodsToPo: true,
+	enableExternalPushTracking: true,
+});
 
 const col = reactive({
 	dsNum: true,
@@ -241,38 +305,11 @@ function resetQuery() {
 }
 
 async function onGenerate() {
-	const domain = await resolveExternalDomain();
-	if (!domain) {
-		ElMessage.warning('当前用户无租户信息,无法生成交货单');
-		return;
-	}
-	const user = String(userInfoStore.userInfos?.account ?? '').trim();
-	if (!user) {
-		ElMessage.warning('当前用户缺少登录账号,无法生成交货单');
-		return;
-	}
-
 	generating.value = true;
 	try {
-		const result = await createDeliveryScheduleExternal(domain, user);
-		const arr = result.split('|');
-		if (arr[0]?.trim() !== 'OK') {
-			ElMessage.error(arr[1] ? `生成交货单失败:${arr[1]}` : '生成交货单失败');
-			await loadList();
-			return;
-		}
-		try {
-			const result1 = await autoTransferDoOrPoExternal(domain);
-			if (String(result1).trim().toLowerCase() === 'ok') {
-				ElMessage.success('生成交货单成功,转DO/生成推送SAP事务成功');
-			} else {
-				ElMessage.warning(`生成交货单成功,转DO/生成推送SAP事务失败:${result1}`);
-			}
-		} catch (e: any) {
-			ElMessage.warning(
-				`生成交货单成功,转DO/生成推送SAP事务失败:${e?.message ?? ''}`.trim() || '转DO/生成推送SAP事务失败'
-			);
-		}
+		const result = await generateDeliverySchedule();
+		ElMessage.success(result?.message || `生成交货单完成,共生成 ${result?.createdCount ?? 0} 条`);
+		await showGenerateResult(result);
 		await loadList();
 	} catch (e: any) {
 		ElMessage.error(e?.message ? `生成交货单失败:${e.message}` : '生成交货单失败');
@@ -282,6 +319,227 @@ async function onGenerate() {
 	}
 }
 
+async function showGenerateResult(result: DeliveryScheduleGenerateResult) {
+	const rows = [
+		['需求计划数', result?.demandCount],
+		['候选交货单数', result?.candidateCount],
+		['已生成交货单数', result?.createdCount],
+		['自动生成 PR 数', result?.purchaseRequestCount],
+		['PR 合并减少数', result?.purchaseRequestMergeReducedCount],
+		['生成 DO/PO 单数', result?.purchaseOrderCount],
+		['生成 DO/PO 明细数', result?.purchaseOrderLineCount],
+		['写 QadTracking 数', result?.qadTrackingCount],
+		['异常数', result?.exceptionCount],
+		['跳过数', result?.skippedCount],
+	].filter(([, value]) => value !== undefined && value !== null);
+
+	await ElMessageBox.alert(
+		h('div', { class: 'generate-result-summary' }, [
+			h('p', { class: 'generate-result-message' }, result?.message || '生成交货单完成'),
+			h(
+				'ul',
+				{ class: 'generate-result-list' },
+				rows.map(([label, value]) => h('li', { key: label }, [h('span', label as string), h('strong', String(value))]))
+			),
+			(result?.exceptionCount ?? 0) > 0
+				? h('p', { class: 'generate-result-warning' }, '本次生成存在异常,请到“交货单异常记录”查看明细。')
+				: null,
+			h('p', { class: 'generate-result-tip' }, '本次执行摘要和规则快照已写入系统操作日志。'),
+			h('div', { class: 'generate-result-actions' }, [
+				(result?.exceptionCount ?? 0) > 0
+					? h(
+							ElButton,
+							{
+								type: 'warning',
+								size: 'small',
+								onClick: () => router.push('/aidop/s3/material-plan/delivery-exception'),
+							},
+							() => '查看异常记录'
+						)
+					: null,
+				h(
+					ElButton,
+					{
+						size: 'small',
+						onClick: () => {
+							router.push({
+								path: '/log/oplog',
+								query: {
+									controllerName: 'S3',
+									actionName: 'S3_DELIVERY_GENERATE',
+								},
+							});
+						},
+					},
+					() => '查看操作日志'
+				),
+			]),
+		]),
+		'生成结果摘要',
+		{ confirmButtonText: '知道了' }
+	);
+}
+
+async function onViewRules() {
+	viewingRules.value = true;
+	try {
+		const snapshot = await fetchEffectiveRuleOptions({
+			moduleCode: 'S3',
+			scenarioCode: 'DELIVERY_GENERATE',
+		});
+		await showRuleSnapshot(snapshot);
+	} catch (e: any) {
+		ElMessage.error(e?.message ? `查询当前规则失败:${e.message}` : '查询当前规则失败');
+	} finally {
+		viewingRules.value = false;
+	}
+}
+
+async function onEditRules() {
+	viewingRules.value = true;
+	try {
+		const snapshot = await fetchEffectiveRuleOptions({
+			moduleCode: 'S3',
+			scenarioCode: 'DELIVERY_GENERATE',
+		});
+		applyRuleSnapshotToForm(snapshot);
+		ruleEditVisible.value = true;
+	} catch (e: any) {
+		ElMessage.error(e?.message ? `查询当前规则失败:${e.message}` : '查询当前规则失败');
+	} finally {
+		viewingRules.value = false;
+	}
+}
+
+function applyRuleSnapshotToForm(snapshot: RuleConfigSnapshot) {
+	const options = (snapshot.options || {}) as S3DeliveryGenerateRuleOptions;
+	ruleForm.quotaRequiredTotal = Number(options.quotaRequiredTotal ?? 100);
+	ruleForm.quotaTolerance = Number(options.quotaTolerance ?? 0.0001);
+	ruleForm.poBuyerCodes = [...(options.poBuyerCodes || [])];
+	ruleForm.doUsages = [...(options.doUsages || [])];
+	ruleForm.defaultLeadDays = Number(options.defaultLeadDays ?? 1);
+	ruleForm.enablePackagingRoundUp = options.enablePackagingRoundUp !== false;
+	ruleForm.shortageCreatePr = options.shortageCreatePr !== false;
+	ruleForm.enablePrMerge = options.enablePrMerge !== false;
+	ruleForm.enableRequireGoodsToPo = options.enableRequireGoodsToPo !== false;
+	ruleForm.enableExternalPushTracking = options.enableExternalPushTracking !== false;
+}
+
+async function onSaveRules() {
+	if (!ruleForm.poBuyerCodes.length) {
+		ElMessage.warning('普通 PO 采购组不能为空');
+		return;
+	}
+	if (!ruleForm.doUsages.length) {
+		ElMessage.warning('DO 可用供应类别不能为空');
+		return;
+	}
+
+	await ElMessageBox.confirm('保存后会立即影响后续 S3 交货单生成,确定保存当前规则吗?', '保存规则确认', {
+		confirmButtonText: '保存',
+		cancelButtonText: '取消',
+		type: 'warning',
+	});
+
+	savingRules.value = true;
+	try {
+		const snapshot = await saveS3DeliveryGenerateRule({
+			tenantId: 0,
+			factoryId: 0,
+			options: {
+				quotaRequiredTotal: ruleForm.quotaRequiredTotal,
+				quotaTolerance: ruleForm.quotaTolerance,
+				poBuyerCodes: [...ruleForm.poBuyerCodes],
+				doUsages: [...ruleForm.doUsages],
+				defaultLeadDays: ruleForm.defaultLeadDays,
+				enablePackagingRoundUp: ruleForm.enablePackagingRoundUp,
+				shortageCreatePr: ruleForm.shortageCreatePr,
+				enablePrMerge: ruleForm.enablePrMerge,
+				enableRequireGoodsToPo: ruleForm.enableRequireGoodsToPo,
+				enableExternalPushTracking: ruleForm.enableExternalPushTracking,
+			},
+		});
+		applyRuleSnapshotToForm(snapshot);
+		ElMessage.success('规则已保存');
+		ruleEditVisible.value = false;
+		await showRuleSnapshot(snapshot);
+	} catch (e: any) {
+		ElMessage.error(e?.message ? `保存规则失败:${e.message}` : '保存规则失败');
+	} finally {
+		savingRules.value = false;
+	}
+}
+
+async function showRuleSnapshot(snapshot: RuleConfigSnapshot) {
+	const options = (snapshot.options || {}) as S3DeliveryGenerateRuleOptions;
+	const rows = [
+		['规则来源', formatRuleSource(snapshot.source)],
+		['模块/场景', `${snapshot.moduleCode || '-'} / ${snapshot.scenarioCode || '-'}`],
+		['解析时间', formatDateTime(snapshot.resolvedAt)],
+		['租户/工厂', `${snapshot.tenantId ?? 0} / ${snapshot.factoryId ?? '-'}`],
+		['配额合计要求', formatPercent(options.quotaRequiredTotal)],
+		['配额误差容忍', formatValue(options.quotaTolerance)],
+		['普通 PO 采购组', formatList(options.poBuyerCodes)],
+		['DO 可用供应类别', formatList(options.doUsages)],
+		['默认提前期', `${options.defaultLeadDays ?? '-'} 天`],
+		['包装量向上取整', formatBool(options.enablePackagingRoundUp)],
+		['PO 不足生成 PR', formatBool(options.shortageCreatePr)],
+		['启用 PR 合并', formatBool(options.enablePrMerge)],
+		['要货令转 DO/PO', formatBool(options.enableRequireGoodsToPo)],
+		['写 QadTracking', formatBool(options.enableExternalPushTracking)],
+		['生效配置项数', snapshot.appliedItems?.length ?? 0],
+	].filter(([, value]) => value !== undefined && value !== null);
+	const warnings = snapshot.warnings || [];
+
+	await ElMessageBox.alert(
+		h('div', { class: 'rule-snapshot-summary' }, [
+			h('p', { class: 'rule-snapshot-message' }, '当前 S3 交货单生成规则来自只读规则配置接口。'),
+			h(
+				'ul',
+				{ class: 'rule-snapshot-list' },
+				rows.map(([label, value]) => h('li', { key: label }, [h('span', label as string), h('strong', String(value))]))
+			),
+			warnings.length > 0
+				? h('div', { class: 'rule-snapshot-warning' }, [
+						h('p', '规则解析存在告警:'),
+						h(
+							'ul',
+							warnings.map((item) => h('li', { key: item }, item))
+						),
+					])
+				: null,
+			h('p', { class: 'rule-snapshot-tip' }, '该弹窗只展示当前生效规则,不会修改配置,也不会触发交货单生成。'),
+		]),
+		'当前生效规则',
+		{ confirmButtonText: '知道了', customClass: 'rule-snapshot-dialog' }
+	);
+}
+
+function formatRuleSource(source?: string) {
+	return source === 'configured' ? '数据库配置' : source === 'default' ? '代码默认值' : source || '-';
+}
+
+function formatBool(value?: boolean) {
+	return value === true ? '开启' : value === false ? '关闭' : '-';
+}
+
+function formatList(value?: string[]) {
+	return Array.isArray(value) && value.length > 0 ? value.join('、') : '-';
+}
+
+function formatPercent(value?: number) {
+	return value === undefined || value === null ? '-' : `${value}%`;
+}
+
+function formatValue(value?: unknown) {
+	return value === undefined || value === null || value === '' ? '-' : String(value);
+}
+
+function formatDateTime(value?: string) {
+	if (!value) return '-';
+	return String(value).replace('T', ' ').slice(0, 19);
+}
+
 async function onPublish() {
 	if (!selectedPublishableIds.value.length) {
 		ElMessage.warning('请勾选状态为初始的数据');
@@ -356,4 +614,97 @@ onMounted(loadList);
 	display: flex;
 	justify-content: flex-end;
 }
+.rule-edit-alert {
+	margin-bottom: 12px;
+}
+.rule-edit-form {
+	.rule-edit-unit {
+		margin-left: 8px;
+		color: var(--el-text-color-secondary);
+	}
+}
+
+:deep(.generate-result-summary) {
+	.generate-result-message {
+		margin: 0 0 10px;
+		color: var(--el-text-color-primary);
+	}
+	.generate-result-list {
+		padding: 0;
+		margin: 0;
+		list-style: none;
+		display: grid;
+		grid-template-columns: repeat(2, minmax(0, 1fr));
+		gap: 8px 16px;
+		li {
+			display: flex;
+			justify-content: space-between;
+			gap: 12px;
+			padding: 6px 8px;
+			border-radius: 4px;
+			background: var(--el-fill-color-lighter);
+		}
+		strong {
+			color: var(--el-color-primary);
+		}
+	}
+	.generate-result-warning {
+		margin: 12px 0 0;
+		color: var(--el-color-warning);
+	}
+	.generate-result-tip {
+		margin: 8px 0 0;
+		color: var(--el-text-color-secondary);
+		font-size: 12px;
+	}
+	.generate-result-actions {
+		display: flex;
+		gap: 8px;
+		margin-top: 12px;
+	}
+}
+
+:deep(.rule-snapshot-summary) {
+	.rule-snapshot-message {
+		margin: 0 0 10px;
+		color: var(--el-text-color-primary);
+	}
+	.rule-snapshot-list {
+		padding: 0;
+		margin: 0;
+		list-style: none;
+		display: grid;
+		grid-template-columns: repeat(2, minmax(0, 1fr));
+		gap: 8px 16px;
+		li {
+			display: flex;
+			justify-content: space-between;
+			gap: 12px;
+			padding: 6px 8px;
+			border-radius: 4px;
+			background: var(--el-fill-color-lighter);
+		}
+		span {
+			color: var(--el-text-color-secondary);
+		}
+		strong {
+			color: var(--el-color-primary);
+			text-align: right;
+			word-break: break-all;
+		}
+	}
+	.rule-snapshot-warning {
+		margin-top: 12px;
+		color: var(--el-color-warning);
+		ul {
+			margin: 4px 0 0;
+			padding-left: 18px;
+		}
+	}
+	.rule-snapshot-tip {
+		margin: 10px 0 0;
+		color: var(--el-text-color-secondary);
+		font-size: 12px;
+	}
+}
 </style>

+ 68 - 3
Web/src/views/system/log/oplog/index.vue

@@ -104,13 +104,19 @@
 					<span> 日志详情 </span>
 				</div>
 			</template>
-			<pre v-loading="state.loadingDetail">{{ state.content }}</pre>
+			<div v-loading="state.loadingDetail" class="log-detail">
+				<section v-for="item in state.detailItems" :key="item.label" class="log-detail-section">
+					<h4>{{ item.label }}</h4>
+					<pre>{{ item.value }}</pre>
+				</section>
+			</div>
 		</el-dialog>
 	</div>
 </template>
 
 <script lang="ts" setup name="sysOpLog">
 import { onMounted, reactive } from 'vue';
+import { useRoute } from 'vue-router';
 import { ElMessage } from 'element-plus';
 import { downloadByData, getFileName } from '/@/utils/download';
 
@@ -121,6 +127,7 @@ import { SysLogOpApi } from '/@/api-services/api';
 import TenantSelect from '/@/views/system/tenant/component/tenantSelect.vue';
 
 const userStore = useUserInfo();
+const route = useRoute();
 const state = reactive({
 	loading: false,
 	loadingDetail: false,
@@ -145,15 +152,31 @@ const state = reactive({
 	logData: [] as Array<SysLogOp>,
 	dialogVisible: false,
 	content: '',
+	detailItems: [] as Array<{ label: string; value: string }>,
 });
 
 onMounted(async () => {
 	if (userStore.userInfos.accountType == 999) {
 		state.queryParams.tenantId = userStore.userInfos.currentTenantId as any;
 	}
+	applyQueryParams();
 	handleQuery();
 });
 
+const applyQueryParams = () => {
+	const controllerName = firstQueryValue(route.query.controllerName);
+	const actionName = firstQueryValue(route.query.actionName);
+	const account = firstQueryValue(route.query.account);
+	if (controllerName) state.queryParams.controllerName = controllerName as any;
+	if (actionName) state.queryParams.actionName = actionName as any;
+	if (account) state.queryParams.account = account as any;
+};
+
+const firstQueryValue = (value: unknown) => {
+	if (Array.isArray(value)) return value[0] ? String(value[0]) : '';
+	return value == null ? '' : String(value);
+};
+
 // 查询操作
 const handleQuery = async () => {
 	if (state.queryParams.startTime == null) state.queryParams.startTime = undefined;
@@ -219,14 +242,36 @@ const handleCurrentChange = (val: number) => {
 // 查看详情
 const viewDetail = async (row: any) => {
 	state.content = '';
+	state.detailItems = [];
 	state.dialogVisible = true;
 	state.loadingDetail = true;
 	var res = await getAPI(SysLogOpApi).apiSysLogOpDetailIdGet(row.id);
-	row.message = res.data.result?.message ?? '';
-	state.content = row.message;
+	const detail = res.data.result;
+	state.content = detail?.message ?? '';
+	state.detailItems = buildDetailItems(detail);
 	state.loadingDetail = false;
 };
 
+const buildDetailItems = (detail?: SysLogOp) => {
+	if (!detail) return [];
+	return [
+		{ label: '日志消息', value: detail.message },
+		{ label: '请求参数', value: detail.requestParam },
+		{ label: '返回结果', value: detail.returnResult },
+		{ label: '异常信息', value: detail.exception },
+	]
+		.filter((x) => x.value != null && String(x.value).trim() !== '')
+		.map((x) => ({ label: x.label, value: formatLogValue(String(x.value)) }));
+};
+
+const formatLogValue = (value: string) => {
+	try {
+		return JSON.stringify(JSON.parse(value), null, 2);
+	} catch {
+		return value;
+	}
+};
+
 // 设置行颜色
 const tableRowClassName = (row: any) => {
 	return row.row.exception != null ? 'warning-row' : '';
@@ -271,6 +316,26 @@ pre {
 	white-space: break-spaces;
 	line-height: 20px;
 }
+.log-detail {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+.log-detail-section {
+	h4 {
+		margin: 0 0 8px;
+		font-size: 14px;
+		font-weight: 600;
+	}
+	pre {
+		max-height: 360px;
+		margin: 0;
+		padding: 12px;
+		overflow: auto;
+		border-radius: 4px;
+		background: var(--el-fill-color-lighter);
+	}
+}
 .el-table .warning-row {
 	--el-table-tr-bg-color: var(--el-color-warning-light-9);
 }

+ 129 - 0
doc/db/rule/01_ado_rule_config.sql

@@ -0,0 +1,129 @@
+-- 全模块规则配置表草案
+-- 状态:评审草案;P29 已在 aidopdev 研发库验证执行
+-- 试点:S3 / DELIVERY_GENERATE
+
+CREATE TABLE IF NOT EXISTS ado_rule_profile (
+    id BIGINT PRIMARY KEY,
+    module_code VARCHAR(32) NOT NULL COMMENT '模块编码,如 S3/S4/S8/S9',
+    scenario_code VARCHAR(64) NOT NULL COMMENT '场景编码,如 DELIVERY_GENERATE',
+    profile_code VARCHAR(64) NOT NULL COMMENT '规则方案编码',
+    profile_name VARCHAR(128) NOT NULL COMMENT '规则方案名称',
+    tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示全局默认',
+    factory_id BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示租户级或全局',
+    version INT NOT NULL DEFAULT 1 COMMENT '版本号',
+    is_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
+    effective_from DATETIME NULL COMMENT '生效开始时间',
+    effective_to DATETIME NULL COMMENT '生效结束时间',
+    remark VARCHAR(500) NULL,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    created_by VARCHAR(64) NULL,
+    updated_at DATETIME NULL,
+    updated_by VARCHAR(64) NULL,
+    UNIQUE KEY uk_rule_profile_code (module_code, scenario_code, profile_code, tenant_id, factory_id, version),
+    KEY idx_rule_profile_effective (module_code, scenario_code, tenant_id, factory_id, is_enabled, effective_from, effective_to)
+) COMMENT='全模块规则配置方案';
+
+CREATE TABLE IF NOT EXISTS ado_rule_item (
+    id BIGINT PRIMARY KEY,
+    profile_id BIGINT NOT NULL COMMENT '规则方案 ID',
+    rule_code VARCHAR(64) NOT NULL COMMENT '规则编码',
+    rule_name VARCHAR(128) NOT NULL COMMENT '规则名称',
+    value_type VARCHAR(32) NOT NULL COMMENT 'string/number/boolean/string_array/number_array/json',
+    rule_value TEXT NULL COMMENT '规则值,数组和对象使用 JSON',
+    is_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
+    sort_no INT NOT NULL DEFAULT 0,
+    remark VARCHAR(500) NULL,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    created_by VARCHAR(64) NULL,
+    updated_at DATETIME NULL,
+    updated_by VARCHAR(64) NULL,
+    UNIQUE KEY uk_rule_item_code (profile_id, rule_code),
+    KEY idx_rule_item_profile (profile_id, is_enabled)
+) COMMENT='全模块规则配置项';
+
+CREATE TABLE IF NOT EXISTS ado_rule_schema (
+    id BIGINT PRIMARY KEY,
+    module_code VARCHAR(32) NOT NULL,
+    scenario_code VARCHAR(64) NOT NULL,
+    rule_code VARCHAR(64) NOT NULL,
+    rule_name VARCHAR(128) NOT NULL,
+    value_type VARCHAR(32) NOT NULL,
+    default_value TEXT NULL COMMENT '默认值,数组和对象使用 JSON',
+    required TINYINT NOT NULL DEFAULT 0,
+    validation_rule TEXT NULL COMMENT 'min/max/enum/regex 等 JSON 描述',
+    ui_control VARCHAR(32) NULL COMMENT 'input/number/switch/multi_select/json',
+    option_source TEXT NULL COMMENT '静态枚举或数据源说明',
+    is_enabled TINYINT NOT NULL DEFAULT 1,
+    sort_no INT NOT NULL DEFAULT 0,
+    remark VARCHAR(500) NULL,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    created_by VARCHAR(64) NULL,
+    updated_at DATETIME NULL,
+    updated_by VARCHAR(64) NULL,
+    UNIQUE KEY uk_rule_schema_code (module_code, scenario_code, rule_code),
+    KEY idx_rule_schema_scene (module_code, scenario_code, is_enabled)
+) COMMENT='全模块规则定义';
+
+-- S3 / DELIVERY_GENERATE 默认规则方案
+INSERT INTO ado_rule_profile
+(id, module_code, scenario_code, profile_code, profile_name, tenant_id, factory_id, version, is_enabled, remark)
+VALUES
+(1003000000001, 'S3', 'DELIVERY_GENERATE', 'S3_DELIVERY_GENERATE_DEFAULT', 'S3 交货单生成默认规则', 0, 0, 1, 1, 'P10 规则配置试点默认方案')
+AS new
+ON DUPLICATE KEY UPDATE
+    profile_name=new.profile_name,
+    is_enabled=new.is_enabled,
+    remark=new.remark,
+    updated_at=CURRENT_TIMESTAMP;
+
+-- S3 / DELIVERY_GENERATE 规则定义
+INSERT INTO ado_rule_schema
+(id, module_code, scenario_code, rule_code, rule_name, value_type, default_value, required, validation_rule, ui_control, option_source, is_enabled, sort_no, remark)
+VALUES
+(1003000000101, 'S3', 'DELIVERY_GENERATE', 'quotaRequiredTotal', '货源配额要求合计', 'number', '100', 1, '{"min":0,"max":100}', 'number', NULL, 1, 10, '货源配额合计校验目标值'),
+(1003000000102, 'S3', 'DELIVERY_GENERATE', 'quotaTolerance', '配额误差容忍', 'number', '0.0001', 1, '{"min":0}', 'number', NULL, 1, 20, '配额合计误差容忍'),
+(1003000000103, 'S3', 'DELIVERY_GENERATE', 'poBuyerCodes', '普通 PO 可用采购组', 'string_array', '["110","160","170"]', 1, NULL, 'multi_select', 'static:110,160,170', 1, 30, '普通 PO 采购组白名单'),
+(1003000000104, 'S3', 'DELIVERY_GENERATE', 'doUsages', 'DO 可用供应类别', 'string_array', '["标准","VMI","委外加工"]', 1, NULL, 'multi_select', 'static:标准,VMI,委外加工', 1, 40, 'DO 可用用途白名单'),
+(1003000000105, 'S3', 'DELIVERY_GENERATE', 'defaultLeadDays', '默认提前期天数', 'number', '1', 1, '{"min":0}', 'number', NULL, 1, 50, '货源提前期缺失或小于等于 0 时使用'),
+(1003000000106, 'S3', 'DELIVERY_GENERATE', 'enablePackagingRoundUp', '启用包装量向上取整', 'boolean', 'true', 1, NULL, 'switch', NULL, 1, 60, '是否按 packaging_qty 圆整'),
+(1003000000107, 'S3', 'DELIVERY_GENERATE', 'shortageCreatePr', 'PO 不足自动生成 PR', 'boolean', 'true', 1, NULL, 'switch', NULL, 1, 70, '采购单不足时是否生成 PR'),
+(1003000000108, 'S3', 'DELIVERY_GENERATE', 'enablePrMerge', '启用 PR 合并', 'boolean', 'true', 1, NULL, 'switch', NULL, 1, 80, '是否启用本次生成 PR 合并'),
+(1003000000109, 'S3', 'DELIVERY_GENERATE', 'enableRequireGoodsToPo', '启用要货令转 DO/PO', 'boolean', 'true', 1, NULL, 'switch', NULL, 1, 90, '是否将 IsRequireGoods=1 的 PR 转 DO/PO'),
+(1003000000110, 'S3', 'DELIVERY_GENERATE', 'enableExternalPushTracking', '启用外部事务写入', 'boolean', 'true', 1, NULL, 'switch', NULL, 1, 100, '是否写 QadTracking;P22 为保持现有 P5 行为默认开启')
+AS new
+ON DUPLICATE KEY UPDATE
+    rule_name=new.rule_name,
+    value_type=new.value_type,
+    default_value=new.default_value,
+    required=new.required,
+    validation_rule=new.validation_rule,
+    ui_control=new.ui_control,
+    option_source=new.option_source,
+    is_enabled=new.is_enabled,
+    sort_no=new.sort_no,
+    remark=new.remark,
+    updated_at=CURRENT_TIMESTAMP;
+
+-- S3 / DELIVERY_GENERATE 默认规则项
+INSERT INTO ado_rule_item
+(id, profile_id, rule_code, rule_name, value_type, rule_value, is_enabled, sort_no, remark)
+VALUES
+(1003000000201, 1003000000001, 'quotaRequiredTotal', '货源配额要求合计', 'number', '100', 1, 10, NULL),
+(1003000000202, 1003000000001, 'quotaTolerance', '配额误差容忍', 'number', '0.0001', 1, 20, NULL),
+(1003000000203, 1003000000001, 'poBuyerCodes', '普通 PO 可用采购组', 'string_array', '["110","160","170"]', 1, 30, NULL),
+(1003000000204, 1003000000001, 'doUsages', 'DO 可用供应类别', 'string_array', '["标准","VMI","委外加工"]', 1, 40, NULL),
+(1003000000205, 1003000000001, 'defaultLeadDays', '默认提前期天数', 'number', '1', 1, 50, NULL),
+(1003000000206, 1003000000001, 'enablePackagingRoundUp', '启用包装量向上取整', 'boolean', 'true', 1, 60, NULL),
+(1003000000207, 1003000000001, 'shortageCreatePr', 'PO 不足自动生成 PR', 'boolean', 'true', 1, 70, NULL),
+(1003000000208, 1003000000001, 'enablePrMerge', '启用 PR 合并', 'boolean', 'true', 1, 80, NULL),
+(1003000000209, 1003000000001, 'enableRequireGoodsToPo', '启用要货令转 DO/PO', 'boolean', 'true', 1, 90, NULL),
+(1003000000210, 1003000000001, 'enableExternalPushTracking', '启用外部事务写入', 'boolean', 'true', 1, 100, 'P22 为保持现有 P5 行为默认开启;生产切换前仍需确认 QadTracking 消费机制')
+AS new
+ON DUPLICATE KEY UPDATE
+    rule_name=new.rule_name,
+    value_type=new.value_type,
+    rule_value=new.rule_value,
+    is_enabled=new.is_enabled,
+    sort_no=new.sort_no,
+    remark=new.remark,
+    updated_at=CURRENT_TIMESTAMP;

+ 50 - 0
doc/db/rule/02_enable_rule_repository_checklist.md

@@ -0,0 +1,50 @@
+# 规则数据库仓储启用前清单
+
+本文用于后续从 `EmptyRuleConfigRepository` 切换到 `DbRuleConfigRepository` 前的受控检查。当前仅为启用方案,不执行 DDL、不启用数据库仓储。
+
+## 一、启用条件
+
+- [ ] `01_ado_rule_config.sql` 已完成结构评审,确认表名、主键、索引、审计字段和版本策略。
+- [ ] 已在目标环境执行 `ado_rule_profile`、`ado_rule_item`、`ado_rule_schema` DDL。
+- [ ] 已写入 S3 / `DELIVERY_GENERATE` 默认 Profile、Schema、Item。
+- [ ] 已确认同一 `module_code + scenario_code + tenant_id + factory_id` 下不会有多个同时生效的同版本冲突方案。
+- [ ] 已确认 `tenant_id=0`、`factory_id=0` 的全局默认语义符合当前多租户模型。
+- [ ] 已确认 `enableExternalPushTracking` 是否继续默认开启,尤其是外部 `QadTracking` 消费机制和验证租户。
+
+## 二、切换方案
+
+推荐使用显式开关控制规则仓储来源:
+
+- `RuleConfig:RepositoryMode=Empty`:默认值,继续使用 `EmptyRuleConfigRepository`。
+- `RuleConfig:RepositoryMode=Db`:切换到 `DbRuleConfigRepository`。
+
+切换实现建议:
+
+1. 保持 `Empty` 为默认值,避免缺表或配置缺失影响启动。
+2. 在服务注册处根据配置选择 `IRuleConfigRepository` 实现。
+3. `Db` 模式启动时先做轻量健康检查:确认三张表存在并可查询。
+4. 健康检查失败时应阻断启用,回退到 `Empty` 或直接启动失败,具体策略需上线前确认。
+
+## 三、受控验证
+
+- [ ] 在测试库启用 `Db` 模式。
+- [ ] 调用 S3 交货单生成前,查询默认规则项并与代码默认值逐项比对。
+- [ ] 只执行一次小范围 S3 生成,保存接口返回和 `sys_log_op.ReturnResult`。
+- [ ] 确认 `RuleSnapshot.source=configured`。
+- [ ] 确认 `RuleSnapshot.options` 与 `ado_rule_item` 生效值一致。
+- [ ] 确认生成结果计数、异常记录、PR/DO/PO/QadTracking 写入与 P8 验证口径一致。
+- [ ] 若出现异常,立即切回 `Empty` 模式并重新验证默认规则链路。
+
+## 四、回退方案
+
+- 配置回退:将 `RuleConfig:RepositoryMode` 改回 `Empty`。
+- 数据回退:禁用问题 Profile 或 RuleItem,不直接删除记录。
+- 运行回退:停止 S3 真实生成,只保留查询和日志回查。
+- 复盘要求:保留异常接口返回、`sys_log_op`、规则表当前数据快照和切换时间点。
+
+## 五、暂不启用原因
+
+- 当前 `ado_rule_*` DDL 仍为评审草案。
+- 规则配置页面尚未完成,人工改表风险较高。
+- 高风险开关缺少 Schema 显式标记,`DbRuleConfigRepository` 暂不设置 `IsHighRiskSwitch`。
+- 还未实现启动缺表保护和配置开关。

+ 115 - 0
doc/db/rule/README.md

@@ -0,0 +1,115 @@
+# Rule 配置脚本目录
+
+本目录存放全模块规则配置相关 SQL 草案。
+
+当前文件:
+
+- `01_ado_rule_config.sql`:全模块规则配置表草案,包含 `ado_rule_profile`、`ado_rule_item`、`ado_rule_schema`,以及 S3 / `DELIVERY_GENERATE` 默认规则种子。
+- `02_enable_rule_repository_checklist.md`:数据库规则仓储启用前检查与开关方案。
+
+## 注意
+
+- 当前 SQL 已在 `aidopdev` 研发库执行并验证;预发/生产执行前仍需确认表命名、主键策略、权限、审计字段、版本策略和初始化 ID 段。
+- 规则配置只承载参数、阈值、名单、策略枚举和开关,不承载 SQL、事务顺序、写表目标或 Service 调用链。
+- S3 试点为保持 P5 现有行为,`enableExternalPushTracking` 草案默认开启;后续正式生产配置仍需结合外部系统消费机制确认。
+- P26 已新增后端实体和 `DbRuleConfigRepository` 草案。
+- P27 的 `RuleConfig:RepositoryMode=Empty|Db` 开关方案保留为预发/生产启用参考。
+- P28 研发阶段已直接启用 `DbRuleConfigRepository`,因此研发库需要执行 DDL 和默认种子;缺表或字段不一致会直接暴露错误。
+- P29 已在 `user-mysql-aidopdev` 默认库 `aidopdev` 执行 DDL 和 S3 默认种子,并验证 Profile 1 条、Schema 10 条、Item 10 条。
+
+## 建议启用顺序
+
+1. 研发库已完成 `01_ado_rule_config.sql` DDL、默认种子和读取验证。
+2. 研发阶段已直接启用 `DbRuleConfigRepository`,并完成 S3 规则读取、覆盖、前端编辑和保存恢复验证。
+3. 预发/生产启用前,按 `02_enable_rule_repository_checklist.md` 补充仓储开关、缺表保护、权限和回退策略。
+4. 正式上线前需结合真实业务验收,确认 `enableExternalPushTracking` 等高风险开关的默认值。
+
+## 研发库执行记录
+
+- **环境**:`user-mysql-aidopdev` / `aidopdev`
+- **执行内容**:`ado_rule_profile`、`ado_rule_item`、`ado_rule_schema` DDL 与 S3 / `DELIVERY_GENERATE` 默认种子。
+- **验证结果**:Profile 1 条、Schema 10 条、Item 10 条;仓储查询可返回 10 条生效规则项。
+- **执行提示**:P32 已将 `ON DUPLICATE KEY UPDATE ... VALUES(col)` 调整为 `AS new` 别名写法,研发库重跑无弃用警告。
+
+## 最小生成验证记录
+
+- **接口**:`POST /api/Supply/delivery-schedule/generate`
+- **结果**:`demandCount=1`、`createdCount=0`、`skippedCount=1`、`qadTrackingCount=0`
+- **规则快照**:`RuleSnapshot.Source=configured`,`AppliedItems=10`
+- **操作日志**:`SysLogOp.Id=805860787408965`
+- **说明**:本次需求已有关联交货单,因此没有新增业务单据,仅验证 DB 规则读取和操作日志快照链路。
+
+## 规则覆盖验证记录
+
+- **临时修改**:`ado_rule_item.defaultLeadDays` 从 `1` 改为 `2`
+- **接口**:`POST /api/Supply/delivery-schedule/generate`
+- **验证结果**:`RuleSnapshot.Options.DefaultLeadDays=2`,`AppliedItems` 对应 `RuleValue=2`
+- **操作日志**:`SysLogOp.Id=805862349242437`
+- **恢复结果**:已将 `ado_rule_item.defaultLeadDays` 恢复为 `1`
+- **说明**:匿名接口下 `TenantId=0`,本轮用全局规则临时覆盖验证,不保留额外租户级测试 Profile。
+
+## SQL 幂等重跑记录
+
+- **调整内容**:三段种子 SQL 改为 `AS new` 行别名写法,替代 `VALUES(col)`。
+- **补充内容**:`ado_rule_schema` 种子显式写入 `is_enabled=1`。
+- **验证环境**:`user-mysql-aidopdev` / `aidopdev`
+- **验证结果**:Profile、Schema、Item 三段种子 SQL 重跑成功,`Warnings=0`;默认数据计数保持 1/10/10。
+
+## 只读查询接口
+
+- **接口**:`GET /api/RuleConfig/effective-options`
+- **默认参数**:`moduleCode=S3`、`scenarioCode=DELIVERY_GENERATE`
+- **可选参数**:`tenantId`、`factoryId`
+- **返回内容**:当前生效 `RuleSnapshot`,包含最终 Options 和 AppliedItems。
+- **说明**:接口只读,不修改 `ado_rule_*`,也不触发 S3 生成。
+
+## 前端只读入口
+
+- **页面**:S3 / 物料交货计划。
+- **按钮**:`查看当前规则`,位于“生成交货单”旁。
+- **展示内容**:规则来源、模块/场景、解析时间、租户/工厂、关键 S3 Options、生效配置项数量和 Warnings。
+- **说明**:只做业务可读摘要展示,不编辑配置、不保存规则、不触发 S3 生成。
+
+## 研发阶段编辑入口
+
+- **接口**:`POST /api/RuleConfig/s3-delivery-generate/save`
+- **页面**:S3 / 物料交货计划,按钮为 `编辑当前规则`。
+- **保存范围**:S3 / `DELIVERY_GENERATE` 全局默认 Profile 下的既有规则项。
+- **更新方式**:直接更新 `ado_rule_item.rule_value`,保存后返回最新 `RuleSnapshot`。
+- **边界**:不新增 Profile/Schema,不做版本发布、审批、回滚和多模块通用编辑器。
+
+## 保存闭环验证记录
+
+- **验证项**:`defaultLeadDays`
+- **临时保存**:`1` -> `2`
+- **验证结果**:保存接口返回 `RuleSnapshot.Options.DefaultLeadDays=2`,查询接口和数据库均确认 `rule_value=2`。
+- **恢复结果**:已通过保存接口恢复为 `1`,查询接口和数据库均确认恢复。
+- **说明**:验证过程未触发 S3 生成,不新增业务单据或外部事务。
+
+## 当前边界
+
+- S3 研发试点规则链路已完成:DDL、种子、仓储读取、生成规则快照、只读接口、前端查看、前端编辑、保存恢复验证。
+- 多模块规则配置推广暂不真实开发,仅作为后续方向;推广前需先确认公共页面、权限、版本发布、回滚和首个模块试点。
+- 当前任务流仅停留在执行日志和异常入口层面的闭环雏形;通用任务流能力另行立项。
+
+## 执行前检查清单
+
+- [ ] **表命名**:确认 `ado_rule_profile`、`ado_rule_item`、`ado_rule_schema` 是否符合全模块命名约定。
+- [ ] **主键策略**:确认使用手工固定 ID、雪花 ID,还是数据库自增;当前草案使用固定 ID。
+- [ ] **ID 段**:确认 `1003000000001` 等 S3 默认种子 ID 不与其它种子冲突。
+- [ ] **租户/工厂覆盖**:确认 `tenant_id = 0`、`factory_id = 0` 的全局/租户级语义符合现有多租户模型。
+- [ ] **唯一约束**:确认 `profile_code + tenant_id + factory_id + version` 与 `profile_id + rule_code` 能满足后续版本管理。
+- [ ] **审计字段**:确认是否需要统一使用项目已有 `CreateTime/CreateUserId` 风格,或保留 `created_at/created_by`。
+- [ ] **软删除**:确认是否需要 `is_deleted` 字段;当前草案只使用 `is_enabled`。
+- [ ] **外键策略**:确认是否添加外键;当前草案不加物理外键,避免跨模块迁移耦合。
+- [ ] **高风险默认值**:确认 S3 试点 `enableExternalPushTracking=true` 是否仅用于保持现有 P5 行为,生产正式配置是否需要改为默认关闭。
+- [ ] **权限菜单**:确认后续规则配置页面归属菜单和权限编码。
+- [ ] **缓存策略**:确认后续只读服务是否采用 30-60 秒 TTL 缓存。
+- [ ] **回滚策略**:确认执行 SQL 前是否需要备份同名对象或提供 drop 脚本。
+
+## 暂不执行原因
+
+- 当前仅完成 S3 规则试点设计,S4/S8/S9 的规则场景尚未统一评审。
+- 表属于全模块基础设施,需先确认命名、审计字段、权限和版本策略。
+- 高风险开关会影响外部事务写入,需要结合 P8 受控验证。
+- 后端数据库仓储仍为未启用草案,配置页面尚未实现,提前建表价值有限。

+ 1153 - 0
doc/plan/S3调用旧系统Business API解析.md

@@ -0,0 +1,1153 @@
+# S3 调用旧系统 Business API 解析
+
+> 状态:解析文档。本文只说明 S3 交货计划页面曾调用的旧系统 `api/business/*` 接口的处理逻辑、算法、数据源、写入范围和数据流,为后续按新系统架构迁移提供依据;本文不代表继续保留旧 API 依赖。
+
+## 一、背景与结论
+
+最新代码曾在 S3 物料交货计划页面引入两段旧系统直连:
+
+- `api/business/replenishment/CreateDeliverySchedule`
+- `api/business/resource-examine/AutoTransferDoOrPo`
+
+前端调用顺序是:先执行 `CreateDeliverySchedule` 生成交货单,再执行 `AutoTransferDoOrPo` 自动合并 PR、转 DO/PO 或推送 SAP。
+
+这两个接口表面上服务于“生成交货单”按钮,但旧系统实际包含两类能力:
+
+- 交货计划分配算法:根据需求计划、货源清单、PO/DO 可用量生成交货单;采购单不足时生成 PR 和异常记录。
+- 采购执行副作用链路:合并 PR、将要货令转采购订单/要货令、写 SAP 推送事务。
+
+因此迁移时不能只替换 URL。应按新系统设计拆为 `.NET Service`、任务流节点、MDP/DWD 数据沉淀和外部系统事务四部分。
+
+## 二、旧接口入口
+
+| 旧接口 | 控制器入口 | 应用服务 | 作用 |
+|---|---|---|---|
+| `POST /api/business/replenishment/CreateDeliverySchedule?domain=&user=` | `ReplenishmentController.CreateDeliverySchedule` | `ReplenishmentAppService.CreateDeliverySchedule` | 从已发布需求计划生成交货单;必要时自动生成 PR 和异常 |
+| `POST /api/business/resource-examine/AutoTransferDoOrPo?domain=` | `ResourceExamineController.AutoTransferDoOrPo` | `ResourceExamineAppService.AutoTransferDoOrPo` | 串联 PR 自动合并、要货令转 DO/PO、采购申请推 SAP |
+
+旧系统参数含义:
+
+- `domain`:按旧系统用法等同租户/工厂范围,代码中多处使用 `tenant_id.ToString() == domain`。
+- `user`:写入交货单或 PR 的创建人、更新人字段。
+
+## 三、总体调用链
+
+```mermaid
+flowchart TD
+  FE["S3 前端生成交货单按钮"] --> A["CreateDeliverySchedule(domain, user)"]
+  A --> A1["读取已发布需求计划"]
+  A1 --> A2["按货源清单和 PO/DO 可用量分配"]
+  A2 --> A3["写 srm_polist_ds 交货单"]
+  A2 --> A4["采购单不足时写 srm_pr_main PR"]
+  A2 --> A5["写 DeliveryExceptionMaster 异常"]
+  FE --> B["AutoTransferDoOrPo(domain)"]
+  B --> B1["PrAutoMerge2 自动合并 PR"]
+  B1 --> B2["PrAutoApprove 要货令转 DO/PO"]
+  B2 --> B3["PrSendSAP 采购申请推 SAP 事务"]
+```
+
+旧调用链是强副作用链路,包含多表写入和跨系统事务。新系统迁移时应把“生成可执行数据”和“执行外部副作用”分开。
+
+## 四、CreateDeliverySchedule 处理逻辑
+
+### 4.1 处理范围
+
+`CreateDeliverySchedule` 面向 S3 物料交货计划,其目标是把已发布、仍有净需求量的需求计划转为交货单。
+
+旧逻辑处理范围包括:
+
+- 过滤已发布且 `tosechedqty > 0` 的需求计划。
+- 排除历史版本需求计划。
+- 判断已有交货单状态,避免重复占用。
+- 仅处理采购件物料。
+- 根据货源清单配额拆分供应商需求量。
+- 根据采购订单/要货令可用量分配交货单。
+- 采购单不足时自动创建 PR。
+- 记录无法生成交货单的异常原因。
+- 为交货单和 PR 分配编号。
+
+### 4.2 主要数据源
+
+| 数据源 | 用途 |
+|---|---|
+| `ic_demandschedule` | 需求计划来源;筛选已发布、净需求数量大于 0 的计划 |
+| `srm_polist_ds` | 查询已有交货单,判断需求计划是否已占用、是否需要增量生成 |
+| `ItemMaster` | 判断物料是否为采购件,获取物料单位等信息 |
+| `ic_item` | 获取中台物料主数据、物料 ID、名称、单位、价格相关字段 |
+| `srm_purchase` | 货源清单;获取供应商、配额、提前期、最小包装量、最小起订量、采购价格、供应类别等 |
+| `PurOrdDetail` | 采购单明细;计算 PO/DO 未交数量和可占用数量 |
+| `PurOrdMaster` | 采购单主表;按采购组、供应类别过滤可用 PO/DO |
+| `scm_shd` / `scm_shdzb` | 送货单数据;扣减已送货或已完成数量 |
+| `GeneralizedCodeMaster` | 系统参数,如 `ScheduleAgreement` 是否启用计划协议 |
+| 编号服务 | 生成交货单号 `M8` 和采购申请号 `PR` |
+
+### 4.3 写入对象
+
+| 写入对象 | 写入内容 |
+|---|---|
+| `srm_polist_ds` | 新生成的交货单,状态默认为 `N` |
+| `srm_pr_main` | PO/DO 可用量不足时自动生成的采购申请或要货令 |
+| `DeliveryExceptionMaster` | 无物料、无货源清单、配额不为 100%、采购单不足等异常 |
+
+### 4.4 核心算法步骤
+
+1. 获取当前 `domain` 下已发布、净需求量大于 0、非历史版本的需求计划,并按到货日期排序。
+2. 查询这些需求计划已经生成过的交货单。
+3. 对于已有交货单的需求计划:
+   - 已发布或已关闭交货单继续占用需求。
+   - 未发布交货单置为无效,允许重新生成。
+   - 如果已生成数量小于净需求量,按差额构造待生成需求。
+4. 获取采购件物料主数据,只保留采购件。
+5. 获取货源清单,要求当前物料存在有效货源清单。
+6. 获取未关闭、剩余未交数量大于 0 的采购单明细。
+7. 过滤可用 PO/DO:
+   - PO:采购组为 `110`、`160`、`170`。
+   - DO:供应类别为 `标准`、`VMI`、`委外加工`。
+8. 按需求计划逐条处理,再按货源清单配额分配供应商需求量。
+9. 校验货源清单配额合计必须为 100%,否则记录异常并跳过。
+10. 计算供应商应分配数量,按配额向上取整。
+11. 计算当前供应商可用采购单数量:
+    - 采购单数量
+    - 减送货单占用
+    - 减已有交货单占用
+    - 减本次计算已占用
+    - 减已完成送货单但关闭交货单未体现的数量
+12. 如果可用数量不足:
+    - 按差额生成 `srm_pr_main`。
+    - 下单日期按计划协议或货源清单提前期计算。
+    - 记录异常,提示采购订单不足并已生成 PR。
+13. 对可用采购单明细按优先级占用:
+    - 先 PO,再 DO。
+    - 同类按创建时间从早到晚。
+14. 分配数量时考虑最小包装量,满足时按包装量向上圆整。
+15. 生成 `srm_polist_ds` 交货单,初始状态 `N`。
+16. 为所有交货单生成 `M8` 编号。
+17. 为自动生成的 PR 生成 `PR` 编号。
+18. 在事务中写入交货单、PR 和异常记录。
+
+### 4.5 数据流
+
+```mermaid
+flowchart LR
+  Demand["ic_demandschedule 已发布需求"] --> Calc["配额/可用量/包装量计算"]
+  Source["srm_purchase 货源清单"] --> Calc
+  Item["ItemMaster / ic_item"] --> Calc
+  PO["PurOrdMaster / PurOrdDetail"] --> Calc
+  ASN["scm_shd / scm_shdzb"] --> Calc
+  ExistingDS["srm_polist_ds 已有交货单"] --> Calc
+  Config["GeneralizedCodeMaster 参数"] --> Calc
+  Calc --> DS["srm_polist_ds 新交货单"]
+  Calc --> PR["srm_pr_main 自动 PR"]
+  Calc --> EX["DeliveryExceptionMaster 异常"]
+```
+
+## 五、AutoTransferDoOrPo 处理逻辑
+
+`AutoTransferDoOrPo` 是组合接口,按顺序执行:
+
+1. `PrAutoMerge2(domain)`
+2. `PrAutoApprove(domain)`
+3. `PrSendSAP(domain)`
+
+任一步返回非 `ok`,后续步骤停止。
+
+### 5.1 PrAutoMerge2:PR 自动合并
+
+处理目标:合并待处理 PR,减少后续转单数量。
+
+处理范围:
+
+- 仅处理当前 `domain`。
+- 处理状态 `state == 1` 的 PR。
+- 排除模拟计算版本 `analogcalcversion` 不为空的数据。
+- 处理时间窗口为当前周开始后的 5 周。
+- 如果建议下单日期早于明天,会先推迟到明天,并同步调整建议到货日期。
+
+主要数据源:
+
+| 数据源 | 用途 |
+|---|---|
+| `srm_pr_main` | 待合并 PR |
+| `ic_item` | 物料信息 |
+| `srm_po_occupy` | PR 占用关系,合并后需要改挂到新 PR |
+| `srm_purchase` | 最小包装量、最小起订量等合并后的数量规则 |
+| 编号服务 | 合并后新 PR 生成正式 `PR` 编号 |
+
+合并规则:
+
+- 按 5 个周区间分组处理。
+- 在同一周内,按供应商、物料、是否要货令、供应类别、是否关联销售订单等维度合并。
+- 多条可合并 PR 会生成一条新 PR。
+- 原 PR 状态改为关闭或无效状态,记录 `refer_pr_billno` 指向新 PR。
+- 合并后的申请数量按货源清单最小包装量向上圆整,并满足最小起订量。
+- 原 PR 的占用关系更新到新 PR。
+
+写入对象:
+
+| 写入对象 | 写入内容 |
+|---|---|
+| `srm_pr_main` | 新增合并后的 PR,更新原 PR 状态和引用编号 |
+| `srm_po_occupy` | 将占用关系从原 PR 改挂到新 PR |
+
+### 5.2 PrAutoApprove:要货令转 DO/PO
+
+处理目标:将 `IsRequireGoods == 1` 的要货令自动转为采购订单/要货令。
+
+处理范围:
+
+- 当前 `domain`。
+- `IsRequireGoods == 1`。
+- `state == 1`。
+- 建议下单日期在当前周后 5 周内。
+- 排除模拟计算版本。
+
+主要数据源:
+
+| 数据源 | 用途 |
+|---|---|
+| `srm_pr_main` | 要转单的要货令 |
+| `srm_purchase` | 供应商类别、货源清单与采购配置 |
+| `srm_supplier` | 供应商联系方式和地址 |
+| `ic_item` | 物料编码、型号、名称、单位 |
+| `srm_po_occupy` | 原 PR 占用关系,转单后改挂到 PO 行 |
+| `ItemMaster` | 写入 `PurOrdDetail` 时补充库存地点、单位等 |
+| 编号服务 | 生成 `DO` 编号 |
+
+核心处理:
+
+- 先按 PR 类型拆分:
+  - 普通 PR,供应类别不是 `委外`,按正常 PO 类型处理。
+  - 委外 PR,按委外类型处理。
+- 每条 PR 先转为内部 `srm_po_list` 明细。
+- 按供应类别、供应商分组形成 PO 主单。
+- 为 PO 生成 `DO` 编号。
+- 再转换为快开/ERP 兼容的采购单主表和明细表:
+  - `PurOrdMaster`
+  - `PurOrdDetail`
+  - `PurOrdDetailBatch`
+- 写入前会设置采购组、订单类型、供应类别、库存地点等字段。
+
+写入对象:
+
+| 写入对象 | 写入内容 |
+|---|---|
+| `srm_pr_main` | 更新 PR 状态,旧逻辑中转明细时会把 PR 状态置为 `4` |
+| `srm_po_occupy` | 占用关系从 PR 改挂到 PO/DO 明细 |
+| `PurOrdMaster` | 生成采购订单/要货令主表 |
+| `PurOrdDetail` | 生成采购订单/要货令明细 |
+| `PurOrdDetailBatch` | 有批次明细时写入批次记录 |
+
+### 5.3 PrSendSAP:采购申请推 SAP
+
+处理目标:将不需要转要货令的采购申请写入 SAP 推送事务。
+
+处理范围:
+
+- 当前 `domain`。
+- `IsRequireGoods == 0`。
+- `state == 1`。
+- 建议下单日期在当前周后 5 周内。
+- 排除模拟计算版本。
+
+处理逻辑:
+
+- 查询待推送 PR。
+- 获取事务序列号。
+- 将 PR 状态改为 `2`。
+- 向 `QadTracking` 写入事务记录:
+  - `Ufld1 = 'C'`
+  - `TransType = 'nbr-pr-mes'`
+  - `Subject = '请购单'`
+  - `Order = pr_billno`
+  - `Int1 = PR Id`
+
+写入对象:
+
+| 写入对象 | 写入内容 |
+|---|---|
+| `srm_pr_main` | 状态更新为已推送或待外部处理状态 `2` |
+| `QadTracking` | SAP/QAD 推送事务 |
+
+## 六、新系统现状对比
+
+| 旧能力 | 新系统当前状态 | 差距 |
+|---|---|---|
+| 前端直连旧 `8087/api/business` | S3 目录已去除直连,改回新系统 `/api/Supply/delivery-schedule/generate` | 已完成前端直连收敛 |
+| 基于 PO 行生成交货单 | `DeliveryScheduleService.BatchGenerate` 保留按采购单批量生成能力,并沉淀 `mdp_std_delivery_schedule`、`dwd_supplier_delivery` | 作为人工选单补充入口保留 |
+| 基于需求计划和货源配额生成交货单 | `DeliveryScheduleService.Generate` 已按已发布需求计划、货源清单配额、PO/DO 可用量生成交货单,并写 `DeliveryExceptionMaster` | P1 已落地;采购单不足仅记录异常,不自动生成 PR |
+| 采购单不足自动生成 PR | `DeliveryScheduleService.Generate` 在采购单不足时自动生成 `srm_pr_main`,并继续写异常记录追溯 | P2 已落地;不自动合并、不转 DO/PO、不推 SAP |
+| PR 自动合并 | `PurchaseRequestMergeService` 已合并本次生成且尚未入库的 PR 候选 | P3 安全版已落地;不改动历史 PR 和占用关系 |
+| 要货令转 DO/PO | `PurchaseOrderTransferService` 已处理本次生成且 `IsRequireGoods == 1` 的 PR,生成 `PurOrdMaster` / `PurOrdDetail` | P4 初版已落地;不处理历史 PR 和 `srm_po_occupy` |
+| PR 推 SAP | `PurchaseRequestExternalPushService` 已处理本次生成且 `IsRequireGoods == 0` 的 PR,写入 `QadTracking` 并将 PR 状态置为 `2` | P5 初版已落地;不直接调用 SAP/QAD,不处理历史 PR |
+| 迁移链路测试 | 已补充 P3 合并规则和 P5 推送候选筛选单元测试 | P6 测试固化已落地;不依赖真实数据库,不写 `QadTracking` |
+| 执行日志 | `S3OperationLogService` 已复用 Admin.NET `sys_log_op` 写 S3 生成交货单成功/失败摘要 | P7 初版已落地;不新增业务日志表,后续接任务流时再补节点日志 |
+
+## 七、按新设计的承载建议
+
+### 7.1 Service 承载稳定流程
+
+建议拆分为以下后端服务能力:
+
+| 新系统能力 | 建议承载 |
+|---|---|
+| 交货计划生成 | `DeliveryScheduleService` 或拆出 `DeliveryScheduleGenerationService` |
+| 需求计划转交货单算法 | `.NET Service`,规则参数配置化 |
+| 自动生成 PR | `PurchaseRequestService` 下沉为领域能力,供交货计划服务调用 |
+| PR 自动合并 | 新增 `PurchaseRequestMergeService` |
+| 要货令转 DO/PO | 新增 `PurchaseOrderTransferService` |
+| SAP 推送事务 | `PurchaseRequestExternalPushService`;后续可迁入任务流系统节点写白名单事务 |
+
+### 7.2 配置承载可变规则
+
+以下规则适合进入配置层或规则层:
+
+- 计划协议开关。
+- 采购组过滤规则,如 `110`、`160`、`170`。
+- DO 可用供应类别,如 `标准`、`VMI`、`委外加工`。
+- 货源配额必须等于 100%。
+- 最小包装量圆整策略。
+- 最小起订量。
+- 提前期计算规则。
+- 时间窗口,如当前周后 5 周。
+- PR/DO/交货单编号规则。
+
+### 7.3 MDP/DWD 数据沉淀
+
+旧逻辑写入运行表后,新系统应同步沉淀:
+
+| 运行表/结果 | 建议沉淀 |
+|---|---|
+| `srm_polist_ds` | `mdp_std_delivery_schedule`、`dwd_supplier_delivery` |
+| `srm_pr_main` | `mdp_std_supply_demand` 或采购申请标准表;同时进入供应风险计算 |
+| `PurOrdMaster` / `PurOrdDetail` | `mdp_std_purchase_order`、`dwd_supplier_delivery` |
+| `DeliveryExceptionMaster` | 供应异常事实表或任务流待办来源 |
+| `QadTracking` | 外部事务日志、接口调用日志、任务流执行记录 |
+
+### 7.4 任务流承载副作用执行
+
+`AutoTransferDoOrPo` 不建议继续作为前端一键静默执行。建议拆成任务流:
+
+```mermaid
+flowchart LR
+  Gen["生成交货计划"] --> Check["检查 PR/PO/SAP 待处理项"]
+  Check --> Approve["人工确认/审批"]
+  Approve --> Merge["系统任务: 合并 PR"]
+  Merge --> Transfer["系统任务: 转 DO/PO"]
+  Transfer --> Sap["系统任务: 写 SAP 推送事务"]
+  Sap --> Verify["读取 DWD/事务日志验证"]
+```
+
+## 八、迁移风险
+
+| 风险 | 说明 | 建议 |
+|---|---|---|
+| 副作用过大 | 自动转 DO/PO 和推 SAP 会直接影响采购执行 | 先做受控后端入口和任务流确认,不在前端静默调用 |
+| 旧表字段依赖强 | 旧算法依赖多张运行表和字段口径 | 先梳理字段映射,再逐步替换为 MDP 标准层 |
+| 配额和包装量规则影响结果 | 货源配额、最小包装、最小起订量决定生成数量 | 规则配置化,并保留执行日志 |
+| 数据一致性 | 交货单、PR、PO/DO、占用关系需要事务一致 | 后端 Service 内统一事务处理 |
+| 外部系统事务 | `QadTracking` 可能由外部 SAP/QAD 作业消费 | 必须确认消费机制后再迁移 |
+| 跨模块影响 | 采购申请、采购订单可能被 S1/S2/S6 或订单交付模块使用 | 迁移前按模块确认影响范围 |
+
+## 九、建议迁移顺序
+
+1. **P0:收敛前端旧 API 直连**
+   - S3 前端只调用新系统后端。
+   - 旧 API 不再暴露给前端。
+
+2. **P1:补齐交货计划生成算法**
+   - 在新系统 Service 中补需求计划、货源清单、配额、包装量、异常记录。
+   - 写入 `srm_polist_ds` 后同步 MDP/DWD。
+   - 当前已落地到 `DeliveryScheduleService.Generate`;采购单不足仅写异常,不自动生成 PR。
+
+3. **P2:自动生成 PR**
+   - 将采购单不足自动生成 PR 做成明确领域服务。
+   - 生成原因、来源需求计划、异常说明要可追溯。
+   - 当前已落地到 `DeliveryScheduleService.Generate`;后续合并/转单/推送仍按 P3-P5 处理。
+
+4. **P3:PR 合并**
+   - 迁移 `PrAutoMerge2`。
+   - 合并规则配置化,执行结果写日志。
+   - 当前安全版已落地:仅合并本次生成且尚未入库的 PR 候选,不改动历史 PR 和 `srm_po_occupy`。
+
+5. **P4:DO/PO 转单**
+   - 迁移 `PrAutoApprove`。
+   - 先人工确认,再允许任务流系统节点执行。
+   - 当前初版已落地:仅处理本次生成且 `IsRequireGoods == 1` 的 PR,生成 `PurOrdMaster` / `PurOrdDetail`;不处理历史 PR 和 `srm_po_occupy`。
+
+6. **P5:SAP/QAD 推送事务**
+   - 确认 `QadTracking` 当前消费机制。
+   - 迁移 `PrSendSAP` 中写 `QadTracking` 的事务动作。
+   - 当前初版已落地:仅处理本次生成且 `IsRequireGoods == 0` 的 PR;写入 `QadTracking`,并将 PR 状态置为 `2`;不直接调用 SAP/QAD,不扫描历史 PR。
+
+7. **P6:测试与执行边界固化**
+   - 为 PR 合并规则补充单元测试,覆盖同供应商/物料/周维度合并、不同要货令类型不合并、跨周不合并。
+   - 为 SAP/QAD 推送候选筛选补充单元测试,确保只选择本次生成、非要货令且已有 PR 编号的 PR。
+   - 当前已落地到 `Admin.NET.Plugin.AiDOP.Tests`,不依赖真实数据库,不触发 `QadTracking` 外部消费。
+
+8. **P7:执行日志复用**
+   - 复用 Admin.NET 框架已有 `sys_log_op` 操作日志,不新增 S3 专用日志表。
+   - 通过 `S3OperationLogService` 记录 `S3_DELIVERY_GENERATE` 的成功/失败摘要。
+   - 成功日志记录需求数、交货单数、PR 数、合并减少数、DO/PO 数、`QadTracking` 数和异常数等返回结果。
+   - 失败日志记录异常摘要,且日志失败不影响主流程。
+
+9. **P8:受控验证**
+   - 在真实调用 `delivery-schedule/generate` 前,先确认验证租户、验证工厂、需求计划、货源清单、采购单/DO 可用量、编号规则和外部事务消费开关。
+   - 必须确认 `QadTracking` 当前是否被 SAP/QAD 作业实时消费;未确认前不得在生产数据上执行。
+   - 优先使用测试租户或隔离环境;若只能在共享环境验证,应先备份本次可能写入的业务对象范围,并约定止损窗口。
+   - 验证时只执行一次受控生成,记录执行时间、登录账号、租户、返回结果和 `sys_log_op` 日志 Id。
+   - 执行后按交货单、PR、DO/PO、外部事务、异常记录、MDP/DWD、操作日志七类结果核对。
+
+10. **P9:任务流接入设计**
+   - 首版不改 `Admin.NET.Plugin.ApprovalFlow` 核心,不新增通用“系统任务节点”。
+   - 复用 ApprovalFlow 做人工确认、审批人配置、条件分支、超时升级、通知和时间线。
+   - S3 的系统动作仍由后端白名单 Service 执行,包括生成交货单、PR 合并、DO/PO 转单、写 `QadTracking` 和结果核对。
+   - 审批通过后由 S3 业务 Handler 或受控后端入口触发系统动作,执行结果写 `sys_log_op`。
+   - 不影响订单评审、合同评审、S8 异常协同等已接入审批流的既有业务。
+
+11. **P10:全模块规则配置设计**
+   - 不为 S3 单独建设孤立规则表,改为设计全模块通用规则配置表。
+   - 通用规则表按 `module_code + scenario_code` 隔离模块与场景,S3 作为首个试点。
+   - 配置范围仅限参数、阈值、名单、策略枚举和开关;不配置 SQL、事务顺序、写表目标和 Service 调用链。
+   - 后端通过通用规则配置服务读取,再由模块转换成强类型 Options 并做业务校验。
+   - S3 首批试点场景为 `module_code = S3`、`scenario_code = DELIVERY_GENERATE`。
+
+12. **P11:MDP/DWD 沉淀补齐设计**
+   - 当前已沉淀交货单、采购申请、采购订单和供应交付宽表。
+   - 后续需补齐异常、外部事务和执行闭环视图,避免副作用动作只停留在运行表。
+   - 建议优先设计标准对象,不立即建表,待 P8 受控验证和外部消费机制确认后再落库。
+
+13. **P12:前端生成结果展示**
+   - S3 交货单列表生成完成后展示结构化执行摘要。
+   - 展示需求数、候选交货单数、生成交货单数、PR 数、合并减少数、DO/PO 数、`QadTracking` 数、异常数和跳过数。
+   - 若存在异常,提示到“交货单异常记录”查看。
+   - 不新增后端接口,不额外查询 `sys_log_op`。
+
+14. **P13:前端闭环快捷入口**
+   - 在生成结果摘要中增加“查看异常记录”和“查看操作日志”入口。
+   - 异常记录入口仅在 `ExceptionCount > 0` 时展示。
+   - 操作日志入口跳转系统操作日志页,并提示可按 `S3_DELIVERY_GENERATE` 查询。
+   - 不改系统日志页,不新增后端接口。
+
+15. **P14:操作日志快捷筛选联动**
+   - S3 生成结果摘要跳转操作日志时携带 `controllerName=S3`、`actionName=S3_DELIVERY_GENERATE`。
+   - 系统操作日志页支持读取 URL query 并预填筛选条件。
+   - 该能力是通用增强,不新增后端接口,不改变日志查询语义。
+
+16. **P15:操作日志详情增强**
+   - 系统操作日志详情弹窗展示 `message`、`requestParam`、`returnResult`、`exception`。
+   - 对 JSON 字符串做格式化展示,方便查看 S3 生成结果摘要。
+   - 属于通用日志页展示增强,不新增后端接口。
+
+17. **P16:全模块规则配置 DDL 草案**
+   - 新增《全模块规则配置表设计草案》。
+   - 将 P10 的通用规则配置设计细化为 `ado_rule_profile`、`ado_rule_item`、`ado_rule_schema` 草案。
+   - 补充 S3 / DELIVERY_GENERATE 默认规则种子示例。
+   - 本阶段仍不执行 SQL、不接入代码。
+
+18. **P17:规则配置只读服务设计**
+   - 在《全模块规则配置表设计草案》中补充 `RuleConfigService` 服务层设计。
+   - 明确生效规则合并算法、类型转换、非法配置处理和缓存策略。
+   - 补充 `S3DeliveryGenerateRuleOptions` 示例和后续 S3 接入点。
+   - 本阶段仍不写代码、不建表。
+
+19. **P18:规则配置 SQL 草案**
+   - 新增 `doc/db/rule/01_ado_rule_config.sql`,整理通用规则配置表 DDL 与 S3 默认种子。
+   - 新增 `doc/db/rule/README.md`,说明脚本目录和执行前提。
+   - 当前仍为 SQL 草案,不执行数据库变更。
+
+20. **P19:规则配置 DDL 执行前清单**
+   - 在 `doc/db/rule/README.md` 补充 SQL 执行前检查清单。
+   - 在《全模块规则配置表设计草案》中补充 DDL 评审清单和暂不执行原因。
+   - 当前仍不执行 SQL。
+
+21. **P20:规则配置纯逻辑测试骨架**
+   - 新增 `RuleConfigResolver`,只处理层级合并和类型转换,不访问数据库。
+   - 新增单元测试覆盖工厂/租户/全局覆盖、数组/布尔/数字转换、高风险开关保守回退和同层级冲突。
+   - 当前不接入 `DeliveryScheduleService`,不改变运行行为。
+
+22. **P21:规则配置只读服务骨架**
+   - 新增 `RuleConfigService` 服务骨架,当前使用空配置层返回默认值。
+   - 新增 `S3DeliveryGenerateRuleOptions`,默认值与当前 S3 生成链路保持一致。
+   - 新增单元测试确认无配置时返回默认值和取消令牌行为。
+   - 当前不访问 `ado_rule_*` 表,不接入业务链路。
+
+23. **P22:S3 生成链路接入默认 Options**
+   - `DeliveryScheduleService.Generate` 开始读取 `S3DeliveryGenerateRuleOptions`。
+   - 用 Options 替换配额目标、配额误差、PO 采购组、DO 用途、默认提前期、包装取整、PR 生成、PR 合并、DO/PO 转单和 `QadTracking` 写入开关。
+   - `RuleConfigService` 当前仍返回默认值,不访问数据库,因此默认行为保持不变。
+   - `EnableExternalPushTracking` 选择默认 `true`,保持 P5 现有写 `QadTracking` 行为。
+
+24. **P23:执行日志记录规则快照**
+   - `DeliveryScheduleService.Generate` 返回结果增加本次生效的 `S3DeliveryGenerateRuleOptions` 快照。
+   - 现有 `S3OperationLogService` 会把返回结果写入 `sys_log_op.ReturnResult`,因此操作日志详情可回查本次执行使用的规则值。
+   - 前端生成摘要只提示“执行摘要和规则快照已写入系统操作日志”,不在弹窗展开全部规则项。
+   - 当前仍不读取 `ado_rule_*`,规则快照来源于默认 Options。
+
+25. **P24:规则快照日志结构标准化**
+   - 新增 `RuleConfigSnapshot<TOptions>`,统一记录 `moduleCode`、`scenarioCode`、`source`、`resolvedAt`、`tenantId`、`factoryId`、`options`、`appliedItems`、`warnings`。
+   - `DeliveryScheduleGenerateResult` 从裸 `RuleOptions` 调整为 `RuleSnapshot`。
+   - 当前 `source=default`;后续接入数据库配置后可切换为 `configured` 或进一步细分来源。
+   - 前端类型同步为 `ruleSnapshot`,摘要弹窗仍不展开规则明细。
+
+26. **P25:规则配置只读数据源接口骨架**
+   - 新增 `IRuleConfigRepository`,作为 `RuleConfigService` 获取生效规则层的只读边界。
+   - 新增 `EmptyRuleConfigRepository`,当前返回空配置层,不访问 `ado_rule_*`。
+   - `RuleConfigService.ResolveOptionsAsync` 改为从仓储读取 `RuleConfigLayer` 后交给 `RuleConfigResolver`。
+   - 单元测试覆盖默认空仓储、内存配置仓储和 `RuleSnapshot.source=configured`。
+
+27. **P26:`ado_rule_*` 只读实体与数据库仓储草案**
+   - 新增 `AdoRuleProfile`、`AdoRuleItem`、`AdoRuleSchema`,字段对齐 `doc/db/rule/01_ado_rule_config.sql`。
+   - 新增 `DbRuleConfigRepository` 草案,可按模块、场景、租户、工厂读取生效规则层。
+   - 数据库仓储当前不注册为默认实现,`RuleConfigService` 仍默认使用 `EmptyRuleConfigRepository`。
+   - 当前不执行 DDL、不要求数据库存在 `ado_rule_*` 表,不改变 S3 运行行为。
+
+28. **P27:数据库规则仓储启用前检查与开关方案**
+   - 新增 [`doc/db/rule/02_enable_rule_repository_checklist.md`](../db/rule/02_enable_rule_repository_checklist.md),记录启用条件、开关方案、受控验证和回退方案。
+   - 建议使用 `RuleConfig:RepositoryMode=Empty|Db` 控制仓储来源,默认保持 `Empty`。
+   - 启用 `Db` 前必须完成 DDL、种子数据、缺表保护、同层级版本冲突检查和 P8 受控验证。
+   - P27 当时只文档化,不改 DI、不启用 `DbRuleConfigRepository`;P28 研发阶段已调整为直接启用。
+
+29. **P28:研发阶段直接启用数据库规则仓储**
+   - 按研发阶段节奏,暂不实现复杂开关和缺表自动回退。
+   - `DbRuleConfigRepository` 改为默认可注入实现,运行时开始读取 `ado_rule_*`。
+   - `EmptyRuleConfigRepository` 保留为单元测试和手动回退用实现。
+   - 若数据库未建表或种子缺失,允许错误直接暴露,便于研发阶段快速修正。
+
+30. **P29:研发库执行 `ado_rule_*` DDL 并验证仓储读取**
+   - 已在 `user-mysql-aidopdev` 默认库 `aidopdev` 执行 `doc/db/rule/01_ado_rule_config.sql` 中的 DDL 和 S3 默认种子。
+   - 已创建 `ado_rule_profile`、`ado_rule_item`、`ado_rule_schema`。
+   - 已验证 S3 / `DELIVERY_GENERATE` 默认数据:Profile 1 条、Schema 10 条、Item 10 条。
+   - 已用 `DbRuleConfigRepository` 同款 SQL 验证可读取 10 条生效规则项。
+   - 执行时 MySQL 返回 `VALUES()` 弃用提示,不影响本次验证;后续可视 MySQL 版本调整 SQL 写法。
+
+31. **P30:最小 S3 生成验证 DB 规则快照**
+   - 已启动本地后端并调用 `POST /api/Supply/delivery-schedule/generate`。
+   - 本次研发库仅有 1 条待生成需求,但已存在关联交货单,因此未新增交货单、PR、DO/PO 或 `QadTracking`。
+   - 接口返回 `ruleSnapshot.source=configured`,`appliedItems` 10 条。
+   - `SysLogOp.ReturnResult` 已确认 `RuleSnapshot.Source=configured`、`RuleSnapshot.Options.EnableExternalPushTracking=true`。
+   - 本次验证日志:`SysLogOp.Id=805860787408965`,`CreatedCount=0`,`QadTrackingCount=0`,消息为“没有可生成交货单的需求计划”。
+
+32. **P31:验证数据库规则值覆盖 Options**
+   - 因当前生成接口匿名调用下 `TenantId=0`,租户级 Profile 不会命中;本轮改为临时覆盖全局 `defaultLeadDays`。
+   - 临时将 `ado_rule_item.defaultLeadDays` 从 `1` 改为 `2`,调用 `POST /api/Supply/delivery-schedule/generate`。
+   - 接口返回和操作日志均确认 `RuleSnapshot.Options.DefaultLeadDays=2`,`AppliedItems` 中对应 `RuleValue=2`。
+   - 验证日志:`SysLogOp.Id=805862349242437`,`CreatedCount=0`,`QadTrackingCount=0`。
+   - 验证后已将 `ado_rule_item.defaultLeadDays` 恢复为 `1`。
+
+33. **P32:修正规则 SQL 的 MySQL `VALUES()` 弃用提示**
+   - 将 `doc/db/rule/01_ado_rule_config.sql` 中三段 `ON DUPLICATE KEY UPDATE ... VALUES(col)` 改为 `AS new` 行别名写法。
+   - `ado_rule_schema` 种子显式写入 `is_enabled=1`,避免依赖数据库默认值。
+   - 已在 `aidopdev` 研发库重复执行 Profile、Schema、Item 三段种子 SQL,均无 `VALUES()` 弃用警告。
+   - 验证后 S3 默认数据仍为 Profile 1 条、Schema 10 条、Item 10 条。
+
+34. **P33:规则配置只读查询 API**
+   - 新增 `RuleConfigQueryService`,提供 `GET /api/RuleConfig/effective-options`。
+   - 首版只支持 S3 / `DELIVERY_GENERATE`,返回当前生效 `RuleSnapshot`。
+   - 查询参数支持 `moduleCode`、`scenarioCode`、`tenantId`、`factoryId`;不传时默认 S3 场景并使用当前用户租户。
+   - 新增 `RuleConfigService.ResolveS3DeliveryGenerateSnapshotAsync`,统一生成规则快照。
+   - 仅查询,不编辑、不保存、不触发 S3 生成。
+
+35. **P34:前端规则快照只读入口**
+   - `Web/src/views/aidop/s3/api/deliverySchedule.ts` 新增 `fetchEffectiveRuleOptions()`,调用 `GET /api/RuleConfig/effective-options`。
+   - `Web/src/views/aidop/s3/supply/deliveryScheduleList.vue` 在“生成交货单”旁新增“查看当前规则”按钮。
+   - 弹窗展示来源、模块/场景、解析时间、租户/工厂、关键 S3 规则项、生效配置项数和告警。
+   - 仅前端只读展示,不编辑规则、不写 `ado_rule_*`、不触发 S3 生成。
+
+### 9.1 P8 验证前置检查
+
+| 检查项 | 说明 |
+|---|---|
+| 环境 | 优先测试库;若是共享库,需确认无人同时跑同租户 S3 生成 |
+| 租户/工厂 | 明确 `tenant_id`、`factory_id` 和验证数据边界 |
+| 需求计划 | 至少包含一条已发布需求计划,且可覆盖 PO 足够、PO 不足、货源异常等场景 |
+| 货源清单 | 供应商配额合计需可验证 100% 和非 100% 两类结果 |
+| 可用 PO/DO | 准备可分配采购单行,避免只验证异常分支 |
+| 编号规则 | 确认 `M8`、`PR`、`DO` 编号规则可用 |
+| 外部事务 | 明确 `QadTracking` 是否被外部作业消费,必要时暂停消费或使用隔离租户 |
+| 操作日志 | 确认 `sys_log_op` 可写,便于回查 `S3_DELIVERY_GENERATE` |
+
+### 9.2 P8 执行步骤
+
+1. 记录执行前时间点、登录账号、租户和验证数据范围。
+2. 查询执行前基线数量:`srm_polist_ds`、`srm_pr_main`、`PurOrdMaster`、`PurOrdDetail`、`DeliveryExceptionMaster`、`QadTracking`、`sys_log_op`。
+3. 通过前端按钮或后端接口只执行一次 `POST /api/Supply/delivery-schedule/generate`。
+4. 保存接口返回结果,重点记录 `CreatedCount`、`PurchaseRequestCount`、`PurchaseRequestMergeReducedCount`、`PurchaseOrderCount`、`PurchaseOrderLineCount`、`QadTrackingCount`、`ExceptionCount`。
+5. 查询执行后新增记录,按本次执行时间、租户、编号前缀和业务字段交叉核对。
+6. 若 `QadTrackingCount > 0`,立即确认外部作业是否消费、消费状态是否符合预期。
+7. 将执行结果、异常和后续处理建议写入验证记录。
+
+### 9.3 P8 核对 SQL 口径
+
+以下 SQL 仅作为核对口径,真实执行前应补充具体 `tenant_id`、时间窗口和验证对象条件:
+
+```sql
+-- 交货单
+SELECT COUNT(*) FROM srm_polist_ds
+WHERE tenant_id = @tenant_id AND create_time >= @run_start;
+
+-- 自动生成 PR
+SELECT COUNT(*) FROM srm_pr_main
+WHERE tenant_id = @tenant_id AND create_time >= @run_start;
+
+-- DO/PO 主表与明细
+SELECT COUNT(*) FROM PurOrdMaster
+WHERE tenant_id = @tenant_id AND CreateTime >= @run_start;
+
+SELECT COUNT(*) FROM PurOrdDetail
+WHERE tenant_id = @tenant_id AND CreateTime >= @run_start;
+
+-- 交货异常
+SELECT COUNT(*) FROM DeliveryExceptionMaster
+WHERE tenant_id = @tenant_id AND opt_time >= @run_start;
+
+-- SAP/QAD 外部事务
+SELECT COUNT(*) FROM QadTracking
+WHERE Domain = @tenant_id AND CreateTime >= @run_start AND TransType = 'nbr-pr-mes';
+
+-- Admin.NET 操作日志
+SELECT COUNT(*) FROM sys_log_op
+WHERE tenant_id = @tenant_id AND action_name = 'S3_DELIVERY_GENERATE' AND log_date_time >= @run_start;
+```
+
+### 9.4 P8 止损与回滚原则
+
+- 若外部系统消费机制未确认,只允许在隔离环境验证,不在生产或共享业务租户执行。
+- 若执行后发现 `QadTracking` 被真实消费,不应直接删除业务单据,应先冻结后续消费并保留现场。
+- 本轮生成涉及多表事务,数据库层面虽然同事务提交,但外部消费可能异步发生;回滚方案必须区分内部业务表和外部系统状态。
+- 建议先以“作废/状态回退/标记测试数据”为主,避免物理删除导致审计链断裂。
+- 所有手工修正都应记录到 `sys_log_op`、验证记录或后续任务流日志中。
+
+### 9.5 P9 局部任务流闭环
+
+S3 的“任务流”首版定义为:**ApprovalFlow 人工确认 + S3 白名单系统动作 + `sys_log_op` 执行日志 + P8 验证口径**。不把 ApprovalFlow 一次性改造成完整任务流引擎,避免影响已有审批场景。
+
+```mermaid
+flowchart LR
+  Draft["S3 生成申请<br/>选择租户/工厂/验证范围"] --> Approve["ApprovalFlow<br/>人工确认/审批"]
+  Approve -->|通过| Execute["S3 后端白名单动作<br/>DeliveryScheduleService.Generate"]
+  Approve -->|拒绝/撤回| Stop["终止<br/>不执行系统动作"]
+  Execute --> Log["sys_log_op<br/>S3_DELIVERY_GENERATE"]
+  Execute --> Result["业务结果<br/>交货单/PR/DO-PO/QadTracking/异常"]
+  Result --> Verify["按 P8 口径核对<br/>内部表 + 外部事务 + 日志"]
+```
+
+#### ApprovalFlow 复用范围
+
+| 能力 | 复用方式 |
+|---|---|
+| 人工确认 | 配置 S3 专用 BizType 和审批流程,审批通过后才允许执行系统动作 |
+| 审批人配置 | 继续使用指定用户、角色、部门、部门负责人、发起人本人 |
+| 条件分支 | 可按验证范围、是否产生外部事务、风险等级等业务字段走不同审批线 |
+| 超时/升级 | 使用现有超时提醒、自动处理、手动升级能力 |
+| 通知与待办 | 复用审批中心、站内通知和已有外部通知配置 |
+| 时间线 | 记录人工审批过程;系统动作结果暂写 `sys_log_op` |
+
+#### S3 Service 承载范围
+
+| 系统动作 | 承载位置 | 首版边界 |
+|---|---|---|
+| 生成交货单 | `DeliveryScheduleService.Generate` | 仍由后端统一事务执行 |
+| PR 合并 | `PurchaseRequestMergeService` | 仅处理本次生成候选 |
+| DO/PO 转单 | `PurchaseOrderTransferService` | 仅处理本次生成且 `IsRequireGoods == 1` 的 PR |
+| SAP/QAD 推送事务 | `PurchaseRequestExternalPushService` | 仅写 `QadTracking`,不直接调用 SAP/QAD |
+| 执行日志 | `S3OperationLogService` | 写 `sys_log_op` 成功/失败摘要 |
+| 结果验证 | P8 核对口径 | 首版人工核对,后续再自动化 |
+
+#### 后续代码接入前置决策
+
+真正接入 ApprovalFlow 前,需要先确认以下参数,避免影响既有审批流:
+
+| 决策项 | 建议首版 |
+|---|---|
+| BizType | 新增 `S3_DELIVERY_GENERATE`,不复用订单评审或 S8 BizType |
+| BizId 来源 | 首版可使用一条 S3 生成申请记录;若不新增表,则需先定义临时业务对象承载方式 |
+| 审批通过触发 | 由 S3 BizHandler 在 `OnFlowCompleted(Approved)` 后调用白名单 Service |
+| 拒绝/撤回 | 不执行系统动作,只保留审批时间线 |
+| 并发互斥 | 同租户/工厂同一时间仅允许一个 S3 生成流程处于审批中或执行中 |
+| 外部事务 | `QadTracking` 消费机制未确认时,流程不得执行推送事务分支 |
+| 日志 | 人工审批看 ApprovalFlow 时间线;系统动作看 `sys_log_op` |
+| 兼容性 | 不修改 ApprovalFlow 节点 JSON,不新增通用系统任务节点 |
+
+#### 暂不做的事项
+
+- 不修改 `Admin.NET.Plugin.ApprovalFlow` 插件核心。
+- 不新增通用系统任务节点、自动重试、补偿节点。
+- 不改变既有订单评审、合同评审、S8 异常协同流程。
+- 不把系统任务伪装成人工待办。
+- 不在未确认 `QadTracking` 消费机制前自动触发外部事务。
+
+### 9.6 P10 全模块规则配置设计
+
+规则配置的目标是把可变参数从代码中抽出,但不把系统做成低代码执行器。核心事务、算法结构和写表逻辑继续由后端 Service 承载。
+
+#### 总体原则
+
+| 原则 | 说明 |
+|---|---|
+| 全模块复用 | 使用通用规则配置表,避免 S3/S4/S8/S9 各自建孤立配置表 |
+| 场景隔离 | 通过 `module_code + scenario_code` 区分模块与业务场景 |
+| 强类型读取 | 通用服务只负责取配置,模块转换为自己的 Options 并校验 |
+| 有默认值 | 没有配置或配置非法时,按场景选择回退默认值或阻断执行 |
+| 不配置执行链 | 不允许配置 SQL、写表目标、事务顺序、Service 调用链 |
+| 可审计 | 配置需记录版本、启用状态、生效时间、创建/更新人 |
+
+#### 建议表结构
+
+```text
+ado_rule_profile
+- id
+- module_code          -- S3 / S4 / S8 / S9
+- scenario_code        -- DELIVERY_GENERATE / EXCEPTION_DETECT / KPI_CALC
+- profile_code         -- 规则方案编码
+- profile_name         -- 规则方案名称
+- tenant_id            -- 0 表示全局默认
+- factory_id           -- 0 表示租户级或全局
+- version
+- is_enabled
+- effective_from
+- effective_to
+- remark
+- created_at / created_by
+- updated_at / updated_by
+
+ado_rule_item
+- id
+- profile_id
+- rule_code            -- 如 poBuyerCodes / quotaRequiredTotal
+- rule_name
+- value_type           -- string / number / boolean / string_array / number_array / json
+- rule_value           -- JSON 或字符串值
+- is_enabled
+- sort_no
+- remark
+- created_at / created_by
+- updated_at / updated_by
+
+ado_rule_schema(可选)
+- id
+- module_code
+- scenario_code
+- rule_code
+- rule_name
+- value_type
+- default_value
+- required
+- validation_rule      -- 如 min/max/enum/regex 的 JSON 描述
+- ui_control           -- input / number / switch / multi_select / json
+- option_source        -- 静态枚举或数据源说明
+- remark
+```
+
+#### 生效优先级
+
+读取某个规则场景时,按以下优先级合并:
+
+1. `tenant_id + factory_id` 精确匹配的工厂级配置。
+2. `tenant_id + factory_id = 0` 的租户级配置。
+3. `tenant_id = 0 + factory_id = 0` 的全局默认配置。
+4. 模块代码内默认值。
+
+若多个版本同时满足,应选择当前时间位于 `effective_from/effective_to` 范围内且版本最新的启用方案。
+
+#### S3 首批规则项
+
+`module_code = S3`,`scenario_code = DELIVERY_GENERATE`:
+
+| rule_code | 类型 | 默认值 | 说明 |
+|---|---|---|---|
+| `quotaRequiredTotal` | number | `100` | 货源配额要求合计 |
+| `quotaTolerance` | number | `0.0001` | 配额误差容忍 |
+| `poBuyerCodes` | string_array | `["110","160","170"]` | 普通 PO 可用采购组 |
+| `doUsages` | string_array | `["标准","VMI","委外加工"]` | DO 可用供应类别 |
+| `defaultLeadDays` | number | `1` | 提前期缺省天数 |
+| `enablePackagingRoundUp` | boolean | `true` | 是否按包装量向上取整 |
+| `shortageCreatePr` | boolean | `true` | PO 不足时是否生成 PR |
+| `enablePrMerge` | boolean | `true` | 是否启用本次生成 PR 合并 |
+| `enableRequireGoodsToPo` | boolean | `true` | 是否启用要货令转 DO/PO |
+| `enableExternalPushTracking` | boolean | `true` | 是否写 `QadTracking`;P22 为保持现有 P5 行为默认开启,正式生产配置仍需确认外部消费机制 |
+
+#### 不纳入首批配置的内容
+
+- 交货单、PR、DO/PO、`QadTracking` 的事务顺序。
+- 写入表和字段映射。
+- SQL 查询结构。
+- 编号生成算法,继续复用 `NumberRuleService`。
+- PR 合并核心分组键,首版先固化在代码和单元测试中。
+- 外部 SAP/QAD 真实接口调用。
+
+#### 模块读取方式
+
+后续实现时建议提供通用读取服务:
+
+```text
+RuleConfigService.GetEffectiveOptions(moduleCode, scenarioCode, tenantId, factoryId)
+```
+
+S3 侧再转换为:
+
+```text
+S3DeliveryGenerateRuleOptions
+```
+
+模块转换时负责:
+
+- 类型转换。
+- 必填校验。
+- 枚举/范围校验。
+- 非法配置处理策略:回退默认值或阻断执行。
+- 将最终生效配置写入执行日志,便于追溯。
+
+### 9.7 P11 MDP/DWD 沉淀补齐设计
+
+S3 生成链路已经不只是“生成交货单”,而是串联需求、交货单、PR、DO/PO、外部事务和异常。中台沉淀需要覆盖完整执行闭环,避免看板和诊断只能看到部分运行表。
+
+#### 当前已沉淀对象
+
+| 运行表/结果 | 当前标准层/DWD | 状态 |
+|---|---|---|
+| `srm_polist_ds` | `mdp_std_delivery_schedule`、`dwd_supplier_delivery` | 已有 |
+| `srm_pr_main` | `mdp_std_purchase_request` | 已有标准层,DWD 闭环仍需补 |
+| `PurOrdMaster` / `PurOrdDetail` | `mdp_std_purchase_order`、`dwd_supplier_delivery` | 已有 |
+| 送货/收货结果 | `mdp_std_delivery_result`、`dwd_supplier_delivery` | 已有 |
+| 物料齐套/缺料 | `mdp_std_material_readiness`、`dwd_material_readiness`、`dwd_material_shortage` | 已有 |
+| S3 KPI | `ado_s9_kpi_value_*` | 已有首批 |
+
+#### 当前缺口
+
+| 缺口 | 说明 | 建议 |
+|---|---|---|
+| 交货异常标准化 | `DeliveryExceptionMaster` 仍是运行表口径 | 设计 `mdp_std_delivery_exception` 或通用 `mdp_std_business_exception` |
+| 外部事务标准化 | `QadTracking` 仍是外部消费事务表 | 设计 `mdp_std_external_transaction` |
+| 执行闭环视图 | PR、DO/PO、外部事务、异常没有统一闭环查询 | 设计 `dwd_supply_execution_closure` |
+| 供应风险口径 | 文档提到 `dwd_supplier_risk`,当前需确认是否已建表/落地 | 后续统一风险指标口径 |
+| 任务流节点结果 | ApprovalFlow 时间线只记录人工审批,系统动作结果暂写 `sys_log_op` | 后续可沉淀到任务流执行日志或闭环 DWD |
+
+#### 建议标准对象
+
+1. `mdp_std_delivery_exception` 或 `mdp_std_business_exception`
+
+用于承载 `DeliveryExceptionMaster`,建议字段包括:
+
+```text
+tenant_id
+factory_id
+module_code
+scenario_code
+exception_code
+exception_type
+severity
+source_object_type
+source_object_id
+item_code
+supplier_code
+need_qty
+exception_message
+status
+occur_time
+resolved_time
+source_system
+source_biz_key
+sync_batch_id
+sync_time
+```
+
+2. `mdp_std_external_transaction`
+
+用于承载 `QadTracking`、未来 SAP/QAD/MES/WMS 等外部事务,建议字段包括:
+
+```text
+tenant_id
+factory_id
+module_code
+scenario_code
+external_system
+transaction_type
+transaction_no
+source_object_type
+source_object_id
+biz_no
+payload_snapshot
+transaction_status
+created_at
+consumed_at
+result_message
+source_system
+source_biz_key
+sync_batch_id
+sync_time
+```
+
+3. `dwd_supply_execution_closure`
+
+用于按一次 S3 生成或一个需求对象串起闭环,建议字段包括:
+
+```text
+tenant_id
+factory_id
+stat_date
+execution_id
+demand_no
+item_code
+supplier_code
+delivery_plan_no
+pr_no
+po_no
+qad_transaction_no
+exception_count
+delivery_schedule_qty
+pr_qty
+po_qty
+external_transaction_count
+closure_status
+risk_level
+last_event_time
+source_system
+sync_batch_id
+calc_time
+```
+
+#### 落地顺序建议
+
+1. 先在文档和 P8 验证中确认 `DeliveryExceptionMaster`、`QadTracking` 的字段口径和消费机制。
+2. 再补标准层对象:异常标准表、外部事务标准表。
+3. 最后补 DWD 闭环视图,给看板、ChatBI、智慧诊断和任务流验证读取。
+4. 在 S3 稳定后,将通用对象推广到 S4/S8/S9 等其它模块。
+
+#### 暂不立即落表的原因
+
+- `QadTracking` 是否被外部实时消费尚未确认,不能贸然改变外部事务口径。
+- 异常标准表可能跨 S3/S6/S8 等模块,需要统一 `module_code`、`scenario_code`、严重度和状态枚举。
+- DWD 闭环表依赖执行 ID 或业务关联键,目前 S3 生成链路尚未引入统一 `execution_id`。
+- 先通过 P8 验证收集样本,再设计 DDL 风险更低。
+
+### 9.8 P12 前端生成结果展示
+
+S3 交货单列表页已从“只展示一行成功提示”调整为“生成结果摘要弹窗”。弹窗读取后端 `DeliveryScheduleGenerateResult`,展示本次执行影响面,帮助用户判断是否需要查看异常或日志。
+
+| 展示项 | 来源字段 |
+|---|---|
+| 需求计划数 | `DemandCount` |
+| 候选交货单数 | `CandidateCount` |
+| 已生成交货单数 | `CreatedCount` |
+| 自动生成 PR 数 | `PurchaseRequestCount` |
+| PR 合并减少数 | `PurchaseRequestMergeReducedCount` |
+| 生成 DO/PO 单数 | `PurchaseOrderCount` |
+| 生成 DO/PO 明细数 | `PurchaseOrderLineCount` |
+| 写 `QadTracking` 数 | `QadTrackingCount` |
+| 异常数 | `ExceptionCount` |
+| 跳过数 | `SkippedCount` |
+
+首版仅展示摘要和提示:
+
+- 若异常数大于 0,提示到“交货单异常记录”查看。
+- 执行摘要已由后端写入 `sys_log_op`,前端暂不直接查询系统操作日志。
+- 不新增后端接口,不触发额外业务动作。
+
+P13 已补充闭环入口:
+
+- `ExceptionCount > 0` 时,摘要弹窗显示“查看异常记录”,跳转 `/aidop/s3/material-plan/delivery-exception`。
+- 摘要弹窗显示“查看操作日志”,跳转 `/log/oplog`,并提示按 `S3_DELIVERY_GENERATE` 查询。
+- 首版只跳转已有页面,不改系统日志页过滤能力。
+
+P14 已补充操作日志筛选联动:
+
+- S3 摘要弹窗跳转 `/log/oplog?controllerName=S3&actionName=S3_DELIVERY_GENERATE`。
+- 操作日志页初始化时读取 `controllerName`、`actionName`、`account` query 并自动查询。
+- 不新增日志 API,不改变系统日志页原有手动查询、重置和导出能力。
+
+P15 已补充操作日志详情增强:
+
+- 详情弹窗分块展示日志消息、请求参数、返回结果和异常信息。
+- 对 JSON 字符串自动格式化,便于查看 S3 `DeliveryScheduleGenerateResult`。
+- 不改变日志写入结构和查询接口。
+
+### 9.9 P16 全模块规则配置 DDL 草案
+
+P16 已新增独立草案文档:[`全模块规则配置表设计草案.md`](./全模块规则配置表设计草案.md)。
+
+该草案把 P10 的规则配置原则进一步落成可评审结构:
+
+- `ado_rule_profile`:规则方案头,支持模块、场景、租户、工厂、版本和生效时间。
+- `ado_rule_item`:规则项,存储具体规则值。
+- `ado_rule_schema`:规则定义,描述默认值、校验、前端控件和选项来源。
+- S3 试点规则种子:`module_code = S3`、`scenario_code = DELIVERY_GENERATE`。
+
+当前状态仍为设计草案:
+
+- 不执行 SQL。
+- 不替换 `DeliveryScheduleService` 中现有规则常量。
+- 不新增配置页面。
+- 不改变生产运行行为。
+
+P17 已在同一草案文档中补充只读服务设计:
+
+- `RuleConfigService` 负责按模块、场景、租户、工厂读取生效规则。
+- 合并顺序为代码默认、全局、租户、工厂。
+- 支持 `string`、`number`、`boolean`、数组和 `json` 类型转换。
+- 首版建议使用短 TTL 缓存,不做复杂热更新。
+- S3 后续转换为 `S3DeliveryGenerateRuleOptions`,再接入 `DeliveryScheduleService`。
+
+P18 已将规则配置草案整理成独立 SQL 文件:
+
+- [`../db/rule/01_ado_rule_config.sql`](../db/rule/01_ado_rule_config.sql)
+- [`../db/rule/README.md`](../db/rule/README.md)
+
+当前状态仍为评审草案,尚未执行。正式执行前需要确认主键 ID 段、权限、审计字段、版本策略和高风险开关默认值。
+
+P19 已补充执行前评审清单:
+
+- `doc/db/rule/README.md`:执行前检查清单和暂不执行原因。
+- `全模块规则配置表设计草案.md`:结构评审、业务评审、运维评审和暂不执行原因。
+
+P20 已新增规则配置纯逻辑骨架:
+
+- `RuleConfigResolver` 负责纯内存规则合并与类型转换。
+- 单元测试已覆盖 S3 后续接入所需的核心边界。
+- 仍不依赖 `ado_rule_*` 表,不读取数据库。
+
+P21 已新增规则配置只读服务骨架:
+
+- `RuleConfigService` 暴露通用 `ResolveOptionsAsync` 和 S3 专用 `ResolveS3DeliveryGenerateOptionsAsync`。
+- `S3DeliveryGenerateRuleOptions` 承载 S3 交货单生成默认规则。
+- 当前使用空配置层,后续可替换为数据库读取结果。
+
+P22 已将默认 Options 接入 S3 生成链路:
+
+- 当前仍不读取 `ado_rule_*`,只通过 `RuleConfigService` 获取默认 Options。
+- 默认值与既有硬编码保持一致,避免行为变化。
+- `EnableExternalPushTracking=true` 是本阶段明确选择,用于保持 P5 已落地的 `QadTracking` 写入行为;正式配置投产时仍需结合 P8 重新确认。
+
+P23 已将本次生效规则写入执行日志:
+
+- `DeliveryScheduleGenerateResult.RuleSnapshot` 保存当前规则快照。
+- `sys_log_op.ReturnResult` 可同时看到执行计数、生成结果和规则快照。
+- 前端摘要保持简洁,只提供日志入口和规则快照写入提示。
+
+P24 已将规则快照结构标准化:
+
+- `RuleConfigSnapshot<TOptions>` 统一承载规则来源、模块场景、解析时间、租户/工厂和最终 Options。
+- S3 当前写入 `moduleCode=S3`、`scenarioCode=DELIVERY_GENERATE`、`source=default`。
+- 后续 `ado_rule_*` 真正接入后,可在同一结构内记录生效配置项和告警信息。
+
+P25 已新增规则配置只读数据源接口骨架:
+
+- `IRuleConfigRepository.GetEffectiveLayersAsync` 定义读取生效规则层的边界。
+- `EmptyRuleConfigRepository` 作为默认实现,当前仍返回空配置层,保持 S3 默认行为不变。
+- `RuleConfigService` 不再直接写死空配置层,后续只需替换仓储实现即可读取 `ado_rule_*`。
+- 单元测试已验证内存仓储能覆盖默认值,并让规则快照来源变为 `configured`。
+
+P26 已新增 `ado_rule_*` 只读实体和数据库仓储草案:
+
+- `AdoRuleProfile`、`AdoRuleItem`、`AdoRuleSchema` 对齐 SQL 草案表结构。
+- `DbRuleConfigRepository` 可读取全局、租户、工厂三类规则方案,并转换为 `RuleConfigLayer`。
+- 当前不把数据库仓储注册为默认实现,避免未执行 DDL 时影响启动或 S3 生成。
+- 数据库仓储中的高风险开关识别暂不启用,待 Schema 增加明确标记后再落地。
+
+P27 已补充数据库规则仓储启用前方案:
+
+- 启用清单见 [`doc/db/rule/02_enable_rule_repository_checklist.md`](../db/rule/02_enable_rule_repository_checklist.md)。
+- 推荐通过 `RuleConfig:RepositoryMode` 控制 `Empty` / `Db` 仓储来源。
+- `Db` 模式启用前需验证表存在、默认种子一致、规则快照来源变为 `configured`、生成结果与 P8 口径一致。
+- 回退路径为配置切回 `Empty`,并保留规则表、操作日志和接口返回用于复盘。
+
+P28 已按研发阶段策略直接启用数据库规则仓储:
+
+- `DbRuleConfigRepository` 作为默认 `IRuleConfigRepository` 注入实现。
+- `EmptyRuleConfigRepository` 不再默认注册,仅保留测试和手动回退用途。
+- 当前不做 `RuleConfig:RepositoryMode` 开关和缺表保护,缺表/错表会在执行时直接暴露。
+- 启用后若 `ado_rule_*` 默认数据存在,S3 生成日志中的 `RuleSnapshot.source` 应为 `configured`。
+
+P29 已在研发库执行并验证规则配置表:
+
+- 目标库:`user-mysql-aidopdev` / `aidopdev`。
+- 表:`ado_rule_profile`、`ado_rule_item`、`ado_rule_schema`。
+- S3 默认种子:Profile 1 条、Schema 10 条、Item 10 条。
+- 生效查询已返回 10 条规则,包括 `quotaRequiredTotal=100`、`poBuyerCodes=["110","160","170"]`、`enableExternalPushTracking=true`。
+- 下一步可通过一次 S3 小范围生成确认 `sys_log_op.ReturnResult` 中 `RuleSnapshot.source=configured`。
+
+P30 已完成最小生成验证:
+
+- 调用接口:`POST /api/Supply/delivery-schedule/generate`。
+- 接口返回:`demandCount=1`、`createdCount=0`、`skippedCount=1`、`qadTrackingCount=0`。
+- 规则快照:`RuleSnapshot.Source=configured`,`AppliedItems=10`。
+- 规则值:`EnableExternalPushTracking=true`,来源为 `global:S3_DELIVERY_GENERATE_DEFAULT`。
+- 操作日志:`SysLogOp.Id=805860787408965`,`ReturnResult` 已写入完整规则快照。
+- 本次未新增业务单据,适合作为规则读取和日志快照链路验证。
+
+P31 已完成数据库规则覆盖验证:
+
+- 临时修改:`ado_rule_item` 中 `profile_id=1003000000001`、`rule_code=defaultLeadDays` 的 `rule_value` 从 `1` 改为 `2`。
+- 验证接口:`POST /api/Supply/delivery-schedule/generate`。
+- 验证结果:`RuleSnapshot.Options.DefaultLeadDays=2`,对应 `AppliedItems` 的 `RuleValue=2`。
+- 操作日志:`SysLogOp.Id=805862349242437`。
+- 验证后已恢复:`defaultLeadDays=1`。
+- 本轮未新增业务单据,仅验证数据库配置值能覆盖代码默认 Options。
+
+P32 已修正 SQL 幂等种子写法:
+
+- `ON DUPLICATE KEY UPDATE` 不再使用已弃用的 `VALUES(col)`。
+- 三段种子 SQL 已改为 `AS new` 别名写法。
+- `ado_rule_schema` 插入列显式包含 `is_enabled`,研发库重跑时不再依赖默认值。
+- 研发库重跑结果:Profile、Schema、Item 三段均 `Warnings=0`,默认数据计数保持 1/10/10。
+
+P33 已新增规则配置只读 API:
+
+- 接口:`GET /api/RuleConfig/effective-options?moduleCode=S3&scenarioCode=DELIVERY_GENERATE`。
+- 返回:`RuleConfigSnapshot<S3DeliveryGenerateRuleOptions>`。
+- 可选调试参数:`tenantId`、`factoryId`。
+- 首版只读,仅支持 S3 交货单生成规则;后续配置页可复用该接口做初始展示。
+
+P34 已在 S3 交货单页面新增规则快照只读入口:
+
+- 按钮:`查看当前规则`,位于“生成交货单”旁。
+- 展示内容:规则来源、模块/场景、解析时间、关键 Options、生效项数量和 Warnings。
+- 行为边界:只调用 P33 查询接口,不保存配置、不触发生成、不展示完整 JSON。
+
+P35 已新增研发阶段规则编辑最小闭环:
+
+- 后端新增 `POST /api/RuleConfig/s3-delivery-generate/save`,只允许保存 S3 / `DELIVERY_GENERATE` 既有规则项。
+- 保存范围限定为全局默认 Profile(`tenant_id=0`、`factory_id=0`)下的 10 个 S3 交货单生成 Options。
+- 前端 S3 交货单页面新增 `编辑当前规则` 按钮,保存后直接更新 `ado_rule_item.rule_value` 并刷新规则快照。
+- 首版不新增 Profile/Schema,不做版本发布、回滚、审批、权限菜单和多模块通用编辑器。
+
+P36 已完成规则编辑保存闭环验证:
+
+- 启动本地后端并调用 `POST /api/RuleConfig/s3-delivery-generate/save`。
+- 临时将 `defaultLeadDays` 从 `1` 保存为 `2`,接口返回和 `GET /api/RuleConfig/effective-options` 均确认 `RuleSnapshot.Options.DefaultLeadDays=2`。
+- 数据库 `ado_rule_item` 同步确认 `rule_code=defaultLeadDays` 的 `rule_value=2`。
+- 验证后已通过同一保存接口恢复 `defaultLeadDays=1`,接口和数据库均复查通过。
+- 本轮未触发 S3 生成,不新增交货单、PR、DO/PO 或 `QadTracking`。
+
+### 9.11 当前进度结论
+
+截至 P36,S3 研发试点的主要功能已完成:
+
+- 旧 `CreateDeliverySchedule` / `AutoTransferDoOrPo` 链路已拆入新系统后端 Service,覆盖交货单生成、自动 PR、PR 合并、DO/PO 转单和 `QadTracking` 事务写入。
+- 前端 S3 交货计划入口已改为调用新系统 `/api/Supply/delivery-schedule/*`,不再通过旧 `api/business` 链式调用。
+- 生成结果摘要、异常入口、操作日志入口和操作日志详情已补齐,可支撑研发阶段追溯。
+- S3 生成规则已接入 `ado_rule_*` 数据库配置,支持查询、前端查看、前端编辑和保存闭环验证。
+- 研发库已验证规则读取、规则覆盖、保存恢复;默认值已恢复为 `defaultLeadDays=1`。
+
+当前未继续展开的能力:
+
+- **任务流**:目前只有“后端白名单动作 + 执行摘要 + 操作日志 + 异常入口”的执行闭环雏形;通用任务流模型、节点实例、审批编排、重试补偿和任务流页面尚未开发,建议作为下一阶段专题。
+- **多模块规则配置推广**:已记录为后续方向,当前不做真实开发;后续需先出方案和首个模块试点清单,再确认实施。
+- **生产化能力**:权限菜单、配置版本发布、回滚、审批、缺表保护和仓储开关暂不作为本轮 S3 试点范围。
+
+轻量收尾待办:
+
+- 前后端版本号按提交规则递增。
+- 清理 `__pycache__`、`logs/`、`server/.publish/`、`server/artifacts/` 等生成目录,不纳入提交。
+- 整理 Git 状态并按实际纳入的前后端改动提交;提交后按团队约定推送。
+- 如需正式验收,再准备真实业务数据端到端跑一轮,核对交货单、PR、DO/PO、`QadTracking`、异常和操作日志。
+
+## 十、验收口径
+
+后续真正迁移代码时,可按以下口径验收:
+
+- S3 前端无 `123.60.180.165`、`api/business` 直连。
+- 交货单生成结果可追溯到需求计划、采购单、货源清单和编号规则。
+- 自动生成 PR 时,有明确原因和来源需求。
+- PR 合并、DO/PO 转单、SAP 推送有任务流或后端执行日志。
+- MDP/DWD 能反映交货单、PR、PO/DO 的最新状态。
+- 外部系统事务失败时,不影响已完成的内部生成结果,且可重试或人工处理。

+ 566 - 0
doc/plan/全模块规则配置表设计草案.md

@@ -0,0 +1,566 @@
+# 全模块规则配置表设计草案
+
+> 状态:设计草案,未执行建表。  
+> 试点场景:S3 / DELIVERY_GENERATE。  
+> 原则:配置参数、阈值、名单、策略枚举和开关;不配置 SQL、事务顺序、写表目标或 Service 调用链。
+
+## 一、设计目标
+
+当前 S3 交货单生成链路中,采购组、DO 用途、配额阈值、提前期默认值、包装取整、PR/DO/外部事务开关等规则仍写在 Service 中。后续 S4、S8、S9 等模块也会出现类似规则参数。若每个模块单独建表,会重复实现版本、生效时间、租户/工厂覆盖、审计和页面能力。
+
+本草案建议建设一套全模块通用规则配置表:
+
+- 用 `module_code + scenario_code` 隔离模块和业务场景。
+- 用 `tenant_id + factory_id` 支持全局、租户级、工厂级覆盖。
+- 用 `profile + item + schema` 分离规则方案、规则值和规则定义。
+- 由通用服务读取配置,再由模块转换为强类型 Options 并校验。
+
+## 二、适用范围
+
+适合配置:
+
+- 阈值,如配额合计、风险阈值、超时小时数。
+- 名单,如采购组、供应类别、异常类型白名单。
+- 策略枚举,如圆整策略、风险等级策略。
+- 开关,如是否生成 PR、是否写外部事务。
+- 默认参数,如默认提前期、默认检测周期。
+
+不适合配置:
+
+- SQL 查询结构。
+- 写表目标和字段映射。
+- 事务顺序。
+- Service 调用链。
+- 外部接口真实调用逻辑。
+- 核心算法代码。
+
+## 三、表结构草案
+
+### 3.1 `ado_rule_profile`
+
+规则方案头。一个 Profile 表示某模块某场景在某个租户/工厂范围内的一组规则。
+
+```sql
+CREATE TABLE ado_rule_profile (
+    id BIGINT PRIMARY KEY,
+    module_code VARCHAR(32) NOT NULL COMMENT '模块编码,如 S3/S4/S8/S9',
+    scenario_code VARCHAR(64) NOT NULL COMMENT '场景编码,如 DELIVERY_GENERATE',
+    profile_code VARCHAR(64) NOT NULL COMMENT '规则方案编码',
+    profile_name VARCHAR(128) NOT NULL COMMENT '规则方案名称',
+    tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示全局默认',
+    factory_id BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示租户级或全局',
+    version INT NOT NULL DEFAULT 1 COMMENT '版本号',
+    is_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
+    effective_from DATETIME NULL COMMENT '生效开始时间',
+    effective_to DATETIME NULL COMMENT '生效结束时间',
+    remark VARCHAR(500) NULL,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    created_by VARCHAR(64) NULL,
+    updated_at DATETIME NULL,
+    updated_by VARCHAR(64) NULL,
+    UNIQUE KEY uk_rule_profile_code (module_code, scenario_code, profile_code, tenant_id, factory_id, version),
+    KEY idx_rule_profile_effective (module_code, scenario_code, tenant_id, factory_id, is_enabled, effective_from, effective_to)
+) COMMENT='全模块规则配置方案';
+```
+
+### 3.2 `ado_rule_item`
+
+规则项。一个 Profile 下包含多个 `rule_code`。
+
+```sql
+CREATE TABLE ado_rule_item (
+    id BIGINT PRIMARY KEY,
+    profile_id BIGINT NOT NULL COMMENT '规则方案 ID',
+    rule_code VARCHAR(64) NOT NULL COMMENT '规则编码',
+    rule_name VARCHAR(128) NOT NULL COMMENT '规则名称',
+    value_type VARCHAR(32) NOT NULL COMMENT 'string/number/boolean/string_array/number_array/json',
+    rule_value TEXT NULL COMMENT '规则值,数组和对象使用 JSON',
+    is_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
+    sort_no INT NOT NULL DEFAULT 0,
+    remark VARCHAR(500) NULL,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    created_by VARCHAR(64) NULL,
+    updated_at DATETIME NULL,
+    updated_by VARCHAR(64) NULL,
+    UNIQUE KEY uk_rule_item_code (profile_id, rule_code),
+    KEY idx_rule_item_profile (profile_id, is_enabled)
+) COMMENT='全模块规则配置项';
+```
+
+### 3.3 `ado_rule_schema`
+
+规则定义,可选但建议保留。用于描述某个模块场景允许配置哪些规则,以及默认值、校验和前端控件。
+
+```sql
+CREATE TABLE ado_rule_schema (
+    id BIGINT PRIMARY KEY,
+    module_code VARCHAR(32) NOT NULL,
+    scenario_code VARCHAR(64) NOT NULL,
+    rule_code VARCHAR(64) NOT NULL,
+    rule_name VARCHAR(128) NOT NULL,
+    value_type VARCHAR(32) NOT NULL,
+    default_value TEXT NULL COMMENT '默认值,数组和对象使用 JSON',
+    required TINYINT NOT NULL DEFAULT 0,
+    validation_rule TEXT NULL COMMENT 'min/max/enum/regex 等 JSON 描述',
+    ui_control VARCHAR(32) NULL COMMENT 'input/number/switch/multi_select/json',
+    option_source TEXT NULL COMMENT '静态枚举或数据源说明',
+    is_enabled TINYINT NOT NULL DEFAULT 1,
+    sort_no INT NOT NULL DEFAULT 0,
+    remark VARCHAR(500) NULL,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    created_by VARCHAR(64) NULL,
+    updated_at DATETIME NULL,
+    updated_by VARCHAR(64) NULL,
+    UNIQUE KEY uk_rule_schema_code (module_code, scenario_code, rule_code),
+    KEY idx_rule_schema_scene (module_code, scenario_code, is_enabled)
+) COMMENT='全模块规则定义';
+```
+
+## 四、生效优先级
+
+读取某个场景规则时,按以下顺序合并:
+
+1. 工厂级:`tenant_id = 当前租户` 且 `factory_id = 当前工厂`。
+2. 租户级:`tenant_id = 当前租户` 且 `factory_id = 0`。
+3. 全局级:`tenant_id = 0` 且 `factory_id = 0`。
+4. 模块代码默认值。
+
+同一层级存在多个启用版本时,选择当前时间落在 `effective_from/effective_to` 范围内、`version` 最大的 Profile。
+
+建议规则读取结果写入执行日志,便于追溯本次业务动作使用了哪套规则。
+
+## 五、S3 试点规则种子示例
+
+### 5.1 Profile
+
+```sql
+INSERT INTO ado_rule_profile
+(id, module_code, scenario_code, profile_code, profile_name, tenant_id, factory_id, version, is_enabled, remark)
+VALUES
+(1003000000001, 'S3', 'DELIVERY_GENERATE', 'S3_DELIVERY_GENERATE_DEFAULT', 'S3 交货单生成默认规则', 0, 0, 1, 1, 'P10 规则配置试点默认方案');
+```
+
+### 5.2 Schema
+
+```sql
+INSERT INTO ado_rule_schema
+(id, module_code, scenario_code, rule_code, rule_name, value_type, default_value, required, validation_rule, ui_control, option_source, sort_no, remark)
+VALUES
+(1003000000101, 'S3', 'DELIVERY_GENERATE', 'quotaRequiredTotal', '货源配额要求合计', 'number', '100', 1, '{"min":0,"max":100}', 'number', NULL, 10, '货源配额合计校验目标值'),
+(1003000000102, 'S3', 'DELIVERY_GENERATE', 'quotaTolerance', '配额误差容忍', 'number', '0.0001', 1, '{"min":0}', 'number', NULL, 20, '配额合计误差容忍'),
+(1003000000103, 'S3', 'DELIVERY_GENERATE', 'poBuyerCodes', '普通 PO 可用采购组', 'string_array', '["110","160","170"]', 1, NULL, 'multi_select', 'static:110,160,170', 30, '普通 PO 采购组白名单'),
+(1003000000104, 'S3', 'DELIVERY_GENERATE', 'doUsages', 'DO 可用供应类别', 'string_array', '["标准","VMI","委外加工"]', 1, NULL, 'multi_select', 'static:标准,VMI,委外加工', 40, 'DO 可用用途白名单'),
+(1003000000105, 'S3', 'DELIVERY_GENERATE', 'defaultLeadDays', '默认提前期天数', 'number', '1', 1, '{"min":0}', 'number', NULL, 50, '货源提前期缺失或小于等于 0 时使用'),
+(1003000000106, 'S3', 'DELIVERY_GENERATE', 'enablePackagingRoundUp', '启用包装量向上取整', 'boolean', 'true', 1, NULL, 'switch', NULL, 60, '是否按 packaging_qty 圆整'),
+(1003000000107, 'S3', 'DELIVERY_GENERATE', 'shortageCreatePr', 'PO 不足自动生成 PR', 'boolean', 'true', 1, NULL, 'switch', NULL, 70, '采购单不足时是否生成 PR'),
+(1003000000108, 'S3', 'DELIVERY_GENERATE', 'enablePrMerge', '启用 PR 合并', 'boolean', 'true', 1, NULL, 'switch', NULL, 80, '是否启用本次生成 PR 合并'),
+(1003000000109, 'S3', 'DELIVERY_GENERATE', 'enableRequireGoodsToPo', '启用要货令转 DO/PO', 'boolean', 'true', 1, NULL, 'switch', NULL, 90, '是否将 IsRequireGoods=1 的 PR 转 DO/PO'),
+(1003000000110, 'S3', 'DELIVERY_GENERATE', 'enableExternalPushTracking', '启用外部事务写入', 'boolean', 'true', 1, NULL, 'switch', NULL, 100, '是否写 QadTracking;P22 为保持现有 P5 行为默认开启');
+```
+
+### 5.3 Item
+
+```sql
+INSERT INTO ado_rule_item
+(id, profile_id, rule_code, rule_name, value_type, rule_value, is_enabled, sort_no, remark)
+VALUES
+(1003000000201, 1003000000001, 'quotaRequiredTotal', '货源配额要求合计', 'number', '100', 1, 10, NULL),
+(1003000000202, 1003000000001, 'quotaTolerance', '配额误差容忍', 'number', '0.0001', 1, 20, NULL),
+(1003000000203, 1003000000001, 'poBuyerCodes', '普通 PO 可用采购组', 'string_array', '["110","160","170"]', 1, 30, NULL),
+(1003000000204, 1003000000001, 'doUsages', 'DO 可用供应类别', 'string_array', '["标准","VMI","委外加工"]', 1, 40, NULL),
+(1003000000205, 1003000000001, 'defaultLeadDays', '默认提前期天数', 'number', '1', 1, 50, NULL),
+(1003000000206, 1003000000001, 'enablePackagingRoundUp', '启用包装量向上取整', 'boolean', 'true', 1, 60, NULL),
+(1003000000207, 1003000000001, 'shortageCreatePr', 'PO 不足自动生成 PR', 'boolean', 'true', 1, 70, NULL),
+(1003000000208, 1003000000001, 'enablePrMerge', '启用 PR 合并', 'boolean', 'true', 1, 80, NULL),
+(1003000000209, 1003000000001, 'enableRequireGoodsToPo', '启用要货令转 DO/PO', 'boolean', 'true', 1, 90, NULL),
+(1003000000210, 1003000000001, 'enableExternalPushTracking', '启用外部事务写入', 'boolean', 'true', 1, 100, 'P22 为保持现有 P5 行为默认开启;生产切换前仍需确认 QadTracking 消费机制');
+```
+
+## 六、读取服务草案
+
+后续代码实现建议分两层:
+
+```text
+RuleConfigService
+  GetEffectiveItems(moduleCode, scenarioCode, tenantId, factoryId)
+  GetEffectiveOptions<TOptions>(moduleCode, scenarioCode, tenantId, factoryId)
+
+S3DeliveryGenerateRuleOptions
+  QuotaRequiredTotal
+  QuotaTolerance
+  PoBuyerCodes
+  DoUsages
+  DefaultLeadDays
+  EnablePackagingRoundUp
+  ShortageCreatePr
+  EnablePrMerge
+  EnableRequireGoodsToPo
+  EnableExternalPushTracking
+```
+
+模块侧负责:
+
+- 类型转换。
+- 必填校验。
+- 枚举、范围校验。
+- 非法配置处理:阻断执行或回退默认值。
+- 将最终生效配置写入 `sys_log_op` 或任务流执行日志。
+
+## 七、落地顺序
+
+1. 评审本草案,确认通用表结构和规则范围。
+2. 形成正式 DDL,纳入 `doc/db/` 或迁移脚本;当前 SQL 草案见 [`../db/rule/01_ado_rule_config.sql`](../db/rule/01_ado_rule_config.sql)。
+3. 只写入全局默认 Profile 和 S3 试点 Schema/Item。
+4. 实现只读 `RuleConfigService`。
+5. S3 `DeliveryScheduleService` 读取 `S3DeliveryGenerateRuleOptions`,但先保留代码默认兜底。
+6. 增加配置查询/编辑页面。
+7. 推广到 S4/S8/S9 等模块。
+
+## 八、风险与约束
+
+- 通用规则表属于跨模块基础设施,正式建表前需要确认命名、权限、审计和版本策略。
+- `enableExternalPushTracking` 等高风险开关正式生产配置建议默认关闭;S3 试点草案为保持 P5 现有行为暂默认开启,必须结合 P8 受控验证和外部消费机制确认。
+- 配置非法时不能静默产生错误业务结果;应在执行前校验并明确报错。
+- 不应通过规则配置绕过任务流、人审确认或外部事务止损机制。
+
+## 九、P17 只读服务设计
+
+P17 目标是先定义规则配置服务层形态,暂不写代码。服务首版只负责读取和解析配置,不负责编辑、发布、审批或自动生成业务逻辑。
+
+### 9.1 服务职责
+
+`RuleConfigService` 建议职责:
+
+- 按 `module_code + scenario_code + tenant_id + factory_id` 获取当前生效规则。
+- 合并工厂级、租户级、全局级和代码默认值。
+- 根据 `ado_rule_schema` 做基础类型转换和必填校验。
+- 输出通用字典或模块强类型 Options。
+- 返回本次生效的 Profile、版本、规则项和值,便于执行日志追溯。
+
+不建议职责:
+
+- 不执行 SQL 或业务动作。
+- 不决定事务顺序。
+- 不直接调用业务 Service。
+- 不在读取时修改配置状态。
+- 不绕过模块自己的业务校验。
+
+### 9.2 建议接口
+
+```csharp
+public interface IRuleConfigService
+{
+    Task<RuleConfigSnapshot> GetEffectiveSnapshotAsync(
+        string moduleCode,
+        string scenarioCode,
+        long tenantId,
+        long? factoryId,
+        CancellationToken cancellationToken = default);
+
+    Task<TOptions> GetEffectiveOptionsAsync<TOptions>(
+        string moduleCode,
+        string scenarioCode,
+        long tenantId,
+        long? factoryId,
+        TOptions defaults,
+        CancellationToken cancellationToken = default)
+        where TOptions : class, new();
+}
+```
+
+`RuleConfigSnapshot` 建议包含:
+
+```text
+module_code
+scenario_code
+tenant_id
+factory_id
+profiles[]       -- 实际命中的全局/租户/工厂 Profile
+items{}          -- 合并后的 rule_code -> value
+warnings[]       -- 非致命配置问题
+loaded_at
+```
+
+### 9.3 合并算法
+
+1. 加载模块代码默认值。
+2. 查找全局级启用 Profile:`tenant_id = 0`、`factory_id = 0`。
+3. 查找租户级启用 Profile:`tenant_id = 当前租户`、`factory_id = 0`。
+4. 查找工厂级启用 Profile:`tenant_id = 当前租户`、`factory_id = 当前工厂`。
+5. 每一层只取当前时间有效、版本最高的 Profile。
+6. 按全局、租户、工厂顺序覆盖同名 `rule_code`。
+7. 对合并后的规则做类型转换和校验。
+8. 返回 Snapshot,并由模块转换为强类型 Options。
+
+若同一层级出现多个有效 Profile 且版本相同,应视为配置冲突:
+
+- 默认策略:阻断执行并报错。
+- 可选策略:按 `updated_at` 最新,但不建议首版使用,避免隐藏配置问题。
+
+### 9.4 类型转换规则
+
+| value_type | 存储格式 | 目标类型 |
+|---|---|---|
+| `string` | 原始字符串 | `string` |
+| `number` | 数字字符串 | `decimal` / `int` / `long`,由 Options 属性决定 |
+| `boolean` | `true` / `false` | `bool` |
+| `string_array` | JSON 数组 | `List<string>` |
+| `number_array` | JSON 数组 | `List<decimal>` / `List<int>` |
+| `json` | JSON 对象 | 模块自定义 DTO |
+
+转换失败处理:
+
+- 必填规则转换失败:阻断执行。
+- 非必填规则转换失败:记录 warning,回退默认值。
+- 高风险开关转换失败:按更保守值处理,例如 `enableExternalPushTracking = false`。
+
+### 9.5 缓存策略
+
+首版建议只做短 TTL 缓存,不做复杂热更新:
+
+- Cache Key:`moduleCode:scenarioCode:tenantId:factoryId`。
+- TTL:30-60 秒。
+- 编辑配置后可通过管理页手动清缓存,或等待 TTL 自然失效。
+- 执行业务动作时,应把 Snapshot 的 Profile/版本写入执行日志,避免缓存导致无法追溯。
+
+如果后续引入“发布规则”动作,可以在发布成功后主动清缓存。
+
+### 9.6 S3 Options 示例
+
+```csharp
+public sealed class S3DeliveryGenerateRuleOptions
+{
+    public decimal QuotaRequiredTotal { get; set; } = 100m;
+    public decimal QuotaTolerance { get; set; } = 0.0001m;
+    public List<string> PoBuyerCodes { get; set; } = new() { "110", "160", "170" };
+    public List<string> DoUsages { get; set; } = new() { "标准", "VMI", "委外加工" };
+    public int DefaultLeadDays { get; set; } = 1;
+    public bool EnablePackagingRoundUp { get; set; } = true;
+    public bool ShortageCreatePr { get; set; } = true;
+    public bool EnablePrMerge { get; set; } = true;
+    public bool EnableRequireGoodsToPo { get; set; } = true;
+    public bool EnableExternalPushTracking { get; set; } = true;
+}
+```
+
+### 9.7 S3 接入点
+
+后续接入 `DeliveryScheduleService` 时,建议替换以下硬编码:
+
+| 当前硬编码 | Options 字段 |
+|---|---|
+| 配额合计 `100` | `QuotaRequiredTotal` |
+| 配额误差 `0.0001` | `QuotaTolerance` |
+| 普通 PO 采购组 `110/160/170` | `PoBuyerCodes` |
+| DO 用途 `标准/VMI/委外加工` | `DoUsages` |
+| `lead_time <= 0` 默认 `1` 天 | `DefaultLeadDays` |
+| 包装量向上取整 | `EnablePackagingRoundUp` |
+| PO 不足生成 PR | `ShortageCreatePr` |
+| 本次 PR 合并 | `EnablePrMerge` |
+| 要货令转 DO/PO | `EnableRequireGoodsToPo` |
+| 普通 PR 写 `QadTracking` | `EnableExternalPushTracking` |
+
+接入时应遵守:
+
+- P22 为保持现有 P5 行为,S3 默认仍开启外部事务写入;正式配置表投产时,可按 P8 验证结果决定是否改为默认关闭。
+- 即使配置关闭某动作,也要在返回结果和日志里说明“因规则配置跳过”。
+- 每次执行将最终 Options Snapshot 写入 `sys_log_op.ReturnResult` 或 `Message` 关联字段。
+
+### 9.8 后续实现顺序
+
+1. 正式建表并插入 S3 默认 Schema/Item。
+2. 实现只读 `RuleConfigService`。当前已先落地服务骨架,暂不访问数据库。
+3. 为合并算法和类型转换补单元测试。当前已先落地纯逻辑 `RuleConfigResolver` 与单元测试,不依赖数据库。
+4. S3 只读接入 Options,但保留代码默认兜底。
+5. 增加规则配置管理页面。
+6. 引入发布、审计和缓存清理能力。
+
+### 9.9 P20 纯逻辑测试骨架
+
+当前已新增纯逻辑解析器和单元测试:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigResolver.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Rule/RuleConfigResolverTests.cs`
+
+覆盖范围:
+
+- 工厂级覆盖租户级,租户级覆盖全局。
+- `string_array` 转 `List<string>`。
+- `boolean` 转 `bool`。
+- `number` 转 `decimal` / `int`。
+- 高风险开关非法值回退保守值 `false`。
+- 同优先级重复规则返回错误。
+
+该实现不访问数据库、不接入 S3 业务链路,用于后续 `RuleConfigService` 的合并/转换核心。
+
+### 9.10 P21 只读服务骨架
+
+当前已新增只读服务骨架和 S3 默认 Options:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleProfile.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleItem.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleSchema.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigService.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigRepository.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/S3DeliveryGenerateRuleOptions.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigSnapshot.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Rule/RuleConfigServiceTests.cs`
+
+P21 当前行为:
+
+- `RuleConfigService.ResolveOptionsAsync` 使用空配置层调用 `RuleConfigResolver`。
+- `ResolveS3DeliveryGenerateOptionsAsync` 返回 `S3DeliveryGenerateRuleOptions` 默认值。
+- 不访问 `ado_rule_*` 表,不依赖数据库。
+
+P22 已把默认 Options 接入 `DeliveryScheduleService`,P23 已把本次生效 Options 快照随现有执行日志写入 `sys_log_op.ReturnResult`。
+
+P24 已将快照标准化为 `DeliveryScheduleGenerateResult.RuleSnapshot`:
+
+- `moduleCode` / `scenarioCode`:标识模块和规则场景。
+- `source`:当前为 `default`,后续接入数据库后可标识配置来源。
+- `resolvedAt`:本次规则解析时间。
+- `tenantId` / `factoryId`:本次解析上下文。
+- `options`:最终生效的强类型 Options。
+- `appliedItems` / `warnings`:预留给后续数据库配置生效项和告警信息。
+
+当前仍不访问 `ado_rule_*` 表,后续接入数据库时,只需把空配置层替换为 `ado_rule_profile` / `ado_rule_item` 读取结果,再复用现有 Resolver。
+
+P25 已将规则读取边界抽为只读数据源接口:
+
+- `IRuleConfigRepository.GetEffectiveLayersAsync` 负责按 `moduleCode + scenarioCode + tenantId + factoryId` 返回生效规则层。
+- `EmptyRuleConfigRepository` 当前作为默认实现,返回空配置层,不访问数据库。
+- `RuleConfigService.ResolveOptionsAsync` 从仓储读取规则层,再调用 `RuleConfigResolver` 合并转换。
+- 测试中使用内存仓储验证配置层可以覆盖默认 Options,并让 `RuleSnapshot.source=configured`。
+
+后续读取真实 `ado_rule_*` 时,优先新增数据库仓储实现,不修改 `RuleConfigResolver` 和业务服务。
+
+P26 已新增数据库只读仓储草案:
+
+- `AdoRuleProfile`、`AdoRuleItem`、`AdoRuleSchema` 对齐当前 SQL 草案字段。
+- `DbRuleConfigRepository` 按 `moduleCode + scenarioCode + tenantId + factoryId` 查询生效 Profile 和 Item。
+- 查询层级包括全局默认、租户级和工厂级,转换为优先级 100/200/300 的 `RuleConfigLayer`。
+- 该仓储当前不作为默认实现注册,避免数据库未执行 DDL 时影响业务。
+- 当前 Schema 尚未包含明确的高风险开关字段,因此数据库仓储暂不设置 `IsHighRiskSwitch`。
+
+正式启用数据库仓储前,还需完成 DDL 执行、字段策略复核、重复版本阻断和受控验证。
+
+P27 已补充启用前检查与开关方案,作为后续预发/生产启用参考:
+
+- 独立清单见 [`../db/rule/02_enable_rule_repository_checklist.md`](../db/rule/02_enable_rule_repository_checklist.md)。
+- 建议通过 `RuleConfig:RepositoryMode=Empty|Db` 显式控制仓储来源。
+- 预发/生产默认应保持 `Empty`,只有完成 DDL、种子、缺表保护和受控验证后才允许切换 `Db`。
+- 切换后应在 `sys_log_op.ReturnResult` 中确认 `RuleSnapshot.source=configured`。
+- 异常时立即回退 `Empty`,并保留规则表快照、操作日志和接口返回。
+
+P28 已按研发阶段策略直接启用数据库仓储:
+
+- `DbRuleConfigRepository` 改为默认可注入实现。
+- `EmptyRuleConfigRepository` 保留为测试和手动回退实现,不再默认自动注册。
+- 当前研发环境不增加仓储开关和缺表自动回退,缺表或字段不一致时直接暴露错误。
+- 后续进入预发/生产前,再按 P27 清单补开关、健康检查和回退策略。
+
+P29 已在研发库完成 DDL 与种子验证:
+
+- 执行环境:`user-mysql-aidopdev` 默认库 `aidopdev`。
+- 已创建 `ado_rule_profile`、`ado_rule_item`、`ado_rule_schema`。
+- S3 / `DELIVERY_GENERATE` 默认 Profile 1 条、Schema 10 条、Item 10 条。
+- `DbRuleConfigRepository` 同款查询可返回 10 条生效规则项。
+- P32 已将 `ON DUPLICATE KEY UPDATE ... VALUES(col)` 调整为 `AS new` 别名写法,重跑种子 SQL 不再出现弃用提示。
+
+P30 已完成最小生成验证:
+
+- 本地后端已启动,调用 `POST /api/Supply/delivery-schedule/generate` 成功。
+- 接口返回 `ruleSnapshot.source=configured`,说明 S3 生成链路已读取数据库规则。
+- `SysLogOp.ReturnResult` 使用后端序列化字段名,确认 `RuleSnapshot.Source=configured`、`AppliedItems` 10 条。
+- 本次验证未新增交货单、PR、DO/PO 或 `QadTracking`,仅写入操作日志。
+- 验证日志:`SysLogOp.Id=805860787408965`。
+
+P31 已完成规则覆盖验证:
+
+- 由于匿名生成接口下 `TenantId=0`,本轮未用租户级 Profile,而是临时修改全局默认规则项。
+- 将 `defaultLeadDays` 从 `1` 临时改为 `2` 后调用生成接口。
+- 返回结果和 `SysLogOp.ReturnResult` 均确认 `RuleSnapshot.Options.DefaultLeadDays=2`。
+- 验证日志:`SysLogOp.Id=805862349242437`。
+- 验证后已恢复 `defaultLeadDays=1`。
+
+P32 已完成 SQL 草案修正:
+
+- 三段种子 `INSERT` 均改为 `AS new` 行别名写法。
+- `ado_rule_schema` 种子显式写入 `is_enabled=1`。
+- 已在 `aidopdev` 重复执行 Profile、Schema、Item 三段种子,`Warnings=0`。
+- 默认数据计数保持 Profile 1 条、Schema 10 条、Item 10 条。
+
+P33 已新增只读查询接口:
+
+- `GET /api/RuleConfig/effective-options`
+- 首版只支持 `moduleCode=S3`、`scenarioCode=DELIVERY_GENERATE`。
+- 返回当前生效 `RuleSnapshot`,包含来源、上下文、最终 Options、AppliedItems 和 Warnings。
+- 支持通过 `tenantId`、`factoryId` 查询指定上下文,便于研发调试。
+- 该接口不修改规则配置,也不触发 S3 生成。
+
+P34 已新增前端只读展示入口:
+
+- S3 交货单页面提供 `查看当前规则` 按钮。
+- 调用 P33 接口读取当前生效规则快照。
+- 弹窗仅展示业务可读摘要:来源、上下文、关键 Options、生效配置项数和 Warnings。
+- 首版不做规则编辑、版本切换、保存或发布能力。
+
+P35 已新增研发阶段编辑最小闭环:
+
+- `POST /api/RuleConfig/s3-delivery-generate/save` 直接更新 S3 / `DELIVERY_GENERATE` 默认 Profile 下的既有 `ado_rule_item.rule_value`。
+- S3 交货单页面新增 `编辑当前规则`,可编辑配额、采购组、DO 用途、提前期和关键开关。
+- 保存后返回最新 `RuleSnapshot` 并在前端展示,便于确认数据库配置已生效。
+- 该能力仍是研发阶段最小闭环,不包含规则版本发布、回滚、审批流、权限菜单和多模块通用编辑器。
+
+P36 已完成保存闭环验证:
+
+- 使用 P35 保存接口临时将 `defaultLeadDays` 从 `1` 改为 `2`。
+- `GET /api/RuleConfig/effective-options` 返回 `RuleSnapshot.Options.DefaultLeadDays=2`。
+- 数据库 `ado_rule_item.rule_value` 同步变为 `2`。
+- 随后使用同一接口恢复 `defaultLeadDays=1`,接口和数据库均确认恢复。
+- 验证过程不触发 S3 生成,不产生业务单据或外部事务。
+
+## 十、P19 DDL 评审清单
+
+`doc/db/rule/01_ado_rule_config.sql` 当前仍是 SQL 草案,执行前需完成以下评审。
+
+### 10.1 结构评审
+
+| 项目 | 检查点 |
+|---|---|
+| 表命名 | `ado_rule_*` 是否符合全模块公共配置命名 |
+| 主键策略 | 固定 ID、雪花 ID、自增 ID 选择是否统一 |
+| ID 段 | S3 默认种子 ID 是否与已有种子数据冲突 |
+| 审计字段 | `created_at/created_by` 是否需要改为项目统一字段 |
+| 软删除 | 是否需要 `is_deleted`;当前草案仅有 `is_enabled` |
+| 外键 | 是否加物理外键;当前建议不加,避免迁移耦合 |
+| 索引 | 生效查询、规则项查询、Schema 查询索引是否覆盖首版读取路径 |
+
+### 10.2 业务评审
+
+| 项目 | 检查点 |
+|---|---|
+| 生效优先级 | 工厂级、租户级、全局级、代码默认是否满足业务需要 |
+| 版本策略 | 同一层级多版本同时有效时是否阻断执行 |
+| 默认值 | S3 默认规则是否与当前代码行为一致 |
+| 高风险开关 | `enableExternalPushTracking` 在 S3 试点中默认开启是否仅用于保持现有 P5 行为,正式生产配置是否需要默认关闭 |
+| 规则范围 | 是否坚持只配置参数、阈值、名单、枚举和开关 |
+| 非法配置 | 是否明确阻断执行或回退默认值的策略 |
+
+### 10.3 运维评审
+
+| 项目 | 检查点 |
+|---|---|
+| 权限 | 后续规则配置页面归属菜单和权限编码 |
+| 缓存 | 首版 30-60 秒 TTL 是否可接受 |
+| 审计 | 配置变更是否需要接入 `sys_log_op` 或差异日志 |
+| 回滚 | 是否准备 drop/备份策略 |
+| 环境 | 是否先在测试库执行并验证读取服务 |
+
+### 10.4 暂不执行原因
+
+- `ado_rule_*` 是全模块基础设施,不能只按 S3 单模块视角落库。
+- S4/S8/S9 的规则配置场景尚未评审,需确认表结构是否足够通用。
+- 高风险规则开关涉及外部事务,需要先完成 P8 受控验证。
+- 后端只读服务和配置页面尚未实现,提前建表无法产生业务价值。
+- 当前阶段文档和 SQL 草案已足够支撑评审,不需要立即变更数据库。

+ 123 - 12
doc/plan/数据中台模块扩展开发指南-S3范式.md

@@ -66,56 +66,167 @@ flowchart TB
 
 ---
 
-## 3. 新模块(例:S4 / S8)推荐落地步骤
+## 3. 完整 S3 模块的迁移边界
+
+S3 范式不只是 “MDP 同步 + DWD 宽表” 的 ETL 模板。以 S3 供应协同为例,一个完整的新系统模块相对旧系统还要处理旧 `api/business`、存储过程、业务运行表、外部事务和任务闭环的拆分。
+
+旧系统常见形态是:前端或定时任务直接调用 `api/business/*`,接口内同时完成算法计算、多表写入、单据状态推进和外部系统事务。例如 S3 交货计划链路中的 `CreateDeliverySchedule`、`AutoTransferDoOrPo`,实际覆盖了交货单生成、PR 生成、PR 合并、DO/PO 转单和 SAP/QAD 推送事务。
+
+新系统应按下表拆分承载:
+
+| 旧系统能力 | 旧系统常见位置 | 新系统承载位置 | S3 处理原则 |
+|---|---|---|---|
+| 页面直接调用旧业务接口 | `http://123.60.180.165:8087/api/business/*` | 前端只调用新系统 `/api/<module>/*` | 不在前端保留旧系统 URL、`domain/user` 直传和跨系统副作用调用 |
+| 稳定业务流程 | 旧 `AppService` / 存储过程 / raw SQL | `.NET Service` + `dop_core` 运行表 | 交货计划、采购申请、DO/PO 转单等由后端 Service 承载 |
+| 算法计算 | 旧接口内硬编码 | `.NET Service`,结果写 `mdp_std_*` / `dwd_*` | 配额分配、可用量占用、包装量圆整等保留确定性代码 |
+| 可变规则与参数 | 旧代码常量、系统参数表 | `dop_rule` / 配置表 / 编号服务 | 采购组、供应类别、时间窗口、提前期、编号规则等配置化 |
+| 数据接入与标准化 | 旧业务表直查 | `mdp_stg_*` / `mdp_std_*` / `mdp_entity` | 源表先贴源,再标准化为可复用数据资产 |
+| 分析与看板 | 旧接口临时 JOIN | `dwd_*` / `ado_s9_kpi_value_*` / 查询服务 | 看板、ChatBI、诊断优先读 DWD 和指标层 |
+| 有副作用动作 | 接口内静默执行 | 后端白名单 Service + 任务流节点 | PR 合并、DO/PO 转单、SAP 推送需可审计、可重试 |
+| 外部系统事务 | `QadTracking` 等事务表 | 外部事务 Service / 接口日志 / 任务流执行记录 | 先确认外部消费机制,再迁移写入逻辑 |
+
+### 3.1 S3 端到端数据流
+
+完整 S3 模块建议形成以下数据流:
+
+```mermaid
+flowchart LR
+  Legacy["旧/外部业务表<br/>需求/PR/PO/送货/库存"] --> Stg["mdp_stg_* 贴源"]
+  Stg --> Std["mdp_std_* 标准层"]
+  Std --> Service["S3 后端 Service<br/>交货计划/PR/转单"]
+  Service --> Core["dop_core 运行表<br/>srm_polist_ds / srm_pr_main / PurOrd*"]
+  Core --> Dwd["dwd_supplier_delivery<br/>dwd_material_readiness<br/>dwd_supplier_risk"]
+  Dwd --> Metric["metric_* / ado_s9_kpi_value_*"]
+  Metric --> View["看板 / ChatBI / 智慧诊断"]
+  View --> Flow["任务流<br/>审批/人工任务/系统任务/验证"]
+  Flow --> Service
+```
+
+### 3.2 S3 旧 API 的拆分示例
+
+| 旧接口/过程 | 旧逻辑范围 | 新系统拆分 |
+|---|---|---|
+| `replenishment/CreateDeliverySchedule` | 读取已发布需求计划,按货源清单配额、PO/DO 可用量、最小包装量生成交货单;采购单不足时生成 PR 和异常 | `DeliveryScheduleService` + 采购申请领域能力 + 异常事实/任务;结果沉淀 `mdp_std_delivery_schedule`、`dwd_supplier_delivery` |
+| `resource-examine/AutoTransferDoOrPo` | 串联 `PrAutoMerge2`、`PrAutoApprove`、`PrSendSAP` | 拆为 PR 合并 Service、DO/PO 转单 Service、外部事务 Service,并由任务流控制执行 |
+| `PrAutoMerge2` | 5 周窗口内按供应商、物料、供应类别、是否要货令等合并 PR | 采购申请合并 Service;合并规则配置化,写执行日志 |
+| `PrAutoApprove` | 要货令转 `PurOrdMaster` / `PurOrdDetail` / `PurOrdDetailBatch` | DO/PO 转单 Service;建议接任务流确认后执行 |
+| `PrSendSAP` | 更新 PR 状态并写 `QadTracking` SAP/QAD 推送事务 | 外部事务 Service;S3 P5 初版已处理本次生成普通 PR,不直接调用 SAP/QAD |
+| P3-P5 迁移测试 | 旧系统多为接口内串联,缺少独立边界测试 | 新系统需补领域 Service 单测,锁定合并、转单、外部事务候选筛选等规则 |
+| 执行日志 | 旧接口内日志不统一 | 初版优先复用 Admin.NET `sys_log_op`;任务流接入后再补节点级执行日志 |
+
+### 3.3 迁移时的判断顺序
+
+扩展新模块时,先不要直接照搬旧 API。建议按以下顺序判断:
+
+1. 旧能力是只读查询、算法计算,还是有副作用业务动作。
+2. 涉及哪些运行表、外部事务表和跨模块能力。
+3. 稳定流程是否应进入 `.NET Service`。
+4. 可变规则是否能配置化。
+5. 结果是否需要进入 `mdp_std_*`、`dwd_*`、指标层。
+6. 是否需要任务流承载人工确认、系统任务、等待和验证。
+7. 是否存在 SAP/QAD/MES/WMS 等外部系统副作用,是否需要先保留事务日志或接口日志。
+
+---
+
+## 4. 新模块(例:S4 / S8)推荐落地步骤
 
 1. **对齐设计**:在 [`新系统数据库设计方案.md`](./新系统数据库设计方案.md) 中找到本模块的 **`mdp_std_*` / `dwd_*` 命名**;执行 [`doc/db/mdp/`](../db/mdp/) 下与自己对齐的脚本(至少 **宽表**)。
-2. **登记 `mdp_entity`**:为每个要拉取的源表配置 `entity_code`、源表名、目标 `mdp_stg_*`(`tenant_id` 规则与现有 S3 一致,如全局模板 `0`)。
-3. **新建 `*MdpSyncTransformService`(或模块内 `*EtlService`)**:  
+2. **识别旧能力边界**:列出旧 API、存储过程、定时任务、运行表和外部事务;先判断是查询/算法/业务动作/外部副作用。
+3. **登记 `mdp_entity`**:为每个要拉取的源表配置 `entity_code`、源表名、目标 `mdp_stg_*`(`tenant_id` 规则与现有 S3 一致,如全局模板 `0`)。
+4. **新建 `*MdpSyncTransformService`(或模块内 `*EtlService`)**:  
    - Staging:可复制 `SyncStagingAsync` 模式(按实体循环)。  
    - Standard / DWD:方法内 **yield/枚举 SQL**,避免散落。  
    - 日志:`mdp_transform_run_log.job_code` 使用新常量(如 `S4_PO_SYNC_TRANSFORM`)。
-4. **新建 `IJob`**:`[JobDetail]` + `[Period]` 或 Cron;`ExecuteAsync` 里 `RunFullAsync`。
-5. **平台任务**:内置作业会在 **`/platform/job`** 出现;需要可在 `AidopMenuLinkSync` / 种子菜单中补说明。
-6. **监控**:复制 `S3MdpMonitorService` 为模块专用服务 **或** 泛化列表接口按 `job_code` 过滤。
-7. **指标**(若需要):在 `AidopKpiMasterSeed` 或管理端补 `ado_smart_ops_kpi_master`,计算结果写入 `ado_s9_kpi_value_*`,`module_code` 用 `S4` / `S8`。
-8. **前端**:列表/看板改为请求 **你们暴露的 API**,由 API 读 `dwd_*` / 指标表,而非直拼源表。
+5. **新建业务 Service**:对于旧 API 中的稳定业务流程和复杂事务,拆入后端 Service;前端不得直连旧 `api/business`。
+6. **新建 `IJob`**:`[JobDetail]` + `[Period]` 或 Cron;`ExecuteAsync` 里 `RunFullAsync`。
+7. **平台任务**:内置作业会在 **`/platform/job`** 出现;需要可在 `AidopMenuLinkSync` / 种子菜单中补说明。
+8. **监控**:复制 `S3MdpMonitorService` 为模块专用服务 **或** 泛化列表接口按 `job_code` 过滤。
+9. **指标**(若需要):在 `AidopKpiMasterSeed` 或管理端补 `ado_smart_ops_kpi_master`,计算结果写入 `ado_s9_kpi_value_*`,`module_code` 用 `S4` / `S8`。
+10. **任务流**(若有副作用动作):PR 合并、DO/PO 转单、SAP 推送等高风险动作应通过任务流或后端白名单 Service 受控执行。
+11. **前端**:列表/看板改为请求 **你们暴露的 API**,由 API 读 `dwd_*` / 指标表,而非直拼源表或旧系统接口。
+12. **结果反馈**:有副作用动作的前端入口应展示结构化执行摘要,如生成数量、异常数量、外部事务数量和日志提示,避免只弹“成功”;摘要应尽量提供异常记录、操作日志等闭环入口。
+
+任务流首版建议基于现有 ApprovalFlow 做局部闭环:ApprovalFlow 承载人工确认、审批人、条件分支、通知和时间线;系统动作仍由本模块后端白名单 Service 执行,执行结果写 `sys_log_op` 或模块日志。除非已完成兼容性设计和回归验证,不要直接修改 ApprovalFlow 核心来新增通用系统任务节点。
+
+副作用动作落地后,还应补齐标准层和 DWD 闭环口径:运行表只负责承载业务事务,`mdp_std_*` / `dwd_*` 应负责给看板、ChatBI、智慧诊断和任务流验证提供稳定读取面。异常、外部事务、系统动作结果不应长期只停留在运行表或日志里。
 
 ---
 
-## 4. 与「纯配置驱动中台」的关系
+## 5. 与「纯配置驱动中台」的关系
 
 方案中的 **`mdp_pipeline*`、`mdp_field_mapping` 通用引擎** 仍在演进。当前阶段:
 
 - **表可先建**:见 `doc/db/mdp/02_mdp_pipeline_orchestration.sql` 等。  
 - **业务上线**:仍可采用 **S3 同款代码管道**,避免被未实现的解析器阻塞。  
+- **业务规则**:采购组、供应类别、时间窗口、提前期、阈值、动作开关等可逐步进入全模块通用规则配置表;复杂事务和性能敏感算法仍由 Service 承载;生成类动作应把本次生效 `RuleSnapshot` 写入执行日志。
 - **后续**:把 `Build*Commands` 中的 SQL 逐步替换为 **读配置生成的 SQL** 或外置策略——需单独技术决议(含:非代码配置与热更新)。
 
+规则配置应优先采用“通用规则表 + 模块场景隔离 + 强类型 Options”的模式,而不是为每个模块分别建设孤立规则表。配置项只承载参数、阈值、名单、策略枚举和开关,不承载 SQL、事务顺序、写表目标或 Service 调用链;业务服务应依赖规则服务,规则服务再通过只读仓储接口读取生效配置层。
+
 ---
 
-## 5. 给 Cursor 用的提示模板(粘贴到对话)
+## 6. 给 Cursor 用的提示模板(粘贴到对话)
 
 ```text
 请在本仓库按《doc/plan/数据中台模块扩展开发指南-S3范式.md》的 S3 范式扩展 <模块名>:
 - 优先阅读 S3MdpSyncTransformService / S3MdpSyncTransformJob
+- 先识别旧 API / 存储过程 / 外部事务 / 前端直连项,区分查询、算法、业务动作和外部副作用
 - 新作业 job_code 建议 <XXX>
 - 读数目标表:<列出 mdp_std_* / dwd_* / ado_s9_*>
 - 不要新建与 ado_s9_kpi_value_* 并行的指标表除非我确认
+- 涉及 PR、PO/DO、SAP/QAD、MES/WMS 等副作用动作时,先列跨模块影响和任务流/Service 方案,等确认后再改
 - 修改范围尽量仅限 <插件/目录>,跨模块影响请先说明
 ```
 
 ---
 
-## 6. PR / 自检清单
+## 7. PR / 自检清单
 
 - [ ] 新增同步是否写入 `mdp_sync_log` / `mdp_transform_run_log`(可观测)  
 - [ ] `mdp_entity` 是否已登记或与代码内实体配置一致  
 - [ ] 看板/API 是否优先使用 `dwd_*` / 指标表  
 - [ ] `module_code`、指标主数据是否与设计一致  
+- [ ] 涉及副作用动作时,是否已补充不依赖真实外部系统的领域规则测试  
+- [ ] 业务执行结果是否已复用 `sys_log_op` 或任务流日志记录成功/失败摘要  
+- [ ] 副作用动作上线或真实验证前,是否已完成测试租户、外部消费开关、核对 SQL 和止损方案确认  
+- [ ] 若接入任务流,是否明确 ApprovalFlow 人工节点与业务 Service 系统动作边界,并避免影响既有审批流  
+- [ ] 若新增规则配置,是否优先使用全模块通用规则表,并按 `module_code + scenario_code` 做场景隔离  
+- [ ] 规则服务是否通过只读仓储接口读取配置层,避免业务服务直接查询规则表  
+- [ ] 若业务链路已读取规则 Options,执行日志是否记录 `RuleSnapshot`(来源、模块场景、解析时间、最终 Options),便于回查  
 - [ ] 是否增加或更新了 [`doc/db/mdp/`](../db/mdp/) 中对应 DDL(若有新表)  
+- [ ] 前端是否仍直连旧 `123.60.180.165` / `api/business` / 其它外部业务接口  
+- [ ] 旧 API、存储过程或定时任务中的算法、写表范围和外部副作用是否已拆清楚  
+- [ ] PR 合并、DO/PO 转单、SAP/QAD 推送等动作是否有后端 Service、任务流或执行日志承载  
+- [ ] 运行结果是否沉淀到 `mdp_std_*` / `dwd_*` / 指标层,避免只写业务运行表  
+- [ ] 异常、外部事务、系统动作结果是否有标准层或 DWD 闭环设计,避免诊断只能读运行表  
+- [ ] 前端副作用入口是否展示结构化执行摘要,并提示异常记录或操作日志查看路径  
+- [ ] 若已有异常记录或操作日志页面,前端是否提供快捷跳转,避免用户只能手动找菜单  
+- [ ] 若跳转通用日志页面,是否可通过 URL query 预填筛选条件,避免用户重复输入动作编码  
+- [ ] 操作日志详情是否能展示返回结果和异常信息,便于追溯副作用动作执行结果  
 
 ---
 
-## 7. 参考与索引
+## 8. 参考与索引
 
 - 数据库总体方案:[`新系统数据库设计方案.md`](./新系统数据库设计方案.md) §2、§4、§6  
+- 全模块规则配置草案:[`全模块规则配置表设计草案.md`](./全模块规则配置表设计草案.md)  
 - DDL 脚本目录:[`doc/db/mdp/README.md`](../db/mdp/README.md)  
+- S3 旧 API 解析:[`S3调用旧系统Business API解析.md`](./S3调用旧系统Business API解析.md)  
+
+---
+
+## 9. 当前 S3 试点进度
+
+截至 P36,S3 研发试点主要功能已完成,可作为后续模块参考范式:
+
+- **业务链路**:交货单生成、自动 PR、PR 合并、DO/PO 转单和 `QadTracking` 写入已迁入新系统后端 Service。
+- **前端入口**:S3 交货计划页面调用新系统 `/api/Supply/delivery-schedule/*`,不再长期依赖旧 `api/business`。
+- **执行追溯**:生成结果摘要、异常记录入口、操作日志深链和日志详情增强已补齐。
+- **规则配置**:S3 / `DELIVERY_GENERATE` 已接入 `ado_rule_*`,支持数据库读取、规则快照、只读查询、前端查看、前端编辑和保存恢复验证。
+- **数据中台沉淀**:交货单、PR、PO/DO 和供应交付相关标准层 / DWD 口径已纳入 S3 迁移文档,后续可继续补齐异常与外部事务闭环宽表。
+
+当前明确不展开:
+
+- **通用任务流**:本轮只形成“白名单 Service + 操作日志 + 异常入口”的执行闭环雏形;通用任务流模型、任务节点表、审批编排、重试补偿和前端任务页另行立项。
+- **多模块规则推广**:先作为后续方向记录,不在当前 S3 收尾中真实开发。推广前需先确认通用规则页面、权限、版本策略和首个模块试点。
+- **生产化增强**:规则发布/回滚、审批、缺表保护、仓储开关和配置变更审计作为后续生产化工作,不影响 S3 研发试点结论。

+ 113 - 0
doc/plan/旧Business API迁移映射清单.md

@@ -0,0 +1,113 @@
+# 旧 Business API 迁移映射清单
+
+> 状态:方案说明 + S3 P0 收敛记录。本文整理旧系统 `D:\Projects\ZZYDOP_old\MicroServices\Business` 中 `api/business/*` 能力在新系统中的承载位置;其中 S3 交货计划前端直连旧 API 已开始按本方案收敛,其它模块仍按实际边界分批确认后处理。
+
+## 一、当前决策边界
+
+本轮讨论先确认以下原则:
+
+- 暂不引入低代码 Service、智能体自动执行等扩展设计。
+- Service 代码承载稳定流程、复杂事务和性能敏感算法。
+- 配置承载可变规则和参数,包括公式、阈值、权重、策略、编号规则、调度频率和字段映射。
+- 审批流后续可抽象为任务流,支撑“诊断建议 -> 审批确认 -> 受控执行 -> 效果验证”的闭环。
+- 旧 Business API 作为业务口径和历史实现参考,不作为新系统长期依赖目标。
+- 已确认的 S3 小范围修改可先执行;涉及数据库结构、外部系统副作用动作和跨模块公共能力的改造仍需单独确认。
+
+## 二、旧 API 来源概览
+
+旧 API 主要来自:
+
+- `D:\Projects\ZZYDOP_old\MicroServices\Business\Business.HttpApi\Controllers\ResourceExamineController.cs`
+- `D:\Projects\ZZYDOP_old\MicroServices\Business\Business.HttpApi\Controllers\ReplenishmentController.cs`
+- `D:\Projects\ZZYDOP_old\MicroServices\Business\Business.HttpApi\Controllers\SystemJobController.cs`
+- `D:\Projects\ZZYDOP_old\MicroServices\Business\Business.HttpApi\Controllers\VSMController.cs`
+- `D:\Projects\ZZYDOP_old\MicroServices\Business\Business.HttpApi\Controllers\SqeController.cs`
+
+当前新系统仍有少量前端直连旧 `http://123.60.180.165:8087/api/business/*`:
+
+- `Web/src/views/aidop/api/workOrderScheduling.ts`
+- `Web/src/views/aidop/api/workOrderDispatch.ts`
+- `Web/src/views/aidop/api/seOrderReview.ts`
+
+最新拉取后曾发现 S3 交货计划页面新增了前端直连旧 API:
+
+- `Web/src/views/aidop/s3/api/deliverySchedule.ts`
+  - `api/business/replenishment/CreateDeliverySchedule`
+  - `api/business/resource-examine/AutoTransferDoOrPo`
+
+该直连与本方案“旧 Business API 不作为新系统长期依赖目标”的原则不一致,已列为 S3 P0 收敛项。当前处理方向:前端恢复调用新系统 `/api/Supply/delivery-schedule/generate`;`AutoTransferDoOrPo` 对应的 PR 合并、转 DO/PO、推送 SAP 等动作后续应拆入新系统采购/供应 Service 或任务流,不由前端直连旧 API。
+
+## 三、按新系统承载方式分类
+
+| 类别 | 旧 API 能力 | 新系统承载位置 | 迁移原则 |
+|---|---|---|---|
+| 业务动作类 | 订单评审、PR 合并/审核、PR 推 SAP、交货计划生成、外协生成、工单调整 | `.NET Service` + `dop_core` 业务表 | 不直接迁旧 API;按新表结构重写确定性服务 |
+| 算法计算类 | 生产排程、齐套缺料、工单优先级、ROP、周计划/日计划、需求分析 | `.NET Service` + Job;结果写 `mdp_std_*` / `dwd_*` / `metric_*` | 算法内核代码化,参数和规则配置化 |
+| 规则公式类 | 日期推导、状态校验、库存校验、编号规则、阈值判断 | `dop_rule`:`sys_formula` / `sys_biz_rule` / 编号服务 | 可表达的规则热配置,复杂事务仍由 Service 执行 |
+| 批处理同步类 | WMS 同步、订单/采购订单同步、发货同步、基础数据同步 MongoDB | `dop_mdp`:`mdp_source` / `mdp_entity` / `mdp_pipeline*` / Quartz Job | 数据接入、清洗、转换可重跑可观测 |
+| 分析报表类 | VSM、库存天数、PLT、预测实际、采购预测、SQE 图表 | `dop_bi`:`dwd_*` / `metric_*` / `dashboard_snapshot` / 查询服务 | 不逐句翻译旧 SQL,沉淀为宽表和指标 |
+| 外部系统接口类 | PR 推 SAP、采购预测接口、第三方数据拉取 | 动作类走 Service;拉数据类走 `mdp_api_config` | 区分“有副作用动作”和“只读数据源” |
+| 工具基础类 | Token、Snowflake ID、日志维护、系统版本、测试超时 | 新系统基础能力或废弃 | 能复用框架则复用,演示/测试接口不迁移 |
+
+## 四、S3 示例映射
+
+| 旧能力 | 旧接口示例 | 新系统承载建议 | 当前状态 |
+|---|---|---|---|
+| 交货计划生成 | `resource-examine/createdemandschedule`、`replenishment/CreateDeliverySchedule` | `DeliveryScheduleService`,写 `srm_polist_ds` 兼容表并沉淀 `mdp_std_delivery_schedule` / `dwd_supplier_delivery` | S3 已有新服务和中台沉淀路径;前端不应直连旧 API |
+| PR 合并 / 转 DO/PO / 推送 SAP | `resource-examine/AutoTransferDoOrPo` | 新系统采购/供应 Service + 任务流节点;执行结果写业务表和 DWD/指标 | S3 P0 待补后端新服务,不由前端直连旧 API |
+| 齐套/缺料检查 | `resource-examine/producedayplankittingcheck`、`WorkOrdKittingCheckByNo` | `WorkOrderMaterialReadinessService` + `S3MdpSyncTransformService.RefreshMaterialReadinessAsync`,写 `dwd_material_readiness` / `dwd_material_shortage` | S3 已优先读 DWD,保留旧表回退 |
+| 工序外协生成 | `resource-examine/AutoMergeWWPR`、`AutoCreatePWDetailBatch` | `ProcessOutsourceOrderService` + 编号服务,沉淀 `mdp_std_process_outsource_order` / `dwd_process_outsource_delivery` | S3 已有新服务,S3/S6 边界仍需后续评审 |
+| 供应风险 | 旧接口中分散在 PR、采购、交付、齐套逻辑 | DWD 风险表和指标层,如 `dwd_supplier_risk`、`ado_s9_kpi_value_*` | S3 已有首批 DWD 风险结果 |
+| 供应协同看板分析 | 旧 VSM / 报表接口 | `dwd_supplier_delivery`、`dwd_material_readiness`、`metric_*`、ChatBI/智慧诊断 | 后续继续补指标和快照层 |
+
+## 五、当前新系统残留直连旧 API
+
+| 新系统文件 | 旧接口 | 初步判断 | 建议承载位置 |
+|---|---|---|---|
+| `Web/src/views/aidop/api/workOrderScheduling.ts` | `resource-examine/productionschedule` | 生产排程动作/算法 | S2/S6 生产排程 Service + 可配置参数 |
+| `Web/src/views/aidop/api/workOrderScheduling.ts` | `resource-examine/AutomaticPrAdjustDate` | 物料需求/PR 日期调整 | 需求计划或采购申请 Service;周期性重算可进 Job |
+| `Web/src/views/aidop/api/workOrderScheduling.ts` | `api/business/replenishmentWorkOrdCheckChangePriority` | 路径疑似与旧控制器不一致,旧控制器为 `replenishment/WorkOrdCheckChangePriority` | 工单优先级调整 Service + `dop_rule` 权重配置 |
+| `Web/src/views/aidop/api/workOrderDispatch.ts` | `resource-examine/producedayplankittingcheck` | 工单下达前齐套检查 | 齐套/缺料 Service + DWD 结果 |
+| `Web/src/views/aidop/api/workOrderDispatch.ts` | `resource-examine/AutomaticPrAdjustDate` | 物料需求生成/调整 | MRP/采购需求 Service 或 Job |
+| `Web/src/views/aidop/api/seOrderReview.ts` | `resource-examine/receiveresult` | 订单资源检查/订单评审动作 | 订单评审 Service + 审批流/任务流 |
+| `Web/src/views/aidop/api/seOrderReview.ts` | `resource-examine/reviewExamineResult` | 交期确认/评审结果确认 | 订单交期确认 Service + 任务流节点 |
+| `Web/src/views/aidop/s3/api/deliverySchedule.ts` | `replenishment/CreateDeliverySchedule` | S3 交货计划生成 | 已收敛为调用新系统 `/api/Supply/delivery-schedule/generate` |
+| `Web/src/views/aidop/s3/api/deliverySchedule.ts` | `resource-examine/AutoTransferDoOrPo` | PR 合并 / 转 DO/PO / 推送 SAP | 后续迁到新系统 Service 或任务流;不保留前端直连 |
+
+> 注:S3 直连项按 P0 收敛;其它残留直连后续应按 S0/S1/S2/S4/S6 实际模块边界分批处理。
+
+## 六、任务流方向
+
+审批流后续建议抽象为任务流,但不替代业务 Service。任务流负责“谁处理、按什么顺序、何时等待、何时升级、何时验证”,业务写入仍通过白名单 Service。
+
+推荐节点类型:
+
+- 审批节点:同意、拒绝、退回、会签。
+- 人工任务节点:催交、确认原因、填写处理结果、上传证据。
+- 系统任务节点:调用白名单 Service,例如刷新齐套、生成候选交货计划、创建通知、刷新指标。
+- 条件网关:按风险等级、延期天数、金额、缺料数量分支。
+- 等待节点:等待供应商回复、等待到货、N 天后自动验证。
+- 验证节点:读取 `dwd_*` / `metric_*` 判断改善是否有效。
+- 回调节点:更新业务状态、写时间线、记录执行结果。
+
+任务流闭环:
+
+```mermaid
+flowchart LR
+  Diagnosis["ChatBI / 智慧诊断"] --> Suggest["建议"]
+  Suggest --> Plan["执行计划草案"]
+  Plan --> Flow["任务流"]
+  Flow --> Approve["审批/确认"]
+  Approve --> Manual["人工任务"]
+  Manual --> Service["系统任务调用 Service"]
+  Service --> Verify["效果验证"]
+  Verify --> Close["关闭或重新改善"]
+```
+
+## 七、后续工作建议
+
+1. 以 S3 为样板,确认“已替代旧 API”的验收口径和回退口径。
+2. 对当前新系统残留直连旧 API 建任务包,按模块分批迁移。
+3. 先补任务流设计方案,不急于改造审批流代码。
+4. 对每个旧 API 输出最终处理结论:`重写 Service`、`进入 Job/MDP`、`进入 DWD/指标`、`保留外部 API 数据源`、`废弃`。
+5. 最终方案确认后,再进入程序和数据库改造。

+ 137 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Rule/RuleConfigResolverTests.cs

@@ -0,0 +1,137 @@
+using Admin.NET.Plugin.AiDOP.Rule;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.Rule;
+
+public class RuleConfigResolverTests
+{
+    [Fact]
+    public void Resolve_WhenFactoryTenantAndGlobalProvided_AppliesHigherPriorityValues()
+    {
+        var defaults = new TestOptions { QuotaRequiredTotal = 100m, DefaultLeadDays = 1 };
+        var layers = new[]
+        {
+            Layer("global", 10, Item("defaultLeadDays", "number", "2")),
+            Layer("tenant", 20, Item("defaultLeadDays", "number", "3")),
+            Layer("factory", 30, Item("defaultLeadDays", "number", "4"))
+        };
+
+        var result = RuleConfigResolver.Resolve(defaults, layers);
+
+        Assert.True(result.Success);
+        Assert.Equal(100m, result.Options.QuotaRequiredTotal);
+        Assert.Equal(4, result.Options.DefaultLeadDays);
+    }
+
+    [Fact]
+    public void Resolve_WhenStringArrayProvided_ConvertsToStringList()
+    {
+        var defaults = new TestOptions();
+        var layers = new[]
+        {
+            Layer("global", 10, Item("poBuyerCodes", "string_array", """["110","160","170"]"""))
+        };
+
+        var result = RuleConfigResolver.Resolve(defaults, layers);
+
+        Assert.True(result.Success);
+        Assert.Equal(new[] { "110", "160", "170" }, result.Options.PoBuyerCodes);
+    }
+
+    [Fact]
+    public void Resolve_WhenBooleanProvided_ConvertsToBool()
+    {
+        var defaults = new TestOptions { EnablePrMerge = false };
+        var layers = new[]
+        {
+            Layer("global", 10, Item("enablePrMerge", "boolean", "true"))
+        };
+
+        var result = RuleConfigResolver.Resolve(defaults, layers);
+
+        Assert.True(result.Success);
+        Assert.True(result.Options.EnablePrMerge);
+    }
+
+    [Fact]
+    public void Resolve_WhenNumberProvided_ConvertsToDecimalAndInt()
+    {
+        var defaults = new TestOptions();
+        var layers = new[]
+        {
+            Layer("global", 10,
+                Item("quotaTolerance", "number", "0.01"),
+                Item("defaultLeadDays", "number", "5"))
+        };
+
+        var result = RuleConfigResolver.Resolve(defaults, layers);
+
+        Assert.True(result.Success);
+        Assert.Equal(0.01m, result.Options.QuotaTolerance);
+        Assert.Equal(5, result.Options.DefaultLeadDays);
+    }
+
+    [Fact]
+    public void Resolve_WhenHighRiskSwitchInvalid_FallsBackToConservativeFalse()
+    {
+        var defaults = new TestOptions { EnableExternalPushTracking = true };
+        var layers = new[]
+        {
+            Layer("global", 10, Item("enableExternalPushTracking", "boolean", "bad", highRisk: true))
+        };
+
+        var result = RuleConfigResolver.Resolve(defaults, layers);
+
+        Assert.True(result.Success);
+        Assert.False(result.Options.EnableExternalPushTracking);
+        Assert.Contains(result.Warnings, x => x.Contains("保守值 false"));
+    }
+
+    [Fact]
+    public void Resolve_WhenSamePriorityHasDuplicateRule_ReturnsError()
+    {
+        var defaults = new TestOptions();
+        var layers = new[]
+        {
+            Layer("tenant-a", 20, Item("defaultLeadDays", "number", "2")),
+            Layer("tenant-b", 20, Item("defaultLeadDays", "number", "3"))
+        };
+
+        var result = RuleConfigResolver.Resolve(defaults, layers);
+
+        Assert.False(result.Success);
+        Assert.Contains(result.Errors, x => x.Contains("重复规则"));
+        Assert.Equal(defaults.DefaultLeadDays, result.Options.DefaultLeadDays);
+    }
+
+    private static RuleConfigLayer Layer(string scope, int priority, params RuleConfigItem[] items)
+    {
+        return new RuleConfigLayer
+        {
+            Scope = scope,
+            Priority = priority,
+            Items = items.ToList()
+        };
+    }
+
+    private static RuleConfigItem Item(string ruleCode, string valueType, string value, bool highRisk = false)
+    {
+        return new RuleConfigItem
+        {
+            RuleCode = ruleCode,
+            ValueType = valueType,
+            RuleValue = value,
+            IsHighRiskSwitch = highRisk
+        };
+    }
+
+    private sealed class TestOptions
+    {
+        public decimal QuotaRequiredTotal { get; set; } = 100m;
+        public decimal QuotaTolerance { get; set; } = 0.0001m;
+        public List<string> PoBuyerCodes { get; set; } = new() { "110" };
+        public int DefaultLeadDays { get; set; } = 1;
+        public bool EnablePrMerge { get; set; } = true;
+        public bool EnableExternalPushTracking { get; set; }
+    }
+}

+ 182 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Rule/RuleConfigServiceTests.cs

@@ -0,0 +1,182 @@
+using Admin.NET.Plugin.AiDOP.Rule;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.Rule;
+
+public class RuleConfigServiceTests
+{
+    [Fact]
+    public async Task ResolveOptionsAsync_WhenNoConfiguredLayers_ReturnsDefaults()
+    {
+        var service = new RuleConfigService();
+        var defaults = new S3DeliveryGenerateRuleOptions
+        {
+            QuotaRequiredTotal = 99m,
+            DefaultLeadDays = 2,
+            EnableExternalPushTracking = true
+        };
+
+        var result = await service.ResolveOptionsAsync("S3", "DELIVERY_GENERATE", 1000, 2000, defaults);
+
+        Assert.True(result.Success);
+        Assert.Equal(99m, result.Options.QuotaRequiredTotal);
+        Assert.Equal(2, result.Options.DefaultLeadDays);
+        Assert.True(result.Options.EnableExternalPushTracking);
+        Assert.Empty(result.AppliedItems);
+    }
+
+    [Fact]
+    public async Task ResolveS3DeliveryGenerateOptionsAsync_WhenNoConfiguredLayers_ReturnsS3Defaults()
+    {
+        var service = new RuleConfigService();
+
+        var result = await service.ResolveS3DeliveryGenerateOptionsAsync(1000, 2000);
+
+        Assert.True(result.Success);
+        Assert.Equal(100m, result.Options.QuotaRequiredTotal);
+        Assert.Equal(0.0001m, result.Options.QuotaTolerance);
+        Assert.Equal(new[] { "110", "160", "170" }, result.Options.PoBuyerCodes);
+        Assert.Equal(new[] { "标准", "VMI", "委外加工" }, result.Options.DoUsages);
+        Assert.Equal(1, result.Options.DefaultLeadDays);
+        Assert.True(result.Options.EnablePackagingRoundUp);
+        Assert.True(result.Options.ShortageCreatePr);
+        Assert.True(result.Options.EnablePrMerge);
+        Assert.True(result.Options.EnableRequireGoodsToPo);
+        Assert.True(result.Options.EnableExternalPushTracking);
+    }
+
+    [Fact]
+    public async Task ResolveOptionsAsync_WhenRepositoryReturnsLayers_AppliesConfiguredRules()
+    {
+        var service = new RuleConfigService(new InMemoryRuleConfigRepository(new[]
+        {
+            new RuleConfigLayer
+            {
+                Scope = "global",
+                Priority = 100,
+                Items = new List<RuleConfigItem>
+                {
+                    new() { RuleCode = "defaultLeadDays", ValueType = "number", RuleValue = "3" },
+                    new() { RuleCode = "enableExternalPushTracking", ValueType = "boolean", RuleValue = "false" }
+                }
+            }
+        }));
+
+        var result = await service.ResolveS3DeliveryGenerateOptionsAsync(1000, 2000);
+
+        Assert.True(result.Success);
+        Assert.Equal(3, result.Options.DefaultLeadDays);
+        Assert.False(result.Options.EnableExternalPushTracking);
+        Assert.Equal(2, result.AppliedItems.Count);
+    }
+
+    [Fact]
+    public async Task ResolveOptionsAsync_WhenCancellationRequested_Throws()
+    {
+        var service = new RuleConfigService();
+        using var cts = new CancellationTokenSource();
+        await cts.CancelAsync();
+
+        await Assert.ThrowsAsync<OperationCanceledException>(() =>
+            service.ResolveOptionsAsync("S3", "DELIVERY_GENERATE", 1000, null, new S3DeliveryGenerateRuleOptions(), cts.Token));
+    }
+
+    [Fact]
+    public async Task CreateSnapshot_WhenNoAppliedItems_UsesDefaultSource()
+    {
+        var service = new RuleConfigService();
+        var resolvedAt = new DateTime(2026, 5, 16, 12, 0, 0);
+        var result = await service.ResolveS3DeliveryGenerateOptionsAsync(1000, 2000);
+
+        var snapshot = service.CreateSnapshot("S3", "DELIVERY_GENERATE", 1000, 2000, result, resolvedAt);
+
+        Assert.Equal("S3", snapshot.ModuleCode);
+        Assert.Equal("DELIVERY_GENERATE", snapshot.ScenarioCode);
+        Assert.Equal("default", snapshot.Source);
+        Assert.Equal(resolvedAt, snapshot.ResolvedAt);
+        Assert.Equal(1000, snapshot.TenantId);
+        Assert.Equal(2000, snapshot.FactoryId);
+        Assert.Same(result.Options, snapshot.Options);
+        Assert.Empty(snapshot.AppliedItems);
+        Assert.Empty(snapshot.Warnings);
+    }
+
+    [Fact]
+    public async Task CreateSnapshot_WhenAppliedItemsExist_UsesConfiguredSource()
+    {
+        var service = new RuleConfigService(new InMemoryRuleConfigRepository(new[]
+        {
+            new RuleConfigLayer
+            {
+                Scope = "factory",
+                Priority = 300,
+                Items = new List<RuleConfigItem>
+                {
+                    new() { RuleCode = "defaultLeadDays", ValueType = "number", RuleValue = "5" }
+                }
+            }
+        }));
+        var result = await service.ResolveS3DeliveryGenerateOptionsAsync(1000, 2000);
+
+        var snapshot = service.CreateSnapshot("S3", "DELIVERY_GENERATE", 1000, 2000, result, DateTime.Now);
+
+        Assert.Equal("configured", snapshot.Source);
+        Assert.Single(snapshot.AppliedItems);
+        Assert.Equal(5, snapshot.Options.DefaultLeadDays);
+    }
+
+    [Fact]
+    public async Task ResolveS3DeliveryGenerateSnapshotAsync_ReturnsSnapshot()
+    {
+        var service = new RuleConfigService(new InMemoryRuleConfigRepository(new[]
+        {
+            new RuleConfigLayer
+            {
+                Scope = "global",
+                Priority = 100,
+                Items = new List<RuleConfigItem>
+                {
+                    new() { RuleCode = "defaultLeadDays", ValueType = "number", RuleValue = "4" }
+                }
+            }
+        }));
+        var resolvedAt = new DateTime(2026, 5, 16, 13, 30, 0);
+
+        var snapshot = await service.ResolveS3DeliveryGenerateSnapshotAsync(1000, null, resolvedAt);
+
+        Assert.Equal("S3", snapshot.ModuleCode);
+        Assert.Equal("DELIVERY_GENERATE", snapshot.ScenarioCode);
+        Assert.Equal("configured", snapshot.Source);
+        Assert.Equal(resolvedAt, snapshot.ResolvedAt);
+        Assert.Equal(1000, snapshot.TenantId);
+        Assert.Null(snapshot.FactoryId);
+        Assert.Equal(4, snapshot.Options.DefaultLeadDays);
+        Assert.Single(snapshot.AppliedItems);
+    }
+
+    private sealed class InMemoryRuleConfigRepository : IRuleConfigRepository
+    {
+        private readonly IReadOnlyList<RuleConfigLayer> _layers;
+
+        public InMemoryRuleConfigRepository(IReadOnlyList<RuleConfigLayer> layers)
+        {
+            _layers = layers;
+        }
+
+        public Task<IReadOnlyList<RuleConfigLayer>> GetEffectiveLayersAsync(
+            string moduleCode,
+            string scenarioCode,
+            long tenantId,
+            long? factoryId,
+            CancellationToken cancellationToken = default)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            _ = moduleCode;
+            _ = scenarioCode;
+            _ = tenantId;
+            _ = factoryId;
+
+            return Task.FromResult(_layers);
+        }
+    }
+}

+ 99 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S3/Supply/PurchaseRequestMigrationFlowTests.cs

@@ -0,0 +1,99 @@
+using Admin.NET.Plugin.AiDOP.Supply;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S3.Supply;
+
+public class PurchaseRequestMigrationFlowTests
+{
+    [Fact]
+    public void MergeGeneratedRequests_WhenSameSupplierItemAndWeek_MergesQuantities()
+    {
+        var service = new PurchaseRequestMergeService();
+        var requests = new List<PurchaseRequestMain>
+        {
+            CreateRequest(1, new DateTime(2026, 5, 11), requireGoods: 0, qty: 10),
+            CreateRequest(2, new DateTime(2026, 5, 13), requireGoods: 0, qty: 15)
+        };
+
+        var result = service.MergeGeneratedRequests(requests);
+
+        var merged = Assert.Single(result.Requests);
+        Assert.Equal(2, result.OriginalCount);
+        Assert.Equal(1, result.MergedCount);
+        Assert.Equal(1, result.ReducedCount);
+        Assert.Equal(25, merged.PrRqty);
+        Assert.Equal(new DateTime(2026, 5, 11), merged.PrSsendDate);
+    }
+
+    [Fact]
+    public void MergeGeneratedRequests_WhenRequireGoodsDiffers_DoesNotMerge()
+    {
+        var service = new PurchaseRequestMergeService();
+        var requests = new List<PurchaseRequestMain>
+        {
+            CreateRequest(1, new DateTime(2026, 5, 11), requireGoods: 0, qty: 10),
+            CreateRequest(2, new DateTime(2026, 5, 13), requireGoods: 1, qty: 15)
+        };
+
+        var result = service.MergeGeneratedRequests(requests);
+
+        Assert.Equal(2, result.Requests.Count);
+        Assert.Equal(0, result.ReducedCount);
+    }
+
+    [Fact]
+    public void MergeGeneratedRequests_WhenDifferentWeek_DoesNotMerge()
+    {
+        var service = new PurchaseRequestMergeService();
+        var requests = new List<PurchaseRequestMain>
+        {
+            CreateRequest(1, new DateTime(2026, 5, 17), requireGoods: 0, qty: 10),
+            CreateRequest(2, new DateTime(2026, 5, 18), requireGoods: 0, qty: 15)
+        };
+
+        var result = service.MergeGeneratedRequests(requests);
+
+        Assert.Equal(2, result.Requests.Count);
+        Assert.Equal(0, result.ReducedCount);
+    }
+
+    [Fact]
+    public void SelectPushCandidates_WhenNormalPrHasBillNo_ReturnsOnlySapQadCandidates()
+    {
+        var requests = new List<PurchaseRequestMain>
+        {
+            CreateRequest(1, new DateTime(2026, 5, 11), requireGoods: 0, qty: 10, billNo: "PR001"),
+            CreateRequest(2, new DateTime(2026, 5, 11), requireGoods: 1, qty: 10, billNo: "PR002"),
+            CreateRequest(3, new DateTime(2026, 5, 11), requireGoods: 0, qty: 10, billNo: null),
+            CreateRequest(4, new DateTime(2026, 5, 11), requireGoods: 0, qty: 10, billNo: " ")
+        };
+
+        var candidates = PurchaseRequestExternalPushService.SelectPushCandidates(requests);
+
+        var candidate = Assert.Single(candidates);
+        Assert.Equal(1, candidate.Id);
+        Assert.Equal("PR001", candidate.PrBillNo);
+    }
+
+    private static PurchaseRequestMain CreateRequest(long id, DateTime sendDate, int requireGoods, decimal qty, string? billNo = "PR")
+    {
+        return new PurchaseRequestMain
+        {
+            Id = id,
+            TenantId = 1000,
+            CompanyId = 10,
+            FactoryId = 20,
+            IcitemId = 30,
+            PrPurchaseId = 40,
+            SupplierType = "标准",
+            IsRequireGoods = requireGoods,
+            PrBillNo = billNo,
+            PrRqty = qty,
+            PrAqty = qty,
+            PrSqty = qty,
+            PrSsendDate = sendDate,
+            PrSarriveDate = sendDate.AddDays(3),
+            CreateTime = sendDate.AddHours(8)
+        };
+    }
+}

+ 47 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleItem.cs

@@ -0,0 +1,47 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 全模块规则配置项。
+/// </summary>
+[SugarTable("ado_rule_item", "全模块规则配置项")]
+public sealed class AdoRuleItem
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "profile_id")]
+    public long ProfileId { get; set; }
+
+    [SugarColumn(ColumnName = "rule_code", Length = 64)]
+    public string RuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "rule_name", Length = 128)]
+    public string RuleName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "value_type", Length = 32)]
+    public string ValueType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "rule_value", IsNullable = true)]
+    public string? RuleValue { get; set; }
+
+    [SugarColumn(ColumnName = "is_enabled")]
+    public int IsEnabled { get; set; }
+
+    [SugarColumn(ColumnName = "sort_no")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnName = "remark", Length = 500, IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; }
+
+    [SugarColumn(ColumnName = "created_by", Length = 64, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+
+    [SugarColumn(ColumnName = "updated_by", Length = 64, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+}

+ 56 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleProfile.cs

@@ -0,0 +1,56 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 全模块规则配置方案。
+/// </summary>
+[SugarTable("ado_rule_profile", "全模块规则配置方案")]
+public sealed class AdoRuleProfile
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "module_code", Length = 32)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "scenario_code", Length = 64)]
+    public string ScenarioCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "profile_code", Length = 64)]
+    public string ProfileCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "profile_name", Length = 128)]
+    public string ProfileName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id")]
+    public long FactoryId { get; set; }
+
+    [SugarColumn(ColumnName = "version")]
+    public int Version { get; set; }
+
+    [SugarColumn(ColumnName = "is_enabled")]
+    public int IsEnabled { get; set; }
+
+    [SugarColumn(ColumnName = "effective_from", IsNullable = true)]
+    public DateTime? EffectiveFrom { get; set; }
+
+    [SugarColumn(ColumnName = "effective_to", IsNullable = true)]
+    public DateTime? EffectiveTo { get; set; }
+
+    [SugarColumn(ColumnName = "remark", Length = 500, IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; }
+
+    [SugarColumn(ColumnName = "created_by", Length = 64, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+
+    [SugarColumn(ColumnName = "updated_by", Length = 64, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+}

+ 62 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/Entity/AdoRuleSchema.cs

@@ -0,0 +1,62 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 全模块规则定义。
+/// </summary>
+[SugarTable("ado_rule_schema", "全模块规则定义")]
+public sealed class AdoRuleSchema
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "module_code", Length = 32)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "scenario_code", Length = 64)]
+    public string ScenarioCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "rule_code", Length = 64)]
+    public string RuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "rule_name", Length = 128)]
+    public string RuleName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "value_type", Length = 32)]
+    public string ValueType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "default_value", IsNullable = true)]
+    public string? DefaultValue { get; set; }
+
+    [SugarColumn(ColumnName = "required")]
+    public int Required { get; set; }
+
+    [SugarColumn(ColumnName = "validation_rule", IsNullable = true)]
+    public string? ValidationRule { get; set; }
+
+    [SugarColumn(ColumnName = "ui_control", Length = 32, IsNullable = true)]
+    public string? UiControl { get; set; }
+
+    [SugarColumn(ColumnName = "option_source", IsNullable = true)]
+    public string? OptionSource { get; set; }
+
+    [SugarColumn(ColumnName = "is_enabled")]
+    public int IsEnabled { get; set; }
+
+    [SugarColumn(ColumnName = "sort_no")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnName = "remark", Length = 500, IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; }
+
+    [SugarColumn(ColumnName = "created_by", Length = 64, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+
+    [SugarColumn(ColumnName = "updated_by", Length = 64, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+}

+ 184 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigQueryService.cs

@@ -0,0 +1,184 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 规则配置只读查询接口。研发阶段首版只开放 S3 / DELIVERY_GENERATE。
+/// </summary>
+[ApiDescriptionSettings(Order = 330, Description = "规则配置查询")]
+[Route("api/RuleConfig")]
+[AllowAnonymous]
+[NonUnify]
+public class RuleConfigQueryService : IDynamicApiController, ITransient
+{
+    private readonly RuleConfigService _ruleConfigService;
+    private readonly UserManager _userManager;
+    private readonly ISqlSugarClient _db;
+
+    public RuleConfigQueryService(RuleConfigService ruleConfigService, UserManager userManager, ISqlSugarClient db)
+    {
+        _ruleConfigService = ruleConfigService;
+        _userManager = userManager;
+        _db = db;
+    }
+
+    [DisplayName("查询当前生效规则")]
+    [HttpGet("effective-options")]
+    public async Task<object> GetEffectiveOptions([FromQuery] RuleConfigQueryInput input)
+    {
+        var moduleCode = string.IsNullOrWhiteSpace(input.ModuleCode) ? "S3" : input.ModuleCode.Trim();
+        var scenarioCode = string.IsNullOrWhiteSpace(input.ScenarioCode) ? "DELIVERY_GENERATE" : input.ScenarioCode.Trim();
+        if (!string.Equals(moduleCode, "S3", StringComparison.OrdinalIgnoreCase)
+            || !string.Equals(scenarioCode, "DELIVERY_GENERATE", StringComparison.OrdinalIgnoreCase))
+        {
+            throw Oops.Oh("当前只支持查询 S3 / DELIVERY_GENERATE 规则配置");
+        }
+
+        var tenantId = input.TenantId > 0 ? input.TenantId : _userManager.TenantId;
+        var factoryId = input.FactoryId > 0 ? input.FactoryId : null;
+        return await _ruleConfigService.ResolveS3DeliveryGenerateSnapshotAsync(tenantId, factoryId);
+    }
+
+    [DisplayName("保存S3交货单生成规则")]
+    [HttpPost("s3-delivery-generate/save")]
+    public async Task<object> SaveS3DeliveryGenerate([FromBody] S3DeliveryGenerateRuleSaveInput input)
+    {
+        var tenantId = input.TenantId > 0 ? input.TenantId : 0;
+        var factoryId = input.FactoryId > 0 ? input.FactoryId.Value : 0;
+        var profile = (await _db.Ado.SqlQueryAsync<RuleProfileIdRow>(
+            """
+            SELECT id AS Id
+            FROM ado_rule_profile
+            WHERE module_code='S3'
+              AND scenario_code='DELIVERY_GENERATE'
+              AND tenant_id=@TenantId
+              AND factory_id=@FactoryId
+              AND is_enabled=1
+            ORDER BY version DESC, id DESC
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@FactoryId", factoryId))).FirstOrDefault();
+        if (profile == null || profile.Id <= 0)
+        {
+            throw Oops.Oh($"未找到 S3 / DELIVERY_GENERATE 规则方案:tenantId={tenantId},factoryId={factoryId}");
+        }
+
+        var values = BuildS3RuleValues(input.Options);
+        if (values.Count == 0) throw Oops.Oh("没有可保存的规则项");
+
+        var now = DateTime.Now;
+        var account = _userManager.Account ?? "system";
+        try
+        {
+            _db.Ado.BeginTran();
+            foreach (var item in values)
+            {
+                var affected = await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE ado_rule_item
+                    SET rule_value=@RuleValue,
+                        updated_at=@UpdatedAt,
+                        updated_by=@UpdatedBy
+                    WHERE profile_id=@ProfileId
+                      AND rule_code=@RuleCode
+                      AND is_enabled=1
+                    """,
+                    new SugarParameter("@RuleValue", item.Value),
+                    new SugarParameter("@UpdatedAt", now),
+                    new SugarParameter("@UpdatedBy", account),
+                    new SugarParameter("@ProfileId", profile.Id),
+                    new SugarParameter("@RuleCode", item.Key));
+                if (affected <= 0) throw Oops.Oh($"规则项不存在或未启用:{item.Key}");
+            }
+
+            _db.Ado.CommitTran();
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+
+        return await _ruleConfigService.ResolveS3DeliveryGenerateSnapshotAsync(tenantId, factoryId == 0 ? null : factoryId);
+    }
+
+    private static Dictionary<string, string> BuildS3RuleValues(S3DeliveryGenerateRuleOptionsInput options)
+    {
+        if (options == null) throw Oops.Oh("规则参数不能为空");
+
+        var values = new Dictionary<string, string>();
+        if (options.QuotaRequiredTotal.HasValue)
+        {
+            if (options.QuotaRequiredTotal.Value < 0 || options.QuotaRequiredTotal.Value > 100) throw Oops.Oh("配额合计要求必须在 0-100 之间");
+            values["quotaRequiredTotal"] = options.QuotaRequiredTotal.Value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
+        }
+        if (options.QuotaTolerance.HasValue)
+        {
+            if (options.QuotaTolerance.Value < 0) throw Oops.Oh("配额误差容忍不能小于 0");
+            values["quotaTolerance"] = options.QuotaTolerance.Value.ToString("0.########", System.Globalization.CultureInfo.InvariantCulture);
+        }
+        if (options.PoBuyerCodes != null) values["poBuyerCodes"] = SerializeStringArray(NormalizeStringArray(options.PoBuyerCodes, "普通 PO 采购组"));
+        if (options.DoUsages != null) values["doUsages"] = SerializeStringArray(NormalizeStringArray(options.DoUsages, "DO 可用供应类别"));
+        if (options.DefaultLeadDays.HasValue)
+        {
+            if (options.DefaultLeadDays.Value < 0 || options.DefaultLeadDays.Value > 365) throw Oops.Oh("默认提前期必须在 0-365 天之间");
+            values["defaultLeadDays"] = options.DefaultLeadDays.Value.ToString();
+        }
+        if (options.EnablePackagingRoundUp.HasValue) values["enablePackagingRoundUp"] = ToRuleBool(options.EnablePackagingRoundUp.Value);
+        if (options.ShortageCreatePr.HasValue) values["shortageCreatePr"] = ToRuleBool(options.ShortageCreatePr.Value);
+        if (options.EnablePrMerge.HasValue) values["enablePrMerge"] = ToRuleBool(options.EnablePrMerge.Value);
+        if (options.EnableRequireGoodsToPo.HasValue) values["enableRequireGoodsToPo"] = ToRuleBool(options.EnableRequireGoodsToPo.Value);
+        if (options.EnableExternalPushTracking.HasValue) values["enableExternalPushTracking"] = ToRuleBool(options.EnableExternalPushTracking.Value);
+        return values;
+    }
+
+    private static List<string> NormalizeStringArray(IEnumerable<string> value, string label)
+    {
+        var list = value.Select(x => (x ?? string.Empty).Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
+        if (list.Count == 0) throw Oops.Oh($"{label}不能为空");
+        return list;
+    }
+
+    private static string SerializeStringArray(List<string> value)
+    {
+        return System.Text.Json.JsonSerializer.Serialize(value);
+    }
+
+    private static string ToRuleBool(bool value)
+    {
+        return value ? "true" : "false";
+    }
+
+    private sealed class RuleProfileIdRow
+    {
+        public long Id { get; set; }
+    }
+}
+
+public sealed class RuleConfigQueryInput
+{
+    public string? ModuleCode { get; set; }
+    public string? ScenarioCode { get; set; }
+    public long TenantId { get; set; }
+    public long? FactoryId { get; set; }
+}
+
+public sealed class S3DeliveryGenerateRuleSaveInput
+{
+    public long TenantId { get; set; }
+    public long? FactoryId { get; set; }
+    public S3DeliveryGenerateRuleOptionsInput Options { get; set; } = new();
+}
+
+public sealed class S3DeliveryGenerateRuleOptionsInput
+{
+    public decimal? QuotaRequiredTotal { get; set; }
+    public decimal? QuotaTolerance { get; set; }
+    public List<string>? PoBuyerCodes { get; set; }
+    public List<string>? DoUsages { get; set; }
+    public int? DefaultLeadDays { get; set; }
+    public bool? EnablePackagingRoundUp { get; set; }
+    public bool? ShortageCreatePr { get; set; }
+    public bool? EnablePrMerge { get; set; }
+    public bool? EnableRequireGoodsToPo { get; set; }
+    public bool? EnableExternalPushTracking { get; set; }
+}

+ 152 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigRepository.cs

@@ -0,0 +1,152 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 规则配置只读数据源边界。后续数据库实现只需替换该接口实现。
+/// </summary>
+public interface IRuleConfigRepository
+{
+    Task<IReadOnlyList<RuleConfigLayer>> GetEffectiveLayersAsync(
+        string moduleCode,
+        string scenarioCode,
+        long tenantId,
+        long? factoryId,
+        CancellationToken cancellationToken = default);
+}
+
+/// <summary>
+/// 默认空数据源,主要用于单元测试或手动回退。
+/// </summary>
+public sealed class EmptyRuleConfigRepository : IRuleConfigRepository
+{
+    public Task<IReadOnlyList<RuleConfigLayer>> GetEffectiveLayersAsync(
+        string moduleCode,
+        string scenarioCode,
+        long tenantId,
+        long? factoryId,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        _ = moduleCode;
+        _ = scenarioCode;
+        _ = tenantId;
+        _ = factoryId;
+
+        return Task.FromResult<IReadOnlyList<RuleConfigLayer>>(Array.Empty<RuleConfigLayer>());
+    }
+}
+
+/// <summary>
+/// `ado_rule_*` 数据库只读仓储。研发阶段默认启用,用于验证规则配置链路。
+/// </summary>
+public sealed class DbRuleConfigRepository : IRuleConfigRepository, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public DbRuleConfigRepository(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<IReadOnlyList<RuleConfigLayer>> GetEffectiveLayersAsync(
+        string moduleCode,
+        string scenarioCode,
+        long tenantId,
+        long? factoryId,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        var now = DateTime.Now;
+        var normalizedFactoryId = factoryId.GetValueOrDefault();
+
+        var rows = await _db.Ado.SqlQueryAsync<RuleConfigDbRow>(
+            """
+            SELECT
+                p.id AS ProfileId,
+                p.tenant_id AS TenantId,
+                p.factory_id AS FactoryId,
+                p.profile_code AS ProfileCode,
+                i.rule_code AS RuleCode,
+                i.value_type AS ValueType,
+                i.rule_value AS RuleValue,
+                i.is_enabled AS ItemEnabled,
+                i.sort_no AS SortNo,
+                s.required AS Required,
+                s.ui_control AS UiControl
+            FROM ado_rule_profile p
+            INNER JOIN ado_rule_item i ON i.profile_id = p.id
+            LEFT JOIN ado_rule_schema s
+                ON s.module_code = p.module_code
+                AND s.scenario_code = p.scenario_code
+                AND s.rule_code = i.rule_code
+                AND s.is_enabled = 1
+            WHERE p.module_code = @ModuleCode
+                AND p.scenario_code = @ScenarioCode
+                AND p.is_enabled = 1
+                AND i.is_enabled = 1
+                AND (p.effective_from IS NULL OR p.effective_from <= @Now)
+                AND (p.effective_to IS NULL OR p.effective_to >= @Now)
+                AND (
+                    (p.tenant_id = 0 AND p.factory_id = 0)
+                    OR (p.tenant_id = @TenantId AND p.factory_id = 0)
+                    OR (p.tenant_id = @TenantId AND p.factory_id = @FactoryId)
+                )
+            ORDER BY p.tenant_id, p.factory_id, p.version, i.sort_no, i.id
+            """,
+            new SugarParameter("@ModuleCode", moduleCode),
+            new SugarParameter("@ScenarioCode", scenarioCode),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@FactoryId", normalizedFactoryId),
+            new SugarParameter("@Now", now));
+
+        return rows
+            .GroupBy(x => new { x.ProfileId, x.TenantId, x.FactoryId, x.ProfileCode })
+            .Select(x => new RuleConfigLayer
+            {
+                Scope = BuildScope(x.Key.ProfileCode, x.Key.TenantId, x.Key.FactoryId),
+                Priority = BuildPriority(x.Key.TenantId, x.Key.FactoryId),
+                Items = x
+                    .OrderBy(item => item.SortNo)
+                    .Select(item => new RuleConfigItem
+                    {
+                        RuleCode = item.RuleCode,
+                        ValueType = item.ValueType,
+                        RuleValue = item.RuleValue,
+                        Required = item.Required == 1,
+                        IsEnabled = item.ItemEnabled == 1,
+                        IsHighRiskSwitch = false
+                    })
+                    .ToList()
+            })
+            .OrderBy(x => x.Priority)
+            .ToList();
+    }
+
+    private static int BuildPriority(long tenantId, long factoryId)
+    {
+        if (tenantId > 0 && factoryId > 0) return 300;
+        if (tenantId > 0) return 200;
+        return 100;
+    }
+
+    private static string BuildScope(string profileCode, long tenantId, long factoryId)
+    {
+        if (tenantId > 0 && factoryId > 0) return $"factory:{tenantId}:{factoryId}:{profileCode}";
+        if (tenantId > 0) return $"tenant:{tenantId}:{profileCode}";
+        return $"global:{profileCode}";
+    }
+
+    private sealed class RuleConfigDbRow
+    {
+        public long ProfileId { get; set; }
+        public long TenantId { get; set; }
+        public long FactoryId { get; set; }
+        public string ProfileCode { get; set; } = string.Empty;
+        public string RuleCode { get; set; } = string.Empty;
+        public string ValueType { get; set; } = string.Empty;
+        public string? RuleValue { get; set; }
+        public int ItemEnabled { get; set; }
+        public int SortNo { get; set; }
+        public int? Required { get; set; }
+        public string? UiControl { get; set; }
+    }
+}

+ 205 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigResolver.cs

@@ -0,0 +1,205 @@
+using System.Reflection;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 规则配置纯逻辑解析器。只负责层级合并和类型转换,不访问数据库、不执行业务动作。
+/// </summary>
+public static class RuleConfigResolver
+{
+    public static RuleConfigResolveResult<TOptions> Resolve<TOptions>(TOptions defaults, IEnumerable<RuleConfigLayer> layers)
+        where TOptions : class, new()
+    {
+        var result = new RuleConfigResolveResult<TOptions> { Options = CopyOptions(defaults) };
+        var properties = typeof(TOptions)
+            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+            .Where(x => x.CanRead && x.CanWrite)
+            .ToDictionary(x => Normalize(x.Name), x => x);
+
+        var orderedLayers = layers.OrderBy(x => x.Priority).ToList();
+        foreach (var priorityGroup in orderedLayers.GroupBy(x => x.Priority))
+        {
+            var duplicateRules = priorityGroup
+                .SelectMany(x => x.Items)
+                .GroupBy(x => Normalize(x.RuleCode))
+                .Where(x => x.Count() > 1)
+                .Select(x => x.Key)
+                .ToList();
+
+            foreach (var duplicate in duplicateRules)
+            {
+                result.Errors.Add($"同一优先级存在重复规则:{duplicate}");
+            }
+
+            if (duplicateRules.Count > 0) continue;
+
+            foreach (var layer in priorityGroup)
+            {
+                foreach (var item in layer.Items.Where(x => x.IsEnabled))
+                {
+                    if (!properties.TryGetValue(Normalize(item.RuleCode), out var property))
+                    {
+                        result.Warnings.Add($"未找到规则对应属性:{item.RuleCode}");
+                        continue;
+                    }
+
+                    if (TryConvert(item, property.PropertyType, out var value, out var error))
+                    {
+                        property.SetValue(result.Options, value);
+                        result.AppliedItems.Add(new RuleConfigAppliedItem(layer.Scope, item.RuleCode, item.RuleValue));
+                        continue;
+                    }
+
+                    if (item.Required)
+                    {
+                        result.Errors.Add(error);
+                        continue;
+                    }
+
+                    result.Warnings.Add(error);
+                    if (item.IsHighRiskSwitch && property.PropertyType == typeof(bool))
+                    {
+                        property.SetValue(result.Options, false);
+                        result.Warnings.Add($"高风险开关配置非法,已按保守值 false 处理:{item.RuleCode}");
+                    }
+                }
+            }
+        }
+
+        return result;
+    }
+
+    private static TOptions CopyOptions<TOptions>(TOptions source)
+        where TOptions : class, new()
+    {
+        var target = new TOptions();
+        foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(x => x.CanRead && x.CanWrite))
+        {
+            property.SetValue(target, property.GetValue(source));
+        }
+
+        return target;
+    }
+
+    private static bool TryConvert(RuleConfigItem item, Type targetType, out object? value, out string error)
+    {
+        var raw = item.RuleValue ?? string.Empty;
+        var nullableType = Nullable.GetUnderlyingType(targetType);
+        targetType = nullableType ?? targetType;
+
+        try
+        {
+            if (targetType == typeof(string))
+            {
+                value = raw;
+                error = string.Empty;
+                return true;
+            }
+
+            if (targetType == typeof(bool))
+            {
+                if (bool.TryParse(raw, out var boolValue))
+                {
+                    value = boolValue;
+                    error = string.Empty;
+                    return true;
+                }
+            }
+            else if (targetType == typeof(int))
+            {
+                if (int.TryParse(raw, out var intValue))
+                {
+                    value = intValue;
+                    error = string.Empty;
+                    return true;
+                }
+            }
+            else if (targetType == typeof(long))
+            {
+                if (long.TryParse(raw, out var longValue))
+                {
+                    value = longValue;
+                    error = string.Empty;
+                    return true;
+                }
+            }
+            else if (targetType == typeof(decimal))
+            {
+                if (decimal.TryParse(raw, out var decimalValue))
+                {
+                    value = decimalValue;
+                    error = string.Empty;
+                    return true;
+                }
+            }
+            else if (targetType == typeof(List<string>))
+            {
+                value = JsonSerializer.Deserialize<List<string>>(raw) ?? new List<string>();
+                error = string.Empty;
+                return true;
+            }
+            else if (targetType == typeof(List<int>))
+            {
+                value = JsonSerializer.Deserialize<List<int>>(raw) ?? new List<int>();
+                error = string.Empty;
+                return true;
+            }
+            else if (targetType == typeof(List<decimal>))
+            {
+                value = JsonSerializer.Deserialize<List<decimal>>(raw) ?? new List<decimal>();
+                error = string.Empty;
+                return true;
+            }
+            else
+            {
+                value = JsonSerializer.Deserialize(raw, targetType);
+                error = string.Empty;
+                return true;
+            }
+        }
+        catch (Exception ex)
+        {
+            value = null;
+            error = $"规则 {item.RuleCode} 类型转换失败:{ex.Message}";
+            return false;
+        }
+
+        value = null;
+        error = $"规则 {item.RuleCode} 类型转换失败:值 {raw} 无法转换为 {targetType.Name}";
+        return false;
+    }
+
+    private static string Normalize(string value)
+    {
+        return value.Replace("_", string.Empty).ToLowerInvariant();
+    }
+}
+
+public sealed class RuleConfigLayer
+{
+    public string Scope { get; set; } = string.Empty;
+    public int Priority { get; set; }
+    public List<RuleConfigItem> Items { get; set; } = new();
+}
+
+public sealed class RuleConfigItem
+{
+    public string RuleCode { get; set; } = string.Empty;
+    public string ValueType { get; set; } = string.Empty;
+    public string? RuleValue { get; set; }
+    public bool Required { get; set; }
+    public bool IsEnabled { get; set; } = true;
+    public bool IsHighRiskSwitch { get; set; }
+}
+
+public sealed class RuleConfigResolveResult<TOptions>
+{
+    public TOptions Options { get; set; } = default!;
+    public List<string> Warnings { get; set; } = new();
+    public List<string> Errors { get; set; } = new();
+    public List<RuleConfigAppliedItem> AppliedItems { get; set; } = new();
+    public bool Success => Errors.Count == 0;
+}
+
+public sealed record RuleConfigAppliedItem(string Scope, string RuleCode, string? RuleValue);

+ 83 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigService.cs

@@ -0,0 +1,83 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 全模块规则配置只读服务骨架。P21 阶段不访问数据库,仅提供默认值解析入口。
+/// </summary>
+public class RuleConfigService : ITransient
+{
+    private const string DefaultSource = "default";
+    private const string ConfiguredSource = "configured";
+    private readonly IRuleConfigRepository _repository;
+
+    public RuleConfigService(IRuleConfigRepository? repository = null)
+    {
+        _repository = repository ?? new EmptyRuleConfigRepository();
+    }
+
+    public async Task<RuleConfigResolveResult<TOptions>> ResolveOptionsAsync<TOptions>(
+        string moduleCode,
+        string scenarioCode,
+        long tenantId,
+        long? factoryId,
+        TOptions defaults,
+        CancellationToken cancellationToken = default)
+        where TOptions : class, new()
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var layers = await _repository.GetEffectiveLayersAsync(moduleCode, scenarioCode, tenantId, factoryId, cancellationToken);
+        return RuleConfigResolver.Resolve(defaults, layers);
+    }
+
+    public Task<RuleConfigResolveResult<S3DeliveryGenerateRuleOptions>> ResolveS3DeliveryGenerateOptionsAsync(
+        long tenantId,
+        long? factoryId,
+        CancellationToken cancellationToken = default)
+    {
+        return ResolveOptionsAsync(
+            "S3",
+            "DELIVERY_GENERATE",
+            tenantId,
+            factoryId,
+            new S3DeliveryGenerateRuleOptions(),
+            cancellationToken);
+    }
+
+    public async Task<RuleConfigSnapshot<S3DeliveryGenerateRuleOptions>> ResolveS3DeliveryGenerateSnapshotAsync(
+        long tenantId,
+        long? factoryId,
+        DateTime? resolvedAt = null,
+        CancellationToken cancellationToken = default)
+    {
+        var result = await ResolveS3DeliveryGenerateOptionsAsync(tenantId, factoryId, cancellationToken);
+        if (!result.Success)
+        {
+            throw Oops.Oh($"S3交货单生成规则配置无效:{string.Join(";", result.Errors)}");
+        }
+
+        return CreateSnapshot("S3", "DELIVERY_GENERATE", tenantId, factoryId, result, resolvedAt ?? DateTime.Now);
+    }
+
+    public RuleConfigSnapshot<TOptions> CreateSnapshot<TOptions>(
+        string moduleCode,
+        string scenarioCode,
+        long tenantId,
+        long? factoryId,
+        RuleConfigResolveResult<TOptions> result,
+        DateTime resolvedAt)
+        where TOptions : class, new()
+    {
+        return new RuleConfigSnapshot<TOptions>
+        {
+            ModuleCode = moduleCode,
+            ScenarioCode = scenarioCode,
+            Source = result.AppliedItems.Count == 0 ? DefaultSource : ConfiguredSource,
+            ResolvedAt = resolvedAt,
+            TenantId = tenantId,
+            FactoryId = factoryId,
+            Options = result.Options,
+            AppliedItems = result.AppliedItems,
+            Warnings = result.Warnings
+        };
+    }
+}

+ 18 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/RuleConfigSnapshot.cs

@@ -0,0 +1,18 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// 规则解析快照,用于记录一次业务执行实际生效的规则上下文。
+/// </summary>
+public sealed class RuleConfigSnapshot<TOptions>
+    where TOptions : class, new()
+{
+    public string ModuleCode { get; set; } = string.Empty;
+    public string ScenarioCode { get; set; } = string.Empty;
+    public string Source { get; set; } = string.Empty;
+    public DateTime ResolvedAt { get; set; }
+    public long TenantId { get; set; }
+    public long? FactoryId { get; set; }
+    public TOptions Options { get; set; } = new();
+    public List<RuleConfigAppliedItem> AppliedItems { get; set; } = new();
+    public List<string> Warnings { get; set; } = new();
+}

+ 18 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Rule/S3DeliveryGenerateRuleOptions.cs

@@ -0,0 +1,18 @@
+namespace Admin.NET.Plugin.AiDOP.Rule;
+
+/// <summary>
+/// S3 交货单生成规则配置。默认值需与当前 DeliveryScheduleService 行为保持一致。
+/// </summary>
+public sealed class S3DeliveryGenerateRuleOptions
+{
+    public decimal QuotaRequiredTotal { get; set; } = 100m;
+    public decimal QuotaTolerance { get; set; } = 0.0001m;
+    public List<string> PoBuyerCodes { get; set; } = new() { "110", "160", "170" };
+    public List<string> DoUsages { get; set; } = new() { "标准", "VMI", "委外加工" };
+    public int DefaultLeadDays { get; set; } = 1;
+    public bool EnablePackagingRoundUp { get; set; } = true;
+    public bool ShortageCreatePr { get; set; } = true;
+    public bool EnablePrMerge { get; set; } = true;
+    public bool EnableRequireGoodsToPo { get; set; } = true;
+    public bool EnableExternalPushTracking { get; set; } = true;
+}

+ 766 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/DeliveryScheduleService.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Rule;
 using Yitter.IdGenerator;
 
 namespace Admin.NET.Plugin.AiDOP.Supply;
@@ -15,17 +16,32 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
     private readonly SqlSugarRepository<PolistDeliverySchedule> _rep;
     private readonly UserManager _userManager;
     private readonly NumberRuleService _numberRuleService;
+    private readonly PurchaseRequestMergeService _purchaseRequestMergeService;
+    private readonly PurchaseOrderTransferService _purchaseOrderTransferService;
+    private readonly PurchaseRequestExternalPushService _purchaseRequestExternalPushService;
+    private readonly S3OperationLogService _operationLogService;
+    private readonly RuleConfigService _ruleConfigService;
 
     public DeliveryScheduleService(
         ISqlSugarClient db,
         SqlSugarRepository<PolistDeliverySchedule> rep,
         UserManager userManager,
-        NumberRuleService numberRuleService)
+        NumberRuleService numberRuleService,
+        PurchaseRequestMergeService purchaseRequestMergeService,
+        PurchaseOrderTransferService purchaseOrderTransferService,
+        PurchaseRequestExternalPushService purchaseRequestExternalPushService,
+        S3OperationLogService operationLogService,
+        RuleConfigService ruleConfigService)
     {
         _db = db;
         _rep = rep;
         _userManager = userManager;
         _numberRuleService = numberRuleService;
+        _purchaseRequestMergeService = purchaseRequestMergeService;
+        _purchaseOrderTransferService = purchaseOrderTransferService;
+        _purchaseRequestExternalPushService = purchaseRequestExternalPushService;
+        _operationLogService = operationLogService;
+        _ruleConfigService = ruleConfigService;
     }
 
     [DisplayName("物料交货计划列表")]
@@ -217,7 +233,31 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
     [HttpPost("delivery-schedule/generate")]
     public async Task<object> Generate()
     {
-        return await GenerateDeliverySchedulesAsync(string.Empty);
+        try
+        {
+            var result = await GenerateDeliverySchedulesFromDemandAsync();
+            await _operationLogService.WriteAsync(new S3OperationLogDto
+            {
+                Action = "S3_DELIVERY_GENERATE",
+                DisplayTitle = "S3 生成交货单",
+                Message = result.Message,
+                Result = result,
+                Success = true
+            });
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await _operationLogService.WriteAsync(new S3OperationLogDto
+            {
+                Action = "S3_DELIVERY_GENERATE",
+                DisplayTitle = "S3 生成交货单失败",
+                Message = ex.Message,
+                Exception = ex.ToString(),
+                Success = false
+            });
+            throw;
+        }
     }
 
     [DisplayName("批量添加交货单")]
@@ -227,7 +267,7 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
         if (string.IsNullOrWhiteSpace(input.PoNumber))
             throw Oops.Oh("采购单号不能为空");
 
-        return await GenerateDeliverySchedulesAsync(input.PoNumber.Trim());
+        return await GenerateDeliverySchedulesFromPurchaseOrdersAsync(input.PoNumber.Trim());
     }
 
     [DisplayName("发布交货单")]
@@ -338,7 +378,265 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
         return new { total, page, pageSize, list };
     }
 
-    private async Task<DeliveryScheduleGenerateResult> GenerateDeliverySchedulesAsync(string poNumber)
+    private async Task<DeliveryScheduleGenerateResult> GenerateDeliverySchedulesFromDemandAsync()
+    {
+        var account = _userManager.Account ?? "system";
+        var now = DateTime.Now;
+        var optTime = now;
+        var ruleResult = await _ruleConfigService.ResolveS3DeliveryGenerateOptionsAsync(_userManager.TenantId, null);
+        if (!ruleResult.Success)
+        {
+            throw Oops.Oh($"S3交货单生成规则配置无效:{string.Join(";", ruleResult.Errors)}");
+        }
+        var rules = ruleResult.Options;
+        var ruleSnapshot = _ruleConfigService.CreateSnapshot("S3", "DELIVERY_GENERATE", _userManager.TenantId, null, ruleResult, now);
+        var pars = new List<SugarParameter>();
+        var where = new List<string>
+        {
+            "IFNULL(a.status,'') = 'P'",
+            "IFNULL(a.tosechedqty,0) > 0",
+            "(IFNULL(a.ishistoryversion,'') = '' OR a.ishistoryversion = 'N')",
+            "IFNULL(a.IsDeleted,0)=0"
+        };
+        if (_userManager.TenantId > 0)
+        {
+            where.Add("a.tenant_id = @TenantId");
+            pars.Add(new SugarParameter("@TenantId", _userManager.TenantId));
+        }
+
+        var demands = await _db.Ado.SqlQueryAsync<DemandScheduleCandidateRow>(
+            $"""
+            SELECT
+                a.Id AS IcdsId,
+                IFNULL(TRIM(a.itemnum),'') AS ItemNum,
+                a.requestdate AS RequestDate,
+                a.arrivaldate AS ArrivalDate,
+                IFNULL(a.tosechedqty,0) AS DemandQty,
+                a.tenant_id AS TenantId,
+                a.factory_id AS FactoryId,
+                a.company_id AS CompanyId,
+                IFNULL(TRIM(im.PurMfg),'') AS PurMfg,
+                IFNULL(TRIM(im.UM),'') AS Um,
+                IFNULL(TRIM(im.Descr),'') AS ItemName,
+                ic.Id AS IcItemId
+            FROM ic_demandschedule a
+            LEFT JOIN ItemMaster im ON a.itemnum = im.ItemNum
+            LEFT JOIN ic_item ic ON a.itemnum = ic.number AND (a.tenant_id = ic.tenant_id OR a.tenant_id IS NULL)
+            WHERE {string.Join(" AND ", where)}
+            ORDER BY a.arrivaldate, a.requestdate, a.Id
+            """,
+            pars);
+
+        if (demands.Count == 0)
+        {
+            return new DeliveryScheduleGenerateResult
+            {
+                DemandCount = 0,
+                CandidateCount = 0,
+                CreatedCount = 0,
+                RuleSnapshot = ruleSnapshot,
+                Message = "无已发布且待生成交货单的需求计划"
+            };
+        }
+
+        var demandIds = demands.Select(x => x.IcdsId).Distinct().ToList();
+        var existingUsages = await QueryExistingScheduleUsagesAsync(demandIds);
+        var itemNums = demands.Select(x => x.ItemNum).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
+        var sourceLists = await QuerySourceListsAsync(itemNums);
+        var supplierCodes = sourceLists.Select(x => x.SupplierCode).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
+        var poLines = supplierCodes.Count == 0
+            ? new List<PurchaseOrderAvailableRow>()
+            : await QueryAvailablePurchaseOrderLinesAsync(itemNums, supplierCodes, rules);
+
+        var exceptions = new List<DeliveryExceptionMaster>();
+        var schedules = new List<PolistDeliverySchedule>();
+        var purchaseRequests = new List<PurchaseRequestMain>();
+        var prMergeReducedCount = 0;
+        var purchaseOrderTransferResult = new PurchaseOrderTransferResult();
+        var externalPushResult = new PurchaseRequestExternalPushResult();
+        var allocatedByPoLine = new Dictionary<string, decimal>();
+        var skippedCount = 0;
+
+        foreach (var demand in demands)
+        {
+            if (string.IsNullOrWhiteSpace(demand.ItemNum))
+            {
+                skippedCount++;
+                AddDeliveryException(exceptions, demand, optTime, "需求计划缺少物料编码,无法生成交货单", demand.DemandQty);
+                continue;
+            }
+            if (!string.Equals(demand.PurMfg, "P", StringComparison.OrdinalIgnoreCase))
+            {
+                skippedCount++;
+                AddDeliveryException(exceptions, demand, optTime, $"物料[{demand.ItemNum}]不是采购件,不需要生成交货单", demand.DemandQty);
+                continue;
+            }
+
+            var usedQty = existingUsages.TryGetValue(demand.IcdsId, out var used) ? used : 0m;
+            var demandRestQty = demand.DemandQty - usedQty;
+            if (demandRestQty <= 0)
+            {
+                skippedCount++;
+                continue;
+            }
+
+            var itemSources = sourceLists
+                .Where(x => string.Equals(x.ItemNum, demand.ItemNum, StringComparison.OrdinalIgnoreCase))
+                .OrderByDescending(x => x.QuotaRate)
+                .ToList();
+            if (itemSources.Count == 0)
+            {
+                skippedCount++;
+                AddDeliveryException(exceptions, demand, optTime, $"物料交货计划[{demand.ItemNum}:{FormatDate(demand.RequestDate)}]没有维护货源清单,无法转交货单", demandRestQty);
+                continue;
+            }
+
+            var quotaTotal = itemSources.Sum(x => x.QuotaRate);
+            if (Math.Abs(quotaTotal - rules.QuotaRequiredTotal) > rules.QuotaTolerance)
+            {
+                skippedCount++;
+                AddDeliveryException(exceptions, demand, optTime, $"物料交货计划[{demand.ItemNum}:{FormatDate(demand.RequestDate)}]的货源清单供应商配额之和不为{rules.QuotaRequiredTotal:0.####}%,无法转交货单", demandRestQty);
+                continue;
+            }
+
+            var allocatedForDemand = 0m;
+            for (var i = 0; i < itemSources.Count; i++)
+            {
+                var source = itemSources[i];
+                var remainingDemand = demandRestQty - allocatedForDemand;
+                if (remainingDemand <= 0) break;
+
+                var supplierNeedQty = i == itemSources.Count - 1
+                    ? remainingDemand
+                    : Math.Min(remainingDemand, Math.Ceiling(demandRestQty * source.QuotaRate / 100m));
+                if (supplierNeedQty <= 0) continue;
+
+                var supplierAllocated = AllocateDemandToPurchaseLines(
+                    demand,
+                    source,
+                    poLines,
+                    allocatedByPoLine,
+                    supplierNeedQty,
+                    remainingDemand,
+                    schedules,
+                    now,
+                    account,
+                    rules);
+                allocatedForDemand += supplierAllocated;
+
+                var shortageQty = supplierNeedQty - supplierAllocated;
+                if (shortageQty > 0)
+                {
+                    var prCreated = rules.ShortageCreatePr && TryAddPurchaseRequest(purchaseRequests, demand, source, shortageQty, now, account, rules);
+                    AddDeliveryException(
+                        exceptions,
+                        demand,
+                        optTime,
+                        prCreated
+                            ? $"物料交货计划[{demand.ItemNum}:{FormatDate(demand.RequestDate)}]对应供应商[{source.SupplierName}]采购订单不足,已生成采购申请,后续待合并/转单/推送"
+                            : rules.ShortageCreatePr
+                                ? $"物料交货计划[{demand.ItemNum}:{FormatDate(demand.RequestDate)}]对应供应商[{source.SupplierName}]采购订单不足,需补充 {shortageQty:0.####};因物料或供应商主键缺失,未自动生成PR"
+                                : $"物料交货计划[{demand.ItemNum}:{FormatDate(demand.RequestDate)}]对应供应商[{source.SupplierName}]采购订单不足,需补充 {shortageQty:0.####};规则配置关闭自动生成PR",
+                        shortageQty);
+                }
+            }
+        }
+
+        if (schedules.Count == 0 && exceptions.Count == 0)
+        {
+            return new DeliveryScheduleGenerateResult
+            {
+                DemandCount = demands.Count,
+                CandidateCount = 0,
+                CreatedCount = 0,
+                SkippedCount = skippedCount,
+                RuleSnapshot = ruleSnapshot,
+                Message = "没有可生成交货单的需求计划"
+            };
+        }
+
+        try
+        {
+            _db.Ado.BeginTran();
+            if (purchaseRequests.Count > 0 && rules.EnablePrMerge)
+            {
+                var mergeResult = _purchaseRequestMergeService.MergeGeneratedRequests(purchaseRequests);
+                purchaseRequests = mergeResult.Requests;
+                prMergeReducedCount = mergeResult.ReducedCount;
+            }
+            if (purchaseRequests.Count > 0)
+            {
+                await AssignPurchaseRequestNumbersAsync(purchaseRequests, account);
+            }
+            if (rules.EnableRequireGoodsToPo && purchaseRequests.Any(x => x.IsRequireGoods == 1))
+            {
+                purchaseOrderTransferResult = await _purchaseOrderTransferService.TransferGeneratedRequireGoodsAsync(purchaseRequests, account);
+            }
+            if (rules.EnableExternalPushTracking && purchaseRequests.Any(x => x.IsRequireGoods == 0))
+            {
+                externalPushResult = await _purchaseRequestExternalPushService.CreateQadTrackingForGeneratedRequestsAsync(purchaseRequests, account);
+            }
+            if (schedules.Count > 0)
+            {
+                var dsNumbersByKey = new Dictionary<string, string>();
+                foreach (var group in schedules.GroupBy(x => x.Domain ?? string.Empty))
+                {
+                    var groupRows = group.ToList();
+                    var dsNumbers = await _numberRuleService.NextBatchInCurrentTransactionAsync("M8", group.Key, groupRows.Count, account);
+                    if (dsNumbers.Count < groupRows.Count || dsNumbers.Any(string.IsNullOrWhiteSpace))
+                        throw Oops.Oh($"当前交货计划号生成失败,请检查交货计划编号规则维护。Domain={group.Key}");
+
+                    for (var index = 0; index < groupRows.Count; index++)
+                    {
+                        dsNumbersByKey[BuildScheduleTempKey(groupRows[index])] = dsNumbers[index].Trim();
+                    }
+                }
+
+                foreach (var schedule in schedules)
+                {
+                    schedule.DsNum = dsNumbersByKey[BuildScheduleTempKey(schedule)];
+                }
+
+                await _db.Insertable(schedules).ExecuteCommandAsync();
+                await UpsertDeliveryScheduleMdpAsync(schedules, now);
+            }
+            if (purchaseRequests.Count > 0)
+            {
+                await InsertPurchaseRequestsAsync(purchaseRequests);
+            }
+            if (exceptions.Count > 0)
+            {
+                await _db.Insertable(exceptions).ExecuteCommandAsync();
+            }
+            _db.Ado.CommitTran();
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+
+        return new DeliveryScheduleGenerateResult
+        {
+            DemandCount = demands.Count,
+            CandidateCount = schedules.Count,
+            CreatedCount = schedules.Count,
+            ExceptionCount = exceptions.Count,
+            PurchaseRequestCount = purchaseRequests.Count,
+            PurchaseRequestMergeReducedCount = prMergeReducedCount,
+            PurchaseOrderCount = purchaseOrderTransferResult.CreatedOrderCount,
+            PurchaseOrderLineCount = purchaseOrderTransferResult.TransferredPrCount,
+            QadTrackingCount = externalPushResult.TrackingCount,
+            SkippedCount = skippedCount,
+            DsNums = schedules.Select(x => x.DsNum).Where(x => !string.IsNullOrWhiteSpace(x)).Take(20).ToList(),
+            RuleSnapshot = ruleSnapshot,
+            Message = schedules.Count > 0
+                ? $"生成交货单成功,共生成 {schedules.Count} 条;自动生成PR {purchaseRequests.Count} 条;合并减少 {prMergeReducedCount} 条;生成DO/PO {purchaseOrderTransferResult.CreatedOrderCount} 单;写QadTracking {externalPushResult.TrackingCount} 条;异常 {exceptions.Count} 条"
+                : $"未生成交货单,自动生成PR {purchaseRequests.Count} 条,合并减少 {prMergeReducedCount} 条,生成DO/PO {purchaseOrderTransferResult.CreatedOrderCount} 单,写QadTracking {externalPushResult.TrackingCount} 条,已记录异常 {exceptions.Count} 条"
+        };
+    }
+
+    private async Task<DeliveryScheduleGenerateResult> GenerateDeliverySchedulesFromPurchaseOrdersAsync(string poNumber)
     {
         var account = _userManager.Account ?? "system";
         var pars = new List<SugarParameter>();
@@ -597,6 +895,404 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
         }
     }
 
+    private async Task<Dictionary<long, decimal>> QueryExistingScheduleUsagesAsync(List<long> demandIds)
+    {
+        if (demandIds.Count == 0) return new Dictionary<long, decimal>();
+
+        var pars = new List<SugarParameter>();
+        var inClause = BuildInClause(demandIds.Select(x => x.ToString()).ToList(), "DemandId", pars);
+        var rows = await _db.Ado.SqlQueryAsync<ExistingScheduleUsageRow>(
+            $"""
+            SELECT
+                icdsid AS IcdsId,
+                SUM(
+                    CASE
+                        WHEN IFNULL(status,'') = 'C' THEN IFNULL(sentqty,0)
+                        WHEN IFNULL(schedqty,0) >= IFNULL(sentqty,0) THEN IFNULL(schedqty,0)
+                        ELSE IFNULL(sentqty,0)
+                    END
+                ) AS UsedQty
+            FROM srm_polist_ds
+            WHERE isactive = 1 AND icdsid IN ({inClause})
+            GROUP BY icdsid
+            """,
+            pars);
+
+        return rows.ToDictionary(x => x.IcdsId, x => x.UsedQty);
+    }
+
+    private async Task<List<SourceListRow>> QuerySourceListsAsync(List<string> itemNums)
+    {
+        if (itemNums.Count == 0) return new List<SourceListRow>();
+
+        var pars = new List<SugarParameter>();
+        var where = new List<string>
+        {
+            "IFNULL(sp.IsDeleted,0)=0",
+            "IFNULL(sp.supplier_number,'') <> ''",
+            "IFNULL(sp.quota_rate,0) > 0",
+            """
+            (
+              IFNULL(sp.is_active,'') = ''
+              OR sp.is_active = '否'
+              OR sp.is_active = 'N'
+              OR sp.is_active = '0'
+              OR LOWER(sp.is_active) = 'false'
+            )
+            """
+        };
+        if (_userManager.TenantId > 0)
+        {
+            where.Add("sp.tenant_id = @TenantId");
+            pars.Add(new SugarParameter("@TenantId", _userManager.TenantId));
+        }
+        var inClause = BuildInClause(itemNums, "ItemNum", pars);
+        where.Add($"sp.number IN ({inClause})");
+
+        return await _db.Ado.SqlQueryAsync<SourceListRow>(
+            $"""
+            SELECT
+                IFNULL(TRIM(sp.number),'') AS ItemNum,
+                IFNULL(TRIM(sp.supplier_number),'') AS SupplierCode,
+                IFNULL(TRIM(sp.supplier_name),'') AS SupplierName,
+                IFNULL(TRIM(sp.supplier_type),'') AS SupplierType,
+                IFNULL(sp.quota_rate,0) AS QuotaRate,
+                IFNULL(sp.packaging_qty,0) AS PackagingQty,
+                IFNULL(sp.lead_time,0) AS LeadTime,
+                IFNULL(sp.IsRequireGoods,0) AS IsRequireGoods,
+                sp.supplier_id AS SupplierId,
+                IFNULL(TRIM(sp.order_rector_name),'') AS OrderRectorName,
+                IFNULL(TRIM(sp.order_rector_num),'') AS OrderRectorNum,
+                IFNULL(TRIM(sp.purchase_unit),'') AS PurchaseUnit,
+                sp.currency_type AS CurrencyType
+            FROM srm_purchase sp
+            WHERE {string.Join(" AND ", where)}
+            ORDER BY sp.number, IFNULL(sp.quota_priority, 999999), sp.quota_rate DESC
+            """,
+            pars);
+    }
+
+    private async Task<List<PurchaseOrderAvailableRow>> QueryAvailablePurchaseOrderLinesAsync(
+        List<string> itemNums,
+        List<string> supplierCodes,
+        S3DeliveryGenerateRuleOptions rules)
+    {
+        var pars = new List<SugarParameter>();
+        var poBuyerCodesClause = BuildInClause(rules.PoBuyerCodes, "PoBuyerCode", pars);
+        var doUsagesClause = BuildInClause(rules.DoUsages, "DoUsage", pars);
+        var where = new List<string>
+        {
+            "IFNULL(m.Status,'') <> 'C'",
+            "IFNULL(d.Status,'') <> 'C'",
+            "IFNULL(d.ItemNum,'') <> ''",
+            "IFNULL(m.Supp,'') <> ''",
+            "(IFNULL(d.QtyOrded,0) - IFNULL(d.RctQty,0)) > 0",
+            """
+            (
+                (m.PurOrd NOT LIKE 'DO%' AND IFNULL(m.Buyer,'') IN ({0}))
+                OR
+                (m.PurOrd LIKE 'DO%' AND IFNULL(m.USAGE,'') IN ({1}))
+            )
+            """.Replace("{0}", poBuyerCodesClause).Replace("{1}", doUsagesClause)
+        };
+        if (_userManager.TenantId > 0)
+        {
+            where.Add("m.tenant_id = @TenantId");
+            pars.Add(new SugarParameter("@TenantId", _userManager.TenantId));
+        }
+        where.Add($"d.ItemNum IN ({BuildInClause(itemNums, "PoItemNum", pars)})");
+        where.Add($"m.Supp IN ({BuildInClause(supplierCodes, "SupplierCode", pars)})");
+
+        return await _db.Ado.SqlQueryAsync<PurchaseOrderAvailableRow>(
+            $"""
+            SELECT *
+            FROM (
+                SELECT
+                    IFNULL(TRIM(m.Domain),'') AS Domain,
+                    IFNULL(TRIM(m.PurOrd),'') AS PoNumber,
+                    IFNULL(d.Line,0) AS PoLine,
+                    d.RecID AS DetailRecId,
+                    IFNULL(TRIM(d.ItemNum),'') AS ItemNum,
+                    IFNULL(TRIM(d.UM),'') AS Um,
+                    IFNULL(TRIM(m.Buyer),'') AS PurGroup,
+                    IFNULL(TRIM(m.Supp),'') AS SupplierCode,
+                    IFNULL(TRIM(s.Name),'') AS SupplierName,
+                    d.DueDate AS DueDate,
+                    IFNULL(d.QtyOrded,0) AS OrderQty,
+                    IFNULL(d.RctQty,0) AS ReceiptQty,
+                    m.tenant_id AS TenantId,
+                    (
+                        IFNULL(d.QtyOrded,0)
+                        - IFNULL(d.RctQty,0)
+                        - IFNULL((
+                            SELECT SUM(
+                                CASE
+                                    WHEN IFNULL(ds.status,'') = 'C' THEN IFNULL(ds.sentqty,0)
+                                    WHEN IFNULL(ds.schedqty,0) >= IFNULL(ds.sentqty,0) THEN IFNULL(ds.schedqty,0)
+                                    ELSE IFNULL(ds.sentqty,0)
+                                END)
+                            FROM srm_polist_ds ds
+                            WHERE ds.ponumber = d.PurOrd
+                              AND ds.poline = d.Line
+                              AND ds.itemnum = d.ItemNum
+                              AND ds.isactive = 1
+                        ), 0)
+                    ) AS AvailableQty
+                FROM PurOrdMaster m
+                INNER JOIN PurOrdDetail d ON m.RecID = d.PurOrdRecID
+                LEFT JOIN ConsigneeAddressMaster s ON m.Supp = s.Address AND s.Typed = 'Supp'
+                WHERE {string.Join(" AND ", where)}
+            ) t
+            WHERE t.AvailableQty > 0
+            ORDER BY t.ItemNum, t.SupplierCode, CASE WHEN t.PoNumber LIKE 'DO%' THEN 1 ELSE 0 END, t.DueDate, t.DetailRecId
+            """,
+            pars);
+    }
+
+    private static decimal AllocateDemandToPurchaseLines(
+        DemandScheduleCandidateRow demand,
+        SourceListRow source,
+        List<PurchaseOrderAvailableRow> poLines,
+        Dictionary<string, decimal> allocatedByPoLine,
+        decimal supplierNeedQty,
+        decimal remainingDemand,
+        List<PolistDeliverySchedule> schedules,
+        DateTime now,
+        string account,
+        S3DeliveryGenerateRuleOptions rules)
+    {
+        var supplierAllocated = 0m;
+        var lines = poLines
+            .Where(x =>
+                string.Equals(x.ItemNum, demand.ItemNum, StringComparison.OrdinalIgnoreCase)
+                && string.Equals(x.SupplierCode, source.SupplierCode, StringComparison.OrdinalIgnoreCase))
+            .ToList();
+
+        foreach (var line in lines)
+        {
+            if (supplierAllocated >= supplierNeedQty) break;
+
+            var key = BuildPoLineKey(line);
+            var runAllocated = allocatedByPoLine.TryGetValue(key, out var used) ? used : 0m;
+            var availableQty = line.AvailableQty - runAllocated;
+            if (availableQty <= 0) continue;
+
+            var requiredQty = supplierNeedQty - supplierAllocated;
+            var qty = Math.Min(availableQty, requiredQty);
+            if (rules.EnablePackagingRoundUp && source.PackagingQty > 0 && availableQty > qty)
+            {
+                var roundedQty = Math.Ceiling(qty / source.PackagingQty) * source.PackagingQty;
+                if (roundedQty <= availableQty && roundedQty <= remainingDemand - supplierAllocated)
+                {
+                    qty = roundedQty;
+                }
+            }
+            if (qty <= 0) continue;
+
+            allocatedByPoLine[key] = runAllocated + qty;
+            supplierAllocated += qty;
+            schedules.Add(new PolistDeliverySchedule
+            {
+                Id = YitIdHelper.NextId(),
+                Domain = line.Domain ?? string.Empty,
+                IcdsId = demand.IcdsId,
+                DsNum = string.Empty,
+                Status = "N",
+                ItemNum = demand.ItemNum ?? string.Empty,
+                Um = line.Um ?? demand.Um ?? string.Empty,
+                PurGroup = line.PurGroup ?? string.Empty,
+                SupplierCode = source.SupplierCode ?? line.SupplierCode ?? string.Empty,
+                Supplier = string.IsNullOrWhiteSpace(source.SupplierName) ? line.SupplierName ?? string.Empty : source.SupplierName,
+                RequestDate = demand.ArrivalDate ?? now,
+                NeedDate = demand.RequestDate,
+                PoNumber = line.PoNumber ?? string.Empty,
+                PoLine = line.PoLine,
+                SchedQty = qty,
+                LastSentQty = 0,
+                SentQty = 0,
+                RestQty = qty,
+                CreateUser = account,
+                UpdateUser = account,
+                CreateTime = now,
+                UpdateTime = now,
+                Remarks = "由系统按需求计划、货源配额和采购单可用量自动生成",
+                IsActive = 1,
+                ReturnQty = 0,
+                TenantId = demand.TenantId ?? line.TenantId
+            });
+        }
+
+        return supplierAllocated;
+    }
+
+    private static void AddDeliveryException(
+        List<DeliveryExceptionMaster> exceptions,
+        DemandScheduleCandidateRow demand,
+        DateTime optTime,
+        string remark,
+        decimal needQty)
+    {
+        exceptions.Add(new DeliveryExceptionMaster
+        {
+            RecID = YitIdHelper.NextId(),
+            Domain = demand.TenantId?.ToString() ?? string.Empty,
+            IcdsId = demand.IcdsId,
+            OptTime = optTime,
+            ItemNum = demand.ItemNum,
+            Remark = remark,
+            NeedQty = needQty,
+            TenantId = demand.TenantId
+        });
+    }
+
+    private static bool TryAddPurchaseRequest(
+        List<PurchaseRequestMain> purchaseRequests,
+        DemandScheduleCandidateRow demand,
+        SourceListRow source,
+        decimal shortageQty,
+        DateTime now,
+        string account,
+        S3DeliveryGenerateRuleOptions rules)
+    {
+        if (shortageQty <= 0) return false;
+        if (!demand.IcItemId.HasValue || demand.IcItemId.Value <= 0) return false;
+        if (!source.SupplierId.HasValue || source.SupplierId.Value <= 0) return false;
+
+        var arriveDate = demand.ArrivalDate ?? demand.RequestDate ?? now.Date;
+        var leadDays = source.LeadTime > 0 ? (int)Math.Ceiling(source.LeadTime) : rules.DefaultLeadDays;
+        if (leadDays < 0) leadDays = 0;
+        var sendDate = arriveDate.Date.AddDays(-leadDays);
+        if (sendDate < now.Date) sendDate = now.Date;
+
+        purchaseRequests.Add(new PurchaseRequestMain
+        {
+            Id = YitIdHelper.NextId(),
+            PrPurchaseId = source.SupplierId.Value,
+            PrPurchaseNumber = source.SupplierCode,
+            PrPurchaseName = source.SupplierName,
+            PrPurchaser = source.OrderRectorName,
+            PrPurchaserNum = source.OrderRectorNum,
+            PrRqty = shortageQty,
+            PrAqty = shortageQty,
+            PrSqty = shortageQty,
+            IcitemId = demand.IcItemId.Value,
+            IcitemName = string.IsNullOrWhiteSpace(demand.ItemName) ? demand.ItemNum : demand.ItemName,
+            PrSsendDate = sendDate,
+            PrSarriveDate = arriveDate,
+            PrUnit = string.IsNullOrWhiteSpace(source.PurchaseUnit) ? demand.Um : source.PurchaseUnit,
+            State = 1,
+            PrType = 3,
+            CurrencyType = source.CurrencyType ?? 1,
+            CreateBy = null,
+            CreateByName = account,
+            CreateTime = now,
+            UpdateBy = null,
+            UpdateByName = account,
+            UpdateTime = now,
+            TenantId = demand.TenantId ?? 0,
+            FactoryId = demand.FactoryId,
+            OrgId = demand.FactoryId,
+            IsDeleted = false,
+            CompanyId = demand.CompanyId ?? 1000,
+            IsRequireGoods = source.IsRequireGoods,
+            SupplierType = source.SupplierType
+        });
+        return true;
+    }
+
+    private async Task AssignPurchaseRequestNumbersAsync(List<PurchaseRequestMain> purchaseRequests, string account)
+    {
+        foreach (var group in purchaseRequests.GroupBy(x => x.TenantId.ToString()))
+        {
+            var groupRows = group.ToList();
+            var prNumbers = await _numberRuleService.NextBatchInCurrentTransactionAsync("PR", group.Key, groupRows.Count, account);
+            if (prNumbers.Count < groupRows.Count || prNumbers.Any(string.IsNullOrWhiteSpace))
+                throw Oops.Oh($"当前采购申请号生成失败,请检查采购申请编号规则维护。Domain={group.Key}");
+
+            for (var index = 0; index < groupRows.Count; index++)
+            {
+                groupRows[index].PrBillNo = prNumbers[index].Trim();
+            }
+        }
+    }
+
+    private async Task InsertPurchaseRequestsAsync(List<PurchaseRequestMain> purchaseRequests)
+    {
+        if (purchaseRequests.Count == 0) return;
+
+        foreach (var pr in purchaseRequests)
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO srm_pr_main
+                (Id,pr_billno,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
+                 pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
+                 create_by,create_by_name,create_time,update_by,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,IsRequireGoods,supplier_type)
+                VALUES
+                (@Id,@PrBillNo,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
+                 @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
+                 @CreateBy,@CreateByName,@CreateTime,@UpdateBy,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,@IsDeleted,@CompanyId,@IsRequireGoods,@SupplierType)
+                """,
+                new SugarParameter("@Id", pr.Id),
+                new SugarParameter("@PrBillNo", pr.PrBillNo),
+                new SugarParameter("@PrPurchaseId", pr.PrPurchaseId),
+                new SugarParameter("@PrPurchaseNumber", pr.PrPurchaseNumber),
+                new SugarParameter("@PrPurchaseName", pr.PrPurchaseName),
+                new SugarParameter("@PrPurchaser", pr.PrPurchaser),
+                new SugarParameter("@PrPurchaserNum", pr.PrPurchaserNum),
+                new SugarParameter("@PrRqty", pr.PrRqty),
+                new SugarParameter("@PrAqty", pr.PrAqty),
+                new SugarParameter("@PrSqty", pr.PrSqty),
+                new SugarParameter("@IcitemId", pr.IcitemId),
+                new SugarParameter("@IcitemName", pr.IcitemName),
+                new SugarParameter("@PrSsendDate", pr.PrSsendDate),
+                new SugarParameter("@PrSarriveDate", pr.PrSarriveDate),
+                new SugarParameter("@PrUnit", pr.PrUnit),
+                new SugarParameter("@State", pr.State),
+                new SugarParameter("@PrType", pr.PrType),
+                new SugarParameter("@CurrencyType", pr.CurrencyType),
+                new SugarParameter("@CreateBy", pr.CreateBy),
+                new SugarParameter("@CreateByName", pr.CreateByName),
+                new SugarParameter("@CreateTime", pr.CreateTime),
+                new SugarParameter("@UpdateBy", pr.UpdateBy),
+                new SugarParameter("@UpdateByName", pr.UpdateByName),
+                new SugarParameter("@UpdateTime", pr.UpdateTime),
+                new SugarParameter("@TenantId", pr.TenantId),
+                new SugarParameter("@FactoryId", pr.FactoryId),
+                new SugarParameter("@OrgId", pr.OrgId),
+                new SugarParameter("@IsDeleted", pr.IsDeleted ? 1 : 0),
+                new SugarParameter("@CompanyId", pr.CompanyId),
+                new SugarParameter("@IsRequireGoods", pr.IsRequireGoods),
+                new SugarParameter("@SupplierType", pr.SupplierType));
+        }
+    }
+
+    private static string BuildInClause(List<string> values, string prefix, List<SugarParameter> pars)
+    {
+        if (values.Count == 0) return "NULL";
+
+        var names = new List<string>();
+        for (var i = 0; i < values.Count; i++)
+        {
+            var name = $"@{prefix}{i}";
+            names.Add(name);
+            pars.Add(new SugarParameter(name, values[i]));
+        }
+        return string.Join(",", names);
+    }
+
+    private static string BuildPoLineKey(PurchaseOrderAvailableRow row)
+    {
+        return $"{row.PoNumber}|{row.PoLine}|{row.ItemNum}|{row.DetailRecId}";
+    }
+
+    private static string BuildScheduleTempKey(PolistDeliverySchedule row)
+    {
+        return $"{row.Id}|{row.PoNumber}|{row.PoLine}|{row.IcdsId}";
+    }
+
+    private static string FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd") ?? "";
+
     private static List<long> ParseIds(string ids)
     {
         return ids.Split(',', StringSplitOptions.RemoveEmptyEntries)
@@ -687,9 +1383,18 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
 
     private sealed class DeliveryScheduleGenerateResult
     {
+        public int DemandCount { get; set; }
         public int CandidateCount { get; set; }
         public int CreatedCount { get; set; }
+        public int ExceptionCount { get; set; }
+        public int PurchaseRequestCount { get; set; }
+        public int PurchaseRequestMergeReducedCount { get; set; }
+        public int PurchaseOrderCount { get; set; }
+        public int PurchaseOrderLineCount { get; set; }
+        public int QadTrackingCount { get; set; }
+        public int SkippedCount { get; set; }
         public List<string> DsNums { get; set; } = new();
+        public RuleConfigSnapshot<S3DeliveryGenerateRuleOptions>? RuleSnapshot { get; set; }
         public string Message { get; set; } = string.Empty;
     }
 
@@ -708,4 +1413,61 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
         public decimal RestQty { get; set; }
         public long? TenantId { get; set; }
     }
+
+    private sealed class DemandScheduleCandidateRow
+    {
+        public long IcdsId { get; set; }
+        public string? ItemNum { get; set; }
+        public DateTime? RequestDate { get; set; }
+        public DateTime? ArrivalDate { get; set; }
+        public decimal DemandQty { get; set; }
+        public long? TenantId { get; set; }
+        public long? FactoryId { get; set; }
+        public long? CompanyId { get; set; }
+        public string? PurMfg { get; set; }
+        public string? Um { get; set; }
+        public string? ItemName { get; set; }
+        public long? IcItemId { get; set; }
+    }
+
+    private sealed class ExistingScheduleUsageRow
+    {
+        public long IcdsId { get; set; }
+        public decimal UsedQty { get; set; }
+    }
+
+    private sealed class SourceListRow
+    {
+        public string? ItemNum { get; set; }
+        public string? SupplierCode { get; set; }
+        public string? SupplierName { get; set; }
+        public string? SupplierType { get; set; }
+        public decimal QuotaRate { get; set; }
+        public decimal PackagingQty { get; set; }
+        public decimal LeadTime { get; set; }
+        public int IsRequireGoods { get; set; }
+        public long? SupplierId { get; set; }
+        public string? OrderRectorName { get; set; }
+        public string? OrderRectorNum { get; set; }
+        public string? PurchaseUnit { get; set; }
+        public long? CurrencyType { get; set; }
+    }
+
+    private sealed class PurchaseOrderAvailableRow
+    {
+        public string? Domain { get; set; }
+        public string? PoNumber { get; set; }
+        public int PoLine { get; set; }
+        public long DetailRecId { get; set; }
+        public string? ItemNum { get; set; }
+        public string? Um { get; set; }
+        public string? PurGroup { get; set; }
+        public string? SupplierCode { get; set; }
+        public string? SupplierName { get; set; }
+        public DateTime? DueDate { get; set; }
+        public decimal OrderQty { get; set; }
+        public decimal ReceiptQty { get; set; }
+        public long? TenantId { get; set; }
+        public decimal AvailableQty { get; set; }
+    }
 }

+ 225 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseOrderTransferService.cs

@@ -0,0 +1,225 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 采购申请转 DO/PO 服务。P4 初版仅处理本次生成的要货令 PR,不扫描历史 PR。
+/// </summary>
+public class PurchaseOrderTransferService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly NumberRuleService _numberRuleService;
+    private readonly UserManager _userManager;
+
+    public PurchaseOrderTransferService(ISqlSugarClient db, NumberRuleService numberRuleService, UserManager userManager)
+    {
+        _db = db;
+        _numberRuleService = numberRuleService;
+        _userManager = userManager;
+    }
+
+    public async Task<PurchaseOrderTransferResult> TransferGeneratedRequireGoodsAsync(List<PurchaseRequestMain> requests, string account)
+    {
+        var candidates = requests.Where(x => x.IsRequireGoods == 1).ToList();
+        if (candidates.Count == 0) return new PurchaseOrderTransferResult();
+
+        var createdOrders = new List<string>();
+        var transferredPrIds = new List<long>();
+        var groups = candidates
+            .GroupBy(x => new
+            {
+                x.TenantId,
+                x.CompanyId,
+                x.FactoryId,
+                x.PrPurchaseId,
+                x.PrPurchaseNumber,
+                x.PrPurchaseName,
+                SupplierType = x.SupplierType ?? string.Empty
+            })
+            .ToList();
+
+        foreach (var tenantGroup in groups.GroupBy(x => x.Key.TenantId.ToString()))
+        {
+            var groupList = tenantGroup.ToList();
+            var purOrdNumbers = await _numberRuleService.NextBatchInCurrentTransactionAsync("DO", tenantGroup.Key, groupList.Count, account);
+            if (purOrdNumbers.Count < groupList.Count || purOrdNumbers.Any(string.IsNullOrWhiteSpace))
+                throw Oops.Oh($"当前DO/PO单号生成失败,请检查DO编号规则维护。Domain={tenantGroup.Key}");
+
+            for (var i = 0; i < groupList.Count; i++)
+            {
+                var group = groupList[i];
+                var purOrd = purOrdNumbers[i].Trim();
+                var now = DateTime.Now;
+                var supplierType = group.Key.SupplierType;
+                var isOutsource = string.Equals(supplierType, "委外", StringComparison.OrdinalIgnoreCase);
+                var poType = isOutsource ? "PW" : "po";
+                var usage = isOutsource ? "委外加工" : supplierType;
+                var buyer = ResolveBuyer(supplierType);
+                var rows = group.OrderBy(x => x.PrSarriveDate).ThenBy(x => x.PrBillNo).ToList();
+
+                await InsertPurchaseOrderMasterAsync(purOrd, poType, usage, buyer, group.Key.PrPurchaseNumber, now, account, group.Key.TenantId);
+                var masterId = await _db.Ado.GetIntAsync(
+                    "SELECT IFNULL(MAX(RecID),0) FROM PurOrdMaster WHERE PurOrd=@PurOrd",
+                    new SugarParameter("@PurOrd", purOrd));
+                if (masterId <= 0) throw Oops.Oh("DO/PO主表生成失败。");
+
+                for (var lineIndex = 0; lineIndex < rows.Count; lineIndex++)
+                {
+                    await InsertPurchaseOrderDetailAsync(purOrd, poType, masterId, lineIndex + 1, rows[lineIndex], account);
+                    rows[lineIndex].State = 4;
+                    rows[lineIndex].UpdateByName = account;
+                    rows[lineIndex].UpdateTime = now;
+                    transferredPrIds.Add(rows[lineIndex].Id);
+                }
+
+                createdOrders.Add(purOrd);
+            }
+        }
+
+        return new PurchaseOrderTransferResult
+        {
+            CreatedOrderCount = createdOrders.Count,
+            TransferredPrCount = transferredPrIds.Count,
+            CreatedOrders = createdOrders,
+            TransferredPrIds = transferredPrIds
+        };
+    }
+
+    private async Task InsertPurchaseOrderMasterAsync(
+        string purOrd,
+        string poType,
+        string? usage,
+        string buyer,
+        string? supplierCode,
+        DateTime now,
+        string account,
+        long tenantId)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO PurOrdMaster
+            (
+                Confirming, CreditTermsInt, Disc, ExchRate, EstVal, ExchRate1, ExchRate2,
+                FixedPrice, FixedRate, Frt, PartialOK, AmtPrepaid, PrintPO, PST, Recurr,
+                `Release`, Revision, Scheduled, ServiceCharge, SpecialCharge, Taxable,
+                Tax1, Tax2, Tax3, TransportDays, IsActive, IsConfirm, Potype, IsChanged,
+                TaxIn, Amt, IsPriceChanged,
+                Buyer, Domain, PurOrd, OrdDate, ReqBy, Status, Supp, CreateUser, CreateTime,
+                UpdateUser, UpdateTime, `Usage`, FSTID, Typed, tenant_id
+            )
+            VALUES
+            (
+                0, 0, 0, 1, 0, 1, 1,
+                0, 0, 0, 1, 0, 0, 0, 0,
+                0, 0, 0, 0, 0, 1,
+                0, 0, 0, 0, 1, 1, @PoType, 0,
+                1, 0, 0,
+                @Buyer, @Domain, @PurOrd, @Now, 'DO', '', @Supp, @CreateUser, @Now,
+                @UpdateUser, @Now, @Usage, @FSTID, @Typed, @TenantId
+            )
+            """,
+            new SugarParameter("@PoType", poType),
+            new SugarParameter("@Buyer", buyer),
+            new SugarParameter("@Domain", tenantId.ToString()),
+            new SugarParameter("@PurOrd", purOrd),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@Supp", supplierCode),
+            new SugarParameter("@CreateUser", account),
+            new SugarParameter("@UpdateUser", account),
+            new SugarParameter("@Usage", usage),
+            new SugarParameter("@FSTID", string.Equals(usage, "VMI", StringComparison.OrdinalIgnoreCase) ? "3" : string.Empty),
+            new SugarParameter("@Typed", poType == "PW" ? "s" : string.Empty),
+            new SugarParameter("@TenantId", tenantId <= 0 ? null : tenantId));
+    }
+
+    private async Task InsertPurchaseOrderDetailAsync(string purOrd, string poType, int masterId, int line, PurchaseRequestMain pr, string account)
+    {
+        var item = (await _db.Ado.SqlQueryAsync<ItemLookupRow>(
+            """
+            SELECT
+                COALESCE(NULLIF(im.ItemNum,''), ic.number) AS ItemNum,
+                COALESCE(NULLIF(im.Descr,''), ic.name) AS Descr,
+                COALESCE(NULLIF(im.UM,''), ic.unit) AS UM,
+                IFNULL(im.Location, '') AS Location,
+                IFNULL(im.Rev, '') AS Rev,
+                IFNULL(im.Drawing, '') AS Drawing
+            FROM ic_item ic
+            LEFT JOIN ItemMaster im ON ic.number = im.ItemNum
+            WHERE ic.Id = @IcitemId
+            LIMIT 1
+            """,
+            new SugarParameter("@IcitemId", pr.IcitemId))).FirstOrDefault();
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO PurOrdDetail
+            (
+                QtyBO, RctCost, CreditTermsInt, UpdateCurrentCost, CumReceived1, CumReceived2,
+                CumReceived3, CumReceived4, Disc, FixedPrice, InspectReq, SingleLot, SupplyPer,
+                PurOrd, PST, PackingSlipQty, PayUMConv, PurCost, RctQty, QtyOrded, QtyReceived,
+                QtyReturned, Active, QtyReleased, RctUMConversion, Scheduled, ScheduledChanged,
+                SchedMRPReq, SafetyDays, SafetyHours, StdCost, Taxable, TaxIn, MaxTaxableAmt,
+                TransportHours, UMConversion, VAT, IsActive, IsConfirm, Potype, IsChanged,
+                TaxRate, IsRounding, ReceiptQty, BarCodeQty, IsClosed, QtyReturnedRefund, CumQtyBO,
+                Line, ItemNum, Descr, UM, Rev, Drawing, Location, DueDate, NeedDate, LotSerial, PurOrdRecID, Status, Req,
+                CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
+            )
+            VALUES
+            (
+                0, 0, 0, 0, 0, 0,
+                0, 0, 0, 0, 0, 0, 0,
+                @PurOrd, 0, 0, 1, 0, 0, @QtyOrded, 0,
+                0, 1, 0, 1, 0, 0,
+                0, 0, 0, 0, 1, 1, 0,
+                0, 1, 0, 1, 1, @PoType, 0,
+                0, 0, 0, 0, 0, 0, 0,
+                @Line, @ItemNum, @Descr, @UM, @Rev, @Drawing, @Location, @DueDate, @NeedDate, '', @PurOrdRecID, 'R', @Req,
+                @CreateUser, @Now, @UpdateUser, @Now, @TenantId
+            )
+            """,
+            new SugarParameter("@PurOrd", purOrd),
+            new SugarParameter("@PoType", poType),
+            new SugarParameter("@QtyOrded", pr.PrAqty ?? pr.PrSqty ?? pr.PrRqty ?? 0),
+            new SugarParameter("@Line", line),
+            new SugarParameter("@ItemNum", item?.ItemNum ?? pr.IcitemName ?? string.Empty),
+            new SugarParameter("@Descr", item?.Descr ?? pr.IcitemName ?? string.Empty),
+            new SugarParameter("@UM", item?.UM ?? pr.PrUnit ?? string.Empty),
+            new SugarParameter("@Rev", item?.Rev ?? string.Empty),
+            new SugarParameter("@Drawing", item?.Drawing ?? string.Empty),
+            new SugarParameter("@Location", string.IsNullOrWhiteSpace(item?.Location) ? "1001" : item.Location),
+            new SugarParameter("@DueDate", pr.PrSarriveDate),
+            new SugarParameter("@NeedDate", pr.PrSarriveDate),
+            new SugarParameter("@PurOrdRecID", masterId),
+            new SugarParameter("@Req", pr.PrBillNo),
+            new SugarParameter("@CreateUser", account),
+            new SugarParameter("@UpdateUser", account),
+            new SugarParameter("@Now", DateTime.Now),
+            new SugarParameter("@TenantId", pr.TenantId <= 0 ? null : pr.TenantId));
+    }
+
+    private static string ResolveBuyer(string? supplierType)
+    {
+        return supplierType switch
+        {
+            "研发" => "130",
+            "ECR" => "170",
+            _ => "110"
+        };
+    }
+
+    private sealed class ItemLookupRow
+    {
+        public string? ItemNum { get; set; }
+        public string? Descr { get; set; }
+        public string? UM { get; set; }
+        public string? Location { get; set; }
+        public string? Rev { get; set; }
+        public string? Drawing { get; set; }
+    }
+}
+
+public sealed class PurchaseOrderTransferResult
+{
+    public int CreatedOrderCount { get; set; }
+    public int TransferredPrCount { get; set; }
+    public List<string> CreatedOrders { get; set; } = new();
+    public List<long> TransferredPrIds { get; set; } = new();
+}

+ 82 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestExternalPushService.cs

@@ -0,0 +1,82 @@
+using System.Globalization;
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 采购申请外部事务推送服务。P5 初版只写 QadTracking,不直接调用 SAP/QAD。
+/// </summary>
+public class PurchaseRequestExternalPushService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public PurchaseRequestExternalPushService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<PurchaseRequestExternalPushResult> CreateQadTrackingForGeneratedRequestsAsync(List<PurchaseRequestMain> requests, string account)
+    {
+        var candidates = SelectPushCandidates(requests);
+        if (candidates.Count == 0) return new PurchaseRequestExternalPushResult();
+
+        var pushedIds = new List<long>();
+        foreach (var group in candidates.GroupBy(x => x.TenantId.ToString()))
+        {
+            var seqId = GenerateSeqId();
+            foreach (var pr in group)
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO QadTracking
+                    (Ufld1, Domain, SeqID, TransType, Subject, `Order`, Int1, CreateTime, CreateUser, UpdateTime, UpdateUser)
+                    VALUES
+                    ('C', @Domain, @SeqID, 'nbr-pr-mes', '请购单', @Order, @Int1, @Now, @User, @Now, @User)
+                    """,
+                    new SugarParameter("@Domain", group.Key),
+                    new SugarParameter("@SeqID", seqId),
+                    new SugarParameter("@Order", pr.PrBillNo),
+                    new SugarParameter("@Int1", pr.Id),
+                    new SugarParameter("@Now", DateTime.Now),
+                    new SugarParameter("@User", account ?? string.Empty));
+
+                pr.State = 2;
+                pr.UpdateByName = account;
+                pr.UpdateTime = DateTime.Now;
+                pushedIds.Add(pr.Id);
+                seqId++;
+            }
+        }
+
+        return new PurchaseRequestExternalPushResult
+        {
+            TrackingCount = pushedIds.Count,
+            PushedPrIds = pushedIds
+        };
+    }
+
+    public static List<PurchaseRequestMain> SelectPushCandidates(IEnumerable<PurchaseRequestMain> requests)
+    {
+        return requests
+            .Where(x => x.IsRequireGoods == 0 && !string.IsNullOrWhiteSpace(x.PrBillNo))
+            .ToList();
+    }
+
+    private static long GenerateSeqId()
+    {
+        var now = DateTime.Now;
+        var value = string.Concat(
+            now.ToString("yyMMddHHmmss", CultureInfo.InvariantCulture),
+            now.Millisecond.ToString("D3", CultureInfo.InvariantCulture),
+            Random.Shared.Next(0, 1000).ToString("D3", CultureInfo.InvariantCulture));
+        return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seqId)
+            ? seqId + 1
+            : YitIdHelper.NextId();
+    }
+}
+
+public sealed class PurchaseRequestExternalPushResult
+{
+    public int TrackingCount { get; set; }
+    public List<long> PushedPrIds { get; set; } = new();
+}

+ 70 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestMergeService.cs

@@ -0,0 +1,70 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 采购申请合并服务。P3 阶段仅合并本次生成且尚未入库的 PR 候选,不改动历史 PR。
+/// </summary>
+public class PurchaseRequestMergeService : ITransient
+{
+    public PurchaseRequestMergeResult MergeGeneratedRequests(List<PurchaseRequestMain> requests)
+    {
+        if (requests.Count <= 1)
+        {
+            return new PurchaseRequestMergeResult
+            {
+                Requests = requests,
+                OriginalCount = requests.Count,
+                MergedCount = requests.Count
+            };
+        }
+
+        var merged = requests
+            .GroupBy(x => new
+            {
+                x.TenantId,
+                x.CompanyId,
+                x.FactoryId,
+                x.IcitemId,
+                x.PrPurchaseId,
+                x.IsRequireGoods,
+                SupplierType = x.SupplierType ?? string.Empty,
+                WeekStart = GetWeekStart(x.PrSsendDate)
+            })
+            .Select(group =>
+            {
+                var rows = group.OrderBy(x => x.PrSsendDate).ThenBy(x => x.PrSarriveDate).ThenBy(x => x.Id).ToList();
+                var first = rows.First();
+                first.PrRqty = rows.Sum(x => x.PrRqty ?? 0);
+                first.PrAqty = rows.Sum(x => x.PrAqty ?? 0);
+                first.PrSqty = rows.Sum(x => x.PrSqty ?? 0);
+                first.PrSsendDate = rows.Min(x => x.PrSsendDate);
+                first.PrSarriveDate = rows.Min(x => x.PrSarriveDate);
+                first.CreateTime = rows.Min(x => x.CreateTime);
+                first.UpdateTime = DateTime.Now;
+                return first;
+            })
+            .ToList();
+
+        return new PurchaseRequestMergeResult
+        {
+            Requests = merged,
+            OriginalCount = requests.Count,
+            MergedCount = merged.Count
+        };
+    }
+
+    private static DateTime? GetWeekStart(DateTime? value)
+    {
+        if (!value.HasValue) return null;
+        var date = value.Value.Date;
+        var diff = ((int)date.DayOfWeek + 6) % 7;
+        return date.AddDays(-diff);
+    }
+}
+
+public sealed class PurchaseRequestMergeResult
+{
+    public List<PurchaseRequestMain> Requests { get; set; } = new();
+    public int OriginalCount { get; set; }
+    public int MergedCount { get; set; }
+    public int ReducedCount => Math.Max(OriginalCount - MergedCount, 0);
+}

+ 62 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3OperationLogService.cs

@@ -0,0 +1,62 @@
+using System.Text.Json;
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// S3 业务操作日志写入(复用 Admin.NET sys_log_op)。
+/// </summary>
+public class S3OperationLogService : ITransient
+{
+    private readonly SqlSugarRepository<SysLogOp> _logOpRep;
+    private readonly UserManager _userManager;
+
+    public S3OperationLogService(SqlSugarRepository<SysLogOp> logOpRep, UserManager userManager)
+    {
+        _logOpRep = logOpRep;
+        _userManager = userManager;
+    }
+
+    /// <summary>
+    /// 写入 S3 操作日志;失败不影响主业务流程。
+    /// </summary>
+    public async Task WriteAsync(S3OperationLogDto dto)
+    {
+        try
+        {
+            await _logOpRep.InsertAsync(new SysLogOp
+            {
+                Id = YitIdHelper.NextId(),
+                DisplayTitle = dto.DisplayTitle,
+                ControllerName = "S3",
+                ActionName = dto.Action,
+                Message = dto.Message,
+                RequestParam = dto.Request == null ? null : JsonSerializer.Serialize(dto.Request),
+                ReturnResult = dto.Result == null ? null : JsonSerializer.Serialize(dto.Result),
+                Exception = dto.Exception,
+                LogDateTime = DateTime.Now,
+                Account = _userManager.Account,
+                RealName = _userManager.RealName,
+                CreateUserId = _userManager.UserId,
+                TenantId = _userManager.TenantId,
+                Status = dto.Success ? "200" : "500",
+                LogLevel = dto.Success ? Microsoft.Extensions.Logging.LogLevel.Information : Microsoft.Extensions.Logging.LogLevel.Error,
+            });
+        }
+        catch
+        {
+            // 日志失败不应影响 S3 业务动作。
+        }
+    }
+}
+
+public sealed class S3OperationLogDto
+{
+    public string Action { get; set; } = string.Empty;
+    public string DisplayTitle { get; set; } = string.Empty;
+    public string Message { get; set; } = string.Empty;
+    public object? Request { get; set; }
+    public object? Result { get; set; }
+    public string? Exception { get; set; }
+    public bool Success { get; set; } = true;
+}