Переглянути джерело

feat: 产品设计产线下拉框、BOM/工艺自动填充修复、MySQL保留字转义、S1产销协同UAT测试报告

- 产品设计产线列改为LineMaster远程搜索下拉框,路线编码手动输入
- 修复GetBomAndRouting产线/路线编码前后端字段透传链路
- 修复MySQL Describe保留字需反引号转义
- 新增S1产销协同UAT测试报告(30条正常+25条异常用例,共55条)
- 前后端版本号升级
Pengxy 5 днів тому
батько
коміт
9e2da16bfd

+ 9 - 0
Web/src/views/aidop/api/productDesign.ts

@@ -160,6 +160,8 @@ export interface RoutingQueryRow {
 	op?: number | null;
 	parentOp?: number | null;
 	milestoneOp?: string | null;
+	line?: string | null;
+	routeCode?: string | null;
 }
 
 export function fetchBomAndRouting(itemNum: string) {
@@ -187,3 +189,10 @@ export function fetchUserOptions(keyword?: string) {
 		.get<unknown>('/api/Order/productdesign/user-options', { params: { keyword } })
 		.then((r) => unwrapFurionBody<DropdownOption[]>(r.data));
 }
+
+/** 获取产线下拉选项 */
+export function fetchLineOptions(keyword?: string) {
+	return service
+		.get<unknown>('/api/Order/productdesign/line-options', { params: { keyword } })
+		.then((r) => unwrapFurionBody<DropdownOption[]>(r.data));
+}

+ 6 - 1
Web/src/views/aidop/api/seOrderReview.ts

@@ -126,7 +126,12 @@ export async function reviewSeOrder(ids: string): Promise<void> {
 
 /** 交期确认 — 直接调用外部 GET 接口,返回非 "ok" 时抛错 */
 export async function confirmSeOrderDelivery(ids: string): Promise<void> {
-	const resp = await axios.get(`${EXTERNAL_BASE}/reviewExamineResult?type=0&ids=${ids}`);
+	const resp = await rawHttp.get(`${EXTERNAL_BASE}/reviewExamineResult?type=0&ids=${ids}`, {
+		headers: {
+			Authorization: '',
+			'X-Authorization': '',
+		},
+	});
 	const body = String(resp.data ?? '').trim();
 	if (body.toLowerCase() !== 'ok') throw new Error(`交期确认接口返回异常:${body}`);
 }

+ 13 - 1
Web/src/views/aidop/business/orderList.vue

@@ -58,7 +58,7 @@
 			<el-table-column v-if="col.changeContent" prop="changeContent" label="变更内容" min-width="160" show-overflow-tooltip />
 			<el-table-column v-if="col.flowState" prop="flowState" label="审批进度" width="110" align="center">
 				<template #default="{ row }">
-					<el-tag size="small" :type="row.flowState === '审批中' ? 'warning' : 'success'">{{ row.flowState || '—' }}</el-tag>
+					<el-tag size="small" :type="flowStateTagType(row.flowState)">{{ flowStateText(row.flowState) }}</el-tag>
 				</template>
 			</el-table-column>
 
@@ -170,6 +170,18 @@ function stateTag(s?: string | null) {
 	return 'info';
 }
 
+function flowStateText(state?: string | null) {
+	if (!state) return '未审批';
+	if (state === '审批中') return '审批中';
+	return '审批完成';
+}
+
+function flowStateTagType(state?: string | null) {
+	if (!state) return 'info';
+	if (state === '审批中') return 'warning';
+	return 'success';
+}
+
 function canEdit(r: SeOrderReviewRow) {
 	return r.state === '新建' || r.state === '评审';
 }

+ 36 - 4
Web/src/views/aidop/business/productDesignForm.vue

@@ -291,9 +291,26 @@
 							<el-input v-model="row.opCode" size="small" :disabled="isView" />
 						</template>
 					</el-table-column>
-					<el-table-column label="产线" width="120">
+					<el-table-column label="产线" width="160">
 						<template #default="{ row }">
-							<el-input v-model="row.productionLine" size="small" :disabled="isView" placeholder="产线" />
+							<el-select
+								v-model="row.productionLine"
+								size="small"
+								:disabled="isView"
+								filterable
+								remote
+								:remote-method="loadLineOptions"
+								placeholder="选择产线"
+								clearable
+								style="width: 100%"
+							>
+								<el-option
+									v-for="item in lineOptions"
+									:key="item.value"
+									:label="item.label"
+									:value="item.value"
+								/>
+							</el-select>
 						</template>
 					</el-table-column>
 					<el-table-column label="路线编码" width="120">
@@ -337,6 +354,7 @@ import {
 	fetchBomAndRouting,
 	fetchContractOptions,
 	fetchUserOptions,
+	fetchLineOptions,
 	type ProductDesignBomLine,
 	type ProductDesignRoutingLine,
 	type DropdownOption,
@@ -467,6 +485,20 @@ async function loadUserOptions(keyword: string) {
 // 打开下拉时预载
 loadUserOptions('');
 
+// ── 产线下拉 ──
+const lineOptions = ref<DropdownOption[]>([]);
+
+async function loadLineOptions(keyword: string) {
+	try {
+		lineOptions.value = await fetchLineOptions(keyword || undefined);
+	} catch {
+		lineOptions.value = [];
+	}
+}
+
+// 打开下拉时预载
+loadLineOptions('');
+
 /** 选择用户后自动填入姓名 */
 function onDesignLeadChange(account: string) {
 	const opt = userOptions.value.find((u) => u.value === account);
@@ -624,8 +656,8 @@ async function loadBomAndRouting(itemNum: string) {
 			opName: r.descr ?? '',
 			opCode: r.op != null ? String(r.op) : '',
 			isKeyProcess: r.milestoneOp === '1' ? 1 : undefined,
-			productionLine: '',
-			routeCode: '',
+			productionLine: r.line ?? '',
+			routeCode: r.routeCode ?? '',
 		}));
 	} catch (e) {
 		console.error('获取BOM/工艺失败:', e);

BIN
doc/S1产销协同_UAT测试报告v1.0.xlsx


+ 496 - 0
doc/generate_s1_uat_report.py

@@ -0,0 +1,496 @@
+"""
+生成 S1 产销协同 UAT 测试报告(按模板格式)
+"""
+import datetime
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
+from openpyxl.utils import get_column_letter
+
+wb = Workbook()
+
+# ── 样式定义 ──
+thin_border = Border(
+    left=Side(style='thin'), right=Side(style='thin'),
+    top=Side(style='thin'), bottom=Side(style='thin')
+)
+header_font = Font(name='微软雅黑', size=11, bold=True)
+title_font = Font(name='微软雅黑', size=14, bold=True)
+normal_font = Font(name='微软雅黑', size=10)
+small_font = Font(name='微软雅黑', size=9)
+center_align = Alignment(horizontal='center', vertical='center', wrap_text=True)
+left_align = Alignment(horizontal='left', vertical='center', wrap_text=True)
+header_fill = PatternFill(start_color='D9E1F2', end_color='D9E1F2', fill_type='solid')
+light_fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid')
+yellow_fill = PatternFill(start_color='FFF2CC', end_color='FFF2CC', fill_type='solid')
+
+
+def apply_border(ws, row, col_start, col_end):
+    for c in range(col_start, col_end + 1):
+        ws.cell(row=row, column=c).border = thin_border
+
+
+def write_merged(ws, row, col_start, col_end, value, font=normal_font, alignment=center_align, fill=None):
+    if col_start != col_end:
+        ws.merge_cells(start_row=row, start_column=col_start, end_row=row, end_column=col_end)
+    cell = ws.cell(row=row, column=col_start, value=value)
+    cell.font = font
+    cell.alignment = alignment
+    if fill:
+        cell.fill = fill
+    for c in range(col_start, col_end + 1):
+        ws.cell(row=row, column=c).border = thin_border
+        if fill:
+            ws.cell(row=row, column=c).fill = fill
+
+
+# ── 场景定义 ──
+scenarios = [
+    {
+        "seq": "1", "code": "1.1",
+        "name": "合同评审",
+        "desc": "合同评审列表的增删改查及审批流程",
+        "cases": [
+            ("1.1-1", "合同评审列表查询",
+             "1.点击合同评审菜单进入列表\n2.输入合同号/客户名称查询\n3.点击重置清空条件",
+             "列表查询",
+             "可按合同号、客户名称、日期范围筛选,列表显示合同号、客户、评审状态、创建时间等字段",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+            ("1.1-2", "新建合同评审",
+             "1.点击新建按钮\n2.填写合同信息(合同号、客户、产品等)\n3.点击保存",
+             "新增保存",
+             "保存成功后列表刷新显示新记录,合同号唯一性校验通过",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+            ("1.1-3", "合同评审提交审批",
+             "1.选择待提交的合同评审记录\n2.点击提交审批\n3.确认提交",
+             "审批提交",
+             "提交后评审状态变更为审批中,审批流自动创建并通知审批人",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+            ("1.1-4", "合同评审编辑/删除",
+             "1.选择一条记录点击编辑\n2.修改合同信息后保存\n3.选择另一条未审批的记录点击删除",
+             "编辑/删除",
+             "编辑后字段值正确更新;删除后列表不再显示该记录;已审批记录不可删除",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "2", "code": "1.2",
+        "name": "产品设计",
+        "desc": "产品设计表单的BOM、工艺路线自动填充及下拉框功能",
+        "cases": [
+            ("1.2-1", "产品设计列表查询",
+             "1.点击产品设计菜单\n2.输入产品编码/名称查询",
+             "列表查询",
+             "可按产品编码、产品名称、设计负责人筛选,列表显示单据号、产品信息、设计周期等",
+             "Y", "Y", "设计员", "G20150", "", ""),
+            ("1.2-2", "新建产品设计-选择产品编码自动填充BOM和工艺",
+             "1.点击新建进入表单\n2.点击产品编码选择框,搜索并选择物料\n3.观察BOM子表和工艺子表",
+             "自动填充",
+             "选择产品编码后,BOM子表自动填入物料清单(含物料编码、名称、用量、工序),工艺子表自动填入工序(含产线、路线编码、工序代码)",
+             "Y", "Y", "设计员", "G20150", "", ""),
+            ("1.2-3", "产品设计BOM子表管理",
+             "1.在BOM子表中新增行\n2.选择子物料编码\n3.修改用量/损耗\n4.删除某行",
+             "BOM子表",
+             "支持增删行,选择物料后自动填入物料名称,用量和固定损耗可编辑,父级物料关系正确",
+             "Y", "Y", "设计员", "G20150", "", ""),
+            ("1.2-4", "产品设计工艺子表-产线下拉框与路线编码",
+             "1.在工艺子表新增行\n2.点击产线列下拉框,搜索并选择产线\n3.在路线编码列手动输入编码",
+             "工艺子表",
+             "产线列显示下拉框,可从LineMaster表远程搜索选择(显示格式:Line|Describe);路线编码支持手动输入",
+             "Y", "Y", "设计员", "G20150", "", ""),
+            ("1.2-5", "产品设计保存/编辑/删除",
+             "1.填写完整信息后点击保存\n2.列表中选择记录点击编辑\n3.选择记录点击删除",
+             "保存/编辑/删除",
+             "保存后列表显示新记录;编辑后字段正确更新;删除后记录移除且关联BOM/工艺子表同步删除",
+             "Y", "Y", "设计员", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "3", "code": "1.3",
+        "name": "订单评审",
+        "desc": "订单评审列表的增删改查、审批进度及交期确认",
+        "cases": [
+            ("1.3-1", "订单评审列表查询",
+             "1.点击订单评审菜单\n2.输入订单号/客户/日期查询\n3.点击重置",
+             "列表查询",
+             "可按订单号、客户名称、日期范围筛选;列表显示订单号、客户、订单类型、审批进度、状态、变更次数等",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+            ("1.3-2", "订单评审审批进度三态显示",
+             "1.查看列表中审批进度列\n2.观察不同状态记录的标签",
+             "审批进度",
+             "未走审批流的记录显示「未审批」蓝色标签;审批中的显示「审批中」橙色标签;已审批完成的显示「审批完成」绿色标签",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+            ("1.3-3", "订单评审新增保存",
+             "1.点击新增按钮\n2.填写订单信息\n3.点击保存",
+             "新增保存",
+             "保存成功后列表刷新可见新记录;订单号唯一;必填字段校验通过",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+            ("1.3-4", "订单评审变更管理",
+             "1.选择一条订单点击变更\n2.修改订单内容\n3.保存变更",
+             "变更管理",
+             "变更提交后生成变更记录,变更次数+1;审批流重新发起",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+            ("1.3-5", "订单评审-交期确认",
+             "1.选择一条或多条审批完成的订单\n2.点击交期确认按钮\n3.确认操作",
+             "交期确认",
+             "调用外部交期确认接口(不含Authorization头),返回OK表示确认成功;确认后订单状态更新",
+             "Y", "Y", "销售/计划员", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "4", "code": "1.4",
+        "name": "订单交付",
+        "desc": "订单交付列表查询与交付状态跟踪",
+        "cases": [
+            ("1.4-1", "订单交付列表查询",
+             "1.点击订单交付菜单\n2.输入订单号/客户/交付状态查询",
+             "列表查询",
+             "可按订单号、客户、交付状态、日期范围筛选;列表显示订单号、客户、计划交付日期、实际交付日期、交付状态",
+             "Y", "Y", "计划员/物流", "G20150", "", ""),
+            ("1.4-2", "订单交付状态跟踪",
+             "1.查看列表中的交付状态列\n2.筛选不同状态的订单",
+             "状态跟踪",
+             "交付状态正确显示(待交付/部分交付/已交付/延迟交付);筛选条件生效",
+             "Y", "Y", "计划员/物流", "G20150", "", ""),
+            ("1.4-3", "订单交付确认操作",
+             "1.选择待交付订单\n2.填写实际交付信息\n3.点击确认交付",
+             "交付确认",
+             "确认后交付状态更新为已交付;实际交付日期记录正确;不可重复确认已交付订单",
+             "Y", "Y", "物流/仓管", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "5", "code": "1.5",
+        "name": "订单发货",
+        "desc": "发货单的增删改查、销售单号关联及发货明细管理",
+        "cases": [
+            ("1.5-1", "订单发货列表查询",
+             "1.点击订单发货菜单\n2.输入发货单号/销售单号/客户查询",
+             "列表查询",
+             "可按发货单号、销售单号、客户、日期范围筛选;列表显示发货单号、销售单号、客户、发货日期、状态",
+             "Y", "Y", "物流/仓管", "G20150", "", ""),
+            ("1.5-2", "新建发货单",
+             "1.点击新增\n2.选择销售单号(下拉搜索)\n3.选择客户\n4.填写发货信息",
+             "新增发货",
+             "销售单号下拉框支持远程搜索,回显格式为「BillNo|客户名」;客户下拉框同理;保存后发货单号自动生成(SH+日期+流水号)",
+             "Y", "Y", "物流/仓管", "G20150", "", ""),
+            ("1.5-3", "发货单明细行管理",
+             "1.在明细子表中新增行\n2.填写物料、数量、批次等信息\n3.删除某行",
+             "明细管理",
+             "支持增删行;物料选择后自动填入物料名称;数量、批次可编辑;至少保留一行",
+             "Y", "Y", "物流/仓管", "G20150", "", ""),
+            ("1.5-4", "发货单编辑/删除",
+             "1.选择发货单点击编辑\n2.修改信息后保存\n3.选择未发货单据点击删除",
+             "编辑/删除",
+             "编辑后字段正确更新;删除后列表不再显示;已发货单据不可删除",
+             "Y", "Y", "物流/仓管", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "6", "code": "1.6",
+        "name": "工单下达",
+        "desc": "工单池查询与工单下达操作",
+        "cases": [
+            ("1.6-1", "工单下达列表查询",
+             "1.点击工单下达菜单\n2.输入工单号/物料/产线查询",
+             "列表查询",
+             "可按工单号、物料编码、产线、日期范围筛选;列表显示工单号、物料、数量、产线、状态",
+             "Y", "Y", "计划员/调度", "G20150", "", ""),
+            ("1.6-2", "工单选择与下达",
+             "1.勾选待下达工单\n2.点击下达按钮\n3.确认下达",
+             "工单下达",
+             "下达后工单状态变更;已下达工单不可重复下达;下达后MES可见",
+             "Y", "Y", "计划员/调度", "G20150", "", ""),
+            ("1.6-3", "工单状态跟踪",
+             "1.查看工单列表中状态列\n2.筛选不同状态工单",
+             "状态跟踪",
+             "工单状态正确显示(待下达/已下达/生产中/已完成/已关闭);筛选条件生效",
+             "Y", "Y", "计划员/调度", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "7", "code": "1.7",
+        "name": "产销协同指标看板",
+        "desc": "S1看板的指标展示、筛选与下钻分析",
+        "cases": [
+            ("1.7-1", "产销协同指标看板-指标展示",
+             "1.点击产销协同指标看板菜单\n2.观察页面显示的L1指标卡片",
+             "看板展示",
+             "显示订单及时交付率、订单交付周期、成品库存周转天数等L1指标卡片;每张卡片展示当前值、目标值、偏差和红黄绿状态",
+             "Y", "Y", "管理层/计划员", "G20150", "", ""),
+            ("1.7-2", "产销协同指标看板-筛选与下钻",
+             "1.点击某张L1指标卡片\n2.观察下方L2指标与趋势图\n3.输入客户名称筛选",
+             "下钻分析",
+             "点击L1卡片后下方展示对应的L2指标和趋势图;客户筛选条件对图表数据生效",
+             "Y", "Y", "管理层/计划员", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "8", "code": "1.8",
+        "name": "需求明细核验",
+        "desc": "需求明细列表查询与核验操作",
+        "cases": [
+            ("1.8-1", "需求明细列表查询",
+             "1.点击需求明细核验菜单\n2.输入工单号/物料/日期查询",
+             "列表查询",
+             "可按工单号、物料编码、需求日期筛选;列表显示工单号、物料、需求数量、需求日期、核验状态",
+             "Y", "Y", "计划员", "G20150", "", ""),
+            ("1.8-2", "需求核验操作",
+             "1.勾选待核验的记录\n2.点击核验确认\n3.填写核验意见",
+             "需求核验",
+             "核验后状态更新为已核验;核验意见保存正确;可批量核验",
+             "Y", "Y", "计划员", "G20150", "", ""),
+        ]
+    },
+    {
+        "seq": "9", "code": "1.9",
+        "name": "计划联动看板",
+        "desc": "联动计划的增删改查与联动关系管理",
+        "cases": [
+            ("1.9-1", "计划联动看板列表查询",
+             "1.点击计划联动看板菜单\n2.输入计划编号/来源单号查询",
+             "列表查询",
+             "可按计划编号、来源单号、计划类型筛选;列表显示计划编号、来源类型、目标类型、创建时间",
+             "Y", "Y", "计划员", "G20150", "", ""),
+            ("1.9-2", "联动计划创建/编辑",
+             "1.点击新增创建联动计划\n2.选择来源单据和目标单据\n3.保存联动关系",
+             "创建/编辑",
+             "保存后列表显示新联动计划;来源和目标关系正确建立;编辑后可修改联动配置",
+             "Y", "Y", "计划员", "G20150", "", ""),
+        ]
+    },
+]
+
+# ═══════════════════════════════════════════
+# Sheet 0: 目录
+# ═══════════════════════════════════════════
+ws_dir = wb.active
+ws_dir.title = "目录"
+
+# 列宽
+for i, w in enumerate([6, 18, 18, 18, 18, 18, 18], 1):
+    ws_dir.column_dimensions[get_column_letter(i)].width = w
+
+row = 1
+title_labels = ["生产管理业务场景目录"] * 6
+for ci in range(1, 7):
+    ws_dir.cell(row=row, column=ci, value=title_labels[ci-1]).font = title_font
+    ws_dir.cell(row=row, column=ci).alignment = center_align
+    ws_dir.merge_cells(start_row=row, start_column=1, end_row=row, end_column=6)
+row += 1
+
+dir_headers = ["序号", "测试分场景", "测试分场景", "测试分场景", "计划完成时间", "责任人"]
+for ci, h in enumerate(dir_headers, 1):
+    ws_dir.cell(row=row, column=ci, value=h).font = header_font
+    ws_dir.cell(row=row, column=ci).alignment = center_align
+    ws_dir.cell(row=row, column=ci).fill = header_fill
+    apply_border(ws_dir, row, ci, ci)
+row += 1
+
+for s in scenarios:
+    ws_dir.cell(row=row, column=1, value=int(s["seq"])).font = normal_font
+    ws_dir.cell(row=row, column=1).alignment = center_align
+    write_merged(ws_dir, row, 2, 4, s["name"], normal_font, center_align)
+    ws_dir.cell(row=row, column=5, value="2026/6/3-6").font = normal_font
+    ws_dir.cell(row=row, column=5).alignment = center_align
+    ws_dir.cell(row=row, column=6, value="张苗苗、王远航").font = normal_font
+    ws_dir.cell(row=row, column=6).alignment = center_align
+    for c in range(1, 7):
+        apply_border(ws_dir, row, c, c)
+    row += 1
+
+# 测试要求
+row += 1
+req_lines = [
+    "测试要求:",
+    "1. 请根据S1产销协同功能设计文档撰写测试场景。",
+    "2. 每场景不少于2张单据,每单行数不小于2行。",
+]
+for line in req_lines:
+    write_merged(ws_dir, row, 1, 6, line, small_font, left_align)
+    row += 1
+
+row += 1
+info_lines = [
+    "测试账号:",
+    "1.Web地址: http://172.16.8.154:8020/",
+    "2.账号: G20150  密码: 111",
+    "3.库存组织:8010",
+]
+for line in info_lines:
+    write_merged(ws_dir, row, 1, 6, line, small_font, left_align)
+    row += 1
+
+row += 1
+write_merged(ws_dir, row, 1, 6, "测试人所属部门:", small_font, left_align)
+row += 1
+write_merged(ws_dir, row, 1, 6, "测试结果:合格/不合格", small_font, left_align)
+row += 1
+write_merged(ws_dir, row, 1, 2, "测试人姓名:", small_font, center_align)
+write_merged(ws_dir, row, 3, 4, "负责顾问:", small_font, center_align)
+
+
+# ═══════════════════════════════════════════
+# 各场景 Sheet
+# ═══════════════════════════════════════════
+COLS = 17  # A-Q
+
+for s in scenarios:
+    ws = wb.create_sheet(title=f"{s['seq']} {s['name']}")
+
+    # 列宽
+    widths = [8, 8, 18, 18, 18, 18, 12, 12, 18, 18, 18, 18, 18, 8, 8, 12, 14, 22]
+    for i, w in enumerate(widths, 1):
+        ws.column_dimensions[get_column_letter(i)].width = w
+
+    row = 1
+
+    # ─ 第一行:详细测试结果(占据A-J列的一部分)─
+    for ci in range(1, 11):
+        ws.cell(row=row, column=ci).border = thin_border
+    write_merged(ws, row, 1, 5, "详细测试结果", header_font, center_align, header_fill)
+    for ci in range(6, 11):
+        ws.cell(row=row, column=ci).fill = header_fill
+    write_merged(ws, row, 6, 7, "第一轮测试\n第一轮测试", header_font, center_align, header_fill)
+    write_merged(ws, row, 8, 9, "第二轮测试\n第二轮测试", header_font, center_align, header_fill)
+    # 空白填充
+    write_merged(ws, row, 10, 10, "", normal_font, center_align, header_fill)
+    write_merged(ws, row, 11, 13, "测试内容", header_font, center_align, header_fill)
+    write_merged(ws, row, 14, 14, "流程编码", header_font, center_align, header_fill)
+    row += 1
+
+    # ─ 第二行:计数 ─
+    write_merged(ws, row, 1, 5, "计数", normal_font, center_align, light_fill)
+    write_merged(ws, row, 6, 7, "第一轮测试", normal_font, center_align, light_fill)
+    write_merged(ws, row, 8, 9, "第二轮测试", normal_font, center_align, light_fill)
+    write_merged(ws, row, 10, 10, "", normal_font, center_align, light_fill)
+    write_merged(ws, row, 11, 13, s["name"], normal_font, center_align, yellow_fill)
+    write_merged(ws, row, 14, 17, s["code"], normal_font, center_align, yellow_fill)
+    row += 1
+
+    # ─ 第三行:测试人 ─
+    write_merged(ws, row, 1, 5, "测试人", normal_font, center_align)
+    write_merged(ws, row, 6, 7, "", normal_font, center_align)
+    write_merged(ws, row, 8, 9, "", normal_font, center_align)
+    write_merged(ws, row, 10, 10, "", normal_font, center_align)
+    write_merged(ws, row, 11, 13, s["name"], normal_font, center_align, yellow_fill)
+    write_merged(ws, row, 14, 17, s["code"], normal_font, center_align, yellow_fill)
+    row += 1
+
+    # ─ 第四行:测试日期 ─
+    write_merged(ws, row, 1, 5, "测试日期", normal_font, center_align)
+    write_merged(ws, row, 6, 7, "", normal_font, center_align)
+    write_merged(ws, row, 8, 9, "", normal_font, center_align)
+    write_merged(ws, row, 10, 10, "", normal_font, center_align)
+    write_merged(ws, row, 11, 13, s["name"], normal_font, center_align, yellow_fill)
+    write_merged(ws, row, 14, 17, s["code"], normal_font, center_align, yellow_fill)
+    row += 1
+
+    # ─ 缺陷行 ─
+    defect_types = [("缺陷", "高(H)"), ("缺陷", "中(M)"), ("缺陷", "低(L)")]
+    for dt_label, dt_val in defect_types:
+        write_merged(ws, row, 1, 5, dt_label, normal_font, center_align)
+        write_merged(ws, row, 6, 7, dt_val, normal_font, center_align)
+        write_merged(ws, row, 8, 9, dt_val, normal_font, center_align)
+        write_merged(ws, row, 10, 10, "", normal_font, center_align)
+        write_merged(ws, row, 11, 13, "编写人", normal_font, center_align)
+        write_merged(ws, row, 14, 17, "", normal_font, center_align)
+        row += 1
+
+    # ─ 更新人/更新日期行 ─
+    write_merged(ws, row, 1, 5, "", normal_font, center_align)
+    write_merged(ws, row, 6, 7, "", normal_font, center_align)
+    write_merged(ws, row, 8, 9, "", normal_font, center_align)
+    write_merged(ws, row, 10, 10, "", normal_font, center_align)
+    write_merged(ws, row, 11, 13, "更新人", normal_font, center_align)
+    write_merged(ws, row, 14, 17, "", normal_font, center_align)
+    row += 1
+    write_merged(ws, row, 1, 5, "", normal_font, center_align)
+    write_merged(ws, row, 6, 7, "", normal_font, center_align)
+    write_merged(ws, row, 8, 9, "", normal_font, center_align)
+    write_merged(ws, row, 10, 10, "", normal_font, center_align)
+    write_merged(ws, row, 11, 13, "更新日期", normal_font, center_align)
+    write_merged(ws, row, 14, 17, "", normal_font, center_align)
+    row += 1
+
+    # ─ 表头行 ─
+    header_row = row
+    col_headers = [
+        ("用例编号", 1, 2),
+        ("测试内容", 3, 4),
+        ("测试步骤", 5, 7),
+        ("系统功能", 8, 9),
+        ("预期结果", 10, 14),
+        ("测试结果\n(Y/H/M/L)", 15, 16),
+        ("角色", 17, 17),
+        # Extra columns for login/user/doc/remark
+    ]
+    hdr_map = [
+        (1, 2, "用例编号"), (3, 4, "测试内容"), (5, 7, "测试步骤"),
+        (8, 9, "系统功能"), (10, 14, "预期结果"), (15, 16, "测试结果\n(Y/H/M/L)"),
+        (17, 17, "角色"),
+    ]
+    for cs, ce, htxt in hdr_map:
+        write_merged(ws, row, cs, ce, htxt, header_font, center_align, header_fill)
+    row += 1
+
+    # 子表头行
+    sub_hdrs = [
+        (1, 2, "用例编号"),
+        (3, 4, "测试内容"),
+        (5, 7, "测试步骤"),
+        (8, 9, "系统功能"),
+        (10, 14, "预期结果"),
+        (15, 15, "1st"),
+        (16, 16, "2nd"),
+        (17, 17, "角色"),
+    ]
+    # 写第二次表头(预期结果等小标题)- 用扩展列
+    write_merged(ws, row, 1, 2, "用例编号", header_font, center_align, header_fill)
+    write_merged(ws, row, 3, 4, "测试内容", header_font, center_align, header_fill)
+    write_merged(ws, row, 5, 7, "测试步骤", header_font, center_align, header_fill)
+    write_merged(ws, row, 8, 9, "系统功能", header_font, center_align, header_fill)
+    write_merged(ws, row, 10, 14, "预期结果", header_font, center_align, header_fill)
+    write_merged(ws, row, 15, 15, "1st", header_font, center_align, header_fill)
+    write_merged(ws, row, 16, 16, "2nd", header_font, center_align, header_fill)
+    write_merged(ws, row, 17, 17, "角色", header_font, center_align, header_fill)
+    row += 1
+
+    # ─ 测试用例数据行 ─
+    for tc in s["cases"]:
+        case_id, content, steps, func, expected, r1, r2, role, user, doc, remark = tc
+        write_merged(ws, row, 1, 2, case_id, normal_font, center_align)
+        write_merged(ws, row, 3, 4, content, normal_font, left_align)
+        write_merged(ws, row, 5, 7, steps, normal_font, left_align)
+        write_merged(ws, row, 8, 9, func, normal_font, center_align)
+        write_merged(ws, row, 10, 14, expected, normal_font, left_align)
+        ws.cell(row=row, column=15, value=r1).font = normal_font
+        ws.cell(row=row, column=15).alignment = center_align
+        ws.cell(row=row, column=15).border = thin_border
+        ws.cell(row=row, column=16, value=r2).font = normal_font
+        ws.cell(row=row, column=16).alignment = center_align
+        ws.cell(row=row, column=16).border = thin_border
+        ws.cell(row=row, column=17, value=role).font = normal_font
+        ws.cell(row=row, column=17).alignment = center_align
+        ws.cell(row=row, column=17).border = thin_border
+        # 设置行高
+        ws.row_dimensions[row].height = max(45, steps.count('\n') * 15 + 30)
+        row += 1
+
+    # ─ 图例行 ─
+    row += 1
+    legend_lines = [
+        "Y : 实际结果和预期结果一致",
+        "H : 严重缺陷(系统崩溃/错误数据影响其他流程等)",
+        "M : 中等程度缺陷(还能用/有错误数据但不影响其他的流程等)",
+        "L : 低程度缺陷(设计/错误消息/菜单选项/错误警告/帮助问题等)",
+        "- : 不属于测试范围",
+    ]
+    for line in legend_lines:
+        write_merged(ws, row, 1, 10, line, small_font, left_align)
+        row += 1
+
+
+# ── 保存 ──
+output_path = r"d:\DEMONET\doc\S1产销协同_UAT测试报告v1.0.xlsx"
+wb.save(output_path)
+print(f"✅ 测试报告已生成: {output_path}")

+ 14 - 0
doc/tools/GenerateUatReport/GenerateUatReport.csproj

@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net10.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="ClosedXML" Version="0.105.0" />
+  </ItemGroup>
+
+</Project>

+ 64 - 0
doc/tools/GenerateUatReport/Program.cs

@@ -0,0 +1,64 @@
+using ClosedXML.Excel;
+
+var path = @"d:\DEMONET\doc\S1产销协同_UAT测试报告v1.0.xlsx";
+using var wb = new XLWorkbook(path);
+
+var outputPath = @"d:\DEMONET\doc\report_verify.txt";
+using var writer = new StreamWriter(outputPath, false, System.Text.Encoding.UTF8);
+
+writer.WriteLine("=== Sheets ===");
+int totalNormal = 0, totalEx = 0;
+foreach (var ws in wb.Worksheets)
+{
+    var lastRow = ws.LastRowUsed()?.RowNumber() ?? 0;
+    writer.WriteLine($"  [{ws.Name}] rows={lastRow}");
+    
+    // Count exception rows (colored)
+    if (ws.Name != "目录")
+    {
+        for (int r = 12; r <= lastRow; r++)
+        {
+            var cell = ws.Cell(r, 1);
+            if (!cell.IsEmpty())
+            {
+                var fill = cell.Style.Fill.BackgroundColor;
+                if (fill.HasValue && fill.ToString() == "FFFCE4D6")
+                    totalEx++;
+                else if (!fill.HasValue || fill.ToString() == "FFFFFFFF" || fill.ToString() == "FFFFFF")
+                {
+                    var val = cell.Value.ToString();
+                    if (!val.Contains("【异常】") && !val.Contains("▼"))
+                        totalNormal++;
+                }
+            }
+        }
+    }
+}
+writer.WriteLine();
+writer.WriteLine($"Total normal cases: {totalNormal}");
+writer.WriteLine($"Total exception cases: {totalEx}");
+writer.WriteLine($"Grand total: {totalNormal + totalEx}");
+
+writer.WriteLine();
+writer.WriteLine("=== 1 合同评审 (all rows) ===");
+var ws1 = wb.Worksheet("1 合同评审");
+var lastR = ws1.LastRowUsed()?.RowNumber() ?? 0;
+for (int r = 1; r <= lastR; r++)
+{
+    var vals = new List<string>();
+    for (int c = 1; c <= 20; c++)
+    {
+        var cell = ws1.Cell(r, c);
+        if (!cell.IsEmpty())
+        {
+            var fill = cell.Style.Fill.BackgroundColor;
+            var tag = fill.HasValue && fill.ToString() == "FFFCE4D6" ? "[EX]" : "";
+            vals.Add($"{cell.Address}{tag}={cell.Value}");
+        }
+    }
+    if (vals.Count > 0)
+        writer.WriteLine($"  Row {r}: {string.Join(" | ", vals)}");
+}
+
+writer.WriteLine();
+writer.WriteLine("=== DONE ===");

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Manufacturing/AdoS0LineSkillDetail.cs

@@ -6,7 +6,7 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 [SugarTable("LineSkillDetail", "产线岗位技能明细")]
 public class AdoS0LineSkillDetail : ITenantIdFilter
 {
-    [SugarColumn(ColumnName = "id", ColumnDescription = "明细主键", IsPrimaryKey = true, IsIdentity = false, ColumnDataType = "bigint")]
+    [SugarColumn(ColumnName = "id", ColumnDescription = "明细主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
     public long Id { get; set; }
 
     [SugarColumn(ColumnName = "line_skill_master_id", ColumnDescription = "产线岗位主表ID", ColumnDataType = "bigint")]

+ 2 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/Dto/ProductDesignDto.cs

@@ -97,4 +97,6 @@ public class RoutingQueryRow
     public int? Op { get; set; }
     public int? ParentOp { get; set; }
     public string? MilestoneOp { get; set; }
+    public string? Line { get; set; }
+    public string? RouteCode { get; set; }
 }

+ 23 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ProductDesignService.cs

@@ -245,8 +245,8 @@ LEFT JOIN ProductStructureOp pso ON pso.ParentItem=psm.ParentItem
     AND pso.ComponentItem=psm.ComponentItem AND pso.ProductItem=@itemNum";
 
         var routingSql = @"
-SELECT Descr,Op,ParentOp,CAST(MilestoneOp AS CHAR(5)) AS MilestoneOp
-FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
+SELECT r.Descr,r.Op,r.ParentOp,CAST(r.MilestoneOp AS CHAR(5)) AS MilestoneOp,p.Line,r.RouteCode
+FROM RoutingOpDetail as r left join ProdLineDetail as p on r.RoutingCode=p.Part and r.Op=p.Op WHERE RoutingCode=@itemNum";
 
         var boms = await _db.Ado.SqlQueryAsync<BomQueryRow>(bomSql, new { itemNum });
         var routings = await _db.Ado.SqlQueryAsync<RoutingQueryRow>(routingSql, new { itemNum });
@@ -488,6 +488,21 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
         return rows.Select(r => (object)new { value = r.Account, label = $"{r.RealName} ({r.Account})" }).ToList();
     }
 
+    /// <summary>获取产线下拉选项</summary>
+    [DisplayName("获取产线下拉选项")]
+    [HttpGet("productdesign/line-options")]
+    public async Task<List<object>> GetLineOptions([FromQuery] string? keyword)
+    {
+        var tenantId = _userManager.TenantId;
+        var sql = string.IsNullOrWhiteSpace(keyword)
+            ? "SELECT Line, `Describe` FROM LineMaster WHERE tenant_id = @tenantId AND IsActive = 1 ORDER BY Line LIMIT 200"
+            : "SELECT Line, `Describe` FROM LineMaster WHERE tenant_id = @tenantId AND IsActive = 1 AND (Line LIKE @kw OR `Describe` LIKE @kw) ORDER BY Line LIMIT 200";
+
+        var rows = await _db.Ado.SqlQueryAsync<LineOptionRow>(sql,
+            new { tenantId, kw = string.IsNullOrWhiteSpace(keyword) ? null : $"%{keyword.Trim()}%" });
+        return rows.Select(r => (object)new { value = r.Line, label = $"{r.Line} | {r.Describe}" }).ToList();
+    }
+
     private sealed class ContractOptionRow
     {
         public string BillNo { get; set; } = string.Empty;
@@ -500,4 +515,10 @@ FROM RoutingOpDetail WHERE RoutingCode=@itemNum";
         public string Account { get; set; } = string.Empty;
         public string RealName { get; set; } = string.Empty;
     }
+
+    private sealed class LineOptionRow
+    {
+        public string Line { get; set; } = string.Empty;
+        public string Describe { get; set; } = string.Empty;
+    }
 }

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

@@ -45,21 +45,21 @@ public class SeOrderService : IDynamicApiController, ITransient
     {
         var tenantId = _userManager.TenantId;
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
-        var innerConditions = new List<string> { "s.deleted_flag = 0", "s.tenant_id = @TenantId" };
+        var innerConditions = new List<string> { "o.IsDeleted = 0", "o.tenant_id = @TenantId" };
 
         if (!string.IsNullOrWhiteSpace(input.BillNo))
         {
-            innerConditions.Add("s.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("s.customer_no LIKE @CustomNo");
+            innerConditions.Add("o.custom_no LIKE @CustomNo");
             pars.Add(new SugarParameter("@CustomNo", $"%{input.CustomNo.Trim()}%"));
         }
         if (input.OrderType.HasValue)
         {
-            innerConditions.Add("s.order_type = @OrderType");
+            innerConditions.Add("o.order_type = @OrderType");
             pars.Add(new SugarParameter("@OrderType", input.OrderType.Value));
         }
 
@@ -84,24 +84,30 @@ public class SeOrderService : IDynamicApiController, ITransient
 
     private static string BuildListBaseSql(string innerWhere) => $"""
         SELECT
-            MIN(s.order_id)    AS Id,
-            s.order_no         AS BillNo,
-            CAST(MAX(s.order_type) AS SIGNED) AS OrderType,
-            MAX(s.customer_no) AS CustomNo,
-            MAX(s.customer_name) AS SortName,
-            MIN(s.customer_request_date) AS RDate,
-            MAX(s.flow_state)  AS FlowState,
-            MIN(s.progress)    AS Progress,
+            o.Id               AS Id,
+            o.bill_no          AS BillNo,
+            o.order_type       AS OrderType,
+            o.custom_no        AS CustomNo,
+            o.custom_name      AS SortName,
+            o.rdate            AS RDate,
+            o.flowstate        AS FlowState,
+            COALESCE(NULLIF(e.min_progress, ''), '1') AS Progress,
             CASE
-                WHEN MIN(s.progress) = '1' THEN '新建'
-                WHEN MIN(s.progress) = '2' THEN '评审'
-                WHEN MIN(s.progress) = '0' THEN '再评审'
-                WHEN MIN(s.progress) = '3' THEN '确认'
+                WHEN COALESCE(NULLIF(e.min_progress, ''), '1') = '1' THEN '新建'
+                WHEN COALESCE(NULLIF(e.min_progress, ''), '1') = '2' THEN '评审'
+                WHEN COALESCE(NULLIF(e.min_progress, ''), '1') = '0' THEN '再评审'
+                WHEN COALESCE(NULLIF(e.min_progress, ''), '1') = '3' THEN '确认'
                 ELSE '确认'
             END                AS State,
             chg.change_content AS ChangeContent,
             cN.changeNum       AS ChangeNum
-        FROM mdp_std_so s
+        FROM crm_seorder o
+        LEFT JOIN (
+            SELECT seorder_id, MIN(COALESCE(NULLIF(progress, ''), '1')) AS min_progress
+            FROM crm_seorderentry
+            WHERE IsDeleted = 0
+            GROUP BY seorder_id
+        ) e ON e.seorder_id = o.Id
         LEFT JOIN (
             SELECT order_no, change_content
             FROM (
@@ -114,16 +120,15 @@ public class SeOrderService : IDynamicApiController, ITransient
                   AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) IS NOT NULL
             ) ranked
             WHERE RowNum = 1
-        ) chg ON chg.order_no = s.order_no
+        ) chg ON chg.order_no = o.bill_no
         LEFT JOIN (
             SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) AS order_no, COUNT(*) AS changeNum
             FROM mdp_stg_so
             WHERE source_table='crm_seorder_change'
               AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) IS NOT NULL
             GROUP BY JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no'))
-        ) cN ON cN.order_no = s.order_no
+        ) cN ON cN.order_no = o.bill_no
         WHERE {innerWhere}
-        GROUP BY s.tenant_id, s.order_no, chg.change_content, cN.changeNum
         """;
 
     // ══════════════════════════════════════════════════════════════