فهرست منبع

feat(S2): 新增订单生产排程看板甘特图页面及后端接口 - 新增FUNC-S2-013菜单及甘特图数据API - 后端WorkOrderSchedulingService新增gantt-data接口 - 前端ECharts甘特图(计划/实际工期+齐套/交期标识) - 左侧信息面板含工单状态/工序状态/产品编码列 - 日期范围从最小订单交期开始,排序与面板一致 - 版本: server 1.0.378 / web 2.4.312

Pengxy 1 ساعت پیش
والد
کامیت
462d6f37af

+ 1 - 1
Web/package.json

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

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

@@ -137,6 +137,7 @@ const FUNC_DEFS: FuncDef[] = [
 	{ code: 'FUNC-S2-010', name: '工单执行追踪', paths: ['/aidop/production/work-order-trace'], names: ['aidopProductionWorkOrderTrace'] },
 	{ code: 'FUNC-S2-011', name: '工单物料明细', paths: ['/aidop/production/work-order-materials'], names: ['aidopProductionWorkOrderMaterials'] },
 	{ code: 'FUNC-S2-012', name: '工单工序明细', paths: ['/aidop/production/work-order-routings'], names: ['aidopProductionWorkOrderRoutings'] },
+	{ code: 'FUNC-S2-013', name: '订单生产排程看板', paths: ['/aidop/s2/collaboration-kanban/scheduling-gantt'], names: ['aidopS2SchedulingGanttKanban'] },
 
 	// ── S3 供应协同 ──
 	{ code: 'FUNC-S3-001', name: '物料需求计划', paths: ['/aidop/s3/material-plan/demand-schedule'], names: ['aidopS3DemandSchedule'] },

+ 33 - 0
Web/src/views/aidop/api/workOrderScheduling.ts

@@ -330,3 +330,36 @@ export function saveRoutingDetail(body: WorkOrdRoutingSaveInput) {
 export function deleteRoutingDetail(recID: number) {
 	return service.post('/api/Production/scheduling/routing-delete', { recID }).then((r) => r.data);
 }
+
+// ─── 甘特图 ───
+
+export interface GanttOpRow {
+	workOrd?: string | null;
+	itemNum?: string | null;
+	itemName?: string | null;
+	itemModel?: string | null;
+	qtyOrded?: number | null;
+	dueDate?: string | null;
+	status?: string | null;
+	domain?: string | null;
+	mfRate?: number | null;
+	op?: number | null;
+	opDescr?: string | null;
+	planStartDate?: string | null;
+	planEndDate?: string | null;
+	actualStartDate?: string | null;
+	actualEndDate?: string | null;
+	workQty?: number | null;
+	qtyCompleted?: number | null;
+	opStatus?: string | null;
+}
+
+/** 甘特图数据(排程看板) */
+export function fetchGanttData(params: { workOrd?: string; startDate?: string; endDate?: string }) {
+	return service
+		.get<{ list: GanttOpRow[] }>('/api/Production/scheduling/gantt-data', {
+			params: withAidopTenantParams(params),
+			timeout: 60000,
+		})
+		.then((r) => r.data);
+}

+ 569 - 0
Web/src/views/aidop/production/workOrderSchedulingGanttKanban.vue

@@ -0,0 +1,569 @@
+<template>
+	<AidopDemoShell title="订单生产排程看板" subtitle="按工单/工序展示计划与实际排程甘特图,含物料齐套与交期标识">
+		<!-- 筛选栏 -->
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="工单编号">
+				<el-input v-model="query.workOrd" placeholder="模糊搜索" clearable style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="计划日期">
+				<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始" end-placeholder="结束" style="width: 260px" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" :loading="loading" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<!-- 图例 -->
+		<div class="gantt-legend">
+			<span class="legend-item"><span class="legend-bar plan" />计划工期</span>
+			<span class="legend-item"><span class="legend-bar actual" />实际工期</span>
+			<span class="legend-item"><span class="legend-bar delay" />实际延误</span>
+			<span class="legend-item"><el-icon color="#67C23A"><Box /></el-icon> 物料齐套</span>
+			<span class="legend-item"><el-icon color="#E6A23C"><Star /></el-icon> 订单交期</span>
+		</div>
+
+		<!-- 甘特图容器 -->
+		<div class="gantt-wrapper" v-loading="loading">
+			<div v-if="chartRows.length === 0 && !loading" class="gantt-empty">暂无排程数据,请先执行生产排程</div>
+			<div v-else class="gantt-body">
+				<!-- 左侧信息面板 -->
+				<div class="gantt-left" ref="leftPanelRef">
+					<div class="gantt-left-header">
+						<span class="gl-col gl-workord">工单号</span>
+						<span class="gl-col gl-itemnum">产品编码</span>
+						<span class="gl-col gl-item">产品名称</span>
+						<span class="gl-col gl-status">工单状态</span>
+						<span class="gl-col gl-op">工序</span>
+						<span class="gl-col gl-qty">数量</span>
+						<span class="gl-col gl-status">工序状态</span>
+						<span class="gl-col gl-date">计划开工</span>
+						<span class="gl-col gl-date">计划完工</span>
+						<span class="gl-col gl-date">实际开工</span>
+						<span class="gl-col gl-date">实际完工</span>
+					</div>
+					<div class="gantt-left-body" ref="leftScrollRef">
+						<div
+							v-for="(row, idx) in chartRows"
+							:key="idx"
+							:class="['gantt-left-row', row.isGroup ? 'group-row' : '']"
+							:style="{ height: ROW_HEIGHT + 'px' }"
+						>
+							<span class="gl-col gl-workord" :title="row.workOrd">{{ row.isGroup ? row.workOrd : '' }}</span>
+							<span class="gl-col gl-itemnum" :title="row.itemNum">{{ row.isGroup ? row.itemNum : '' }}</span>
+							<span class="gl-col gl-item" :title="row.itemName">{{ row.isGroup ? row.itemName : '' }}</span>
+							<span class="gl-col gl-status">{{ row.isGroup ? fmtStatus(row.status) : '' }}</span>
+							<span class="gl-col gl-op">{{ row.isGroup ? '' : `${row.op ?? ''} ${row.opDescr ?? ''}` }}</span>
+							<span class="gl-col gl-qty">{{ row.isGroup ? row.qtyOrded : row.workQty }}</span>
+							<span class="gl-col gl-status">{{ row.isGroup ? '' : fmtStatus(row.opStatus) }}</span>
+							<span class="gl-col gl-date">{{ fmtDate(row.planStartDate) }}</span>
+							<span class="gl-col gl-date">{{ fmtDate(row.planEndDate) }}</span>
+							<span class="gl-col gl-date">{{ fmtDate(row.actualStartDate) }}</span>
+							<span class="gl-col gl-date">{{ fmtDate(row.actualEndDate) }}</span>
+						</div>
+					</div>
+				</div>
+				<!-- 右侧甘特图 -->
+				<div class="gantt-right">
+					<div ref="chartRef" class="gantt-chart" />
+				</div>
+			</div>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="workOrderSchedulingGanttKanban">
+import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue';
+import * as echarts from 'echarts';
+import { Box, Star } from '@element-plus/icons-vue';
+import AidopDemoShell from '../components/AidopDemoShell.vue';
+import { fetchGanttData, type GanttOpRow } from '../api/workOrderScheduling';
+
+// ── 状态 ──
+const loading = ref(false);
+const rawList = ref<GanttOpRow[]>([]);
+const query = ref({ workOrd: '' });
+const dateRange = ref<string[] | null>(null);
+
+const ROW_HEIGHT = 36;
+
+// ── 数据分组 ──
+interface ChartRow {
+	isGroup: boolean;
+	workOrd?: string;
+	itemNum?: string;
+	itemName?: string;
+	itemModel?: string;
+	qtyOrded?: number;
+	dueDate?: string;
+	status?: string;
+	domain?: string;
+	mfRate?: number;
+	op?: number;
+	opDescr?: string;
+	planStartDate?: string;
+	planEndDate?: string;
+	actualStartDate?: string;
+	actualEndDate?: string;
+	workQty?: number;
+	qtyCompleted?: number;
+	opStatus?: string;
+}
+
+const chartRows = computed<ChartRow[]>(() => {
+	const groups = new Map<string, GanttOpRow[]>();
+	for (const r of rawList.value) {
+		const key = r.workOrd ?? '';
+		if (!groups.has(key)) groups.set(key, []);
+		groups.get(key)!.push(r);
+	}
+	const rows: ChartRow[] = [];
+	for (const [workOrd, ops] of groups) {
+		const first = ops[0];
+		rows.push({
+			isGroup: true,
+			workOrd,
+			itemNum: first.itemNum ?? '',
+			itemName: first.itemName ?? '',
+			itemModel: first.itemModel ?? '',
+			qtyOrded: first.qtyOrded ?? 0,
+			dueDate: first.dueDate ?? undefined,
+			status: first.status ?? '',
+			domain: first.domain ?? '',
+			mfRate: first.mfRate ?? 0,
+		});
+		for (const op of ops) {
+			rows.push({
+				isGroup: false,
+				workOrd: op.workOrd ?? '',
+				itemName: op.itemName ?? '',
+				op: op.op ?? 0,
+				opDescr: op.opDescr ?? '',
+				planStartDate: op.planStartDate ?? undefined,
+				planEndDate: op.planEndDate ?? undefined,
+				actualStartDate: op.actualStartDate ?? undefined,
+				actualEndDate: op.actualEndDate ?? undefined,
+				workQty: op.workQty ?? 0,
+				qtyCompleted: op.qtyCompleted ?? 0,
+				opStatus: op.opStatus ?? undefined,
+			});
+		}
+	}
+	return rows;
+});
+
+// ── 图表 ──
+const chartRef = ref<HTMLDivElement>();
+const leftScrollRef = ref<HTMLDivElement>();
+const leftPanelRef = ref<HTMLDivElement>();
+let chart: echarts.ECharts | null = null;
+
+function fmtDate(d?: string | null): string {
+	if (!d) return '—';
+	return d.substring(0, 10);
+}
+
+const statusOptions = [
+	{ v: 'w', l: '投产' },
+	{ v: 'r', l: '下达' },
+	{ v: 'c', l: '关闭' },
+	{ v: 'p', l: '初始' },
+	{ v: 's', l: '暂停' },
+];
+
+function fmtStatus(v?: string | null): string {
+	if (!v) return '—';
+	const opt = statusOptions.find((o) => o.v === v.toLowerCase());
+	return opt ? opt.l : v;
+}
+
+function toDateNum(d?: string | null): number {
+	if (!d) return NaN;
+	return new Date(d).getTime();
+}
+
+function dayOffset(ts: number, days: number): number {
+	return ts + days * 86400000;
+}
+
+function renderChart() {
+	if (!chartRef.value) return;
+	if (!chart) {
+		chart = echarts.init(chartRef.value);
+	}
+	const rows = chartRows.value;
+	if (rows.length === 0) {
+		chart.clear();
+		return;
+	}
+
+	// 计算时间范围(从最小订单交期开始)
+	let minTs = Infinity;
+	let maxTs = -Infinity;
+	for (const r of rows) {
+		// 用交期确定起始日期
+		const dd = toDateNum(r.dueDate);
+		if (!isNaN(dd) && dd < minTs) minTs = dd;
+		for (const d of [r.planStartDate, r.planEndDate, r.actualStartDate, r.actualEndDate]) {
+			const ts = toDateNum(d);
+			if (!isNaN(ts)) {
+				if (ts > maxTs) maxTs = ts;
+			}
+		}
+	}
+	if (!isFinite(minTs)) {
+		const today = new Date();
+		today.setHours(0, 0, 0, 0);
+		minTs = today.getTime();
+	}
+	if (!isFinite(maxTs)) {
+		maxTs = dayOffset(minTs, 30);
+	}
+	// 前后各扩展1天
+	minTs = dayOffset(minTs, -1);
+	maxTs = dayOffset(maxTs, 1);
+
+	// 与左侧面板顺序一致(yAxis inverse: true,index 0 在顶部)
+	const yLabels = rows.map((r) => {
+		if (r.isGroup) return `{group|${r.workOrd ?? ''}}`;
+		return `  ${r.op ?? ''}`;
+	});
+
+	// 构建系列数据
+	const planData: any[] = [];
+	const actualData: any[] = [];
+	const mfIcons: any[] = [];
+	const dueIcons: any[] = [];
+
+	for (let ri = 0; ri < rows.length; ri++) {
+		const r = rows[ri];
+		const yIdx = ri; // 与左侧顺序一致
+
+		if (r.isGroup) {
+			// 物料齐套标识(group行)
+			if ((r.mfRate ?? 0) > 0) {
+				mfIcons.push({ value: [toDateNum(r.planStartDate) || minTs, yIdx], mfRate: r.mfRate });
+			}
+			// 交期标识(group行)
+			if (r.dueDate) {
+				dueIcons.push({ value: [toDateNum(r.dueDate), yIdx], dueDate: r.dueDate });
+			}
+			continue;
+		}
+
+		// 计划条
+		const ps = toDateNum(r.planStartDate);
+		const pe = toDateNum(r.planEndDate);
+		if (!isNaN(ps) && !isNaN(pe)) {
+			planData.push([ps, pe + 86400000, yIdx, r.opDescr, r.workQty, r.workOrd]);
+		}
+
+		// 实际条
+		const as = toDateNum(r.actualStartDate);
+		const ae = toDateNum(r.actualEndDate);
+		if (!isNaN(as) && !isNaN(ae)) {
+			const isDelay = pe > 0 && ae > pe + 86400000;
+			actualData.push([as, ae + 86400000, yIdx, r.opDescr, r.workQty, isDelay, r.workOrd]);
+		}
+	}
+
+	const option: echarts.EChartsOption = {
+		tooltip: {
+			formatter(params: any) {
+				const d = params.data;
+				if (!d) return '';
+				const name = params.seriesName;
+				const start = new Date(d[0]).toLocaleDateString();
+				const end = new Date(d[1] - 86400000).toLocaleDateString();
+				const op = d[3] ?? '';
+				const qty = d[4] ?? '';
+				// 计划条 workOrd 在 index 5,实际条 workOrd 在 index 6
+				const workOrd = name === '实际工期' ? (d[6] ?? '') : (d[5] ?? '');
+				return `<b>${name}</b><br/>工单号: ${workOrd}<br/>工序: ${op}<br/>数量: ${qty}<br/>${start} ~ ${end}`;
+			},
+		},
+		grid: { left: 5, right: 40, top: 10, bottom: 30, containLabel: false },
+		xAxis: {
+			type: 'time',
+			min: minTs,
+			max: maxTs,
+			axisLabel: {
+				formatter: '{MM}/{dd}',
+				fontSize: 11,
+			},
+			splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e8e8e8' } },
+		},
+		yAxis: {
+			type: 'category',
+			data: yLabels,
+			inverse: true,
+			show: false,
+			splitLine: { show: false },
+			axisTick: { show: false },
+		},
+		series: [
+			{
+				name: '计划工期',
+				type: 'custom',
+				renderItem: (_params: any, api: any) => {
+					const startTs = api.value(0);
+					const endTs = api.value(1);
+					const catIdx = api.value(2);
+					const start = api.coord([startTs, catIdx]);
+					const end = api.coord([endTs, catIdx]);
+					const barH = api.size([0, 1])[1] * 0.35;
+					const y = start[1] - barH / 2 - 2;
+					return {
+						type: 'rect',
+						shape: { x: start[0], y, width: Math.max(end[0] - start[0], 2), height: barH },
+						style: { fill: '#409EFF', opacity: 0.7 },
+					};
+				},
+				encode: { x: [0, 1], y: 2 },
+				data: planData,
+				z: 2,
+			},
+			{
+				name: '实际工期',
+				type: 'custom',
+				renderItem: (_params: any, api: any) => {
+					const startTs = api.value(0);
+					const endTs = api.value(1);
+					const catIdx = api.value(2);
+					const isDelay = api.value(5);
+					const start = api.coord([startTs, catIdx]);
+					const end = api.coord([endTs, catIdx]);
+					const barH = api.size([0, 1])[1] * 0.3;
+					const y = start[1] + 2;
+					return {
+						type: 'rect',
+						shape: { x: start[0], y, width: Math.max(end[0] - start[0], 2), height: barH },
+						style: { fill: isDelay ? '#F56C6C' : '#67C23A', opacity: 0.8 },
+					};
+				},
+				encode: { x: [0, 1], y: 2 },
+				data: actualData,
+				z: 3,
+			},
+			{
+				name: '物料齐套',
+				type: 'scatter',
+				symbol: 'roundRect',
+				symbolSize: [14, 14],
+				itemStyle: { color: '#67C23A' },
+				data: mfIcons.map((d: any) => ({
+					value: d.value,
+					itemStyle: { color: (d.mfRate ?? 0) >= 100 ? '#67C23A' : '#E6A23C' },
+				})),
+				tooltip: {
+					formatter(p: any) {
+						const rate = mfIcons[p.dataIndex]?.mfRate ?? 0;
+						return `物料齐套率: ${rate}%`;
+					},
+				},
+				z: 10,
+			},
+			{
+				name: '订单交期',
+				type: 'scatter',
+				symbol: 'diamond',
+				symbolSize: 16,
+				itemStyle: { color: '#E6A23C' },
+				data: dueIcons,
+				tooltip: {
+					formatter(p: any) {
+						const dd = dueIcons[p.dataIndex]?.dueDate ?? '';
+						return `订单交期: ${dd}`;
+					},
+				},
+				z: 10,
+			},
+		],
+		dataZoom: [
+			{ type: 'inside', xAxisIndex: 0 },
+			{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 2 },
+		],
+	};
+
+	chart.setOption(option, true);
+}
+
+async function doSearch() {
+	loading.value = true;
+	try {
+		const params: any = {};
+		if (query.value.workOrd) params.workOrd = query.value.workOrd;
+		if (dateRange.value?.length === 2) {
+			params.startDate = dateRange.value[0];
+			params.endDate = dateRange.value[1];
+		}
+		const res = await fetchGanttData(params);
+		rawList.value = res.list ?? [];
+		await nextTick();
+		renderChart();
+	} catch (e) {
+		console.error(e);
+		rawList.value = [];
+	} finally {
+		loading.value = false;
+	}
+}
+
+function resetQuery() {
+	query.value.workOrd = '';
+	dateRange.value = null;
+	doSearch();
+}
+
+function onResize() {
+	chart?.resize();
+}
+
+onMounted(() => {
+	doSearch();
+	window.addEventListener('resize', onResize);
+});
+
+onBeforeUnmount(() => {
+	window.removeEventListener('resize', onResize);
+	chart?.dispose();
+	chart = null;
+});
+</script>
+
+<style scoped>
+.gantt-legend {
+	display: flex;
+	gap: 18px;
+	align-items: center;
+	margin-bottom: 10px;
+	font-size: 13px;
+	color: #606266;
+}
+.legend-item {
+	display: inline-flex;
+	align-items: center;
+	gap: 4px;
+}
+.legend-bar {
+	display: inline-block;
+	width: 24px;
+	height: 12px;
+	border-radius: 2px;
+}
+.legend-bar.plan {
+	background: #409eff;
+	opacity: 0.7;
+}
+.legend-bar.actual {
+	background: #67c23a;
+	opacity: 0.8;
+}
+.legend-bar.delay {
+	background: #f56c6c;
+	opacity: 0.8;
+}
+
+.gantt-wrapper {
+	min-height: 400px;
+}
+.gantt-empty {
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	height: 300px;
+	color: #909399;
+	font-size: 14px;
+}
+.gantt-body {
+	display: flex;
+	height: calc(100vh - 260px);
+	min-height: 400px;
+	border: 1px solid #ebeef5;
+	border-radius: 4px;
+	overflow: hidden;
+}
+.gantt-left {
+	flex: 0 0 780px;
+	border-right: 2px solid #dcdfe6;
+	background: #fafafa;
+	display: flex;
+	flex-direction: column;
+}
+.gantt-left-header {
+	display: flex;
+	height: 36px;
+	align-items: center;
+	background: #f5f7fa;
+	border-bottom: 1px solid #ebeef5;
+	font-weight: 600;
+	font-size: 12px;
+	color: #303133;
+	flex-shrink: 0;
+}
+.gantt-left-body {
+	flex: 1;
+	overflow-y: auto;
+	overflow-x: hidden;
+}
+.gantt-left-row {
+	display: flex;
+	align-items: center;
+	border-bottom: 1px solid #f0f0f0;
+	font-size: 12px;
+	color: #606266;
+	padding: 0 4px;
+}
+.gantt-left-row.group-row {
+	background: #ecf5ff;
+	font-weight: 600;
+	color: #303133;
+	height: 40px;
+}
+.gl-col {
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	padding: 0 3px;
+}
+.gl-workord {
+	width: 100px;
+	flex: 0 0 100px;
+}
+.gl-itemnum {
+	width: 100px;
+	flex: 0 0 100px;
+}
+.gl-item {
+	width: 100px;
+	flex: 0 0 100px;
+}
+.gl-op {
+	width: 80px;
+	flex: 0 0 80px;
+}
+.gl-qty {
+	width: 50px;
+	flex: 0 0 50px;
+	text-align: right;
+}
+.gl-status {
+	width: 60px;
+	flex: 0 0 60px;
+	text-align: center;
+}
+.gl-date {
+	width: 80px;
+	flex: 0 0 80px;
+	text-align: center;
+}
+
+.gantt-right {
+	flex: 1;
+	overflow: hidden;
+}
+.gantt-chart {
+	width: 100%;
+	height: 100%;
+}
+</style>

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

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

+ 111 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Production/WorkOrderSchedulingService.cs

@@ -1783,6 +1783,117 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
         return new { message = input.Urgent == 2 ? "已设为特急" : "已设为加急" };
     }
 
+    // ══════════════════════════════════════════════════════════════
+    // 甘特图数据 GET /api/Production/scheduling/gantt-data
+    // ══════════════════════════════════════════════════════════════
+    /// <summary>
+    /// 订单生产排程看板 — 甘特图数据(工单 + 工序级别,含计划/实际开工完工日期、物料齐套率、交期)
+    /// </summary>
+    [DisplayName("甘特图数据")]
+    [HttpGet("scheduling/gantt-data")]
+    public async Task<object> GetGanttData([FromQuery] string? workOrd, [FromQuery] string? startDate, [FromQuery] string? endDate)
+    {
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
+        var where = new List<string>
+        {
+            "w.tenant_id = @TenantId",
+            "IFNULL(w.Status, '') <> ''",
+            "LOWER(w.Status) IN ('w', 'r', 'p')"
+        };
+
+        if (!string.IsNullOrWhiteSpace(workOrd))
+        {
+            where.Add("w.WorkOrd LIKE @WorkOrd");
+            pars.Add(new SugarParameter("@WorkOrd", $"%{workOrd.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(startDate))
+        {
+            where.Add("COALESCE(plan.PlanStartDate, sm.WorkDate) >= @StartDate");
+            pars.Add(new SugarParameter("@StartDate", startDate.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(endDate))
+        {
+            where.Add("COALESCE(plan.PlanEndDate, sm.WorkDate) <= @EndDate");
+            pars.Add(new SugarParameter("@EndDate", endDate.Trim()));
+        }
+
+        // 主查询:工单信息 + 工序排产 + 计划日期范围 + 齐套率
+        var sql = $"""
+            SELECT
+                w.WorkOrd,
+                w.ItemNum,
+                IFNULL(w.ItemName, im.Descr) AS ItemName,
+                im.Descr1 AS ItemModel,
+                w.QtyOrded,
+                DATE(w.DueDate) AS DueDate,
+                LOWER(w.Status) AS Status,
+                COALESCE(NULLIF(TRIM(w.`Domain`), ''), IFNULL(CAST(w.tenant_id AS CHAR), '')) AS Domain,
+                IFNULL(nm.MFRate, 0) AS MFRate,
+                sm.Op,
+                IFNULL(sm.OpDescr, '') AS OpDescr,
+                DATE(COALESCE(plan.PlanStartDate, sm.WorkDate)) AS PlanStartDate,
+                DATE(COALESCE(plan.PlanEndDate, sm.WorkDate)) AS PlanEndDate,
+                DATE(sm.WorkStartTime) AS ActualStartDate,
+                DATE(sm.WorkEndTime) AS ActualEndDate,
+                IFNULL(sm.WorkQty, 0) AS WorkQty,
+                IFNULL(w.QtyCompleted, 0) AS QtyCompleted,
+                LOWER(IFNULL(routing.OpStatus, '')) AS OpStatus
+            FROM ScheduleResultOpMaster sm
+            INNER JOIN WorkOrdMaster w
+                ON w.WorkOrd = sm.WorkOrd
+                AND (
+                    TRIM(IFNULL(w.`Domain`, '')) = TRIM(IFNULL(sm.`Domain`, ''))
+                    OR w.tenant_id = sm.tenant_id
+                )
+            LEFT JOIN ItemMaster im ON w.ItemNum = im.ItemNum
+            LEFT JOIN (
+                SELECT WorkOrd, MAX(MFRate) AS MFRate
+                FROM NbrMaster
+                WHERE tenant_id = @TenantId AND Type = 'SM'
+                GROUP BY WorkOrd
+            ) nm ON nm.WorkOrd = sm.WorkOrd
+            LEFT JOIN (
+                SELECT WorkOrds, Op, MIN(PlanDate) AS PlanStartDate, MAX(PlanDate) AS PlanEndDate
+                FROM PeriodSequenceDet
+                WHERE tenant_id = @TenantId AND IFNULL(IsActive, 1) = 1
+                GROUP BY WorkOrds, Op
+            ) plan ON plan.WorkOrds = sm.WorkOrd AND plan.Op = sm.Op
+            LEFT JOIN (
+                SELECT WorkOrd, CAST(OP AS UNSIGNED) AS OpNum, MAX(Status) AS OpStatus
+                FROM WorkOrdRouting
+                GROUP BY WorkOrd, OP
+            ) routing ON routing.WorkOrd = sm.WorkOrd AND routing.OpNum = CAST(sm.Op AS UNSIGNED)
+            WHERE {string.Join(" AND ", where)}
+            ORDER BY w.WorkOrd ASC, CAST(sm.Op AS UNSIGNED) ASC
+            """;
+
+        var rows = await _db.Ado.SqlQueryAsync<GanttOpRow>(sql, pars);
+        return new { list = rows };
+    }
+
+    private sealed class GanttOpRow
+    {
+        public string? WorkOrd { get; set; }
+        public string? ItemNum { get; set; }
+        public string? ItemName { get; set; }
+        public string? ItemModel { get; set; }
+        public decimal? QtyOrded { get; set; }
+        public DateTime? DueDate { get; set; }
+        public string? Status { get; set; }
+        public string? Domain { get; set; }
+        public decimal? MFRate { get; set; }
+        public int? Op { get; set; }
+        public string? OpDescr { get; set; }
+        public DateTime? PlanStartDate { get; set; }
+        public DateTime? PlanEndDate { get; set; }
+        public DateTime? ActualStartDate { get; set; }
+        public DateTime? ActualEndDate { get; set; }
+        public decimal? WorkQty { get; set; }
+        public decimal? QtyCompleted { get; set; }
+        public string? OpStatus { get; set; }
+    }
+
     // ──────────────── 行类型 ────────────────
 
     private sealed class WorkOrderSchedulingListRow

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -1662,6 +1662,21 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             OrderNo = 10,
             Remark = "S2 工单执行进度看板(列表)"
         };
+
+        yield return new SysMenu
+        {
+            Id = 1329002100022L,
+            Pid = s2CollaborationKanbanDirId,
+            Title = "订单生产排程看板",
+            Path = "/aidop/s2/collaboration-kanban/scheduling-gantt",
+            Name = "aidopS2SchedulingGanttKanban",
+            Component = "/aidop/production/workOrderSchedulingGanttKanban",
+            Icon = "ele-Timeline",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 20,
+            Remark = "S2 订单生产排程看板(甘特图)"
+        };
     }
 
     /// <summary>