Browse Source

feat(S4): 供应商欠料看板催料待办功能 + 审批中心查看欠料信息

- 新增催料待办快照实体 AdoMaterialShortageTodo
- 新增 MATERIAL_SHORTAGE 审批流回调处理器 MaterialShortageBizHandler
- 新增 create-todo / todo-detail API 端点
- 审批中心待办/已办/发起列表增加查看欠料信息按钮
- 新增 MaterialShortageSnapshot 紧凑展示组件(弹窗内嵌)
- 修复 API 响应解包(.result 字段提取)
- 修复 JSON 序列化/反序列化兼容性强类型模型
- 版本升级: web 2.4.322, api 1.0.388
Pengxy 14 giờ trước cách đây
mục cha
commit
8ed90bdd7e

+ 1 - 1
Web/package.json

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

+ 1 - 0
Web/src/constants/aidopFuncCodes.ts

@@ -181,6 +181,7 @@ const FUNC_DEFS: FuncDef[] = [
 		names: ['aidopS4ExecutionKanbanDashboard', 'aidopSmartOpsS4'],
 		names: ['aidopS4ExecutionKanbanDashboard', 'aidopSmartOpsS4'],
 	},
 	},
 	{ code: 'FUNC-S4-006', name: '供应商欠料看板', paths: ['/aidop/s4/execution-kanban/supplier-shortage-kanban'], names: ['aidopS4SupplierShortageKanban'] },
 	{ code: 'FUNC-S4-006', name: '供应商欠料看板', paths: ['/aidop/s4/execution-kanban/supplier-shortage-kanban'], names: ['aidopS4SupplierShortageKanban'] },
+	{ code: 'FUNC-S4-007', name: '催料待办详情', paths: ['/aidop/s4/execution-kanban/material-shortage-todo-detail'], names: ['aidopS4MaterialShortageTodoDetail'] },
 
 
 	// ── S5 物料仓储(扩展编号)──
 	// ── S5 物料仓储(扩展编号)──
 	{ code: 'FUNC-S5-001', name: '来料检验任务列表', paths: ['/aidop/s5/iqc/task-list'], names: ['aidopS5IqcTaskList'] },
 	{ code: 'FUNC-S5-001', name: '来料检验任务列表', paths: ['/aidop/s5/iqc/task-list'], names: ['aidopS5IqcTaskList'] },

+ 19 - 0
Web/src/views/aidop/s4/api/procurementExecution.ts

@@ -346,3 +346,22 @@ export function refreshSupplierShortageKanban() {
 	return service.post<{ ok: boolean; elapsedMs?: number; message?: string }>('/api/ProcurementExecution/supplier-shortage-kanban/refresh').then((r) => r.data);
 	return service.post<{ ok: boolean; elapsedMs?: number; message?: string }>('/api/ProcurementExecution/supplier-shortage-kanban/refresh').then((r) => r.data);
 }
 }
 
 
+// ── 催料待办(MATERIAL_SHORTAGE 审批流)──
+export interface MaterialShortageTodo {
+	id: number;
+	flowStatus: string;
+	flowInstanceId?: number;
+	totalCount: number;
+	createBy?: number;
+	createTime: string;
+	snapshotRows: SupplierShortageKanbanRow[];
+}
+
+export function createMaterialShortageTodo() {
+	return service.post<{ id: number; totalCount: number }>('/api/ProcurementExecution/supplier-shortage-kanban/create-todo').then((r) => (r.data as any)?.result ?? r.data);
+}
+
+export function fetchMaterialShortageTodoDetail(id: number): Promise<MaterialShortageTodo> {
+	return service.get<MaterialShortageTodo>(`/api/ProcurementExecution/supplier-shortage-kanban/todo-detail/${id}`).then((r) => (r.data as any)?.result ?? r.data);
+}
+

+ 99 - 0
Web/src/views/aidop/s4/execution-kanban/components/MaterialShortageSnapshot.vue

