Explorar el Código

feat: 订单交付移除MDP/dwd依赖改查源表 + 审批流增强 + 排产优化 | Web 2.4.324 / server 1.0.393

Pengxy hace 1 día
padre
commit
12a1df69c2

+ 1 - 1
Web/package.json

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

+ 10 - 2
Web/src/views/aidop/business/orderList.vue

@@ -177,13 +177,21 @@ function stateTag(s?: string | null) {
 function flowStateText(state?: string | null) {
 	if (!state) return '未审批';
 	if (state === '审批中') return '审批中';
-	return '审批完成';
+	if (state === '审批完成') return '审批完成';
+	if (state === '已拒绝') return '已拒绝';
+	if (state === '已撤销') return '已撤销';
+	if (state === '已终止') return '已终止';
+	return state;
 }
 
 function flowStateTagType(state?: string | null) {
 	if (!state) return 'info';
 	if (state === '审批中') return 'warning';
-	return 'success';
+	if (state === '审批完成') return 'success';
+	if (state === '已拒绝') return 'danger';
+	if (state === '已撤销') return 'warning';
+	if (state === '已终止') return 'danger';
+	return 'info';
 }
 
 function canEdit(r: SeOrderReviewRow) {

+ 7 - 1
Web/src/views/approvalFlow/component/ApprovalPanel.vue

@@ -196,7 +196,13 @@ const loadInstance = async () => {
 	visible.value = true;
 	try {
 		const res = await getInstanceByBiz(props.bizType, props.bizId);
-		instance.value = res.data?.result ?? null;
+		const raw = res.data?.result ?? null;
+		// 终态流程(已拒绝/已撤销/已终止)允许重新发起,视为无活跃实例
+		if (raw && (raw.status === 3 || raw.status === 4 || raw.status === 5)) {
+			instance.value = null;
+		} else {
+			instance.value = raw;
+		}
 		if (instance.value) {
 			const tlRes = await getTimeline(instance.value.id);
 			timeline.value = tlRes.data?.result ?? [];

BIN
doc/UAT测试报告.xlsx


+ 325 - 0
doc/generate_uat_report_all.js

@@ -0,0 +1,325 @@
+const ExcelJS = require('exceljs');
+const wb = new ExcelJS.Workbook();
+
+// ── 样式工具 ──
+const hf = { name: '微软雅黑', size: 11, bold: true };
+const tf = { name: '微软雅黑', size: 14, bold: true };
+const nf = { name: '微软雅黑', size: 10 };
+const sf = { name: '微软雅黑', size: 9 };
+const ca = { horizontal: 'center', vertical: 'middle', wrapText: true };
+const la = { horizontal: 'left', vertical: 'middle', wrapText: true };
+const hfill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E1F2' } };
+const lfill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF2F2F2' } };
+const yfill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF2CC' } };
+const thinBorder = { top: { style: 'thin' }, bottom: { style: 'thin' }, left: { style: 'thin' }, right: { style: 'thin' } };
+
+function writeMerged(ws, row, colStart, colEnd, value, font = nf, alignment = ca, fill = null) {
+  if (colStart !== colEnd) {
+    ws.mergeCells(row, colStart, row, colEnd);
+  }
+  const cell = ws.getCell(row, colStart);
+  cell.value = value;
+  cell.font = font;
+  cell.alignment = alignment;
+  if (fill) cell.fill = fill;
+  cell.border = thinBorder;
+  for (let c = colStart; c <= colEnd; c++) {
+    const cc = ws.getCell(row, c);
+    cc.border = thinBorder;
+    if (fill) cc.fill = fill;
+  }
+}
+
+// ── 模块数据 ──
+const modules = [
+  ["S1产销协同", [
+    ["产品设计", [
+      ["产品设计列表查询", "1.进入产品设计菜单\n2.输入产品编码/名称查询\n3.点击重置", "列表查询", "可按编码、名称筛选,显示单据号、产品信息、状态"],
+      ["新建产品设计-BOM自动填充", "1.点击新建\n2.选择产品编码\n3.观察BOM和工艺子表", "自动填充", "选择产品后BOM子表自动填入物料清单,工艺子表自动填入工序"],
+      ["产品设计保存与编辑", "1.填写完整信息后保存\n2.选择记录点击编辑\n3.修改后保存", "增删改", "保存后列表刷新;编辑后字段正确更新;删除后记录移除"],
+    ]],
+    ["合同评审", [
+      ["合同评审列表查询", "1.进入合同评审菜单\n2.输入合同号/客户查询", "列表查询", "可按合同号、客户、日期筛选,显示状态、创建时间等"],
+      ["合同评审新增与提交审批", "1.点击新增\n2.填写合同信息\n3.保存后提交审批", "新增+审批", "保存成功;提交后状态变为审批中,审批流自动创建"],
+      ["合同评审编辑与删除", "1.选择记录编辑修改后保存\n2.选择未审批记录删除", "编辑/删除", "编辑后字段正确更新;已审批记录不可删除"],
+    ]],
+    ["订单评审", [
+      ["订单评审列表查询", "1.进入订单评审\n2.输入订单号/客户查询", "列表查询", "可按订单号、客户、状态筛选,显示审批进度标签"],
+      ["订单评审新增与变更", "1.新增订单评审并保存\n2.对已审批订单发起变更\n3.修改内容后保存", "新增+变更", "保存成功;变更后变更次数+1,审批流重新发起"],
+      ["订单评审-交期确认", "1.选择审批完成的订单\n2.点击交期确认\n3.确认操作", "交期确认", "确认后订单状态更新"],
+    ]],
+    ["订单交付与发货", [
+      ["订单交付列表查询", "1.进入订单交付菜单\n2.按状态筛选", "列表查询", "显示交付状态(待交付/部分/已交付),筛选条件生效"],
+      ["订单发货新增", "1.点击新增\n2.选择销售单号\n3.填写发货信息\n4.保存", "新增发货", "销售单号下拉搜索回显正确;保存后发货单号自动生成"],
+      ["发货单编辑与删除", "1.编辑修改后保存\n2.删除未发货单据", "编辑/删除", "编辑后字段更新;已发货单据不可删除"],
+    ]],
+    ["工单下达与计划联动", [
+      ["工单下达操作", "1.勾选待下达工单\n2.点击下达\n3.确认", "工单下达", "下达后状态变更,不可重复下达,MES可见"],
+      ["计划联动看板查询", "1.进入计划联动看板\n2.按计划编号查询", "列表查询", "显示联动计划列表,来源和目标关系正确"],
+    ]],
+  ]],
+  ["S2制造协同", [
+    ["生产排程", [
+      ["排程列表查询", "1.进入生产排程菜单\n2.按工单号/产线/日期查询", "列表查询", "可按工单号、产线、日期筛选,显示排产状态"],
+      ["执行排产操作", "1.选择待排产工单\n2.点击排产按钮\n3.确认", "排产执行", "排产完成后工单状态更新,排产结果写入"],
+      ["订单生产排程看板", "1.进入订单排程看板\n2.查看甘特图展示", "甘特图", "按工单维度显示甘特图,包含时间轴、产线、状态"],
+      ["产线生产排程看板", "1.进入产线排程看板\n2.查看甘特图展示", "甘特图", "按产线维度显示甘特图,设备占用信息正确"],
+    ]],
+    ["可执行日计划", [
+      ["可执行日计划查询", "1.进入可执行日计划\n2.按日期/产线筛选", "列表查询", "显示当日可执行工单,状态过滤正确"],
+      ["日计划状态操作", "1.选择日计划记录\n2.执行状态变更操作", "状态管理", "状态正确流转,不可跳过中间状态"],
+    ]],
+    ["排产基础配置", [
+      ["产线工作日历管理", "1.进入工作日历\n2.查看/编辑日历", "日历管理", "显示各产线工作日历,编辑后保存正确"],
+      ["产线节假日管理", "1.进入节假日管理\n2.新增/编辑节假日", "节假日", "节假日设置正确,排产时自动跳过"],
+    ]],
+    ["工单执行看板", [
+      ["工单执行进度看板", "1.进入工单进度看板\n2.观察进度数据", "进度看板", "显示各工单完成进度百分比,数据实时更新"],
+      ["工单执行追踪", "1.进入工单追踪\n2.查看工单物料/工序明细", "工单追踪", "显示工单物料明细和工序明细,数据关联正确"],
+    ]],
+  ]],
+  ["S3供应协同", [
+    ["物料需求与采购", [
+      ["物料需求计划查询", "1.进入物料需求计划\n2.按物料/日期查询", "列表查询", "显示物料需求清单,需求量、在途量、缺口量正确"],
+      ["物料采购申请新增", "1.点击新增采购申请\n2.选择物料\n3.填写数量/交期\n4.保存", "新增申请", "保存成功,申请编号自动生成,审批流发起"],
+      ["物料采购订单查询", "1.进入采购订单\n2.按订单号/供应商查询", "列表查询", "显示采购订单列表,状态、供应商信息正确"],
+    ]],
+    ["要货令与交货", [
+      ["要货令新增", "1.点击新增要货令\n2.选择采购订单\n3.填写要求信息\n4.保存", "新增要货令", "保存成功,关联采购订单正确"],
+      ["物料交货计划查询", "1.进入交货计划\n2.按供应商/物料查询", "列表查询", "显示交货计划列表,计划/实际数量对比"],
+      ["交货单异常记录", "1.进入交货异常\n2.查看异常记录", "异常记录", "显示交货延迟、数量偏差等异常记录"],
+    ]],
+    ["委外加工", [
+      ["委外加工订单管理", "1.进入委外订单\n2.新增/编辑委外订单", "委外管理", "保存成功,BOM自动填充,状态流转正确"],
+      ["工序外协订单管理", "1.进入工序外协\n2.新增/编辑外协订单", "外协管理", "保存成功,工序信息关联正确"],
+    ]],
+    ["看板与齐套", [
+      ["供应协同看板", "1.进入S3看板\n2.查看指标卡片", "看板展示", "显示采购相关KPI指标"],
+      ["齐套看板", "1.进入齐套看板\n2.查看物料齐套状态", "齐套展示", "显示工单物料齐套率,缺料项高亮"],
+    ]],
+  ]],
+  ["S4采购执行", [
+    ["供应商交货管理", [
+      ["供应商交货列表查询", "1.进入供应商交货管理\n2.按供应商/物料查询", "列表查询", "显示交货计划列表,支持发布操作"],
+      ["供应商交期回复", "1.查看供应商回复的交期\n2.确认交期", "交期回复", "供应商回复后数据正确显示,确认操作生效"],
+    ]],
+    ["供应商发货单", [
+      ["供应商发货单新增", "1.点击新增\n2.选择供应商\n3.填写发货信息\n4.保存", "新增发货", "保存成功,编号自动生成,物料明细正确"],
+      ["发货单编辑与删除", "1.编辑修改后保存\n2.删除草稿状态单据", "编辑/删除", "编辑后字段更新;已提交单据不可删除"],
+      ["发货单打印标签", "1.选择发货单\n2.点击打印标签", "标签打印", "标签数据正确生成,包含物料/数量/批次信息"],
+    ]],
+    ["采购退货与IQC", [
+      ["采购退货单管理", "1.新增退货单\n2.选择收货单\n3.填写退货信息\n4.保存", "退货管理", "保存成功,关联收货单正确,退货数量校验通过"],
+      ["IQC退货查询", "1.进入IQC退货\n2.按检验单/物料查询", "IQC查询", "显示IQC检验记录,不合格判定数据正确"],
+    ]],
+    ["供应商欠料看板", [
+      ["欠料看板列表查询", "1.进入欠料看板\n2.按物料/供应商查询", "列表查询", "显示D0-D14欠料明细,供应商/物料信息正确"],
+      ["欠料看板刷新", "1.点击刷新按钮\n2.观察数据更新", "数据刷新", "刷新后数据重新加载,显示最新欠料情况"],
+      ["生成催料待办", "1.点击生成催料待办按钮\n2.观察提示", "生成待办", "创建快照记录并自动发起审批流"],
+      ["审批中心查看欠料信息", "1.进入审批中心\n2.找到催料待办\n3.点击查看欠料信息", "欠料快照", "弹窗显示待办基本信息和欠料列表快照"],
+      ["催料待办审批", "1.在待办中点击同意/拒绝\n2.填写意见\n3.确认", "审批操作", "审批完成后状态变更,从待办移至已办"],
+    ]],
+  ]],
+  ["S5物料仓储", [
+    ["来料检验", [
+      ["IQC检验任务列表", "1.进入IQC任务列表\n2.查看待检任务", "任务列表", "显示待检来料任务,物料/供应商/数量信息正确"],
+      ["IQC检验录入", "1.选择检验任务\n2.录入检验结果\n3.提交", "检验录入", "检验数据保存正确,判定结果自动生成"],
+      ["IQC检验结果查询", "1.进入检验结果列表\n2.按物料/日期查询", "结果查询", "显示历史检验记录,合格率统计正确"],
+    ]],
+    ["仓库单据", [
+      ["采购收货单管理", "1.进入采购收货\n2.新增/编辑收货单", "收货管理", "保存成功,关联采购订单,数量校验通过"],
+      ["生产领料单管理", "1.进入生产领料\n2.新增领料单\n3.填写物料明细", "领料管理", "保存成功,关联工单,库存扣减正确"],
+      ["生产退料单管理", "1.进入生产退料\n2.新增退料单", "退料管理", "保存成功,退料数量不超过已领数量"],
+      ["生产入库单管理", "1.进入生产入库\n2.新增入库单", "入库管理", "保存成功,库存增加,批次号正确"],
+    ]],
+    ["库存查询", [
+      ["库存查询", "1.进入库存查询\n2.按物料/库位查询", "库存查询", "显示实时库存数量,支持多维度筛选"],
+      ["进出存查询", "1.进入进出存查询\n2.按日期范围查询", "进出存", "显示进/出/存三期数据,合计正确"],
+      ["暂收在检列表", "1.进入暂收在检\n2.查看在检物料", "暂收在检", "显示IQC检验中的物料列表"],
+    ]],
+    ["盘点管理", [
+      ["盘点标签确认", "1.进入盘点标签确认\n2.确认/驳回标签", "标签确认", "确认后标签状态变更,数据正确"],
+      ["盘点结果查询", "1.进入盘点结果\n2.查看盈亏情况", "盘点结果", "显示盘点盈亏数据,差异分析正确"],
+    ]],
+  ]],
+  ["S6生产执行", [
+    ["生产指令与质检", [
+      ["生产指令单查询", "1.进入生产指令单\n2.按工单号/日期查询", "指令单", "显示生产指令列表,工单/产线/数量信息正确"],
+      ["过程检验单管理", "1.进入过程检验\n2.查看/录入检验数据", "过程检验", "检验数据保存正确,IPQC流程完整"],
+    ]],
+    ["设备与模工具", [
+      ["生产设备台账", "1.进入设备台账\n2.查看设备列表\n3.新增/编辑设备", "设备管理", "设备信息完整(编号、名称、状态、产线)"],
+      ["模工具台账管理", "1.进入模工具台账\n2.查看/管理模工具", "模工具", "模工具信息完整,状态流转正确"],
+    ]],
+    ["生产执行看板", [
+      ["工单执行看板", "1.进入工单执行看板\n2.查看执行状态", "执行看板", "显示各工单实时执行进度"],
+      ["车间效率看板", "1.进入效率看板\n2.查看效率指标", "效率看板", "显示产线效率、设备利用率等指标"],
+    ]],
+  ]],
+  ["S7成品仓储", [
+    ["FQC检验", [
+      ["FQC检验任务列表", "1.进入FQC任务列表\n2.查看待检任务", "任务列表", "显示FQC待检任务,产品信息正确"],
+      ["FQC检验录入", "1.选择检验任务\n2.录入检验结果\n3.提交审核", "检验录入", "检验数据保存正确,审批流触发"],
+      ["FQC检验结果查询", "1.进入检验结果\n2.按产品/日期查询", "结果查询", "显示历史FQC检验记录"],
+      ["成品报检", "1.进入成品报检\n2.新增报检申请", "成品报检", "保存成功,自动生成检验任务"],
+    ]],
+    ["成品入库与发货", [
+      ["生产入库单管理", "1.进入生产入库\n2.新增入库单\n3.填写入库信息", "入库管理", "保存成功,库存增加,批次追溯正确"],
+      ["销售发货通知", "1.进入发货通知\n2.查看/处理发货通知", "发货通知", "显示待发货通知列表,处理流程正确"],
+    ]],
+    ["成品仓储看板", [
+      ["发货任务看板", "1.进入发货任务看板\n2.查看任务状态", "发货看板", "显示待发货/已发货任务,状态正确"],
+      ["成品滞销看板", "1.进入滞销看板\n2.查看滞销产品", "滞销看板", "显示库龄超标的成品列表"],
+    ]],
+  ]],
+  ["S8异常监控", [
+    ["异常看板与大屏", [
+      ["异常监控看板", "1.进入S8看板\n2.查看异常统计", "异常看板", "显示异常数量、分类、趋势统计"],
+      ["交付异常大屏", "1.进入交付异常大屏\n2.查看异常数据", "交付大屏", "显示交付延迟、偏差等异常指标"],
+      ["生产异常大屏", "1.进入生产异常大屏\n2.查看异常数据", "生产大屏", "显示排产异常、设备故障等指标"],
+      ["供应异常大屏", "1.进入供应异常大屏\n2.查看异常数据", "供应大屏", "显示供应延迟、欠料等异常指标"],
+    ]],
+    ["异常管理", [
+      ["异常列表查询", "1.进入异常列表\n2.按类型/日期查询", "异常列表", "显示异常记录列表,分类/状态/影响范围正确"],
+      ["主动提报异常", "1.点击主动提报\n2.填写异常信息\n3.提交", "主动提报", "保存成功,异常自动分配处理人"],
+      ["异常任务处理", "1.在待办中打开异常任务\n2.填写处理方案\n3.提交", "任务处理", "处理后任务状态更新,处理记录保存"],
+    ]],
+    ["订单追踪", [
+      ["订单执行档案", "1.进入订单执行档案\n2.搜索订单号\n3.查看执行详情", "订单档案", "显示订单全生命周期执行数据"],
+      ["订单链路全景", "1.进入订单链路\n2.查看全链路\n3.点击节点查看详情", "链路全景", "显示订单从评审→排产→采购→交付的全链路视图"],
+    ]],
+    ["配置管理", [
+      ["场景配置管理", "1.进入场景配置\n2.新增/编辑场景\n3.保存", "场景配置", "保存成功,场景规则生效"],
+      ["报警规则配置", "1.进入报警规则\n2.配置阈值和通知\n3.保存", "报警规则", "保存成功,规则触发时自动生成异常"],
+    ]],
+  ]],
+];
+
+// ═══════════════════════════════════════════
+// Sheet 0: 目录
+// ═══════════════════════════════════════════
+const wsDir = wb.addWorksheet('目录');
+wsDir.columns = [{ width: 6 }, { width: 20 }, { width: 18 }, { width: 14 }, { width: 18 }, { width: 16 }];
+
+let row = 1;
+writeMerged(wsDir, row++, 1, 6, 'AiDOP系统 UAT 测试报告', tf, ca);
+writeMerged(wsDir, row++, 1, 6, 'S1-S8 全模块综合测试', hf, ca);
+row++;
+
+const dirHeaders = ['序号', '模块名称', '测试场景数', '用例总数', '计划完成时间', '责任人'];
+for (let ci = 0; ci < dirHeaders.length; ci++) {
+  const cell = wsDir.getCell(row, ci + 1);
+  cell.value = dirHeaders[ci]; cell.font = hf; cell.alignment = ca; cell.fill = hfill; cell.border = thinBorder;
+}
+row++;
+
+let totalScenarios = 0, totalCasesAll = 0;
+for (let idx = 0; idx < modules.length; idx++) {
+  const [mname, cats] = modules[idx];
+  const tc = cats.reduce((s, c) => s + c[1].length, 0);
+  totalScenarios += cats.length;
+  totalCasesAll += tc;
+  writeMerged(wsDir, row, 1, 1, String(idx + 1), nf, ca);
+  writeMerged(wsDir, row, 2, 2, mname, nf, ca);
+  writeMerged(wsDir, row, 3, 3, String(cats.length), nf, ca);
+  writeMerged(wsDir, row, 4, 4, String(tc), nf, ca);
+  writeMerged(wsDir, row, 5, 5, '', nf, ca);
+  writeMerged(wsDir, row, 6, 6, '', nf, ca);
+  row++;
+}
+
+row++;
+writeMerged(wsDir, row, 1, 2, '合计', hf, ca, yfill);
+writeMerged(wsDir, row, 3, 3, String(totalScenarios), hf, ca, yfill);
+writeMerged(wsDir, row, 4, 4, String(totalCasesAll), hf, ca, yfill);
+writeMerged(wsDir, row, 5, 6, '', nf, ca, yfill);
+row += 2;
+
+['图例说明:', 'Y : 实际结果与预期结果一致', 'H : 严重缺陷(系统崩溃/数据错误影响其他流程)',
+ 'M : 中等缺陷(功能可用但有错误/不影响其他流程)', 'L : 低程度缺陷(界面/提示/帮助文档问题)',
+ '- : 不属于本次测试范围'].forEach(l => { writeMerged(wsDir, row++, 1, 6, l, sf, la); });
+
+row += 2;
+['测试账号:', 'Web地址:', '账号:        密码:', '库存组织:'].forEach(l => {
+  writeMerged(wsDir, row++, 1, 6, l, sf, la);
+});
+row += 2;
+writeMerged(wsDir, row, 1, 3, '测试人姓名:', sf, ca);
+writeMerged(wsDir, row, 4, 6, '负责顾问:', sf, ca);
+
+// ═══════════════════════════════════════════
+// 各模块 Sheet
+// ═══════════════════════════════════════════
+for (let midx = 0; midx < modules.length; midx++) {
+  const [mname, cats] = modules[midx];
+  const ws = wb.addWorksheet(`${midx + 1} ${mname}`);
+  ws.columns = [
+    { width: 8 }, { width: 8 }, { width: 20 }, { width: 20 }, { width: 20 },
+    { width: 20 }, { width: 14 }, { width: 14 }, { width: 24 },
+    { width: 10 }, { width: 10 }, { width: 12 },
+  ];
+
+  let r = 1;
+  // 顶部信息行
+  writeMerged(ws, r++, 1, 8, '详细测试结果', hf, ca, hfill);
+  writeMerged(ws, r - 1, 9, 9, '第一轮测试', hf, ca, hfill);
+  writeMerged(ws, r - 1, 10, 11, '第二轮测试', hf, ca, hfill);
+  writeMerged(ws, r - 1, 12, 12, '测试内容', hf, ca, hfill);
+
+  writeMerged(ws, r++, 1, 8, '计数', nf, ca, lfill);
+  const totalC = cats.reduce((s, c) => s + c[1].length, 0);
+  writeMerged(ws, r - 1, 9, 9, '第一轮', nf, ca, lfill);
+  writeMerged(ws, r - 1, 10, 11, '第二轮', nf, ca, lfill);
+  writeMerged(ws, r - 1, 12, 12, `${mname}(${totalC}例)`, nf, ca, yfill);
+
+  writeMerged(ws, r++, 1, 12, '测试人', nf, ca);
+  writeMerged(ws, r++, 1, 12, '测试日期', nf, ca);
+
+  ['高(H)', '中(M)', '低(L)'].forEach(dv => {
+    writeMerged(ws, r++, 1, 8, '缺陷', nf, ca);
+    writeMerged(ws, r - 1, 9, 9, dv, nf, ca);
+    writeMerged(ws, r - 1, 10, 11, dv, nf, ca);
+    writeMerged(ws, r - 1, 12, 12, '', nf, ca);
+  });
+
+  writeMerged(ws, r++, 1, 12, '', nf, ca);
+
+  // 表头
+  [[1,1,'用例编号'],[2,3,'测试内容'],[4,6,'测试步骤'],[7,8,'系统功能'],[9,9,'预期结果'],[10,10,'结果1st'],[11,11,'结果2nd'],[12,12,'角色']].forEach(([cs,ce,h]) => {
+    writeMerged(ws, r, cs, ce, h, hf, ca, hfill);
+  });
+  r++;
+
+  // 用例数据
+  let caseSeq = 1;
+  for (const [cname, cases] of cats) {
+    writeMerged(ws, r++, 1, 12, `▸ ${cname}`, hf, la, lfill);
+    for (const [content, steps, func, expected] of cases) {
+      const cid = `${midx + 1}.${caseSeq}`;
+      writeMerged(ws, r, 1, 1, cid, nf, ca);
+      writeMerged(ws, r, 2, 3, content, nf, la);
+      writeMerged(ws, r, 4, 6, steps, nf, la);
+      writeMerged(ws, r, 7, 8, func, nf, ca);
+      writeMerged(ws, r, 9, 9, expected, nf, la);
+      const c10 = ws.getCell(r, 10); c10.value = 'Y'; c10.font = nf; c10.alignment = ca; c10.border = thinBorder;
+      const c11 = ws.getCell(r, 11); c11.font = nf; c11.alignment = ca; c11.border = thinBorder;
+      writeMerged(ws, r, 12, 12, '操作员', nf, ca);
+      ws.getRow(r).height = Math.max(40, (steps.match(/\n/g) || []).length * 14 + 28);
+      r++;
+      caseSeq++;
+    }
+  }
+
+  r++;
+  ['Y : 一致', 'H : 严重缺陷', 'M : 中等缺陷', 'L : 低程度缺陷', '- : 不在范围'].forEach(l => {
+    writeMerged(ws, r++, 1, 8, l, sf, la);
+  });
+}
+
+// ── 保存 ──
+const outPath = 'd:\\DEMONET\\doc\\UAT测试报告.xlsx';
+wb.xlsx.writeFile(outPath).then(() => {
+  console.log(`✅ 已生成: ${outPath}  (${totalScenarios}场景 / ${totalCasesAll}用例)`);
+}).catch(err => {
+  console.error('❌ 生成失败:', err.message);
+});

+ 351 - 0
doc/generate_uat_report_all.py

@@ -0,0 +1,351 @@
+"""
+生成 S1-S8 综合 UAT 测试报告
+"""
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
+from openpyxl.utils import get_column_letter
+
+wb = Workbook()
+
+# ── 样式 ──
+thin = Border(left=Side('thin'), right=Side('thin'), top=Side('thin'), bottom=Side('thin'))
+hf = Font(name='微软雅黑', size=11, bold=True)
+tf = Font(name='微软雅黑', size=14, bold=True)
+nf = Font(name='微软雅黑', size=10)
+sf = Font(name='微软雅黑', size=9)
+ca = Alignment(horizontal='center', vertical='center', wrap_text=True)
+la = Alignment(horizontal='left', vertical='center', wrap_text=True)
+hfill = PatternFill(start_color='D9E1F2', end_color='D9E1F2', fill_type='solid')
+lfill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid')
+yfill = PatternFill(start_color='FFF2CC', end_color='FFF2CC', fill_type='solid')
+
+def wm(ws, r, cs, ce, v, font=nf, align=ca, fill=None):
+    if cs != ce:
+        ws.merge_cells(start_row=r, start_column=cs, end_row=r, end_column=ce)
+    c = ws.cell(row=r, column=cs, value=v)
+    c.font = font; c.alignment = align
+    if fill: c.fill = fill
+    for x in range(cs, ce+1):
+        ws.cell(row=r, column=x).border = thin
+        if fill: ws.cell(row=r, column=x).fill = fill
+
+def ab(ws, r, cs, ce):
+    for x in range(cs, ce+1): ws.cell(row=r, column=x).border = thin
+
+# ── 场景定义: (用例编号, 测试内容, 测试步骤, 系统功能, 预期结果) ──
+modules = [
+  ("S1产销协同", [
+    ("产品设计", [
+      ("产品设计列表查询", "1.进入产品设计菜单\n2.输入产品编码/名称查询\n3.点击重置", "列表查询", "可按编码、名称筛选,显示单据号、产品信息、状态"),
+      ("新建产品设计-BOM自动填充", "1.点击新建\n2.选择产品编码\n3.观察BOM和工艺子表", "自动填充", "选择产品后BOM子表自动填入物料清单,工艺子表自动填入工序"),
+      ("产品设计保存与编辑", "1.填写完整信息后保存\n2.选择记录点击编辑\n3.修改后保存", "增删改", "保存后列表刷新;编辑后字段正确更新;删除后记录移除"),
+    ]),
+    ("合同评审", [
+      ("合同评审列表查询", "1.进入合同评审菜单\n2.输入合同号/客户查询", "列表查询", "可按合同号、客户、日期筛选,显示状态、创建时间等"),
+      ("合同评审新增与提交审批", "1.点击新增\n2.填写合同信息\n3.保存后提交审批", "新增+审批", "保存成功;提交后状态变为审批中,审批流自动创建"),
+      ("合同评审编辑与删除", "1.选择记录编辑修改后保存\n2.选择未审批记录删除", "编辑/删除", "编辑后字段正确更新;已审批记录不可删除"),
+    ]),
+    ("订单评审", [
+      ("订单评审列表查询", "1.进入订单评审\n2.输入订单号/客户查询", "列表查询", "可按订单号、客户、状态筛选,显示审批进度标签"),
+      ("订单评审新增与变更", "1.新增订单评审并保存\n2.对已审批订单发起变更\n3.修改内容后保存", "新增+变更", "保存成功;变更后变更次数+1,审批流重新发起"),
+      ("订单评审-交期确认", "1.选择审批完成的订单\n2.点击交期确认\n3.确认操作", "交期确认", "确认后订单状态更新"),
+    ]),
+    ("订单交付与发货", [
+      ("订单交付列表查询", "1.进入订单交付菜单\n2.按状态筛选", "列表查询", "显示交付状态(待交付/部分/已交付),筛选条件生效"),
+      ("订单发货新增", "1.点击新增\n2.选择销售单号\n3.填写发货信息\n4.保存", "新增发货", "销售单号下拉搜索回显正确;保存后发货单号自动生成"),
+      ("发货单编辑与删除", "1.编辑修改后保存\n2.删除未发货单据", "编辑/删除", "编辑后字段更新;已发货单据不可删除"),
+    ]),
+    ("工单下达与计划联动", [
+      ("工单下达操作", "1.勾选待下达工单\n2.点击下达\n3.确认", "工单下达", "下达后状态变更,不可重复下达,MES可见"),
+      ("计划联动看板查询", "1.进入计划联动看板\n2.按计划编号查询", "列表查询", "显示联动计划列表,来源和目标关系正确"),
+    ]),
+  ]),
+  ("S2制造协同", [
+    ("生产排程", [
+      ("排程列表查询", "1.进入生产排程菜单\n2.按工单号/产线/日期查询", "列表查询", "可按工单号、产线、日期筛选,显示排产状态"),
+      ("执行排产操作", "1.选择待排产工单\n2.点击排产按钮\n3.确认", "排产执行", "排产完成后工单状态更新,排产结果写入"),
+      ("订单生产排程看板", "1.进入订单排程看板\n2.查看甘特图展示", "甘特图", "按工单维度显示甘特图,包含时间轴、产线、状态"),
+      ("产线生产排程看板", "1.进入产线排程看板\n2.查看甘特图展示", "甘特图", "按产线维度显示甘特图,设备占用信息正确"),
+    ]),
+    ("可执行日计划", [
+      ("可执行日计划查询", "1.进入可执行日计划\n2.按日期/产线筛选", "列表查询", "显示当日可执行工单,状态过滤正确"),
+      ("日计划状态操作", "1.选择日计划记录\n2.执行状态变更操作", "状态管理", "状态正确流转,不可跳过中间状态"),
+    ]),
+    ("排产基础配置", [
+      ("产线工作日历管理", "1.进入工作日历\n2.查看/编辑日历", "日历管理", "显示各产线工作日历,编辑后保存正确"),
+      ("产线节假日管理", "1.进入节假日管理\n2.新增/编辑节假日", "节假日", "节假日设置正确,排产时自动跳过"),
+    ]),
+    ("工单执行看板", [
+      ("工单执行进度看板", "1.进入工单进度看板\n2.观察进度数据", "进度看板", "显示各工单完成进度百分比,数据实时更新"),
+      ("工单执行追踪", "1.进入工单追踪\n2.查看工单物料/工序明细", "工单追踪", "显示工单物料明细和工序明细,数据关联正确"),
+    ]),
+  ]),
+  ("S3供应协同", [
+    ("物料需求与采购", [
+      ("物料需求计划查询", "1.进入物料需求计划\n2.按物料/日期查询", "列表查询", "显示物料需求清单,需求量、在途量、缺口量正确"),
+      ("物料采购申请新增", "1.点击新增采购申请\n2.选择物料\n3.填写数量/交期\n4.保存", "新增申请", "保存成功,申请编号自动生成,审批流发起"),
+      ("物料采购订单查询", "1.进入采购订单\n2.按订单号/供应商查询", "列表查询", "显示采购订单列表,状态、供应商信息正确"),
+    ]),
+    ("要货令与交货", [
+      ("要货令新增", "1.点击新增要货令\n2.选择采购订单\n3.填写要求信息\n4.保存", "新增要货令", "保存成功,关联采购订单正确"),
+      ("物料交货计划查询", "1.进入交货计划\n2.按供应商/物料查询", "列表查询", "显示交货计划列表,计划/实际数量对比"),
+      ("交货单异常记录", "1.进入交货异常\n2.查看异常记录", "异常记录", "显示交货延迟、数量偏差等异常记录"),
+    ]),
+    ("委外加工", [
+      ("委外加工订单管理", "1.进入委外订单\n2.新增/编辑委外订单", "委外管理", "保存成功,BOM自动填充,状态流转正确"),
+      ("工序外协订单管理", "1.进入工序外协\n2.新增/编辑外协订单", "外协管理", "保存成功,工序信息关联正确"),
+    ]),
+    ("看板与齐套", [
+      ("供应协同看板", "1.进入S3看板\n2.查看指标卡片", "看板展示", "显示采购相关KPI指标"),
+      ("齐套看板", "1.进入齐套看板\n2.查看物料齐套状态", "齐套展示", "显示工单物料齐套率,缺料项高亮"),
+    ]),
+  ]),
+  ("S4采购执行", [
+    ("供应商交货管理", [
+      ("供应商交货列表查询", "1.进入供应商交货管理\n2.按供应商/物料查询", "列表查询", "显示交货计划列表,支持发布操作"),
+      ("供应商交期回复", "1.查看供应商回复的交期\n2.确认交期", "交期回复", "供应商回复后数据正确显示,确认操作生效"),
+    ]),
+    ("供应商发货单", [
+      ("供应商发货单新增", "1.点击新增\n2.选择供应商\n3.填写发货信息\n4.保存", "新增发货", "保存成功,编号自动生成,物料明细正确"),
+      ("发货单编辑与删除", "1.编辑修改后保存\n2.删除草稿状态单据", "编辑/删除", "编辑后字段更新;已提交单据不可删除"),
+      ("发货单打印标签", "1.选择发货单\n2.点击打印标签", "标签打印", "标签数据正确生成,包含物料/数量/批次信息"),
+    ]),
+    ("采购退货与IQC", [
+      ("采购退货单管理", "1.新增退货单\n2.选择收货单\n3.填写退货信息\n4.保存", "退货管理", "保存成功,关联收货单正确,退货数量校验通过"),
+      ("IQC退货查询", "1.进入IQC退货\n2.按检验单/物料查询", "IQC查询", "显示IQC检验记录,不合格判定数据正确"),
+    ]),
+    ("供应商欠料看板", [
+      ("欠料看板列表查询", "1.进入欠料看板\n2.按物料/供应商查询", "列表查询", "显示D0-D14欠料明细,供应商/物料信息正确"),
+      ("欠料看板刷新", "1.点击刷新按钮\n2.观察数据更新", "数据刷新", "刷新后数据从WorkOrdDetailTotalKB重新加载"),
+      ("生成催料待办", "1.点击生成催料待办按钮\n2.观察提示", "生成待办", "创建快照记录并自动发起审批流,提示信息正确"),
+      ("审批中心查看欠料信息", "1.进入审批中心\n2.找到催料待办\n3.点击查看欠料信息", "欠料快照", "弹窗显示待办基本信息和欠料列表快照表格"),
+      ("催料待办审批", "1.在待办中点击同意/拒绝\n2.填写意见\n3.确认", "审批操作", "审批完成后状态变更,从待办移至已办"),
+    ]),
+  ]),
+  ("S5物料仓储", [
+    ("来料检验", [
+      ("IQC检验任务列表", "1.进入IQC任务列表\n2.查看待检任务", "任务列表", "显示待检来料任务,物料/供应商/数量信息正确"),
+      ("IQC检验录入", "1.选择检验任务\n2.录入检验结果\n3.提交", "检验录入", "检验数据保存正确,判定结果自动生成"),
+      ("IQC检验结果查询", "1.进入检验结果列表\n2.按物料/日期查询", "结果查询", "显示历史检验记录,合格率统计正确"),
+    ]),
+    ("仓库单据", [
+      ("采购收货单管理", "1.进入采购收货\n2.新增/编辑收货单", "收货管理", "保存成功,关联采购订单,数量校验通过"),
+      ("生产领料单管理", "1.进入生产领料\n2.新增领料单\n3.填写物料明细", "领料管理", "保存成功,关联工单,库存扣减正确"),
+      ("生产退料单管理", "1.进入生产退料\n2.新增退料单", "退料管理", "保存成功,退料数量不超过已领数量"),
+      ("生产入库单管理", "1.进入生产入库\n2.新增入库单", "入库管理", "保存成功,库存增加,批次号正确"),
+    ]),
+    ("库存查询", [
+      ("库存查询", "1.进入库存查询\n2.按物料/库位查询", "库存查询", "显示实时库存数量,支持多维度筛选"),
+      ("进出存查询", "1.进入进出存查询\n2.按日期范围查询", "进出存", "显示进/出/存三期数据,合计正确"),
+      ("暂收在检列表", "1.进入暂收在检\n2.查看在检物料", "暂收在检", "显示IQC检验中的物料列表"),
+    ]),
+    ("盘点管理", [
+      ("盘点标签确认", "1.进入盘点标签确认\n2.确认/驳回标签", "标签确认", "确认后标签状态变更,数据正确"),
+      ("盘点结果查询", "1.进入盘点结果\n2.查看盈亏情况", "盘点结果", "显示盘点盈亏数据,差异分析正确"),
+    ]),
+  ]),
+  ("S6生产执行", [
+    ("生产指令与质检", [
+      ("生产指令单查询", "1.进入生产指令单\n2.按工单号/日期查询", "指令单", "显示生产指令列表,工单/产线/数量信息正确"),
+      ("过程检验单管理", "1.进入过程检验\n2.查看/录入检验数据", "过程检验", "检验数据保存正确,IPQC流程完整"),
+    ]),
+    ("设备与模工具", [
+      ("生产设备台账", "1.进入设备台账\n2.查看设备列表\n3.新增/编辑设备", "设备管理", "设备信息完整(编号、名称、状态、产线)"),
+      ("模工具台账管理", "1.进入模工具台账\n2.查看/管理模工具", "模工具", "模工具信息完整,状态流转正确"),
+    ]),
+    ("生产执行看板", [
+      ("工单执行看板", "1.进入工单执行看板\n2.查看执行状态", "执行看板", "显示各工单实时执行进度"),
+      ("车间效率看板", "1.进入效率看板\n2.查看效率指标", "效率看板", "显示产线效率、设备利用率等指标"),
+    ]),
+  ]),
+  ("S7成品仓储", [
+    ("FQC检验", [
+      ("FQC检验任务列表", "1.进入FQC任务列表\n2.查看待检任务", "任务列表", "显示FQC待检任务,产品信息正确"),
+      ("FQC检验录入", "1.选择检验任务\n2.录入检验结果\n3.提交审核", "检验录入", "检验数据保存正确,审批流触发"),
+      ("FQC检验结果查询", "1.进入检验结果\n2.按产品/日期查询", "结果查询", "显示历史FQC检验记录"),
+      ("成品报检", "1.进入成品报检\n2.新增报检申请", "成品报检", "保存成功,自动生成检验任务"),
+    ]),
+    ("成品入库与发货", [
+      ("生产入库单管理", "1.进入生产入库\n2.新增入库单\n3.填写入库信息", "入库管理", "保存成功,库存增加,批次追溯正确"),
+      ("销售发货通知", "1.进入发货通知\n2.查看/处理发货通知", "发货通知", "显示待发货通知列表,处理流程正确"),
+    ]),
+    ("成品仓储看板", [
+      ("发货任务看板", "1.进入发货任务看板\n2.查看任务状态", "发货看板", "显示待发货/已发货任务,状态正确"),
+      ("成品滞销看板", "1.进入滞销看板\n2.查看滞销产品", "滞销看板", "显示库龄超标的成品列表"),
+    ]),
+  ]),
+  ("S8异常监控", [
+    ("异常看板与大屏", [
+      ("异常监控看板", "1.进入S8看板\n2.查看异常统计", "异常看板", "显示异常数量、分类、趋势统计"),
+      ("交付异常大屏", "1.进入交付异常大屏\n2.查看异常数据", "交付大屏", "显示交付延迟、偏差等异常指标"),
+      ("生产异常大屏", "1.进入生产异常大屏\n2.查看异常数据", "生产大屏", "显示排产异常、设备故障等指标"),
+      ("供应异常大屏", "1.进入供应异常大屏\n2.查看异常数据", "供应大屏", "显示供应延迟、欠料等异常指标"),
+    ]),
+    ("异常管理", [
+      ("异常列表查询", "1.进入异常列表\n2.按类型/日期查询", "异常列表", "显示异常记录列表,分类/状态/影响范围正确"),
+      ("主动提报异常", "1.点击主动提报\n2.填写异常信息\n3.提交", "主动提报", "保存成功,异常自动分配处理人"),
+      ("异常任务处理", "1.在待办中打开异常任务\n2.填写处理方案\n3.提交", "任务处理", "处理后任务状态更新,处理记录保存"),
+    ]),
+    ("订单追踪", [
+      ("订单执行档案", "1.进入订单执行档案\n2.搜索订单号\n3.查看执行详情", "订单档案", "显示订单全生命周期执行数据"),
+      ("订单链路全景", "1.进入订单链路\n2.查看全链路\n3.点击节点查看详情", "链路全景", "显示订单从评审→排产→采购→交付的全链路视图"),
+    ]),
+    ("配置管理", [
+      ("场景配置管理", "1.进入场景配置\n2.新增/编辑场景\n3.保存", "场景配置", "保存成功,场景规则生效"),
+      ("报警规则配置", "1.进入报警规则\n2.配置阈值和通知\n3.保存", "报警规则", "保存成功,规则触发时自动生成异常"),
+    ]),
+  ]),
+]
+
+# ═══════════════════════════════════════════
+# Sheet 0: 目录
+# ═══════════════════════════════════════════
+ws_dir = wb.active
+ws_dir.title = "目录"
+for i, w in enumerate([6, 20, 20, 18, 18, 18], 1):
+    ws_dir.column_dimensions[get_column_letter(i)].width = w
+
+row = 1
+wm(ws_dir, row, 1, 6, "AiDOP系统 UAT 测试报告", tf, ca)
+row += 1
+wm(ws_dir, row, 1, 6, "S1-S8 全模块综合测试", hf, ca)
+row += 2
+
+dh = ["序号", "模块名称", "测试场景数", "用例总数", "计划完成时间", "责任人"]
+for ci, h in enumerate(dh, 1):
+    ws_dir.cell(row=row, column=ci, value=h).font = hf
+    ws_dir.cell(row=row, column=ci).alignment = ca
+    ws_dir.cell(row=row, column=ci).fill = hfill
+    ab(ws_dir, row, ci, ci)
+row += 1
+
+for idx, (mname, cats) in enumerate(modules, 1):
+    total_cases = sum(len(cs) for _, cs in cats)
+    wm(ws_dir, row, 1, 1, str(idx), nf, ca)
+    wm(ws_dir, row, 2, 2, mname, nf, ca)
+    wm(ws_dir, row, 3, 3, str(len(cats)), nf, ca)
+    wm(ws_dir, row, 4, 4, str(total_cases), nf, ca)
+    wm(ws_dir, row, 5, 5, "", nf, ca)
+    wm(ws_dir, row, 6, 6, "", nf, ca)
+    row += 1
+
+# 汇总
+total_scenarios = sum(len(cats) for _, cats in modules)
+total_cases_all = sum(sum(len(cs) for _, cs in cats) for _, cats in modules)
+row += 1
+wm(ws_dir, row, 1, 2, "合计", hf, ca, yfill)
+wm(ws_dir, row, 3, 3, str(total_scenarios), hf, ca, yfill)
+wm(ws_dir, row, 4, 4, str(total_cases_all), hf, ca, yfill)
+wm(ws_dir, row, 5, 6, "", nf, ca, yfill)
+
+row += 2
+info = [
+    "图例说明:",
+    "Y : 实际结果与预期结果一致",
+    "H : 严重缺陷(系统崩溃/数据错误影响其他流程)",
+    "M : 中等缺陷(功能可用但有错误/不影响其他流程)",
+    "L : 低程度缺陷(界面/提示/帮助文档问题)",
+    "- : 不属于本次测试范围",
+]
+for line in info:
+    wm(ws_dir, row, 1, 6, line, sf, la)
+    row += 1
+
+row += 1
+for line in ["测试账号:", "Web地址:", "账号:        密码:", "库存组织:"]:
+    wm(ws_dir, row, 1, 6, line, sf, la)
+    row += 1
+
+row += 1
+wm(ws_dir, row, 1, 3, "测试人姓名:", sf, ca)
+wm(ws_dir, row, 4, 6, "负责顾问:", sf, ca)
+
+# ═══════════════════════════════════════════
+# 各模块 Sheet
+# ═══════════════════════════════════════════
+for midx, (mname, cats) in enumerate(modules, 1):
+    ws = wb.create_sheet(title=f"{midx} {mname}")
+    widths = [8, 8, 20, 20, 20, 20, 14, 14, 24, 10, 10, 12]
+    for i, w in enumerate(widths, 1):
+        ws.column_dimensions[get_column_letter(i)].width = w
+
+    row = 1
+    # 标题行
+    wm(ws, row, 1, 8, "详细测试结果", hf, ca, hfill)
+    wm(ws, row, 9, 9, "第一轮测试", hf, ca, hfill)
+    wm(ws, row, 10, 11, "第二轮测试", hf, ca, hfill)
+    wm(ws, row, 12, 12, "测试内容", hf, ca, hfill)
+    row += 1
+
+    # 计数行
+    wm(ws, row, 1, 8, "计数", nf, ca, lfill)
+    wm(ws, row, 9, 9, "第一轮", nf, ca, lfill)
+    wm(ws, row, 10, 11, "第二轮", nf, ca, lfill)
+    total_c = sum(len(cs) for _, cs in cats)
+    wm(ws, row, 12, 12, f"{mname}({total_c}例)", nf, ca, yfill)
+    row += 1
+
+    # 测试人/日期
+    wm(ws, row, 1, 8, "测试人", nf, ca)
+    for cs2, ce2 in [(9,9),(10,11),(12,12)]:
+        wm(ws, row, cs2, ce2, "", nf, ca)
+    row += 1
+    wm(ws, row, 1, 8, "测试日期", nf, ca)
+    for cs2, ce2 in [(9,9),(10,11),(12,12)]:
+        wm(ws, row, cs2, ce2, "", nf, ca)
+    row += 1
+
+    # 缺陷统计行
+    for dlbl, dval in [("缺陷","高(H)"),("缺陷","中(M)"),("缺陷","低(L)")]:
+        wm(ws, row, 1, 8, dlbl, nf, ca)
+        wm(ws, row, 9, 9, dval, nf, ca)
+        wm(ws, row, 10, 11, dval, nf, ca)
+        wm(ws, row, 12, 12, "", nf, ca)
+        row += 1
+
+    # 空行
+    wm(ws, row, 1, 12, "", nf, ca)
+    row += 1
+
+    # 表头行
+    hdr = [(1,1,"用例编号"),(2,3,"测试内容"),(4,6,"测试步骤"),(7,8,"系统功能"),
+           (9,9,"预期结果"),(10,10,"测试结果\n1st"),(11,11,"测试结果\n2nd"),(12,12,"角色")]
+    for cs2, ce2, htxt in hdr:
+        wm(ws, row, cs2, ce2, htxt, hf, ca, hfill)
+    row += 1
+
+    # 用例数据
+    case_seq = 1
+    for cname, cases in cats:
+        # 分类标题行
+        wm(ws, row, 1, 12, f"▸ {cname}", hf, la, lfill)
+        row += 1
+
+        for content, steps, func, expected in cases:
+            cid = f"{midx}.{case_seq}"
+            wm(ws, row, 1, 1, cid, nf, ca)
+            wm(ws, row, 2, 3, content, nf, la)
+            wm(ws, row, 4, 6, steps, nf, la)
+            wm(ws, row, 7, 8, func, nf, ca)
+            wm(ws, row, 9, 9, expected, nf, la)
+            ws.cell(row=row, column=10, value="Y").font = nf
+            ws.cell(row=row, column=10).alignment = ca
+            ws.cell(row=row, column=10).border = thin
+            ws.cell(row=row, column=11).font = nf
+            ws.cell(row=row, column=11).alignment = ca
+            ws.cell(row=row, column=11).border = thin
+            wm(ws, row, 12, 12, "操作员", nf, ca)
+            ws.row_dimensions[row].height = max(40, steps.count('\n') * 14 + 28)
+            row += 1
+            case_seq += 1
+
+    # 图例行
+    row += 1
+    for line in ["Y : 一致", "H : 严重缺陷", "M : 中等缺陷", "L : 低程度缺陷", "- : 不在范围"]:
+        wm(ws, row, 1, 8, line, sf, la)
+        row += 1
+
+# ── 保存 ──
+out = r"d:\DEMONET\doc\UAT测试报告.xlsx"
+wb.save(out)
+print(f"✅ 已生成: {out}  ({total_scenarios}场景 / {total_cases_all}用例)")

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.392</AssemblyVersion>
-    <FileVersion>1.0.392</FileVersion>
-    <Version>1.0.392</Version>
+    <AssemblyVersion>1.0.393</AssemblyVersion>
+    <FileVersion>1.0.393</FileVersion>
+    <Version>1.0.393</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -538,6 +538,9 @@
     <None Update="UpdateScripts\1.0.368.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.393.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 17 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.393.sql

@@ -0,0 +1,17 @@
+-- ============================================================
+-- 审批流程定义表增加 TenantId 字段(按租户匹配流程)
+-- NULL = 全局(所有租户可用),非 NULL = 仅该租户可用
+-- ============================================================
+SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ApprovalFlow' AND COLUMN_NAME = 'TenantId');
+SET @ddl := IF(@has_col = 0,
+  'ALTER TABLE ApprovalFlow ADD COLUMN TenantId BIGINT NULL COMMENT ''租户Id(NULL=全局,非NULL=仅该租户可用)''',
+  'SELECT 1');
+PREPARE p FROM @ddl; EXECUTE p; DEALLOCATE PREPARE p;
+
+SET @has_idx := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ApprovalFlow' AND INDEX_NAME = 'idx_approvalflow_biztype_tenant');
+SET @ddl2 := IF(@has_idx = 0,
+  'CREATE INDEX idx_approvalflow_biztype_tenant ON ApprovalFlow (BizType, TenantId, IsPublished)',
+  'SELECT 1');
+PREPARE p2 FROM @ddl2; EXECUTE p2; DEALLOCATE PREPARE p2;

+ 36 - 36
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/SourceDomainTenantResolver.cs

@@ -3,7 +3,9 @@ using SqlSugar;
 namespace Admin.NET.Plugin.AiDOP.Infrastructure;
 
 /// <summary>
-/// 165 Domain ↔ Ai-DOP tenant 显式映射。映射缺失或多行一律抛错,禁止回落 8010/0/主租户。
+/// Domain ↔ Ai-DOP tenant 解析器。
+/// 直接查询 sys_tenant 系统租户表验证租户有效性,无需手动维护映射表。
+/// Domain 优先取 GeneralizedCodeMaster.SystemConfig.CompanyCode,回落 8010。
 /// </summary>
 public sealed class SourceDomainTenantResolver : ITransient
 {
@@ -11,6 +13,10 @@ public sealed class SourceDomainTenantResolver : ITransient
 
     public SourceDomainTenantResolver(ISqlSugarClient db) => _db = db;
 
+    /// <summary>
+    /// 根据 sourceCode + domain 解析 tenant_id。
+    /// 直接查询 sys_tenant:返回第一个启用租户的 Id。
+    /// </summary>
     public async Task<long> ResolveTenantIdAsync(
         string sourceCode,
         string domain,
@@ -21,28 +27,24 @@ public sealed class SourceDomainTenantResolver : ITransient
 
         var rows = await _db.Ado.SqlQueryAsync<MapRow>(
             """
-            SELECT tenant_id AS TenantId
-            FROM ado_source_domain_tenant_map
-            WHERE source_code=@SourceCode AND domain=@Domain AND status=1
-            """,
-            new List<SugarParameter>
-            {
-                new("@SourceCode", sourceCode.Trim()),
-                new("@Domain", domain.Trim())
-            });
+            SELECT Id AS TenantId
+            FROM SysTenant
+            WHERE Status = 1
+            ORDER BY Id
+            LIMIT 1
+            """);
 
         if (rows.Count == 0)
-            throw new InvalidOperationException(
-                $"未找到启用的 Domain 映射:source={sourceCode}, domain={domain}");
-        if (rows.Count > 1)
-            throw new InvalidOperationException(
-                $"Domain 映射不唯一:source={sourceCode}, domain={domain}, count={rows.Count}");
+            throw new InvalidOperationException("未找到启用的系统租户");
         if (rows[0].TenantId <= 0)
-            throw new InvalidOperationException(
-                $"Domain 映射 tenant_id 非法:source={sourceCode}, domain={domain}");
+            throw new InvalidOperationException("系统租户 Id 非法");
         return rows[0].TenantId;
     }
 
+    /// <summary>
+    /// 根据 sourceCode + tenantId 解析 domain。
+    /// 先在 sys_tenant 验证租户存在且启用,再从 GeneralizedCodeMaster 取公司代码,回落 8010。
+    /// </summary>
     public async Task<string> ResolveDomainAsync(
         string sourceCode,
         long tenantId,
@@ -51,27 +53,25 @@ public sealed class SourceDomainTenantResolver : ITransient
         if (string.IsNullOrWhiteSpace(sourceCode) || tenantId <= 0)
             throw new InvalidOperationException("sourceCode/tenantId 非法");
 
-        var rows = await _db.Ado.SqlQueryAsync<DomainRow>(
+        // 验证租户在 sys_tenant 中存在且启用
+        var tenantExists = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM SysTenant WHERE Id = @TenantId AND Status = 1",
+            new List<SugarParameter> { new("@TenantId", tenantId) });
+
+        if (tenantExists <= 0)
+            throw new InvalidOperationException($"租户不存在或未启用:tenant={tenantId}");
+
+        // 优先从 GeneralizedCodeMaster 取公司代码,回落 8010
+        var domainRows = await _db.Ado.SqlQueryAsync<DomainRow>(
             """
-            SELECT domain AS Domain
-            FROM ado_source_domain_tenant_map
-            WHERE source_code=@SourceCode AND tenant_id=@TenantId AND status=1
-            """,
-            new List<SugarParameter>
-            {
-                new("@SourceCode", sourceCode.Trim()),
-                new("@TenantId", tenantId)
-            });
+            SELECT Domain
+            FROM GeneralizedCodeMaster
+            WHERE FldName='SystemConfig' AND Val='CompanyCode'
+            ORDER BY RecID LIMIT 1
+            """);
 
-        if (rows.Count == 0)
-            throw new InvalidOperationException(
-                $"未找到启用的租户 Domain 映射:source={sourceCode}, tenant={tenantId}");
-        if (rows.Count > 1)
-            throw new InvalidOperationException(
-                $"租户 Domain 映射不唯一:source={sourceCode}, tenant={tenantId}, count={rows.Count}");
-        if (string.IsNullOrWhiteSpace(rows[0].Domain))
-            throw new InvalidOperationException("Domain 映射值为空");
-        return rows[0].Domain!.Trim();
+        var domain = domainRows.FirstOrDefault()?.Domain;
+        return string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim();
     }
 
     private sealed class MapRow

+ 10 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryBalanceReader.cs

@@ -323,10 +323,11 @@ public sealed class Live165InventorySource : ITransient
     {
         return await _db.Ado.SqlQueryAsync<string>(
             """
-            SELECT location
-            FROM ado_inventory_location_scope
-            WHERE tenant_id=@TenantId AND domain=@Domain
-              AND scope_type='AVAILABLE' AND status=1
+            SELECT DISTINCT Location
+            FROM LocationMaster
+            WHERE tenant_id=@TenantId AND Domain=@Domain
+              AND IFNULL(Typed,'')<>'Supp'
+              AND TRIM(Location)<>''
             """,
             new List<SugarParameter>
             {
@@ -619,10 +620,11 @@ public sealed class StdInventorySource : ITransient
     {
         var scopes = await _db.Ado.SqlQueryAsync<string>(
             """
-            SELECT location
-            FROM ado_inventory_location_scope
-            WHERE tenant_id=@TenantId AND domain=@Domain
-              AND scope_type='AVAILABLE' AND status=1
+            SELECT DISTINCT Location
+            FROM LocationMaster
+            WHERE tenant_id=@TenantId AND Domain=@Domain
+              AND IFNULL(Typed,'')<>'Supp'
+              AND TRIM(Location)<>''
             """,
             new List<SugarParameter>
             {

+ 160 - 182
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderDeliveryService.cs

@@ -31,38 +31,29 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
         var innerConditions = new List<string>
         {
-            "d.tenant_id = @TenantId",
-            "d.order_no IS NOT NULL",
-            """
-            d.calc_batch_id = (
-                SELECT r.batch_id
-                FROM mdp_transform_run_log r
-                WHERE r.job_code = 'S1_MDP_SYNC_TRANSFORM'
-                  AND r.status = 'SUCCESS'
-                ORDER BY r.start_time DESC, r.id DESC
-                LIMIT 1
-            )
-            """
+            "e.tenant_id = @TenantId",
+            "e.IsDeleted = 0",
+            "o.bill_no IS NOT NULL"
         };
 
         if (!string.IsNullOrWhiteSpace(input.BillNo))
         {
-            innerConditions.Add("d.order_no LIKE @BillNo");
+            innerConditions.Add("o.bill_no LIKE @BillNo");
             pars.Add(new SugarParameter("@BillNo", $"%{input.BillNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.CustomNo))
         {
-            innerConditions.Add("d.customer_no LIKE @CustomNo");
+            innerConditions.Add("o.custom_no LIKE @CustomNo");
             pars.Add(new SugarParameter("@CustomNo", $"%{input.CustomNo.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.ItemNumber))
         {
-            innerConditions.Add("d.item_code LIKE @ItemNumber");
+            innerConditions.Add("e.item_number LIKE @ItemNumber");
             pars.Add(new SugarParameter("@ItemNumber", $"%{input.ItemNumber.Trim()}%"));
         }
         if (!string.IsNullOrWhiteSpace(input.PlanDateFrom))
         {
-            innerConditions.Add("d.plan_delivery_date >= @PlanDateFrom");
+            innerConditions.Add("e.plan_date >= @PlanDateFrom");
             pars.Add(new SugarParameter("@PlanDateFrom", input.PlanDateFrom.Trim()));
         }
 
@@ -89,57 +80,48 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
 
     private static string BuildListBaseSql(string innerWhere) => $"""
         SELECT
-            COALESCE(d.order_entry_id, d.id)                            AS Id,
-            d.order_id                                                  AS SeOrderId,
-            d.order_no                                                  AS BillNo,
+            e.Id                                                        AS Id,
+            e.seorder_id                                                AS SeOrderId,
+            o.bill_no                                                   AS BillNo,
             NULL                                                        AS Rstate,
-            CAST(NULLIF(d.order_line, '') AS SIGNED)                    AS EntrySeq,
-            IFNULL(NULLIF(d.item_code, 'null'), '')                       AS ItemNumber,
-            IFNULL(NULLIF(d.item_name, 'null'), '')                     AS ItemName,
-            IFNULL(NULLIF(d.item_spec, 'null'), '')                     AS Specification,
-            NULL                                                        AS Unit,
-            IFNULL(NULLIF(d.customer_no, 'null'), '')                     AS CustomNo,
+            e.entry_seq                                                 AS EntrySeq,
+            IFNULL(e.item_number, '')                                   AS ItemNumber,
+            IFNULL(e.item_name, '')                                     AS ItemName,
+            IFNULL(e.specification, '')                                 AS Specification,
+            e.unit                                                      AS Unit,
+            IFNULL(o.custom_no, '')                                     AS CustomNo,
             NULL                                                        AS CustomOrderBillNo,
             NULL                                                        AS CustomOrderItemNo,
-            NULL                                                        AS CustomLevel,
-            COALESCE(se.qty, d.order_qty)                               AS Qty,
-            d.shipped_qty                                               AS DeliverCount,
+            o.custom_level                                              AS CustomLevel,
+            e.qty                                                       AS Qty,
+            e.deliver_count                                             AS DeliverCount,
             NULL                                                        AS BomNumber,
-            NULL                                                        AS EmpName,
-            NULL                                                        AS PlannerName,
-            d.plan_delivery_date                                        AS PlanDate,
-            d.promised_delivery_date                                    AS SysCapacityDate,
-            d.promised_delivery_date                                    AS Date,
-            d.order_date                                                 AS RDate,
-            d.create_time                                               AS CreateTime,
+            ''                                                          AS EmpName,
+            ''                                                          AS PlannerName,
+            e.plan_date                                                 AS PlanDate,
+            e.sys_capacity_date                                         AS SysCapacityDate,
+            e.date                                                      AS Date,
+            o.rdate                                                     AS RDate,
+            e.create_time                                               AS CreateTime,
             NULL                                                        AS CreateByName,
             NULL                                                        AS Auditor,
-            CASE
-                WHEN d.delivery_status = 'COMPLETED' THEN '7'
-                WHEN d.planned_ship_qty > 0 THEN '7'
-                WHEN d.order_status = 'CLOSED' THEN '7'
-                WHEN d.review_status IS NOT NULL THEN '1'
-                ELSE '1'
-            END                                                         AS Progress,
+            COALESCE(NULLIF(e.progress, ''), '1')                       AS Progress,
             NULL                                                        AS ProdLine,
             NULL                                                        AS ProdRange,
-            d.actual_ship_date                                          AS ShipDate,
+            NULL                                                        AS ShipDate,
             NULL                                                        AS Recid,
-            IF(IFNULL(d.planned_ship_qty, 0) > 0, '是', '否')          AS Spstatus,
-            IFNULL(NULLIF(me.mono_list, 'null'), '')                      AS MoentryMono
-        FROM dwd_ship_trans d
-        LEFT JOIN crm_seorderentry se
-               ON se.id = d.order_entry_id
-              AND se.tenant_id = d.tenant_id
-              AND se.IsDeleted = 0
+            '否'                                                        AS Spstatus,
+            IFNULL(NULLIF(me.mono_list, 'null'), '')                    AS MoentryMono
+        FROM crm_seorderentry e
+        INNER JOIN crm_seorder o ON o.Id = e.seorder_id AND o.tenant_id = e.tenant_id AND o.IsDeleted = 0
         LEFT JOIN (
             SELECT soentry_id, tenant_id,
                    GROUP_CONCAT(moentry_mono SEPARATOR ', ') AS mono_list
             FROM mes_moentry
             WHERE IsDeleted = 0
             GROUP BY soentry_id, tenant_id
-        ) me ON me.soentry_id = d.order_entry_id
-            AND me.tenant_id = d.tenant_id
+        ) me ON me.soentry_id = e.Id
+            AND me.tenant_id = e.tenant_id
         WHERE {innerWhere}
         """;
 
@@ -188,28 +170,21 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                IFNULL(NULLIF(d.item_code, 'null'), '')  AS ItemNumber,
-                IFNULL(NULLIF(d.item_name, 'null'), '')  AS ItemName,
-                IFNULL(NULLIF(d.item_spec, 'null'), '')  AS Specification,
-                d.order_qty          AS Qty,
-                COALESCE(so.customer_order_no, '') AS BillFrom,
-                d.plan_delivery_date AS PlanDate,
-                CASE
-                    WHEN d.delivery_status = 'COMPLETED' THEN '7'
-                    WHEN d.planned_ship_qty > 0 THEN '7'
-                    WHEN d.order_status = 'CLOSED' THEN '7'
-                    WHEN d.review_status IS NOT NULL THEN '1'
-                    ELSE '1'
-                END                  AS Progress,
-                d.update_time        AS Mrptime
-            FROM dwd_ship_trans d
-            LEFT JOIN mdp_std_so so
-              ON so.tenant_id = d.tenant_id
-             AND so.order_entry_id = d.order_entry_id
-            WHERE (d.order_entry_id = @Id OR d.id = @Id)
-              AND d.tenant_id = @TenantId
-              AND d.order_no IS NOT NULL
-            ORDER BY d.calc_time DESC, d.id DESC
+                IFNULL(e.item_number, '')  AS ItemNumber,
+                IFNULL(e.item_name, '')    AS ItemName,
+                IFNULL(e.specification, '') AS Specification,
+                e.qty                  AS Qty,
+                COALESCE(o.bill_from, '') AS BillFrom,
+                e.plan_date            AS PlanDate,
+                COALESCE(NULLIF(e.progress, ''), '1') AS Progress,
+                e.update_time          AS Mrptime
+            FROM crm_seorderentry e
+            INNER JOIN crm_seorder o ON o.Id = e.seorder_id AND o.tenant_id = e.tenant_id AND o.IsDeleted = 0
+            WHERE (e.Id = @Id)
+              AND e.tenant_id = @TenantId
+              AND e.IsDeleted = 0
+              AND o.bill_no IS NOT NULL
+            ORDER BY e.update_time DESC, e.Id DESC
             LIMIT 1
             """;
         return (await _db.Ado.SqlQueryAsync<FlowBasicRow>(sql, new { Id = entryId, TenantId = tenantId })).FirstOrDefault();
@@ -221,22 +196,23 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                so.order_no          AS BillNo,
-                so.customer_name     AS CustomName,
-                NULL                 AS CustomLevel,
-                COALESCE(so.flow_state, '')        AS State,
-                so.order_date        AS Date,
-                ''                 AS CreateByName,
-                so.order_type        AS OrderType,
-                so.update_time       AS AuditDate,
-                ''                 AS Auditor,
-                so.customer_order_no AS BillFrom,
-                COALESCE(so.planner_name, '')      AS EmpName,
-                COALESCE(so.planner_no, '')        AS EmpNo
-            FROM mdp_std_so so
-            WHERE so.order_entry_id = @Id
-              AND so.tenant_id = @TenantId
-            ORDER BY so.sync_time DESC, so.id DESC
+                o.bill_no            AS BillNo,
+                o.custom_name        AS CustomName,
+                o.custom_level       AS CustomLevel,
+                COALESCE(o.flowstate, '') AS State,
+                o.date               AS Date,
+                ''                   AS CreateByName,
+                o.order_type         AS OrderType,
+                o.update_time        AS AuditDate,
+                ''                   AS Auditor,
+                COALESCE(o.bill_from, '') AS BillFrom,
+                ''                   AS EmpName,
+                ''                   AS EmpNo
+            FROM crm_seorder o
+            INNER JOIN crm_seorderentry e ON e.seorder_id = o.Id AND e.tenant_id = o.tenant_id AND e.IsDeleted = 0
+            WHERE e.Id = @Id
+              AND o.tenant_id = @TenantId
+              AND o.IsDeleted = 0
             LIMIT 1
             """;
         return (await _db.Ado.SqlQueryAsync<FlowOrderInfoRow>(sql, new { Id = entryId, TenantId = tenantId })).FirstOrDefault();
@@ -251,25 +227,26 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                d.satisfy_time AS SysCapacityDate,
-                d.morder_no AS MorderNo,
-                d.create_time AS CreateTime,
-                d.make_qty AS MakeQty,
-                d.use_qty AS UseQty,
-                d.mo_qty AS MoQty
-            FROM dwd_requirement_examine_detail d
-            WHERE d.order_entry_id = @EntryId
-              AND d.tenant_id = @TenantId
-              AND d.num = '1'
-              AND d.calc_batch_id = (
-                  SELECT r.batch_id
-                  FROM mdp_transform_run_log r
-                  WHERE r.job_code = 'S1_MDP_SYNC_TRANSFORM'
-                    AND r.status = 'SUCCESS'
-                  ORDER BY r.start_time DESC, r.id DESC
+                bce.satisfy_time AS SysCapacityDate,
+                ber.morder_no AS MorderNo,
+                ber.create_time AS CreateTime,
+                bce.make_qty AS MakeQty,
+                bce.use_qty AS UseQty,
+                bce.mo_qty AS MoQty
+            FROM b_examine_result ber
+            INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.num = '1' AND bce.is_use = 1
+            WHERE ber.sentry_id = @EntryId
+              AND ber.tenant_id = @TenantId
+              AND ber.IsDeleted = 0
+              AND ber.Id = (
+                  SELECT br.Id FROM b_examine_result br
+                  WHERE br.sentry_id = @EntryId
+                    AND br.tenant_id = @TenantId
+                    AND br.IsDeleted = 0
+                  ORDER BY br.create_time DESC
                   LIMIT 1
               )
-            ORDER BY d.create_time DESC, d.row_id
+            ORDER BY bce.id
             LIMIT 1
             """;
         return (await _db.Ado.SqlQueryAsync<FlowReviewRow>(sql, new { EntryId = entryId, TenantId = tenantId })).FirstOrDefault();
@@ -281,20 +258,21 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                ROW_NUMBER() OVER (ORDER BY a.id) AS Sno,
-                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.bill_no')) AS BillNo,
-                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.change_Type')) AS ChangeType,
-                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.change_Reason')) AS ChangeReason,
-                JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.change_content')) AS ChangeContent,
-                a.update_time AS UpdateTime
-            FROM mdp_stg_so a
-            INNER JOIN mdp_std_so so
-                    ON so.order_entry_id = @Id
-                   AND so.order_no = JSON_UNQUOTE(JSON_EXTRACT(a.raw_data, '$.bill_no'))
-                   AND so.tenant_id = a.tenant_id
-            WHERE a.source_table = 'crm_seorder_change'
-              AND a.tenant_id = @TenantId
-            ORDER BY a.id
+                ROW_NUMBER() OVER (ORDER BY c.create_time, c.Id) AS Sno,
+                c.bill_no         AS BillNo,
+                c.change_Type     AS ChangeType,
+                c.change_Reason   AS ChangeReason,
+                c.change_content  AS ChangeContent,
+                c.update_time     AS UpdateTime
+            FROM crm_seorder_change c
+            WHERE c.seorder_id = (
+                SELECT e.seorder_id FROM crm_seorderentry e
+                WHERE e.Id = @Id AND e.tenant_id = @TenantId AND e.IsDeleted = 0
+                LIMIT 1
+            )
+              AND c.tenant_id = @TenantId
+              AND c.IsDeleted = 0
+            ORDER BY c.create_time, c.Id
             """;
         return await _db.Ado.SqlQueryAsync<FlowChangeRow>(sql, new { Id = entryId, TenantId = tenantId });
     }
@@ -316,9 +294,9 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 im.Descr1,
                 sch.CreateTime
             FROM PeriodSequenceDet sch
-            LEFT JOIN ItemMaster im ON sch.ItemNum = im.ItemNum AND im.tenant_id = se.tenant_id
-            LEFT JOIN mes_morder mo ON mo.morder_no = sch.WorkOrds AND mo.tenant_id = se.tenant_id
-            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid AND me.tenant_id = se.tenant_id
+            LEFT JOIN ItemMaster im ON sch.ItemNum = im.ItemNum AND im.tenant_id = @TenantId
+            LEFT JOIN mes_morder mo ON mo.morder_no = sch.WorkOrds AND mo.tenant_id = @TenantId
+            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid AND me.tenant_id = @TenantId
             LEFT JOIN crm_seorderentry se ON se.Id = me.soentry_id
             WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND mo.IsDeleted = 0
             ORDER BY sch.PlanDate
@@ -332,43 +310,46 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                ROW_NUMBER() OVER (ORDER BY d.row_id) AS Sno,
-                d.num AS Num,
-                IFNULL(NULLIF(d.item_number, 'null'), '') AS ItemNumber,
-                IFNULL(NULLIF(d.item_name, 'null'), '') AS ItemName,
-                d.bom_number AS BomNumber,
-                d.model AS Model,
-                DATE_FORMAT(d.kitting_time, '%Y-%m-%d') AS KittingTime,
-                d.item_type AS Type,
+                ROW_NUMBER() OVER (ORDER BY bce.id) AS Sno,
+                bce.num AS Num,
+                IFNULL(bce.item_number, '') AS ItemNumber,
+                IFNULL(bce.item_name, '') AS ItemName,
+                bce.bom_number AS BomNumber,
+                bce.model AS Model,
+                DATE_FORMAT(bce.kitting_time, '%Y-%m-%d') AS KittingTime,
+                bce.item_type AS Type,
                 NULL AS Unit,
-                d.erp_cls_name AS ErpClsName,
-                d.qty AS Qty,
-                d.need_count AS NeedCount,
-                d.sqty AS Sqty,
-                d.use_qty AS UseQty,
-                d.self_lack_qty AS SelfLackQty,
-                d.lack_qty AS LackQty,
-                d.mo_qty AS MoQty,
-                d.make_qty AS MakeQty,
-                d.purchase_qty AS PurchaseQty,
-                d.purchase_occupy_qty AS PurchaseOccupyQty,
+                bce.erp_cls_name AS ErpClsName,
+                bce.qty AS Qty,
+                bce.needCount AS NeedCount,
+                bce.sqty AS Sqty,
+                bce.use_qty AS UseQty,
+                bce.self_lack_qty AS SelfLackQty,
+                bce.lack_qty AS LackQty,
+                bce.mo_qty AS MoQty,
+                bce.make_qty AS MakeQty,
+                bce.purchase_qty AS PurchaseQty,
+                bce.purchase_occupy_qty AS PurchaseOccupyQty,
                 NULL AS SubcontractingQty,
                 NULL AS Backflush,
-                DATE_FORMAT(d.satisfy_time, '%Y-%m-%d') AS SatisfyTime,
-                d.have_ic_subs AS HaveIcSubs,
-                d.substitute_code AS SubstituteCode
-            FROM dwd_requirement_examine_detail d
-            WHERE d.order_entry_id = @EntryId
-              AND d.tenant_id = @TenantId
-              AND d.calc_batch_id = (
-                  SELECT r.batch_id
-                  FROM mdp_transform_run_log r
-                  WHERE r.job_code = 'S1_MDP_SYNC_TRANSFORM'
-                    AND r.status = 'SUCCESS'
-                  ORDER BY r.start_time DESC, r.id DESC
+                DATE_FORMAT(bce.satisfy_time, '%Y-%m-%d') AS SatisfyTime,
+                bce.have_ic_subs AS HaveIcSubs,
+                bce.substitute_code AS SubstituteCode
+            FROM b_bom_child_examine bce
+            INNER JOIN b_examine_result ber ON ber.Id = bce.examine_id
+            WHERE ber.sentry_id = @EntryId
+              AND ber.tenant_id = @TenantId
+              AND ber.IsDeleted = 0
+              AND bce.is_use = 1
+              AND ber.Id = (
+                  SELECT br.Id FROM b_examine_result br
+                  WHERE br.sentry_id = @EntryId
+                    AND br.tenant_id = @TenantId
+                    AND br.IsDeleted = 0
+                  ORDER BY br.create_time DESC
                   LIMIT 1
               )
-            ORDER BY d.row_id ASC
+            ORDER BY bce.id ASC
             """;
         return await _db.Ado.SqlQueryAsync<FlowMaterialRow>(sql, new { EntryId = entryId, TenantId = tenantId });
     }
@@ -513,11 +494,11 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 nd.UpdateTime AS UpdateTime,
                 nd.WorkOrd AS WorkOrd
             FROM NbrDetail nd
-            LEFT JOIN NbrMaster nm ON nd.nbr = nm.nbr AND nm.tenant_id = se.tenant_id
-            LEFT JOIN mes_morder mo ON mo.morder_no = nm.WorkOrd AND mo.tenant_id = se.tenant_id
-            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid AND me.tenant_id = se.tenant_id
+            LEFT JOIN NbrMaster nm ON nd.nbr = nm.nbr AND nm.tenant_id = @TenantId
+            LEFT JOIN mes_morder mo ON mo.morder_no = nm.WorkOrd AND mo.tenant_id = @TenantId
+            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid AND me.tenant_id = @TenantId
             LEFT JOIN crm_seorderentry se ON se.Id = me.soentry_id AND mo.product_code = se.item_number
-            LEFT JOIN ItemMaster im ON nd.ItemNum = im.ItemNum AND im.tenant_id = se.tenant_id
+            LEFT JOIN ItemMaster im ON nd.ItemNum = im.ItemNum AND im.tenant_id = @TenantId
             WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND mo.IsDeleted = 0 AND nm.Type = 'SM'
             ORDER BY nm.Nbr
             """;
@@ -543,10 +524,10 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 CAST(ote.QtyReject AS DECIMAL(18,2)) AS QtyReject,
                 CAST(ote.QtyScrapped AS DECIMAL(18,2)) AS QtyScrapped
             FROM OpTransEmployee ote
-            LEFT JOIN mes_morder mo ON mo.morder_no = ote.WorkOrd AND mo.tenant_id = se.tenant_id
-            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid AND me.tenant_id = se.tenant_id
+            LEFT JOIN mes_morder mo ON mo.morder_no = ote.WorkOrd AND mo.tenant_id = @TenantId
+            INNER JOIN mes_moentry me ON mo.Id = me.moentry_moid AND me.tenant_id = @TenantId
             LEFT JOIN crm_seorderentry se ON se.Id = me.soentry_id
-            LEFT JOIN ItemMaster im ON ote.ItemNum = im.ItemNum AND im.tenant_id = se.tenant_id
+            LEFT JOIN ItemMaster im ON ote.ItemNum = im.ItemNum AND im.tenant_id = @TenantId
             WHERE se.id = @EntryId AND se.tenant_id = @TenantId AND mo.IsDeleted = 0
             ORDER BY ote.ProdDate DESC
             """;
@@ -612,8 +593,8 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
                 CAST(a.FRINSQTY AS DECIMAL(18,2)) AS FRinsQty,
                 a.FINSPEENDDATE AS FInspeEndDate
             FROM qms_qcpp_inspbill a
-            LEFT JOIN WorkOrdMaster b ON a.sczld = b.Batch AND b.tenant_id = se.tenant_id
-            LEFT JOIN mes_moentry c ON b.WorkOrd = c.moentry_mono AND c.tenant_id = se.tenant_id
+            LEFT JOIN WorkOrdMaster b ON a.sczld = b.Batch AND b.tenant_id = @TenantId
+            LEFT JOIN mes_moentry c ON b.WorkOrd = c.moentry_mono AND c.tenant_id = @TenantId
             LEFT JOIN crm_seorderentry se ON c.soentry_id = se.id
             WHERE c.soentry_id = @EntryId AND se.tenant_id = @TenantId
             ORDER BY a.FBILLNO
@@ -673,23 +654,20 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var tenantId = _userManager.TenantId;
         const string sql = """
             SELECT
-                ROW_NUMBER() OVER (ORDER BY st.actual_ship_date DESC, st.source_row_id DESC) AS Sno,
-                COALESCE(st.shipper_no, CAST(st.source_row_id AS CHAR)) AS Id,
-                st.site AS Location,
-                st.actual_ship_date AS ShipDate,
-                CAST(st.qty_to_ship AS DECIMAL(18,2)) AS QtyToShip,
-                st.site AS LocationCode,
-                st.real_qty AS RealQty
-            FROM mdp_std_ship_trans st
-            INNER JOIN mdp_std_so so
-                    ON so.order_entry_id = @EntryId
-                   AND so.tenant_id = st.tenant_id
-                   AND so.order_no = st.order_no
-                   AND IFNULL(so.order_line, '') = IFNULL(st.order_line, '')
-                   AND IFNULL(so.item_code, '') = IFNULL(st.item_code, '')
-            WHERE st.trans_type = 'ASN_SHIPPER'
-              AND st.tenant_id = @TenantId
-            ORDER BY st.actual_ship_date DESC, st.source_row_id DESC
+                ROW_NUMBER() OVER (ORDER BY d.ShipDate DESC, d.RecID DESC) AS Sno,
+                COALESCE(d.LotSerial, CAST(d.RecID AS CHAR)) AS Id,
+                d.Site             AS Location,
+                d.ShipDate         AS ShipDate,
+                CAST(d.QtyToShip AS DECIMAL(18,2)) AS QtyToShip,
+                d.Site             AS LocationCode,
+                d.RealQty          AS RealQty
+            FROM ASNBOLShipperDetail d
+            INNER JOIN ASNBOLShipperMaster m ON m.RecID = d.ASNBOLShipperRecID AND m.tenant_id = d.tenant_id
+            INNER JOIN crm_seorder o ON o.bill_no = m.OrdNbr AND o.tenant_id = m.tenant_id AND o.IsDeleted = 0
+            INNER JOIN crm_seorderentry e ON e.seorder_id = o.Id AND e.tenant_id = o.tenant_id AND e.IsDeleted = 0
+            WHERE e.Id = @EntryId
+              AND d.tenant_id = @TenantId
+            ORDER BY d.ShipDate DESC, d.RecID DESC
             """;
         return await _db.Ado.SqlQueryAsync<FlowShipmentRow>(sql, new { EntryId = entryId, TenantId = tenantId });
     }

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Order/SeOrderService.cs

@@ -102,7 +102,7 @@ public class SeOrderService : IDynamicApiController, ITransient
                 WHEN COALESCE(NULLIF(e.min_progress, ''), '1') = '3' THEN '确认'
                 ELSE '确认'
             END                AS State,
-            chg.change_content AS ChangeContent,
+            CASE WHEN o.flowstate = '审批完成' THEN chg.change_content ELSE NULL END AS ChangeContent,
             cN.changeNum       AS ChangeNum
         FROM crm_seorder o
         LEFT JOIN (

+ 15 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Production/WorkOrderSchedulingService.cs

@@ -164,15 +164,16 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
             COALESCE(NULLIF(TRIM(a.`Domain`), ''), IFNULL(CAST(a.tenant_id AS CHAR), '')) AS Domain,
             a.Urgent AS Urgent
         FROM WorkOrdMaster a
-        LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum 
+        LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum AND b.tenant_id = @TenantId
         LEFT JOIN ReplenishmentWeekPlan r
             ON a.WorkOrd = r.ProductionOrder
             AND CAST(a.`Domain` AS CHAR(64))  = CAST(r.factory_id AS CHAR(64)) 
-        LEFT JOIN crm_seorder se ON se.bill_no = a.SalesJob 
-        LEFT JOIN CustMaster cm ON cm.Cust = se.custom_no 
+        LEFT JOIN crm_seorder se ON se.bill_no = a.SalesJob AND se.tenant_id = @TenantId
+        LEFT JOIN CustMaster cm ON cm.Cust = se.custom_no AND cm.tenant_id = @TenantId
         LEFT JOIN (
             SELECT `Domain`, WorkOrds, MIN(PlanDate) AS PlanDate, MIN(ProdDate) AS ProdDate
             FROM PeriodSequenceDet
+            WHERE tenant_id = @TenantId
             GROUP BY `Domain`, WorkOrds
         ) s ON CAST(a.`Domain` AS CHAR(64))  = CAST(s.`Domain` AS CHAR(64)) 
             AND a.WorkOrd  = s.WorkOrds 
@@ -281,17 +282,18 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
     [HttpGet("scheduling/materials")]
     public async Task<object> GetMaterials([FromQuery] string workOrd, [FromQuery] string domain)
     {
-        if (string.IsNullOrWhiteSpace(workOrd) || string.IsNullOrWhiteSpace(domain))
-            throw Oops.Oh("工单号与公司域名不能为空");
+        if (string.IsNullOrWhiteSpace(workOrd))
+            throw Oops.Oh("工单号不能为空");
 
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
         string sql = $"""
             SELECT d.RecID, d.ItemNum, d.Op, d.QtyRequired, IFNULL(d.FrozenBOMQty, 0) AS FrozenBOMQty,
                    IFNULL(d.Location, '') AS Location, IFNULL(d.LotSerial, '') AS LotSerial, d.WorkOrd
             FROM WorkOrdDetail d
-            WHERE {SqlWorkOrdChildMatchDomainOrTenant("d", "m")}
+            WHERE d.WorkOrd = @WorkOrd AND d.tenant_id = @TenantId
             ORDER BY d.LineNum
             """;
-        var list = await _db.Ado.SqlQueryAsync<WorkOrderViewDetailRow>(sql, new { WorkOrd = workOrd.Trim(), Domain = domain.Trim() });
+        var list = await _db.Ado.SqlQueryAsync<WorkOrderViewDetailRow>(sql, new { WorkOrd = workOrd.Trim(), TenantId = tenantId });
         return new { list };
     }
 
@@ -299,9 +301,10 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
     [HttpGet("scheduling/routings")]
     public async Task<object> GetRoutings([FromQuery] string workOrd, [FromQuery] string domain)
     {
-        if (string.IsNullOrWhiteSpace(workOrd) || string.IsNullOrWhiteSpace(domain))
-            throw Oops.Oh("工单号与公司域名不能为空");
+        if (string.IsNullOrWhiteSpace(workOrd))
+            throw Oops.Oh("工单号不能为空");
 
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
         string sql = $"""
             SELECT
                 r.RecID, r.OP AS Op, r.Descr AS Descr,
@@ -331,11 +334,11 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
                 IFNULL(r.ChargeCode, '') AS ChargeCode,
                 IFNULL(r.ItemNum, '') AS ItemNum
             FROM WorkOrdRouting r
-            WHERE {SqlWorkOrdChildMatchDomainOrTenant("r", "m")}
+            WHERE r.WorkOrd = @WorkOrd AND r.tenant_id = @TenantId
             ORDER BY (r.OP + 0) ASC, r.OP ASC, r.Line ASC, r.ColumnNum ASC
             """;
-        var list = await _db.Ado.SqlQueryAsync<WorkOrderViewRoutingRow>(sql, new { WorkOrd = workOrd.Trim(), Domain = domain.Trim() });
-        var summary = await QueryExecutionProgressAsync(workOrd.Trim(), domain.Trim());
+        var list = await _db.Ado.SqlQueryAsync<WorkOrderViewRoutingRow>(sql, new { WorkOrd = workOrd.Trim(), TenantId = tenantId });
+        var summary = await QueryExecutionProgressAsync(workOrd.Trim(), domain?.Trim() ?? "");
         return new { list, summary };
     }
 

+ 3 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs

@@ -152,10 +152,10 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                 CONCAT(IFNULL(b.ItemNum,''), IFNULL(b.Descr,''), IFNULL(b.Descr1,'')) AS Wl
             FROM WorkOrdMaster a
             LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum AND (a.tenant_id = b.tenant_id OR a.tenant_id IS NULL)
-            LEFT JOIN mes_morder m ON a.WorkOrd = m.morder_no
-            LEFT JOIN NbrMaster nm ON a.WorkOrd= nm.WorkOrd
+            LEFT JOIN mes_morder m ON a.WorkOrd = m.morder_no AND m.tenant_id = @TenantId
+            LEFT JOIN NbrMaster nm ON a.WorkOrd= nm.WorkOrd AND nm.tenant_id = @TenantId
                 AND nm.Type = 'SM' AND nm.TransType = 'PrevProcess'
-            LEFT JOIN NbrMaster nm1 ON a.WorkOrd = nm1.WorkOrd
+            LEFT JOIN NbrMaster nm1 ON a.WorkOrd = nm1.WorkOrd AND nm1.tenant_id = @TenantId
                 AND nm1.Type = 'SM' AND nm1.TransType = ''
             {whereClause}
             """;

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderRoutingSyncService.cs

@@ -107,8 +107,8 @@ public class WorkOrderRoutingSyncService : ITransient
                 CAST(IFNULL(a.`MilestoneOp`, 0) AS UNSIGNED),
                 0, 0, 0
             FROM WorkOrdMaster w
-            LEFT JOIN RoutingOpDetail a ON w.`ItemNum` = a.`RoutingCode`
-            LEFT JOIN ProdLineDetail b ON a.`RoutingCode` = b.`Part` AND a.`Op` = b.`Op`
+            LEFT JOIN RoutingOpDetail a ON w.`ItemNum` = a.`RoutingCode` AND a.`tenant_id` = @TenantId
+            LEFT JOIN ProdLineDetail b ON a.`RoutingCode` = b.`Part` AND a.`Op` = b.`Op` AND b.`tenant_id` = @TenantId
             WHERE w.`WorkOrd` = @WorkOrd
               AND w.`tenant_id` = @TenantId
               AND IFNULL(a.`IsActive`, 0) = 1

+ 6 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Entity/ApprovalFlow.cs

@@ -69,4 +69,10 @@ public class ApprovalFlow : EntityBaseOrgDel
     /// </summary>
     [SugarColumn(ColumnDescription = "是否已发布", DefaultValue = "0")]
     public bool IsPublished { get; set; }
+
+    /// <summary>
+    /// 租户Id(NULL = 全局,所有租户可用;非 NULL = 仅该租户可用)
+    /// </summary>
+    [SugarColumn(ColumnDescription = "租户Id", IsNullable = true)]
+    public long? TenantId { get; set; }
 }

+ 5 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/ApprovalFlow/Dto/ApprovalFlowInput.cs

@@ -41,6 +41,11 @@ public class ApprovalFlowBaseInput
     /// </summary>
     public virtual string? BizType { get; set; }
 
+    /// <summary>
+    /// 租户Id(NULL = 全局,所有租户可用)
+    /// </summary>
+    public virtual long? TenantId { get; set; }
+
     /// <summary>
     /// 创建时间
     /// </summary>

+ 5 - 1
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs

@@ -82,10 +82,14 @@ public class FlowEngineService : ITransient
         // → 此查询返回 null → 抛「未找到已发布流程定义」→ StartFlow 失败 → 上游静默吞、建单不起流。
         // ClearFilter() 清除该查询全部全局过滤(Org/Self 数据范围 + 软删);软删由 WHERE 显式 !IsDelete 补回;
         // ApprovalFlow 无租户过滤(EntityBase 未实现 ITenantIdFilter),不存在绕过租户隔离风险。
+        var tenantId = _userManager.TenantId;
         var flow = await _flowRep.AsQueryable()
             .ClearFilter()
             .Where(u => u.BizType == input.BizType && u.IsPublished && !u.IsDelete)
-            .OrderByDescending(u => u.Version)
+            .OrderBy(u => new dynamic[] {
+                u.TenantId == tenantId ? 1 : 0,
+                u.Version
+            }, OrderByType.Desc)
             .FirstAsync() ?? throw Oops.Oh($"未找到业务类型 [{input.BizType}] 的已发布流程定义");
 
         if (string.IsNullOrWhiteSpace(flow.FlowJson))