@@ -0,0 +1,99 @@
+<template>
+	<div v-loading="loading">
+		<el-descriptions :column="3" border size="small" style="margin-bottom: 12px">
+			<el-descriptions-item label="待办编号">{{ detail.id }}</el-descriptions-item>
+			<el-descriptions-item label="欠料行数">{{ detail.totalCount }}</el-descriptions-item>
+			<el-descriptions-item label="创建时间">{{ detail.createTime }}</el-descriptions-item>
+			<el-descriptions-item label="审批状态">
+				<el-tag :type="flowStatusTag(detail.flowStatus)" size="small">{{ flowStatusLabel(detail.flowStatus) }}</el-tag>
+			</el-descriptions-item>
+		</el-descriptions>
+
+		<el-divider content-position="left">欠料列表快照</el-divider>
+		<el-table :data="detail.snapshotRows" border stripe max-height="400" size="small">
+			<el-table-column type="index" label="#" width="50" />
+			<el-table-column prop="supplier_Number" label="供应商编码" width="120" show-overflow-tooltip />
+			<el-table-column prop="supplier_Name" label="供应商名称" min-width="140" show-overflow-tooltip />
+			<el-table-column prop="itemNum" label="物料编码" width="120" show-overflow-tooltip />
+			<el-table-column prop="descr" label="物料描述" min-width="140" show-overflow-tooltip />
+			<el-table-column prop="descr1" label="规格型号" min-width="110" show-overflow-tooltip />
+			<el-table-column
+				v-for="d in dayColumns"
+				:key="d.key"
+				:prop="d.key"
+				:label="d.label"
+				width="90"
+				align="right"
+			/>
+		</el-table>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import { fetchMaterialShortageTodoDetail, type SupplierShortageKanbanRow } from '../../api/procurementExecution';
+
+const props = defineProps<{ todoId: number }>();
+
+const loading = ref(false);
+const detail = reactive<{
+	id: number;
+	flowStatus: string;
+	totalCount: number;
+	createTime: string;
+	snapshotRows: SupplierShortageKanbanRow[];
+}>({
+	id: 0,
+	flowStatus: '',
+	totalCount: 0,
+	createTime: '',
+	snapshotRows: [],
+});
+
+const dayColumns = computed(() => {
+	const base = new Date();
+	base.setHours(0, 0, 0, 0);
+	const fmt = (d: Date) => {
+		const m = String(d.getMonth() + 1).padStart(2, '0');
+		const dd = String(d.getDate()).padStart(2, '0');
+		return `${m}-${dd}`;
+	};
+	const cols: Array<{ key: string; label: string }> = [];
+	for (let i = 0; i <= 14; i++) {
+		const d = new Date(base);
+		d.setDate(base.getDate() + i);
+		cols.push({ key: `d${i}`, label: fmt(d) });
+	}
+	return cols;
+});
+
+function flowStatusLabel(s: string) {
+	const map: Record<string, string> = { PENDING: '待发起', IN_PROGRESS: '审批中', APPROVED: '已通过', REJECTED: '已拒绝', CANCELLED: '已撤销', TERMINATED: '已终止' };
+	return map[s] || s || '—';
+}
+
+function flowStatusTag(s: string) {
+	const map: Record<string, string> = { PENDING: 'info', IN_PROGRESS: '', APPROVED: 'success', REJECTED: 'danger', CANCELLED: 'warning', TERMINATED: 'danger' };
+	return (map[s] || 'info') as any;
+}
+
+async function loadDetail() {
+	if (!props.todoId) return;
+	loading.value = true;
+	try {
+		const data = await fetchMaterialShortageTodoDetail(props.todoId);
+		detail.id = data.id;
+		detail.flowStatus = data.flowStatus;
+		detail.totalCount = data.totalCount;
+		detail.createTime = data.createTime;
+		detail.snapshotRows = data.snapshotRows || [];
+	} catch (e: any) {
+		ElMessage.error(e?.message || '加载欠料详情失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+onMounted(() => loadDetail());
+</script>

+ 140 - 0
Web/src/views/aidop/s4/execution-kanban/materialShortageTodoDetail.vue

@@ -0,0 +1,140 @@
+<template>
+	<AidopDemoShell title="催料待办详情" subtitle="供应商欠料快照 + 审批流程">
+		<div v-loading="loading">
+			<!-- 基本信息 -->
+			<el-descriptions :column="3" border size="small" style="margin-bottom: 16px">
+				<el-descriptions-item label="待办编号">{{ detail.id }}</el-descriptions-item>
+				<el-descriptions-item label="欠料行数">{{ detail.totalCount }}</el-descriptions-item>
+				<el-descriptions-item label="创建时间">{{ detail.createTime }}</el-descriptions-item>
+				<el-descriptions-item label="审批状态">
+					<el-tag :type="flowStatusTag(detail.flowStatus)" size="small">{{ flowStatusLabel(detail.flowStatus) }}</el-tag>
+				</el-descriptions-item>
+			</el-descriptions>
+
+			<!-- 欠料列表快照 -->
+			<el-divider content-position="left">欠料列表快照</el-divider>
+			<el-table :data="detail.snapshotRows" border stripe max-height="420" size="small">
+				<el-table-column type="index" label="#" width="50" />
+				<el-table-column prop="supplier_Number" label="供应商编码" width="130" show-overflow-tooltip />
+				<el-table-column prop="supplier_Name" label="供应商名称" min-width="150" show-overflow-tooltip />
+				<el-table-column prop="itemNum" label="物料编码" width="130" show-overflow-tooltip />
+				<el-table-column prop="descr" label="物料描述" min-width="150" show-overflow-tooltip />
+				<el-table-column prop="descr1" label="规格型号" min-width="120" show-overflow-tooltip />
+				<el-table-column
+					v-for="d in dayColumns"
+					:key="d.key"
+					:prop="d.key"
+					:label="d.label"
+					width="100"
+					align="right"
+				/>
+			</el-table>
+
+			<!-- 审批流程面板 -->
+			<ApprovalPanel
+				v-if="detail.id"
+				bizType="MATERIAL_SHORTAGE"
+				:bizId="detail.id"
+				:bizNo="`MS-${detail.id}`"
+				:title="`催料待办(${detail.totalCount}项欠料)`"
+				@refresh="loadDetail"
+			/>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS4MaterialShortageTodoDetail">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import ApprovalPanel from '/@/views/approvalFlow/component/ApprovalPanel.vue';
+import { fetchMaterialShortageTodoDetail, type SupplierShortageKanbanRow } from '../api/procurementExecution';
+
+const route = useRoute();
+const todoId = computed(() => Number(route.query.id) || 0);
+
+const loading = ref(false);
+const detail = reactive<{
+	id: number;
+	flowStatus: string;
+	flowInstanceId?: number;
+	totalCount: number;
+	createTime: string;
+	snapshotRows: SupplierShortageKanbanRow[];
+}>({
+	id: 0,
+	flowStatus: '',
+	totalCount: 0,
+	createTime: '',
+	snapshotRows: [],
+});
+
+const dayColumns = computed(() => {
+	const base = new Date();
+	base.setHours(0, 0, 0, 0);
+	const fmt = (d: Date) => {
+		const y = d.getFullYear();
+		const m = String(d.getMonth() + 1).padStart(2, '0');
+		const dd = String(d.getDate()).padStart(2, '0');
+		return `${y}-${m}-${dd}`;
+	};
+	const cols: Array<{ key: string; label: string }> = [];
+	for (let i = 0; i <= 14; i++) {
+		const d = new Date(base);
+		d.setDate(base.getDate() + i);
+		cols.push({ key: `d${i}`, label: fmt(d) });
+	}
+	return cols;
+});
+
+function flowStatusLabel(s: string) {
+	const map: Record<string, string> = {
+		PENDING: '待发起',
+		IN_PROGRESS: '审批中',
+		APPROVED: '已通过',
+		REJECTED: '已拒绝',
+		CANCELLED: '已撤销',
+		TERMINATED: '已终止',
+	};
+	return map[s] || s || '—';
+}
+
+function flowStatusTag(s: string) {
+	const map: Record<string, string> = {
+		PENDING: 'info',
+		IN_PROGRESS: '',
+		APPROVED: 'success',
+		REJECTED: 'danger',
+		CANCELLED: 'warning',
+		TERMINATED: 'danger',
+	};
+	return (map[s] || 'info') as any;
+}
+
+async function loadDetail() {
+	if (!todoId.value) return;
+	loading.value = true;
+	try {
+		const data = await fetchMaterialShortageTodoDetail(todoId.value);
+		detail.id = data.id;
+		detail.flowStatus = data.flowStatus;
+		detail.flowInstanceId = data.flowInstanceId;
+		detail.totalCount = data.totalCount;
+		detail.createTime = data.createTime;
+		detail.snapshotRows = data.snapshotRows || [];
+	} catch (e: any) {
+		ElMessage.error(e?.message || '加载催料待办详情失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+onMounted(() => loadDetail());
+</script>
+
+<style scoped lang="scss">
+.el-divider {
+	margin-top: 16px;
+}
+</style>

+ 54 - 4
Web/src/views/aidop/s4/execution-kanban/supplierShortageKanbanList.vue

@@ -15,6 +15,7 @@
 
 
 		<div class="toolbar">
 		<div class="toolbar">
 			<el-button type="primary" :loading="refreshing" @click="onRefresh">刷新</el-button>
 			<el-button type="primary" :loading="refreshing" @click="onRefresh">刷新</el-button>
+			<el-button type="warning" :loading="creatingTodo" @click="onCreateTodo">生成催料待办</el-button>
 			<el-popover placement="bottom" width="240" trigger="click">
 			<el-popover placement="bottom" width="240" trigger="click">
 				<template #reference><el-button text>列设置</el-button></template>
 				<template #reference><el-button text>列设置</el-button></template>
 				<el-checkbox v-for="item in toggleItems" :key="item.key" :model-value="col[item.key]" @change="(v) => setColumnVisible(item.key, Boolean(v))">
 				<el-checkbox v-for="item in toggleItems" :key="item.key" :model-value="col[item.key]" @change="(v) => setColumnVisible(item.key, Boolean(v))">
@@ -63,7 +64,8 @@ import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
 import { useRoute } from 'vue-router';
 import { ElMessage } from 'element-plus';
 import { ElMessage } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
-import { fetchSupplierShortageKanbanList, refreshSupplierShortageKanban, type SupplierShortageKanbanRow } from '../api/procurementExecution';
+import { fetchSupplierShortageKanbanList, refreshSupplierShortageKanban, createMaterialShortageTodo, type SupplierShortageKanbanRow } from '../api/procurementExecution';
+import { startFlow } from '/@/views/approvalFlow/api';
 
 
 const route = useRoute();
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '供应商欠料看板');
 const pageTitle = computed(() => (route.meta?.title as string) || '供应商欠料看板');
@@ -79,6 +81,7 @@ const query = reactive({
 
 
 const loading = ref(false);
 const loading = ref(false);
 const refreshing = ref(false);
 const refreshing = ref(false);
+const creatingTodo = ref(false);
 const rows = ref<SupplierShortageKanbanRow[]>([]);
 const rows = ref<SupplierShortageKanbanRow[]>([]);
 const total = ref(0);
 const total = ref(0);
 
 
@@ -181,14 +184,61 @@ async function onRefresh() {
 	refreshing.value = true;
 	refreshing.value = true;
 	try {
 	try {
 		const r = await refreshSupplierShortageKanban();
 		const r = await refreshSupplierShortageKanban();
-		if (r?.ok) ElMessage.success(`刷新完成(${r.elapsedMs ?? 0}ms)`);
-		else ElMessage.error(r?.message || '刷新失败');
-		await loadList();
+		if (r?.ok) {
+			ElMessage.success(`刷新完成(${r.elapsedMs ?? 0}ms)`);
+			await loadList();
+			// 刷新成功后自动生成催料待办
+			await autoCreateTodo();
+		} else {
+			ElMessage.error(r?.message || '刷新失败');
+		}
 	} finally {
 	} finally {
 		refreshing.value = false;
 		refreshing.value = false;
 	}
 	}
 }
 }
 
 
+/** 生成催料待办:创建快照 → 发起审批流 */
+async function onCreateTodo() {
+	if (creatingTodo.value) return;
+	creatingTodo.value = true;
+	try {
+		const res = await createMaterialShortageTodo();
+		if (!res?.id) {
+			ElMessage.error('创建待办失败');
+			return;
+		}
+		// 发起审批流
+		await startFlow({
+			bizType: 'MATERIAL_SHORTAGE',
+			bizId: res.id,
+			bizNo: `MS-${res.id}`,
+			title: `催料待办(${res.totalCount}项欠料)`,
+		});
+		ElMessage.success(`催料待办已生成(${res.totalCount}项欠料),审批已发起`);
+	} catch (e: any) {
+		ElMessage.error(e?.message || '生成催料待办失败');
+	} finally {
+		creatingTodo.value = false;
+	}
+}
+
+/** 刷新后自动创建待办(静默失败,不阻塞用户) */
+async function autoCreateTodo() {
+	try {
+		const res = await createMaterialShortageTodo();
+		if (!res?.id) return;
+		await startFlow({
+			bizType: 'MATERIAL_SHORTAGE',
+			bizId: res.id,
+			bizNo: `MS-${res.id}`,
+			title: `催料待办(${res.totalCount}项欠料)`,
+		});
+		ElMessage.info(`已自动生成催料待办(${res.totalCount}项欠料)`);
+	} catch {
+		/* 自动创建失败静默处理,不阻塞用户 */
+	}
+}
+
 function doSearch() {
 function doSearch() {
 	query.page = 1;
 	query.page = 1;
 	loadList();
 	loadList();

+ 1 - 0
Web/src/views/approvalFlow/center/components/DoneList.vue

@@ -14,6 +14,7 @@
 		<el-table-column label="操作" width="160" align="center" fixed="right">
 		<el-table-column label="操作" width="160" align="center" fixed="right">
 			<template #default="{ row }">
 			<template #default="{ row }">
 				<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 				<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
+				<el-button v-if="row.bizType === 'MATERIAL_SHORTAGE'" size="small" type="primary" text @click="emit('viewBiz', row)">查看欠料信息</el-button>
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 			</template>
 			</template>
 		</el-table-column>
 		</el-table-column>

+ 1 - 0
Web/src/views/approvalFlow/center/components/InitiatedList.vue

@@ -19,6 +19,7 @@
 		<el-table-column label="操作" width="240" align="center" fixed="right">
 		<el-table-column label="操作" width="240" align="center" fixed="right">
 			<template #default="{ row }">
 			<template #default="{ row }">
 				<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 				<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
+				<el-button v-if="row.bizType === 'MATERIAL_SHORTAGE'" size="small" type="primary" text @click="emit('viewBiz', row)">查看欠料信息</el-button>
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 				<el-button v-if="row.status === 1" size="small" text type="primary" @click="emit('urge', row)">催办</el-button>
 				<el-button v-if="row.status === 1" size="small" text type="primary" @click="emit('urge', row)">催办</el-button>
 				<el-button v-if="row.status === 1" size="small" text type="warning" @click="emit('withdraw', row)">撤回</el-button>
 				<el-button v-if="row.status === 1" size="small" text type="warning" @click="emit('withdraw', row)">撤回</el-button>

+ 1 - 0
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -41,6 +41,7 @@
 					</template>
 					</template>
 					<template v-else>
 					<template v-else>
 						<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 						<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
+						<el-button v-if="row.bizType === 'MATERIAL_SHORTAGE'" size="small" type="primary" text @click="emit('viewBiz', row)">查看欠料信息</el-button>
 						<el-button v-if="row.bizType === 'SMART_OPS_IMPROVEMENT' && row.bizId" size="small" type="primary" text @click="goToImprovement(row.bizId)">查看改善计划</el-button>
 						<el-button v-if="row.bizType === 'SMART_OPS_IMPROVEMENT' && row.bizId" size="small" type="primary" text @click="goToImprovement(row.bizId)">查看改善计划</el-button>
 						<el-button size="small" type="success" text @click="emit('approve', row)">同意</el-button>
 						<el-button size="small" type="success" text @click="emit('approve', row)">同意</el-button>
 						<el-button size="small" type="danger" text @click="emit('reject', row)">拒绝</el-button>
 						<el-button size="small" type="danger" text @click="emit('reject', row)">拒绝</el-button>

+ 3 - 1
Web/src/views/approvalFlow/center/index.vue

@@ -70,8 +70,9 @@
 		/>
 		/>
 
 
 		<!-- 业务表单对话框(订单变更等) -->
 		<!-- 业务表单对话框(订单变更等) -->
-		<el-dialog v-model="bizFormVisible" :title="bizFormTitle" width="980px" destroy-on-close>
+		<el-dialog v-model="bizFormVisible" :title="bizFormTitle" :width="bizFormBizType === 'MATERIAL_SHORTAGE' ? '1200px' : '980px'" destroy-on-close>
 			<SalesOrderChangeForm v-if="bizFormBizType === 'ORDER_CHANGE_REVIEW'" :order-id="bizFormBizId" :readonly="true" @cancel="bizFormVisible = false" />
 			<SalesOrderChangeForm v-if="bizFormBizType === 'ORDER_CHANGE_REVIEW'" :order-id="bizFormBizId" :readonly="true" @cancel="bizFormVisible = false" />
+			<MaterialShortageSnapshot v-if="bizFormBizType === 'MATERIAL_SHORTAGE'" :todo-id="bizFormBizId" />
 		</el-dialog>
 		</el-dialog>
 	</div>
 	</div>
 </template>
 </template>
@@ -104,6 +105,7 @@ import InitiatedList from './components/InitiatedList.vue';
 import ApprovalDialog from './components/ApprovalDialog.vue';
 import ApprovalDialog from './components/ApprovalDialog.vue';
 import TimelineDialog from './components/TimelineDialog.vue';
 import TimelineDialog from './components/TimelineDialog.vue';
 import SalesOrderChangeForm from '/@/views/aidop/business/salesOrderChangeForm.vue';
 import SalesOrderChangeForm from '/@/views/aidop/business/salesOrderChangeForm.vue';
+import MaterialShortageSnapshot from '/@/views/aidop/s4/execution-kanban/components/MaterialShortageSnapshot.vue';
 
 
 import type { ActionType } from './components/ApprovalDialog.vue';
 import type { ActionType } from './components/ApprovalDialog.vue';
 
 

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

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

+ 24 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.388.sql

@@ -0,0 +1,24 @@
+-- 1.0.383:供应商欠料看板 — 催料待办快照表 + 隐藏菜单
+-- 功能:刷新/手动触发时快照 WorkOrdDetailTotalKB,生成催料待办并发起 MATERIAL_SHORTAGE (MS001) 审批流。
+-- 幂等:表用 CREATE IF NOT EXISTS;菜单用 Id NOT EXISTS。
+
+-- ① 催料待办快照表
+CREATE TABLE IF NOT EXISTS `ado_material_shortage_todo` (
+  `id`                BIGINT       NOT NULL AUTO_INCREMENT,
+  `tenant_id`         BIGINT       NOT NULL DEFAULT 0,
+  `snapshot_data`     LONGTEXT     NULL     COMMENT '欠料列表 JSON 快照',
+  `flow_status`       VARCHAR(32)  NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/IN_PROGRESS/APPROVED/REJECTED/CANCELLED/TERMINATED',
+  `flow_instance_id`  BIGINT       NULL     COMMENT '审批流实例 Id',
+  `total_count`       INT          NOT NULL DEFAULT 0 COMMENT '快照行数',
+  `create_by`         BIGINT       NULL,
+  `create_time`       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_tenant_time` (`tenant_id`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='催料待办快照(供应商欠料看板)';
+
+-- ② 催料待办详情隐藏菜单(Id=1329004010007,Pid=S4采购执行目录 1322000000004)
+INSERT INTO SysMenu (Id, Pid, Type, Title, Name, Path, Component, Icon, IsIframe, IsHide, IsKeepAlive, IsAffix, OrderNo, Status, CreateTime)
+SELECT 1329004010007, 1322000000004, 2, '催料待办详情', 'aidopS4MaterialShortageTodoDetail',
+       '/aidop/s4/execution-kanban/material-shortage-todo-detail', '/aidop/s4/execution-kanban/materialShortageTodoDetail', 'ele-Document',
+       0, 1, 1, 0, 25, 1, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM SysMenu WHERE Id = 1329004010007);

+ 37 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Entity/AdoMaterialShortageTodo.cs

@@ -0,0 +1,37 @@
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
+
+/// <summary>
+/// 催料待办记录:每次刷新/手动生成时创建一条快照,关联审批流实例。
+/// </summary>
+[SugarTable("ado_material_shortage_todo", "催料待办快照")]
+public class AdoMaterialShortageTodo
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    /// <summary>欠料列表 JSON 快照</summary>
+    [SugarColumn(ColumnName = "snapshot_data", ColumnDataType = "longtext", IsNullable = true)]
+    public string? SnapshotData { get; set; }
+
+    /// <summary>审批流状态:PENDING / IN_PROGRESS / APPROVED / REJECTED / CANCELLED</summary>
+    [SugarColumn(ColumnName = "flow_status", Length = 32)]
+    public string FlowStatus { get; set; } = "PENDING";
+
+    /// <summary>审批流实例 Id(发起后回填)</summary>
+    [SugarColumn(ColumnName = "flow_instance_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? FlowInstanceId { get; set; }
+
+    /// <summary>快照包含的欠料行数</summary>
+    [SugarColumn(ColumnName = "total_count")]
+    public int TotalCount { get; set; }
+
+    /// <summary>创建人 Id</summary>
+    [SugarColumn(ColumnName = "create_by", ColumnDataType = "bigint", IsNullable = true)]
+    public long? CreateBy { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; } = DateTime.Now;
+}

+ 64 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/MaterialShortageBizHandler.cs

@@ -0,0 +1,64 @@
+using Admin.NET.Plugin.ApprovalFlow;
+using Admin.NET.Plugin.ApprovalFlow.Service;
+using Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
+
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
+
+/// <summary>
+/// 催料待办审批业务回调
+/// BizType = "MATERIAL_SHORTAGE",审批流编号 MS001
+/// </summary>
+public class MaterialShortageBizHandler : IFlowBizHandler, ITransient
+{
+    public string BizType => "MATERIAL_SHORTAGE";
+
+    private readonly SqlSugarRepository<AdoMaterialShortageTodo> _todoRep;
+
+    public MaterialShortageBizHandler(SqlSugarRepository<AdoMaterialShortageTodo> todoRep)
+    {
+        _todoRep = todoRep;
+    }
+
+    public async Task OnFlowStarted(long bizId, long instanceId)
+    {
+        try
+        {
+            await _todoRep.AsUpdateable()
+                .SetColumns(t => t.FlowStatus == "IN_PROGRESS")
+                .SetColumns(t => t.FlowInstanceId == instanceId)
+                .Where(t => t.Id == bizId)
+                .ExecuteCommandAsync();
+        }
+        catch (Exception) { /* 表不存在时静默跳过 */ }
+    }
+
+    public async Task OnFlowCompleted(long bizId, long instanceId, FlowInstanceStatusEnum finalStatus, long? lastApproverId)
+    {
+        var status = finalStatus switch
+        {
+            FlowInstanceStatusEnum.Approved => "APPROVED",
+            FlowInstanceStatusEnum.Rejected => "REJECTED",
+            FlowInstanceStatusEnum.Cancelled => "CANCELLED",
+            _ => "TERMINATED",
+        };
+
+        try
+        {
+            await _todoRep.AsUpdateable()
+                .SetColumns(t => t.FlowStatus == status)
+                .Where(t => t.Id == bizId)
+                .ExecuteCommandAsync();
+        }
+        catch (Exception) { /* 表不存在时静默跳过 */ }
+    }
+
+    public Task OnNodeCompleted(long bizId, long instanceId, string nodeId, string nodeName, long? approverUserId)
+    {
+        return Task.CompletedTask;
+    }
+
+    public Task<Dictionary<string, object>> GetBizData(long bizId)
+    {
+        return Task.FromResult(new Dictionary<string, object>());
+    }
+}

+ 86 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShortageKanbanService.cs

@@ -1,4 +1,6 @@
+using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
+using Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
 
 
 namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 
 
@@ -142,5 +144,89 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
             return new { ok = false, elapsedMs = sw.ElapsedMilliseconds, message = ex.Message };
             return new { ok = false, elapsedMs = sw.ElapsedMilliseconds, message = ex.Message };
         }
         }
     }
     }
+
+    /// <summary>
+    /// 创建催料待办:快照当前 WorkOrdDetailTotalKB 数据,返回新记录 Id(前端再用此 Id 发起审批流)
+    /// </summary>
+    [DisplayName("创建催料待办")]
+    [HttpPost("supplier-shortage-kanban/create-todo")]
+    public async Task<object> CreateTodo()
+    {
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var userId = _userManager?.UserId ?? 0;
+
+        // 查询当前全量欠料数据作为快照
+        var hasTenantColumn = await _db.Ado.GetIntAsync("""
+            SELECT COUNT(1) FROM information_schema.COLUMNS
+            WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'WorkOrdDetailTotalKB' AND COLUMN_NAME = 'tenant_id'
+            """) > 0;
+
+        var where = hasTenantColumn ? "WHERE tenant_id = @TenantId" : "";
+        var pars = hasTenantColumn ? new[] { new SugarParameter("@TenantId", tenantId) } : Array.Empty<SugarParameter>();
+
+        var snapshotSql = $"""
+            SELECT
+              IFNULL(supplier_number, '') AS supplier_number,
+              IFNULL(supplier_name, '')   AS supplier_name,
+              IFNULL(ItemNum, '')         AS ItemNum,
+              IFNULL(Descr, '')           AS Descr,
+              IFNULL(Descr1, '')          AS Descr1,
+              IFNULL(D0, 0) AS D0, IFNULL(D1, 0) AS D1, IFNULL(D2, 0) AS D2,
+              IFNULL(D3, 0) AS D3, IFNULL(D4, 0) AS D4, IFNULL(D5, 0) AS D5,
+              IFNULL(D6, 0) AS D6, IFNULL(D7, 0) AS D7, IFNULL(D8, 0) AS D8,
+              IFNULL(D9, 0) AS D9, IFNULL(D10, 0) AS D10, IFNULL(D11, 0) AS D11,
+              IFNULL(D12, 0) AS D12, IFNULL(D13, 0) AS D13, IFNULL(D14, 0) AS D14
+            FROM WorkOrdDetailTotalKB {where}
+            ORDER BY IFNULL(D0, 0) DESC
+            """;
+
+        var rows = await _db.Ado.SqlQueryAsync<SupplierShortageKanbanListRow>(snapshotSql, pars);
+        var snapshotJson = JsonSerializer.Serialize(rows, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+
+        var todo = new AdoMaterialShortageTodo
+        {
+            TenantId = tenantId,
+            SnapshotData = snapshotJson,
+            TotalCount = rows.Count,
+            FlowStatus = "PENDING",
+            CreateBy = userId,
+            CreateTime = DateTime.Now,
+        };
+
+        var id = await _db.Insertable(todo).ExecuteReturnIdentityAsync();
+        return new { id, totalCount = rows.Count };
+    }
+
+    /// <summary>
+    /// 获取催料待办详情(含快照数据)
+    /// </summary>
+    [DisplayName("催料待办详情")]
+    [HttpGet("supplier-shortage-kanban/todo-detail/{id:long}")]
+    public async Task<object> GetTodoDetail(long id)
+    {
+        var todo = await _db.Queryable<AdoMaterialShortageTodo>()
+            .Where(t => t.Id == id)
+            .FirstAsync();
+
+        if (todo == null) return new { code = 404, message = "待办不存在" };
+
+        var snapshotRows = new List<SupplierShortageKanbanListRow>();
+        if (!string.IsNullOrWhiteSpace(todo.SnapshotData))
+        {
+            snapshotRows = JsonSerializer.Deserialize<List<SupplierShortageKanbanListRow>>(todo.SnapshotData,
+                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List<SupplierShortageKanbanListRow>();
+        }
+
+        return new
+        {
+            todo.Id,
+            todo.FlowStatus,
+            todo.FlowInstanceId,
+            todo.TotalCount,
+            todo.CreateBy,
+            todo.CreateTime,
+            snapshotRows,
+        };
+    }
 }
 }