소스 검색

feat: 工单/日计划/交付列表多项优化 - 工单查看表单工序明细列与列表页统一 - 订单交付列表按客户交期倒序 - 可执行日计划过滤空/p/c状态工单 - 工单工序排程列表过滤空状态 - 工单数量编辑子表级联同步 - 消除重复资源检查 - 交期确认同步工单日期

Pengxy 3 달 전
부모
커밋
3b0cb57352

+ 1 - 1
Web/package.json

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

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

@@ -135,3 +135,123 @@ export function fetchSchedulingRoutings(workOrd: string, domain: string) {
 export function fetchSchedulingTrace(workOrd: string, domain: string) {
 	return service.get<Record<string, unknown>>('/api/Production/scheduling/trace', { params: { workOrd, domain } }).then((r) => r.data);
 }
+
+// ══════════════════════════════════════════════════════════════
+// 工单物料明细 / 工序明细 CRUD
+// ══════════════════════════════════════════════════════════════
+
+export interface WorkOrdDetailRow {
+	recID: number;
+	itemNum: string;
+	op: string;
+	qtyRequired: number;
+	frozenBOMQty: number;
+	location: string;
+	lotSerial: string;
+	workOrd: string;
+}
+
+export interface WorkOrdRoutingRow {
+	recID: number;
+	op: string;
+	descr: string;
+	parentOp: string;
+	milestoneOp: number;
+	packingQty: number;
+	qtyComplete: number;
+	qtyReject: number;
+	qtyScrap: number;
+	status: string;
+	workOrd: string;
+	workCtr: string;
+	prodLine: string;
+	stdSetupTime: number;
+	qtyOrded: number;
+	processOut: number;
+	processOutSupp: string;
+	processOutDay: number;
+	machine: string;
+	toolCode: string;
+	runCrew: number;
+	engineer: string;
+	machBdnRate: number;
+	workCode: string;
+	setup: number;
+	machinesperOp: number;
+	labor: number;
+	chargeCode: string;
+	itemNum: string;
+}
+
+export interface WorkOrdDetailSaveInput {
+	recID: number;
+	workOrd: string;
+	itemNum: string;
+	op: string;
+	qtyRequired: number;
+	location: string;
+	lotSerial: string;
+}
+
+export interface WorkOrdRoutingSaveInput {
+	recID: number;
+	workOrd: string;
+	itemNum: string;
+	op: string;
+	descr: string;
+	workCtr: string;
+	prodLine: string;
+	stdSetupTime: number;
+	qtyOrded: number;
+	milestoneOp: boolean;
+	processOut: boolean;
+	processOutSupp: string;
+	processOutDay: number;
+	machine: string;
+	toolCode: string;
+	runCrew: number;
+	engineer: string;
+	machBdnRate: number;
+	workCode: string;
+	setup: number;
+	machinesperOp: number;
+	labor: number;
+	chargeCode: string;
+}
+
+/** 下拉数据 */
+export function fetchWorkOrdOptions() {
+	return service.get<{ list: { WorkOrd: string }[] }>('/api/Production/scheduling/work-ord-options').then((r) => r.data);
+}
+
+export function fetchWorkCtrOptions() {
+	return service.get<{ list: { WorkCtr: string; Descr: string }[] }>('/api/Production/scheduling/work-ctr-options').then((r) => r.data);
+}
+
+export function fetchLineOptions() {
+	return service.get<{ list: { Line: string; Describe: string }[] }>('/api/Production/scheduling/line-options').then((r) => r.data);
+}
+
+export function fetchSuppOptions() {
+	return service.get<{ list: { Supp: string; SuppName: string }[] }>('/api/Production/scheduling/supp-options').then((r) => r.data);
+}
+
+/** 工单物料明细保存 */
+export function saveMaterialDetail(body: WorkOrdDetailSaveInput) {
+	return service.post('/api/Production/scheduling/material-save', body).then((r) => r.data);
+}
+
+/** 工单物料明细删除 */
+export function deleteMaterialDetail(recID: number) {
+	return service.post('/api/Production/scheduling/material-delete', { recID }).then((r) => r.data);
+}
+
+/** 工单工序明细保存 */
+export function saveRoutingDetail(body: WorkOrdRoutingSaveInput) {
+	return service.post('/api/Production/scheduling/routing-save', body).then((r) => r.data);
+}
+
+/** 工单工序明细删除 */
+export function deleteRoutingDetail(recID: number) {
+	return service.post('/api/Production/scheduling/routing-delete', { recID }).then((r) => r.data);
+}

+ 27 - 6
Web/src/views/aidop/business/orderList.vue

@@ -67,7 +67,7 @@
 					<el-button link type="primary" v-if="canEdit(row)" @click="openEdit(row)">编辑</el-button>
 					<el-button link type="primary" @click="openView(row)">查看</el-button>
 					<el-button link type="warning" v-if="canChange(row)" @click="openChange(row)">变更</el-button>
-					<el-button link type="success" v-if="canReorder(row)" @click="onReorder(row)">3级计划重排</el-button>
+					<el-button link type="success" v-if="canReorder(row)" :loading="reorderingId === row.id" @click="onReorder(row)">3级计划重排</el-button>
 				</template>
 			</el-table-column>
 		</el-table>
@@ -88,7 +88,7 @@
 			<SalesOrderForm :mode="formMode" :order-id="editingId" @cancel="formDialogVisible = false" @saved="onSaved" />
 		</el-dialog>
 
-		<el-dialog v-model="changeDialogVisible" title="销售订单变更" width="980px" destroy-on-close>
+		<el-dialog v-model="changeDialogVisible" title="销售订单变更" width="980px" destroy-on-close @close="loadList">
 			<SalesOrderChangeForm :order-id="editingId" @cancel="changeDialogVisible = false" @submitted="onChanged" />
 		</el-dialog>
 	</AidopDemoShell>
@@ -110,6 +110,7 @@ import {
 	type SeOrderReviewRow,
 	unlockSeOrder,
 } from '../api/seOrderReview';
+import { getInstanceByBiz } from '/@/views/approvalFlow/api';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '订单评审');
@@ -132,6 +133,7 @@ const total = ref(0);
 const selectedIds = ref<number[]>([]);
 const reviewing = ref(false);
 const confirming = ref(false);
+const reorderingId = ref<number | null>(null);
 
 const col = reactive({
 	billNo: true,
@@ -205,12 +207,21 @@ async function onReorder(row: SeOrderReviewRow) {
 			'3级计划重排',
 			{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
 		);
+	} catch (e: any) {
+		if (e === 'cancel' || e === 'close') return;
+		throw e;
+	}
+	reorderingId.value = row.id;
+	loading.value = true;
+	try {
 		const data = await refreshSeOrderPlan(row.id, { reason: '订单变更后3级计划重排' });
 		ElMessage.success(data?.message || '3级计划重排成功');
 		await loadList();
 	} catch (e: any) {
-		if (e === 'cancel' || e === 'close') return;
 		ElMessage.error(e?.message || '3级计划重排请求失败');
+	} finally {
+		reorderingId.value = null;
+		loading.value = false;
 	}
 }
 
@@ -309,7 +320,18 @@ function openView(row: SeOrderReviewRow) {
 	formDialogVisible.value = true;
 }
 
-function openChange(row: SeOrderReviewRow) {
+async function openChange(row: SeOrderReviewRow) {
+	try {
+		const res = await getInstanceByBiz('ORDER_CHANGE_REVIEW', row.id);
+		const inst = res.data?.result;
+		if (inst && inst.status === 1) {
+			ElMessage.warning('已有审批中变更单,请刷新列表');
+			await loadList();
+			return;
+		}
+	} catch {
+		/* 查询失败则放行,不阻塞业务 */
+	}
 	editingId.value = row.id;
 	changeDialogVisible.value = true;
 }
@@ -319,9 +341,8 @@ async function onSaved() {
 	await loadList();
 }
 
-async function onChanged() {
+function onChanged() {
 	changeDialogVisible.value = false;
-	await loadList();
 }
 
 onMounted(() => loadList());

+ 148 - 0
Web/src/views/aidop/production/workOrderMaterialDetailForm.vue

@@ -0,0 +1,148 @@
+<template>
+	<div>
+		<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" :disabled="readOnly">
+			<el-row :gutter="16">
+				<el-col :span="24">
+					<el-form-item label="工单编号" prop="workOrd">
+						<el-select v-model="form.workOrd" filterable placeholder="选择工单" style="width: 100%" :disabled="isEdit" @change="onWorkOrdChange">
+							<el-option v-for="w in workOrdOptions" :key="w.WorkOrd" :label="w.WorkOrd" :value="w.WorkOrd" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="物料号" prop="itemNum">
+						<el-input v-model="form.itemNum" placeholder="输入物料号" :disabled="isEdit" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="工序" prop="op">
+						<el-input v-model="form.op" placeholder="输入工序" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="需求数量" prop="qtyRequired">
+						<el-input-number v-model="form.qtyRequired" :precision="4" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="库位" prop="location">
+						<el-input v-model="form.location" placeholder="输入库位" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="24">
+					<el-form-item label="生产批次" prop="lotSerial">
+						<el-input v-model="form.lotSerial" placeholder="输入生产批次" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+		</el-form>
+
+		<div class="footer">
+			<el-button @click="$emit('cancel')">取消</el-button>
+			<el-button v-if="!readOnly" type="primary" :loading="saving" @click="onSave">保存</el-button>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
+import { fetchWorkOrdOptions, saveMaterialDetail } from '../api/workOrderScheduling';
+
+const props = defineProps<{
+	recID?: number;
+	workOrd?: string;
+	itemNum?: string;
+	op?: string;
+	qtyRequired?: number;
+	location?: string;
+	lotSerial?: string;
+	readOnly?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(e: 'cancel'): void;
+	(e: 'saved'): void;
+}>();
+
+const formRef = ref<FormInstance>();
+const saving = ref(false);
+const workOrdOptions = ref<{ WorkOrd: string }[]>([]);
+
+const isEdit = computed(() => (props.recID ?? 0) > 0);
+const readOnly = computed(() => props.readOnly ?? false);
+
+const form = reactive({
+	recID: props.recID ?? 0,
+	workOrd: props.workOrd ?? '',
+	itemNum: props.itemNum ?? '',
+	op: props.op ?? '',
+	qtyRequired: props.qtyRequired ?? 0,
+	location: props.location ?? '',
+	lotSerial: props.lotSerial ?? '',
+});
+
+const rules: FormRules = {
+	workOrd: [{ required: true, message: '请选择工单编号', trigger: 'change' }],
+	itemNum: [{ required: true, message: '请输入物料号', trigger: 'blur' }],
+	qtyRequired: [{ required: true, message: '请输入需求数量', trigger: 'blur' }],
+};
+
+function onWorkOrdChange() {
+	// 工单切换时无需特殊处理
+}
+
+async function onSave() {
+	const valid = await formRef.value?.validate().catch(() => false);
+	if (!valid) return;
+
+	saving.value = true;
+	try {
+		const res = await saveMaterialDetail({
+			recID: form.recID,
+			workOrd: form.workOrd,
+			itemNum: form.itemNum,
+			op: form.op,
+			qtyRequired: form.qtyRequired,
+			location: form.location,
+			lotSerial: form.lotSerial,
+		});
+		ElMessage.success(res?.message || '保存成功');
+		emit('saved');
+	} catch (e: any) {
+		const rd = e?.response?.data;
+		let msg = '';
+		if (rd && typeof rd === 'object') {
+			msg = (typeof rd.message === 'string' && rd.message.trim())
+				|| (typeof rd.title === 'string' && rd.title.trim())
+				|| '';
+		} else if (typeof rd === 'string' && rd.trim()) {
+			msg = rd.trim();
+		}
+		if (!msg && typeof e?.message === 'string' && !e.message.includes('status code')) {
+			msg = e.message;
+		}
+		ElMessage.error(msg || '保存失败');
+	} finally {
+		saving.value = false;
+	}
+}
+
+onMounted(async () => {
+	try {
+		const d = await fetchWorkOrdOptions();
+		workOrdOptions.value = d.list ?? [];
+	} catch {
+		/* ignore */
+	}
+});
+</script>
+
+<style scoped lang="scss">
+.footer {
+	margin-top: 16px;
+	display: flex;
+	justify-content: flex-end;
+	gap: 10px;
+}
+</style>

+ 153 - 11
Web/src/views/aidop/production/workOrderMaterialDetailList.vue

@@ -1,38 +1,113 @@
 <template>
-	<AidopDemoShell :title="pageTitle" subtitle="工单物料明细(只读)">
-		<el-table v-loading="loading" :data="rows" border stripe max-height="560">
+	<AidopDemoShell :title="pageTitle" 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" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="物料编号">
+				<el-input v-model="query.itemNum" placeholder="模糊" clearable style="width: 160px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" :loading="loading" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+				<el-button type="success" :icon="Plus" @click="openAdd">新增</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table v-loading="loading" :data="filteredRows" border stripe max-height="560">
 			<el-table-column type="index" label="#" width="50" />
+			<el-table-column prop="workOrd" label="工单编号" width="140" show-overflow-tooltip />
 			<el-table-column prop="itemNum" label="物料编码" width="140" show-overflow-tooltip />
 			<el-table-column prop="op" label="工序代码" width="100" />
 			<el-table-column prop="qtyRequired" label="需求数量" width="120" align="right" />
 			<el-table-column prop="frozenBOMQty" label="BOM数量" width="120" align="right" />
+			<el-table-column prop="location" label="库位" width="120" show-overflow-tooltip />
+			<el-table-column prop="lotSerial" label="生产批次" width="140" show-overflow-tooltip />
+			<el-table-column label="操作" width="200" fixed="right">
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openView(row)">查看</el-button>
+					<el-button link type="warning" @click="openEdit(row)">编辑</el-button>
+					<el-button link type="danger" @click="onDelete(row)">删除</el-button>
+				</template>
+			</el-table-column>
 		</el-table>
+
+		<el-dialog
+			v-model="dialogVisible"
+			:title="dialogTitle"
+			width="680px"
+			destroy-on-close
+			@close="onDialogClose"
+		>
+			<WorkOrderMaterialDetailForm
+				v-if="dialogVisible"
+				:rec-i-d="currentRow?.recID ?? 0"
+				:work-ord="currentRow?.workOrd || query.workOrd"
+				:item-num="currentRow?.itemNum"
+				:op="currentRow?.op"
+				:qty-required="currentRow?.qtyRequired"
+				:location="currentRow?.location"
+				:lot-serial="currentRow?.lotSerial"
+				:read-only="mode === 'view'"
+				@cancel="dialogVisible = false"
+				@saved="onSaved"
+			/>
+		</el-dialog>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts">
 import { computed, onMounted, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { Plus } from '@element-plus/icons-vue';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
-import { fetchSchedulingMaterials } from '../api/workOrderScheduling';
+import WorkOrderMaterialDetailForm from './workOrderMaterialDetailForm.vue';
+import { fetchSchedulingMaterials, deleteMaterialDetail } from '../api/workOrderScheduling';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '工单物料明细');
 
+const initDomain = computed(() => (route.query.domain as string) || '');
+
+const query = ref({
+	workOrd: (route.query.workOrd as string) || '',
+	itemNum: '',
+});
+
 const loading = ref(false);
 const rows = ref<any[]>([]);
 
-async function load() {
-	const workOrd = (route.query.workOrd as string) || '';
-	const domain = (route.query.domain as string) || '';
-	if (!workOrd || !domain) {
-		ElMessage.warning('缺少工单号或域名');
+/** 前端按物料编号模糊过滤 */
+const filteredRows = computed(() => {
+	const kw = query.value.itemNum.trim().toLowerCase();
+	if (!kw) return rows.value;
+	return rows.value.filter((r: any) => (r.itemNum ?? '').toLowerCase().includes(kw));
+});
+
+const dialogVisible = ref(false);
+const mode = ref<'add' | 'edit' | 'view'>('add');
+const currentRow = ref<any>(null);
+
+const dialogTitle = computed(() => {
+	if (mode.value === 'add') return '新增工单物料明细';
+	if (mode.value === 'edit') return '编辑工单物料明细';
+	return '查看工单物料明细';
+});
+
+async function doSearch() {
+	const workOrd = query.value.workOrd.trim();
+	if (!workOrd) {
+		ElMessage.warning('请输入工单编号');
+		return;
+	}
+	if (!initDomain.value) {
+		ElMessage.warning('缺少域名信息');
 		return;
 	}
 	loading.value = true;
 	try {
-		const d = await fetchSchedulingMaterials(workOrd, domain);
+		const d = await fetchSchedulingMaterials(workOrd, initDomain.value);
 		rows.value = d.list ?? [];
 	} catch (e: any) {
 		ElMessage.error(e?.message || '加载失败');
@@ -41,7 +116,74 @@ async function load() {
 	}
 }
 
-onMounted(() => load());
+function resetQuery() {
+	query.value.workOrd = (route.query.workOrd as string) || '';
+	query.value.itemNum = '';
+	rows.value = [];
+}
+
+function openAdd() {
+	if (!query.value.workOrd.trim()) {
+		ElMessage.warning('请先查询工单');
+		return;
+	}
+	mode.value = 'add';
+	currentRow.value = null;
+	dialogVisible.value = true;
+}
+
+function openEdit(row: any) {
+	mode.value = 'edit';
+	currentRow.value = row;
+	dialogVisible.value = true;
+}
+
+function openView(row: any) {
+	mode.value = 'view';
+	currentRow.value = row;
+	dialogVisible.value = true;
+}
+
+async function onDelete(row: any) {
+	try {
+		await ElMessageBox.confirm(`确认删除物料 ${row.itemNum} 吗?`, '提示', { type: 'warning' });
+	} catch {
+		return;
+	}
+	loading.value = true;
+	try {
+		await deleteMaterialDetail(row.recID);
+		ElMessage.success('删除成功');
+		await doSearch();
+	} catch (e: any) {
+		const rd = e?.response?.data;
+		let msg = '';
+		if (rd && typeof rd === 'object') {
+			msg = (typeof rd.message === 'string' && rd.message.trim())
+				|| (typeof rd.title === 'string' && rd.title.trim())
+				|| '';
+		}
+		if (!msg && typeof e?.message === 'string') msg = e.message;
+		ElMessage.error(msg || '删除失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+function onDialogClose() {
+	currentRow.value = null;
+}
+
+async function onSaved() {
+	dialogVisible.value = false;
+	await doSearch();
+}
+
+onMounted(() => {
+	if (query.value.workOrd) {
+		doSearch();
+	}
+});
 </script>
 
 <style scoped lang="scss">

+ 304 - 0
Web/src/views/aidop/production/workOrderRoutingDetailForm.vue

@@ -0,0 +1,304 @@
+<template>
+	<div>
+		<el-form ref="formRef" :model="form" :rules="rules" label-width="130px" :disabled="readOnly">
+			<el-row :gutter="16">
+				<el-col :span="12">
+					<el-form-item label="工单编号" prop="workOrd">
+						<el-select v-model="form.workOrd" filterable placeholder="选择工单" style="width: 100%" :disabled="isEdit">
+							<el-option v-for="w in workOrdOptions" :key="w.WorkOrd" :label="w.WorkOrd" :value="w.WorkOrd" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="物料编号" prop="itemNum">
+						<el-input v-model="form.itemNum" placeholder="输入物料编号" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="工序" prop="op">
+						<el-input v-model="form.op" placeholder="输入工序" :disabled="isEdit" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="工序名称" prop="descr">
+						<el-input v-model="form.descr" placeholder="输入工序名称" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="工作中心" prop="workCtr">
+						<el-select v-model="form.workCtr" filterable clearable placeholder="选择工作中心" style="width: 100%">
+							<el-option v-for="w in workCtrOptions" :key="w.WorkCtr" :label="`${w.WorkCtr} ${w.Descr}`" :value="w.WorkCtr" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="生产线" prop="prodLine">
+						<el-select v-model="form.prodLine" filterable clearable placeholder="选择生产线" style="width: 100%">
+							<el-option v-for="l in lineOptions" :key="l.Line" :label="`${l.Line} ${l.Describe}`" :value="l.Line" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="准备时间(小时)" prop="stdSetupTime">
+						<el-input-number v-model="form.stdSetupTime" :precision="2" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="需求数量" prop="qtyOrded">
+						<el-input-number v-model="form.qtyOrded" :precision="4" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="关键工序" prop="milestoneOp">
+						<el-radio-group v-model="form.milestoneOp">
+							<el-radio :value="true">是</el-radio>
+							<el-radio :value="false">否</el-radio>
+						</el-radio-group>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="工序委外" prop="processOut">
+						<el-radio-group v-model="form.processOut">
+							<el-radio :value="true">是</el-radio>
+							<el-radio :value="false">否</el-radio>
+						</el-radio-group>
+					</el-form-item>
+				</el-col>
+				<el-col v-if="form.processOut" :span="12">
+					<el-form-item label="供应商" prop="processOutSupp">
+						<el-select v-model="form.processOutSupp" filterable clearable placeholder="选择供应商" style="width: 100%">
+							<el-option v-for="s in suppOptions" :key="s.Supp" :label="s.SuppName" :value="s.Supp" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col v-if="form.processOut" :span="12">
+					<el-form-item label="委外提前期" prop="processOutDay">
+						<el-input-number v-model="form.processOutDay" :precision="0" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="设备类型编码" prop="machine">
+						<el-input v-model="form.machine" placeholder="输入设备类型编码" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="模具类型编码" prop="toolCode">
+						<el-input v-model="form.toolCode" placeholder="输入模具类型编码" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="标准人数" prop="runCrew">
+						<el-input-number v-model="form.runCrew" :precision="0" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="人员技能编码" prop="engineer">
+						<el-input v-model="form.engineer" placeholder="输入人员技能编码" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="单位标准产能(小时)" prop="machBdnRate">
+						<el-input-number v-model="form.machBdnRate" :precision="4" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="工序类型" prop="workCode">
+						<el-radio-group v-model="form.workCode">
+							<el-radio value="M">机械</el-radio>
+							<el-radio value="P">人工</el-radio>
+						</el-radio-group>
+					</el-form-item>
+				</el-col>
+				<el-col :span="8">
+					<el-form-item label="准备时间(秒)" prop="setup">
+						<el-input-number v-model="form.setup" :precision="2" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="8">
+					<el-form-item label="机器时间(秒)" prop="machinesperOp">
+						<el-input-number v-model="form.machinesperOp" :precision="2" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="8">
+					<el-form-item label="人工时间(秒)" prop="labor">
+						<el-input-number v-model="form.labor" :precision="2" :controls="false" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="24">
+					<el-form-item label="作业编码" prop="chargeCode">
+						<el-input v-model="form.chargeCode" placeholder="输入作业编码" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+		</el-form>
+
+		<div class="footer">
+			<el-button @click="$emit('cancel')">取消</el-button>
+			<el-button v-if="!readOnly" type="primary" :loading="saving" @click="onSave">保存</el-button>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
+import {
+	fetchWorkOrdOptions,
+	fetchWorkCtrOptions,
+	fetchLineOptions,
+	fetchSuppOptions,
+	saveRoutingDetail,
+} from '../api/workOrderScheduling';
+
+const props = defineProps<{
+	recID?: number;
+	workOrd?: string;
+	itemNum?: string;
+	op?: string;
+	descr?: string;
+	workCtr?: string;
+	prodLine?: string;
+	stdSetupTime?: number;
+	qtyOrded?: number;
+	milestoneOp?: boolean;
+	processOut?: boolean;
+	processOutSupp?: string;
+	processOutDay?: number;
+	machine?: string;
+	toolCode?: string;
+	runCrew?: number;
+	engineer?: string;
+	machBdnRate?: number;
+	workCode?: string;
+	setup?: number;
+	machinesperOp?: number;
+	labor?: number;
+	chargeCode?: string;
+	readOnly?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(e: 'cancel'): void;
+	(e: 'saved'): void;
+}>();
+
+const formRef = ref<FormInstance>();
+const saving = ref(false);
+
+const workOrdOptions = ref<{ WorkOrd: string }[]>([]);
+const workCtrOptions = ref<{ WorkCtr: string; Descr: string }[]>([]);
+const lineOptions = ref<{ Line: string; Describe: string }[]>([]);
+const suppOptions = ref<{ Supp: string; SuppName: string }[]>([]);
+
+const isEdit = computed(() => (props.recID ?? 0) > 0);
+const readOnly = computed(() => props.readOnly ?? false);
+
+const form = reactive({
+	recID: props.recID ?? 0,
+	workOrd: props.workOrd ?? '',
+	itemNum: props.itemNum ?? '',
+	op: props.op ?? '',
+	descr: props.descr ?? '',
+	workCtr: props.workCtr ?? '',
+	prodLine: props.prodLine ?? '',
+	stdSetupTime: props.stdSetupTime ?? 0,
+	qtyOrded: props.qtyOrded ?? 0,
+	milestoneOp: props.milestoneOp ?? true,
+	processOut: props.processOut ?? false,
+	processOutSupp: props.processOutSupp ?? '',
+	processOutDay: props.processOutDay ?? 0,
+	machine: props.machine ?? '',
+	toolCode: props.toolCode ?? '',
+	runCrew: props.runCrew ?? 0,
+	engineer: props.engineer ?? '',
+	machBdnRate: props.machBdnRate ?? 0,
+	workCode: props.workCode ?? 'M',
+	setup: props.setup ?? 0,
+	machinesperOp: props.machinesperOp ?? 0,
+	labor: props.labor ?? 0,
+	chargeCode: props.chargeCode ?? '',
+});
+
+const rules: FormRules = {
+	workOrd: [{ required: true, message: '请选择工单编号', trigger: 'change' }],
+	op: [{ required: true, message: '请输入工序', trigger: 'blur' }],
+};
+
+async function onSave() {
+	const valid = await formRef.value?.validate().catch(() => false);
+	if (!valid) return;
+
+	saving.value = true;
+	try {
+		const res = await saveRoutingDetail({
+			recID: form.recID,
+			workOrd: form.workOrd,
+			itemNum: form.itemNum,
+			op: form.op,
+			descr: form.descr,
+			workCtr: form.workCtr,
+			prodLine: form.prodLine,
+			stdSetupTime: form.stdSetupTime,
+			qtyOrded: form.qtyOrded,
+			milestoneOp: form.milestoneOp,
+			processOut: form.processOut,
+			processOutSupp: form.processOutSupp,
+			processOutDay: form.processOutDay,
+			machine: form.machine,
+			toolCode: form.toolCode,
+			runCrew: form.runCrew,
+			engineer: form.engineer,
+			machBdnRate: form.machBdnRate,
+			workCode: form.workCode,
+			setup: form.setup,
+			machinesperOp: form.machinesperOp,
+			labor: form.labor,
+			chargeCode: form.chargeCode,
+		});
+		ElMessage.success(res?.message || '保存成功');
+		emit('saved');
+	} catch (e: any) {
+		const rd = e?.response?.data;
+		let msg = '';
+		if (rd && typeof rd === 'object') {
+			msg = (typeof rd.message === 'string' && rd.message.trim())
+				|| (typeof rd.title === 'string' && rd.title.trim())
+				|| '';
+		} else if (typeof rd === 'string' && rd.trim()) {
+			msg = rd.trim();
+		}
+		if (!msg && typeof e?.message === 'string' && !e.message.includes('status code')) {
+			msg = e.message;
+		}
+		ElMessage.error(msg || '保存失败');
+	} finally {
+		saving.value = false;
+	}
+}
+
+onMounted(async () => {
+	try {
+		const [w, c, l, s] = await Promise.all([
+			fetchWorkOrdOptions(),
+			fetchWorkCtrOptions(),
+			fetchLineOptions(),
+			fetchSuppOptions(),
+		]);
+		workOrdOptions.value = w.list ?? [];
+		workCtrOptions.value = c.list ?? [];
+		lineOptions.value = l.list ?? [];
+		suppOptions.value = s.list ?? [];
+	} catch {
+		/* ignore */
+	}
+});
+</script>
+
+<style scoped lang="scss">
+.footer {
+	margin-top: 16px;
+	display: flex;
+	justify-content: flex-end;
+	gap: 10px;
+}
+</style>

+ 168 - 15
Web/src/views/aidop/production/workOrderRoutingDetailList.vue

@@ -1,7 +1,22 @@
 <template>
-	<AidopDemoShell :title="pageTitle" subtitle="工单工序明细(只读)">
-		<el-table v-loading="loading" :data="rows" border stripe max-height="560">
+	<AidopDemoShell :title="pageTitle" 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" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="工序代码">
+				<el-input v-model="query.op" placeholder="模糊" clearable style="width: 160px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" :loading="loading" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+				<el-button type="success" :icon="Plus" @click="openAdd">新增</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table v-loading="loading" :data="filteredRows" border stripe max-height="560">
 			<el-table-column type="index" label="#" width="50" />
+			<el-table-column prop="workOrd" label="工单编号" width="140" show-overflow-tooltip />
 			<el-table-column prop="op" label="工序代码" width="100" />
 			<el-table-column prop="descr" label="工序描述" min-width="140" show-overflow-tooltip />
 			<el-table-column prop="parentOp" label="父级工序" width="100" />
@@ -12,38 +27,109 @@
 					</el-tag>
 				</template>
 			</el-table-column>
-			<el-table-column prop="packingQty" label="包装数量" width="100" align="right" />
+			<el-table-column prop="qtyOrded" label="需求数量" width="100" align="right" />
 			<el-table-column prop="qtyComplete" label="完成数量" width="100" align="right" />
-			<el-table-column prop="qtyReject" label="不良数量" width="100" align="right" />
-			<el-table-column prop="qtyScrap" label="报废数量" width="100" align="right" />
-			<el-table-column prop="status" label="状态" width="100" />
+			<el-table-column label="操作" width="200" fixed="right">
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openView(row)">查看</el-button>
+					<el-button link type="warning" @click="openEdit(row)">编辑</el-button>
+					<el-button link type="danger" @click="onDelete(row)">删除</el-button>
+				</template>
+			</el-table-column>
 		</el-table>
+
+		<el-dialog
+			v-model="dialogVisible"
+			:title="dialogTitle"
+			width="900px"
+			destroy-on-close
+			@close="onDialogClose"
+		>
+			<WorkOrderRoutingDetailForm
+				v-if="dialogVisible"
+				:rec-i-d="currentRow?.recID ?? 0"
+				:work-ord="currentRow?.workOrd || query.workOrd"
+				:item-num="currentRow?.itemNum"
+				:op="currentRow?.op"
+				:descr="currentRow?.descr"
+				:work-ctr="currentRow?.workCtr"
+				:prod-line="currentRow?.prodLine"
+				:std-setup-time="currentRow?.stdSetupTime"
+				:qty-orded="currentRow?.qtyOrded"
+				:milestone-op="currentRow?.milestoneOp === 1"
+				:process-out="currentRow?.processOut === 1"
+				:process-out-supp="currentRow?.processOutSupp"
+				:process-out-day="currentRow?.processOutDay"
+				:machine="currentRow?.machine"
+				:tool-code="currentRow?.toolCode"
+				:run-crew="currentRow?.runCrew"
+				:engineer="currentRow?.engineer"
+				:mach-bdn-rate="currentRow?.machBdnRate"
+				:work-code="currentRow?.workCode"
+				:setup="currentRow?.setup"
+				:machinesper-op="currentRow?.machinesperOp"
+				:labor="currentRow?.labor"
+				:charge-code="currentRow?.chargeCode"
+				:read-only="mode === 'view'"
+				@cancel="dialogVisible = false"
+				@saved="onSaved"
+			/>
+		</el-dialog>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts">
 import { computed, onMounted, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { Plus } from '@element-plus/icons-vue';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
-import { fetchSchedulingRoutings } from '../api/workOrderScheduling';
+import WorkOrderRoutingDetailForm from './workOrderRoutingDetailForm.vue';
+import { fetchSchedulingRoutings, deleteRoutingDetail } from '../api/workOrderScheduling';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '工单工序明细');
 
+const initDomain = computed(() => (route.query.domain as string) || '');
+
+const query = ref({
+	workOrd: (route.query.workOrd as string) || '',
+	op: '',
+});
+
 const loading = ref(false);
 const rows = ref<any[]>([]);
 
-async function load() {
-	const workOrd = (route.query.workOrd as string) || '';
-	const domain = (route.query.domain as string) || '';
-	if (!workOrd || !domain) {
-		ElMessage.warning('缺少工单号或域名');
+/** 前端按工序代码模糊过滤 */
+const filteredRows = computed(() => {
+	const kw = query.value.op.trim().toLowerCase();
+	if (!kw) return rows.value;
+	return rows.value.filter((r: any) => (r.op ?? '').toLowerCase().includes(kw));
+});
+
+const dialogVisible = ref(false);
+const mode = ref<'add' | 'edit' | 'view'>('add');
+const currentRow = ref<any>(null);
+
+const dialogTitle = computed(() => {
+	if (mode.value === 'add') return '新增工单工序明细';
+	if (mode.value === 'edit') return '编辑工单工序明细';
+	return '查看工单工序明细';
+});
+
+async function doSearch() {
+	const workOrd = query.value.workOrd.trim();
+	if (!workOrd) {
+		ElMessage.warning('请输入工单编号');
+		return;
+	}
+	if (!initDomain.value) {
+		ElMessage.warning('缺少域名信息');
 		return;
 	}
 	loading.value = true;
 	try {
-		const d = await fetchSchedulingRoutings(workOrd, domain);
+		const d = await fetchSchedulingRoutings(workOrd, initDomain.value);
 		rows.value = d.list ?? [];
 	} catch (e: any) {
 		ElMessage.error(e?.message || '加载失败');
@@ -52,7 +138,74 @@ async function load() {
 	}
 }
 
-onMounted(() => load());
+function resetQuery() {
+	query.value.workOrd = (route.query.workOrd as string) || '';
+	query.value.op = '';
+	rows.value = [];
+}
+
+function openAdd() {
+	if (!query.value.workOrd.trim()) {
+		ElMessage.warning('请先查询工单');
+		return;
+	}
+	mode.value = 'add';
+	currentRow.value = null;
+	dialogVisible.value = true;
+}
+
+function openEdit(row: any) {
+	mode.value = 'edit';
+	currentRow.value = row;
+	dialogVisible.value = true;
+}
+
+function openView(row: any) {
+	mode.value = 'view';
+	currentRow.value = row;
+	dialogVisible.value = true;
+}
+
+async function onDelete(row: any) {
+	try {
+		await ElMessageBox.confirm(`确认删除工序 ${row.op} 吗?`, '提示', { type: 'warning' });
+	} catch {
+		return;
+	}
+	loading.value = true;
+	try {
+		await deleteRoutingDetail(row.recID);
+		ElMessage.success('删除成功');
+		await doSearch();
+	} catch (e: any) {
+		const rd = e?.response?.data;
+		let msg = '';
+		if (rd && typeof rd === 'object') {
+			msg = (typeof rd.message === 'string' && rd.message.trim())
+				|| (typeof rd.title === 'string' && rd.title.trim())
+				|| '';
+		}
+		if (!msg && typeof e?.message === 'string') msg = e.message;
+		ElMessage.error(msg || '删除失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+function onDialogClose() {
+	currentRow.value = null;
+}
+
+async function onSaved() {
+	dialogVisible.value = false;
+	await doSearch();
+}
+
+onMounted(() => {
+	if (query.value.workOrd) {
+		doSearch();
+	}
+});
 </script>
 
 <style scoped lang="scss">

+ 5 - 9
Web/src/views/aidop/production/workOrderSchedulingViewForm.vue

@@ -46,17 +46,13 @@
 			<el-table-column prop="parentOp" label="父级工序" width="100" />
 			<el-table-column label="关键工序" width="100">
 				<template #default="{ row }">
-					<el-radio-group v-model="row.milestoneOp" disabled>
-						<el-radio :label="1">是</el-radio>
-						<el-radio :label="0">否</el-radio>
-					</el-radio-group>
+					<el-tag :type="row.milestoneOp === 1 ? 'success' : 'info'" size="small">
+						{{ row.milestoneOp === 1 ? '是' : '否' }}
+					</el-tag>
 				</template>
 			</el-table-column>
-			<el-table-column prop="packingQty" label="包装数量" width="100" align="right" />
+			<el-table-column prop="qtyOrded" label="需求数量" width="100" align="right" />
 			<el-table-column prop="qtyComplete" label="完成数量" width="100" align="right" />
-			<el-table-column prop="qtyReject" label="不良数量" width="100" align="right" />
-			<el-table-column prop="qtyScrap" label="报废数量" width="100" align="right" />
-			<el-table-column prop="status" label="状态" width="100" />
 		</el-table>
 
 		<h4 class="sub-title">工单物料明细</h4>
@@ -74,7 +70,7 @@
 
 		<div ref="printRef" class="print-area">
 			<h2>工单查看</h2>
-			<p>工单:{{ master.workOrd }} 物料:{{ master.itemNum }} {{ master.itemName }}</p>
+			<p>工单:{{ master.workOrd }} 物料:{{ master.itemNum }} {{ master.itemName }}</p>
 			<table class="print-table">
 				<tbody>
 					<tr v-for="(r, i) in routings" :key="'r' + i">

+ 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.233</AssemblyVersion>
-    <FileVersion>1.0.233</FileVersion>
-    <Version>1.0.233</Version>
+    <AssemblyVersion>1.0.234</AssemblyVersion>
+    <FileVersion>1.0.234</FileVersion>
+    <Version>1.0.234</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

@@ -81,7 +81,7 @@ public class OrderDeliveryService : IDynamicApiController, ITransient
         var total = await _db.Ado.GetIntAsync(
             $"SELECT COUNT(*) FROM ({baseSql}) AS t {outerWhere}", pars);
         var list = await _db.Ado.SqlQueryAsync<OrderDeliveryListRow>(
-            $"SELECT * FROM ({baseSql}) AS t {outerWhere} ORDER BY t.RDate DESC, t.BillNo ASC, IFNULL(t.EntrySeq, 0) ASC LIMIT {input.PageSize} OFFSET {offset}",
+            $"SELECT * FROM ({baseSql}) AS t {outerWhere} ORDER BY t.PlanDate DESC, t.Id DESC LIMIT {input.PageSize} OFFSET {offset}",
             pars);
 
         return new { total, page = input.Page, pageSize = input.PageSize, list };

+ 8 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderReviewOrchestrationService.cs

@@ -363,18 +363,24 @@ public class OrderReviewOrchestrationService : ITransient
                 var workOrd = await LoadWorkOrdForEntryAsync(entry.TenantId, entry.Id);
                 result.WorkOrders.Add(workOrd);
 
-                // 交期确认后将工单状态设为 p
+                // 交期确认后将工单状态设为 p,同步 OrdDate 和 DueDate
                 if (!string.IsNullOrWhiteSpace(workOrd))
                 {
+                    var confirmDateForWo = confirmDate ?? DateTime.Now;
                     await _db.Ado.ExecuteCommandAsync(
                         """
                         UPDATE WorkOrdMaster
-                        SET Status = 'p', UpdateUser = @User, UpdateTime = @Now
+                        SET Status = 'p',
+                            OrdDate = COALESCE(OrdDate, @OrdDate),
+                            DueDate = @DueDate,
+                            UpdateUser = @User, UpdateTime = @Now
                         WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
                           AND IFNULL(TRIM(Status), '') = ''
                         """,
                         new SugarParameter("@TenantId", entry.TenantId),
                         new SugarParameter("@WorkOrd", workOrd),
+                        new SugarParameter("@OrdDate", DateTime.Now.Date),
+                        new SugarParameter("@DueDate", confirmDateForWo.Date),
                         new SugarParameter("@User", account),
                         new SugarParameter("@Now", DateTime.Now));
 

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

@@ -112,25 +112,21 @@ public class SeOrderService : IDynamicApiController, ITransient
             GROUP BY seorder_id
         ) e ON e.seorder_id = o.Id
         LEFT JOIN (
-            SELECT order_no, change_content
+            SELECT seorder_id, change_content
             FROM (
-                SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) AS order_no,
-                       JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.change_content')) AS change_content,
-                       sync_time,
-                       ROW_NUMBER() OVER (PARTITION BY JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) ORDER BY sync_time DESC, id DESC) AS RowNum
-                FROM mdp_stg_so
-                WHERE source_table='crm_seorder_change'
-                  AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.bill_no')) IS NOT NULL
+                SELECT seorder_id, change_content,
+                       ROW_NUMBER() OVER (PARTITION BY seorder_id ORDER BY create_time DESC, Id DESC) AS RowNum
+                FROM crm_seorder_change
+                WHERE IsDeleted = 0 AND tenant_id = @TenantId
             ) ranked
             WHERE RowNum = 1
-        ) chg ON chg.order_no = o.bill_no
+        ) chg ON chg.seorder_id = o.Id
         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 = o.bill_no
+            SELECT seorder_id, COUNT(*) AS changeNum
+            FROM crm_seorder_change
+            WHERE IsDeleted = 0 AND tenant_id = @TenantId
+            GROUP BY seorder_id
+        ) cN ON cN.seorder_id = o.Id
         WHERE {innerWhere}
         """;
 

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ExecutableDailyPlanService.cs

@@ -31,7 +31,7 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
         {
             "p.OrdQty > 0",
             "p.IsActive = 1",
-            "LOWER(IFNULL(w.Status,'')) <> 'c'",
+            "LOWER(IFNULL(w.Status,'')) NOT IN ('', 'c', 'p')",
             "p.tenant_id = @TenantId"
         };
         if (!string.IsNullOrWhiteSpace(input.PlanDateMin))

+ 27 - 25
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ProductionSchedulingActionService.cs

@@ -14,26 +14,20 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
 {
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
-    private readonly OrderResourceCheckService _resourceCheck;
     private readonly ProductionScheduleGenerationService _scheduleGen;
-    private readonly WorkOrderMaterialDetailSyncService _materialDetailSync;
     private readonly WorkOrderRoutingSyncService _routingSync;
     private readonly AidopActionRunLogWriter _runLog;
 
     public ProductionSchedulingActionService(
         ISqlSugarClient db,
         UserManager userManager,
-        OrderResourceCheckService resourceCheck,
         ProductionScheduleGenerationService scheduleGen,
-        WorkOrderMaterialDetailSyncService materialDetailSync,
         WorkOrderRoutingSyncService routingSync,
         AidopActionRunLogWriter runLog)
     {
         _db = db;
         _userManager = userManager;
-        _resourceCheck = resourceCheck;
         _scheduleGen = scheduleGen;
-        _materialDetailSync = materialDetailSync;
         _routingSync = routingSync;
         _runLog = runLog;
     }
@@ -128,27 +122,32 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
             if (affected == 0)
                 throw Oops.Oh("工单不存在或未更新");
 
-            var link = await LoadWorkOrderEntryLinkAsync(tenantId, workOrd);
-            var warnings = new List<string>();
-            var resourceRechecked = false;
-            if (link is not null && (qty.HasValue || dueDate.HasValue))
-            {
-                if (qty.HasValue)
-                    link.Entry.Qty = qty;
-                if (dueDate.HasValue)
-                    link.Entry.SysCapacityDate = dueDate;
-                await _resourceCheck.RunForEntryAsync(link.Order, link.Entry, workOrd, account, warnings);
-                resourceRechecked = true;
-
-                // 资源检查后同步工单物料明细(WorkOrdDetail)和工艺路线(WorkOrdRouting)
-                await _materialDetailSync.EnsureFromResourceCheckAsync(tenantId, workOrd, account);
-                await _routingSync.EnsureFromRoutingAsync(tenantId, workOrd, account);
-            }
-
             var qtyChanged = qty.HasValue && before?.QtyOrded != qty;
             var dueChanged = dueDate.HasValue && before?.DueDate?.Date != dueDate.Value.Date;
             var priorityChanged = !string.IsNullOrWhiteSpace(input.Priority)
                 && !string.Equals(before?.Priority?.Trim(), input.Priority.Trim(), StringComparison.Ordinal);
+            var warnings = new List<string>();
+
+            // ── 同步 crm_seorderentry,避免后续齐套检查重新加载时读到旧值 ──
+            if (qtyChanged || dueChanged)
+            {
+                var link = await LoadWorkOrderEntryLinkAsync(tenantId, workOrd);
+                if (link is not null)
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE crm_seorderentry
+                        SET qty = COALESCE(@Qty, qty),
+                            sys_capacity_date = COALESCE(@SysCapacityDate, sys_capacity_date),
+                            update_time = NOW()
+                        WHERE Id = @EntryId AND tenant_id = @TenantId AND IsDeleted = 0
+                        """,
+                        new SugarParameter("@Qty", qty ?? (object)DBNull.Value),
+                        new SugarParameter("@SysCapacityDate", dueDate ?? (object)DBNull.Value),
+                        new SugarParameter("@EntryId", link.Entry.Id),
+                        new SugarParameter("@TenantId", tenantId));
+                }
+            }
 
             // 数量变更时同步更新 mes_morder 和 mes_moentry
             if (qtyChanged && qty.HasValue)
@@ -182,6 +181,9 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
             {
                 reschedule = await _scheduleGen.RegenerateForWorkOrderAsync(tenantId, workOrd, input.Domain, account, input.EnableCapacityConstraint);
                 warnings.Add(reschedule.Message);
+
+                // 排程后确保工艺路线数据存在
+                await _routingSync.EnsureFromRoutingAsync(tenantId, workOrd, account);
             }
             else if (priorityChanged)
             {
@@ -203,7 +205,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
                 qtyChanged,
                 dueChanged,
                 priorityChanged,
-                resourceRechecked,
+                resourceRechecked = qtyChanged || dueChanged,
                 reschedule = reschedule is null
                     ? null
                     : new
@@ -219,7 +221,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
             {
                 message = "ok",
                 warnings,
-                resourceRechecked,
+                resourceRechecked = qtyChanged || dueChanged,
                 rescheduleTriggered = reschedule is not null,
                 scheduleRowCount = reschedule?.ScheduleRowCount ?? 0
             };

+ 836 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Production/WorkOrderSchedulingService.cs

@@ -281,7 +281,8 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
             throw Oops.Oh("工单号与公司域名不能为空");
 
         string sql = $"""
-            SELECT d.ItemNum, d.Op, d.QtyRequired, IFNULL(d.FrozenBOMQty, 0) AS FrozenBOMQty
+            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")}
             ORDER BY d.LineNum
@@ -299,15 +300,32 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
 
         string sql = $"""
             SELECT
-                r.OP AS Op,
-                r.Descr AS Descr,
+                r.RecID, r.OP AS Op, r.Descr AS Descr,
                 IFNULL(r.ParentOp, '') AS ParentOp,
                 IFNULL(r.MilestoneOp, 0) AS MilestoneOp,
                 IFNULL(r.PackingQty, 0) AS PackingQty,
                 IFNULL(r.QtyComplete, 0) AS QtyComplete,
                 IFNULL(r.QtyReject, 0) AS QtyReject,
                 IFNULL(r.QtyScrap, 0) AS QtyScrap,
-                r.Status AS Status
+                r.Status AS Status, r.WorkOrd,
+                IFNULL(r.WorkCtr, '') AS WorkCtr,
+                IFNULL(r.ProdLine, '') AS ProdLine,
+                IFNULL(r.StdSetupTime, 0) AS StdSetupTime,
+                IFNULL(r.QtyOrded, 0) AS QtyOrded,
+                IFNULL(r.ProcessOut, 0) AS ProcessOut,
+                IFNULL(r.ProcessOutSupp, '') AS ProcessOutSupp,
+                IFNULL(r.ProcessOutDay, 0) AS ProcessOutDay,
+                IFNULL(r.Machine, '') AS Machine,
+                IFNULL(r.ToolCode, '') AS ToolCode,
+                IFNULL(r.RunCrew, 0) AS RunCrew,
+                IFNULL(r.Engineer, '') AS Engineer,
+                IFNULL(r.MachBdnRate, 0) AS MachBdnRate,
+                IFNULL(r.WorkCode, '') AS WorkCode,
+                IFNULL(r.Setup, 0) AS Setup,
+                IFNULL(r.MachinesperOp, 0) AS MachinesperOp,
+                IFNULL(r.Labor, 0) AS Labor,
+                IFNULL(r.ChargeCode, '') AS ChargeCode,
+                IFNULL(r.ItemNum, '') AS ItemNum
             FROM WorkOrdRouting r
             WHERE {SqlWorkOrdChildMatchDomainOrTenant("r", "m")}
             ORDER BY (r.OP + 0) ASC, r.OP ASC, r.Line ASC, r.ColumnNum ASC
@@ -625,6 +643,19 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
     public async Task<object> Save([FromBody] WorkOrderSchedulingSaveInput input)
     {
         var account = _userManager.Account ?? "system";
+        var workOrd = input.WorkOrd.Trim();
+        var domain = input.Domain.Trim();
+
+        // ── 1. 获取旧的 QtyOrded,用于判断数量是否变更 ──
+        var oldQtyOrded = await _db.Ado.GetDecimalAsync(
+            $"SELECT IFNULL(QtyOrded, 0) FROM WorkOrdMaster WHERE {SqlWorkOrdMasterMatchDomainOrTenant}",
+            new SugarParameter("@WorkOrd", workOrd),
+            new SugarParameter("@Domain", domain));
+
+        var newQtyOrded = input.QtyOrded;
+        var qtyChanged = newQtyOrded.HasValue && oldQtyOrded > 0 && newQtyOrded.Value != oldQtyOrded;
+
+        // ── 2. 更新 WorkOrdMaster ──
         var pars = new List<SugarParameter>
         {
             new("@QtyOrded", input.QtyOrded ?? (object)DBNull.Value),
@@ -633,8 +664,8 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
             new("@Urgent", input.Urgent ?? (object)DBNull.Value),
             new("@UpdateUser", account),
             new("@UpdateTime", DateTime.Now),
-            new("@WorkOrd", input.WorkOrd.Trim()),
-            new("@Domain", input.Domain.Trim())
+            new("@WorkOrd", workOrd),
+            new("@Domain", domain)
         };
 
         var n = await _db.Ado.ExecuteCommandAsync(
@@ -653,6 +684,76 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
         if (n == 0)
             throw Oops.Oh("工单不存在或未更新");
 
+        // ── 3. 数量变更时同步子表 ──
+        if (qtyChanged)
+        {
+            var ratio = newQtyOrded!.Value / oldQtyOrded;
+            var now = DateTime.Now;
+
+            // 3a. WorkOrdDetail: 按比例调整 QtyRequired
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE WorkOrdDetail
+                SET QtyRequired = ROUND(QtyRequired * @Ratio, 6),
+                    UpdateUser = @User, UpdateTime = @Now
+                WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+                  AND IFNULL(IsActive, 0) = 1
+                """,
+                new SugarParameter("@Ratio", ratio),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", domain),
+                new SugarParameter("@WorkOrd", workOrd));
+
+            // 3b. WorkOrdRouting: 同步 QtyOrded
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE WorkOrdRouting
+                SET QtyOrded = @QtyOrded,
+                    UpdateUser = @User, UpdateTime = @Now
+                WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+                  AND IFNULL(IsActive, 0) = 1
+                """,
+                new SugarParameter("@QtyOrded", newQtyOrded.Value),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", domain),
+                new SugarParameter("@WorkOrd", workOrd));
+
+            // 3c. NbrDetail: 按比例调整 QtyOrd 和 CurrQtyOpened(仅下达/投产/暂停状态)
+            var woStatus = await _db.Ado.GetStringAsync(
+                """
+                SELECT IFNULL(LOWER(TRIM(Status)), '') FROM WorkOrdMaster
+                WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1
+                """,
+                new SugarParameter("@TenantId", domain),
+                new SugarParameter("@WorkOrd", workOrd));
+
+            if (woStatus is "r" or "w" or "s")
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE NbrDetail
+                    SET QtyOrd = ROUND(QtyOrd * @Ratio, 6),
+                        CurrQtyOpened = ROUND(CurrQtyOpened * @Ratio, 6),
+                        UpdateUser = @User, UpdateTime = @Now
+                    WHERE tenant_id = @TenantId AND Type = 'SM'
+                      AND Nbr IN (
+                          SELECT Nbr FROM NbrMaster
+                          WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM'
+                            AND IFNULL(IsActive, 0) = 1
+                      )
+                      AND IFNULL(IsActive, 0) = 1
+                      AND IFNULL(Status, '') <> 'C'
+                    """,
+                    new SugarParameter("@Ratio", ratio),
+                    new SugarParameter("@User", account),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@TenantId", domain),
+                    new SugarParameter("@WorkOrd", workOrd));
+            }
+        }
+
         return new { message = "保存成功" };
     }
 
@@ -908,6 +1009,659 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
         return new { message = "同步工艺路线已执行" };
     }
 
+    // ══════════════════════════════════════════════════════════════
+    // 下拉数据 GET endpoints
+    // ══════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 获取工单下拉选项(状态为空/P/R/W/S 且类型为空/RW/TEST/TCN)
+    /// </summary>
+    [DisplayName("工单下拉选项")]
+    [HttpGet("scheduling/work-ord-options")]
+    public async Task<object> GetWorkOrdOptions()
+    {
+        var tenantId = _userManager.TenantId;
+        var list = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT WorkOrd FROM WorkOrdMaster
+            WHERE UPPER(IFNULL(Status, '')) IN ('', 'P', 'R', 'W', 'S')
+              AND UPPER(IFNULL(Typed, '')) IN ('', 'RW', 'TEST', 'TCN')
+              AND tenant_id = @TenantId
+            ORDER BY WorkOrd DESC
+            """,
+            new { TenantId = tenantId });
+        return new { list };
+    }
+
+    /// <summary>
+    /// 获取工作中心下拉选项
+    /// </summary>
+    [DisplayName("工作中心下拉选项")]
+    [HttpGet("scheduling/work-ctr-options")]
+    public async Task<object> GetWorkCtrOptions()
+    {
+        var tenantId = _userManager.TenantId;
+        var list = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT WorkCtr, IFNULL(Descr, '') AS Descr
+            FROM WorkCtrMaster
+            WHERE tenant_id = @TenantId
+            ORDER BY WorkCtr
+            """,
+            new { TenantId = tenantId });
+        return new { list };
+    }
+
+    /// <summary>
+    /// 获取生产线下拉选项
+    /// </summary>
+    [DisplayName("生产线下拉选项")]
+    [HttpGet("scheduling/line-options")]
+    public async Task<object> GetLineOptions()
+    {
+        var tenantId = _userManager.TenantId;
+        var list = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT Line, IFNULL(`Describe`, '') AS `Describe`
+            FROM LineMaster
+            WHERE tenant_id = @TenantId
+            ORDER BY Line
+            """,
+            new { TenantId = tenantId });
+        return new { list };
+    }
+
+    /// <summary>
+    /// 获取供应商下拉选项
+    /// </summary>
+    [DisplayName("供应商下拉选项")]
+    [HttpGet("scheduling/supp-options")]
+    public async Task<object> GetSuppOptions()
+    {
+        var tenantId = _userManager.TenantId;
+        var list = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT Supp, TRIM(CONCAT(Supp, ' ', IFNULL(SortName, ''))) AS SuppName
+            FROM SuppMaster
+            WHERE tenant_id = @TenantId
+            ORDER BY Supp
+            """,
+            new { TenantId = tenantId });
+        return new { list };
+    }
+
+    // ══════════════════════════════════════════════════════════════
+    // 工单物料明细 CRUD
+    // ══════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 保存工单物料明细(新增/编辑)
+    /// </summary>
+    [DisplayName("保存工单物料明细")]
+    [HttpPost("scheduling/material-save")]
+    public async Task<object> SaveMaterialDetail([FromBody] WorkOrdDetailSaveInput input)
+    {
+        if (string.IsNullOrWhiteSpace(input.WorkOrd))
+            throw Oops.Oh("工单编号不能为空");
+        if (string.IsNullOrWhiteSpace(input.ItemNum))
+            throw Oops.Oh("物料号不能为空");
+
+        var tenantId = _userManager.TenantId;
+        var account = _userManager.Account ?? "system";
+        var now = DateTime.Now;
+        var workOrd = input.WorkOrd.Trim();
+
+        // 获取工单主记录信息和域
+        var master = await _db.Ado.SqlQueryAsync<dynamic>(
+            $"""
+            SELECT RecID, UPPER(IFNULL(Status, '')) AS Status, IFNULL(`Domain`, '') AS Domain
+            FROM WorkOrdMaster
+            WHERE WorkOrd = @WorkOrd AND tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new { WorkOrd = workOrd, TenantId = tenantId });
+        if (master == null || master.Count == 0)
+            throw Oops.Oh("工单不存在");
+        var masterRow = master[0];
+        var masterRecId = (long)masterRow.RecID;
+        var woStatus = (string)masterRow.Status;
+        var domain = (string)masterRow.Domain ?? "";
+
+        // 查找物料是否存在于ItemMaster
+        var itemExists = await _db.Ado.SqlQueryAsync<int>(
+            """
+            SELECT COUNT(1) FROM ItemMaster
+            WHERE ItemNum = @ItemNum AND tenant_id = @TenantId
+            """,
+            new { ItemNum = input.ItemNum.Trim(), TenantId = tenantId });
+        if (itemExists[0] == 0)
+            throw Oops.Oh($"物料 {input.ItemNum} 在基础数据中不存在,不能新增");
+
+        // 查找物料的Descr和Unit
+        var itemInfo = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT IFNULL(Descr, '') AS ItemName, IFNULL(UM, '') AS UM
+            FROM ItemMaster
+            WHERE ItemNum = @ItemNum AND tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new { ItemNum = input.ItemNum.Trim(), TenantId = tenantId });
+        var itemName = itemInfo.Count > 0 ? (string)itemInfo[0].ItemName : "";
+        var um = itemInfo.Count > 0 ? (string)itemInfo[0].UM : "";
+
+        var isP = woStatus == "P";
+
+        if (input.RecID == 0)
+        {
+            // ── 新增 ──
+            // 检查工单中是否已有此物料
+            var dupCheck = await _db.Ado.SqlQueryAsync<int>(
+                """
+                SELECT COUNT(1) FROM WorkOrdDetail
+                WHERE WorkOrd = @WorkOrd AND ItemNum = @ItemNum
+                  AND tenant_id = @TenantId
+                """,
+                new { WorkOrd = workOrd, ItemNum = input.ItemNum.Trim(), TenantId = tenantId });
+            if (dupCheck[0] > 0)
+                throw Oops.Oh($"工单 {workOrd} 中已有物料 {input.ItemNum},不能重复新增");
+
+            // 获取下一个RecID
+            var nextIdRows = await _db.Ado.SqlQueryAsync<long>(
+                "SELECT IFNULL(MAX(RecID), 0) + 1 FROM WorkOrdDetail");
+            var newRecId = nextIdRows[0];
+
+            // 获取下一个LineNum
+            var lineNumRows = await _db.Ado.SqlQueryAsync<int>(
+                "SELECT IFNULL(MAX(LineNum), 0) + 1 FROM WorkOrdDetail WHERE WorkOrd = @WorkOrd",
+                new { WorkOrd = workOrd });
+            var lineNum = lineNumRows[0];
+
+            // 插入WorkOrdDetail
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO WorkOrdDetail (
+                    RecID, WorkOrdMasterRecID, `Domain`, WorkOrd, LineNum, ItemNum, Op,
+                    Location, QtyRequired, QtyPosted, UM, LotSerial,
+                    IsActive, IsConfirm, CreateUser, CreateTime, tenant_id
+                ) VALUES (
+                    @RecID, @MasterRecId, @Domain, @WorkOrd, @LineNum, @ItemNum, @Op,
+                    @Location, @QtyRequired, 0, @UM, @LotSerial,
+                    1, 0, @User, @Now, @TenantId
+                )
+                """,
+                new SugarParameter("@RecID", newRecId),
+                new SugarParameter("@MasterRecId", masterRecId),
+                new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain),
+                new SugarParameter("@WorkOrd", workOrd),
+                new SugarParameter("@LineNum", lineNum),
+                new SugarParameter("@ItemNum", input.ItemNum.Trim()),
+                new SugarParameter("@Op", (object?)input.Op ?? DBNull.Value),
+                new SugarParameter("@QtyRequired", input.QtyRequired),
+                new SugarParameter("@Location", (object?)input.Location ?? DBNull.Value),
+                new SugarParameter("@LotSerial", (object?)input.LotSerial ?? DBNull.Value),
+                new SugarParameter("@UM", string.IsNullOrEmpty(um) ? DBNull.Value : um),
+                new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", tenantId));
+
+            // 非P状态需同步创建领料单NbrDetail
+            if (!isP)
+            {
+                await CreateOrUpdateNbrDetailForMaterial(workOrd, domain, input.ItemNum.Trim(),
+                    input.QtyRequired, um, itemName, tenantId, account, now, isNew: true);
+            }
+
+            return new { message = "新增成功", recID = newRecId };
+        }
+        else
+        {
+            // ── 编辑 ──
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE WorkOrdDetail
+                SET ItemNum = @ItemNum, Op = @Op, QtyRequired = @QtyRequired,
+                    Location = @Location, LotSerial = @LotSerial,
+                    UM = @UM,
+                    UpdateUser = @User, UpdateTime = @Now
+                WHERE RecID = @RecID AND tenant_id = @TenantId
+                """,
+                new SugarParameter("@ItemNum", input.ItemNum.Trim()),
+                new SugarParameter("@Op", (object?)input.Op ?? DBNull.Value),
+                new SugarParameter("@QtyRequired", input.QtyRequired),
+                new SugarParameter("@Location", (object?)input.Location ?? DBNull.Value),
+                new SugarParameter("@LotSerial", (object?)input.LotSerial ?? DBNull.Value),
+                new SugarParameter("@UM", string.IsNullOrEmpty(um) ? DBNull.Value : um),
+                new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@RecID", input.RecID),
+                new SugarParameter("@TenantId", tenantId));
+
+            // 非P状态需同步修改领料单NbrDetail
+            if (!isP)
+            {
+                await CreateOrUpdateNbrDetailForMaterial(workOrd, domain, input.ItemNum.Trim(),
+                    input.QtyRequired, um, itemName, tenantId, account, now, isNew: false);
+            }
+
+            return new { message = "保存成功" };
+        }
+    }
+
+    /// <summary>
+    /// 删除工单物料明细
+    /// </summary>
+    [DisplayName("删除工单物料明细")]
+    [HttpPost("scheduling/material-delete")]
+    public async Task<object> DeleteMaterialDetail([FromBody] WorkOrdDetailDeleteInput input)
+    {
+        if (input.RecID == 0)
+            throw Oops.Oh("主键不能为空");
+
+        var tenantId = _userManager.TenantId;
+
+        // 获取物料明细信息用于NbrDetail同步
+        var detailRows = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT d.WorkOrd, d.ItemNum, IFNULL(d.`Domain`, '') AS Domain, m.Status
+            FROM WorkOrdDetail d
+            LEFT JOIN WorkOrdMaster m ON d.WorkOrd = m.WorkOrd AND d.tenant_id = m.tenant_id
+            WHERE d.RecID = @RecID AND d.tenant_id = @TenantId
+            """,
+            new { RecID = input.RecID, TenantId = tenantId });
+        if (detailRows == null || detailRows.Count == 0)
+            throw Oops.Oh("物料明细不存在");
+
+        var row = detailRows[0];
+        var workOrd = (string)row.WorkOrd;
+        var itemNum = (string)row.ItemNum;
+        var domain = (string)row.Domain ?? "";
+        var woStatus = (string)(row.Status ?? "");
+
+        // 删除WorkOrdDetail
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM WorkOrdDetail WHERE RecID = @RecID AND tenant_id = @TenantId",
+            new SugarParameter("@RecID", input.RecID),
+            new SugarParameter("@TenantId", tenantId));
+
+        // 非P状态需同步删除NbrDetail
+        if (woStatus != "P")
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                DELETE FROM NbrDetail
+                WHERE WorkOrd = @WorkOrd AND ItemNum = @ItemNum AND Type = 'SM'
+                  AND tenant_id = @TenantId
+                """,
+                new SugarParameter("@WorkOrd", workOrd),
+                new SugarParameter("@ItemNum", itemNum),
+                new SugarParameter("@TenantId", tenantId));
+        }
+
+        return new { message = "删除成功" };
+    }
+
+    // ══════════════════════════════════════════════════════════════
+    // 工单工序明细 CRUD
+    // ══════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 保存工单工序明细(新增/编辑)
+    /// </summary>
+    [DisplayName("保存工单工序明细")]
+    [HttpPost("scheduling/routing-save")]
+    public async Task<object> SaveRoutingDetail([FromBody] WorkOrdRoutingSaveInput input)
+    {
+        if (string.IsNullOrWhiteSpace(input.WorkOrd))
+            throw Oops.Oh("工单编号不能为空");
+        if (string.IsNullOrWhiteSpace(input.OP))
+            throw Oops.Oh("工序不能为空");
+
+        var tenantId = _userManager.TenantId;
+        var account = _userManager.Account ?? "system";
+        var now = DateTime.Now;
+        var workOrd = input.WorkOrd.Trim();
+
+        // 获取工单主记录信息和域
+        var master = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT RecID, IFNULL(`Domain`, '') AS Domain, IFNULL(ERPfld1, '') AS ERPfld1,
+                   IFNULL(QtyOrded, 0) AS QtyOrded
+            FROM WorkOrdMaster
+            WHERE WorkOrd = @WorkOrd AND tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new { WorkOrd = workOrd, TenantId = tenantId });
+        if (master == null || master.Count == 0)
+            throw Oops.Oh("工单不存在");
+        var masterRow = master[0];
+        var masterRecId = (long)masterRow.RecID;
+        var domain = (string)masterRow.Domain ?? "";
+        var erpfld1 = (string)masterRow.ERPfld1 ?? "";
+        var qtyOrded = (decimal)masterRow.QtyOrded;
+
+        // 检查是否已有相同工序编号(新增时检查,编辑时排除自身)
+        var dupCheck = await _db.Ado.SqlQueryAsync<int>(
+            $"""
+            SELECT COUNT(1) FROM WorkOrdRouting
+            WHERE WorkOrd = @WorkOrd AND OP = @OP
+              AND tenant_id = @TenantId
+              {(input.RecID > 0 ? "AND RecID <> @RecID" : "")}
+            """,
+            new { WorkOrd = workOrd, OP = input.OP.Trim(), TenantId = tenantId, RecID = input.RecID });
+        if (dupCheck[0] > 0)
+            throw Oops.Oh($"工单 {workOrd} 已有相同工序编号 {input.OP},不允许保存");
+
+        // 检查后序是否已有完成数量(编辑时)
+        if (input.RecID > 0)
+        {
+            var currentOpRows = await _db.Ado.SqlQueryAsync<int>(
+                $"""
+                SELECT CAST(IFNULL(OP, 0) AS SIGNED) FROM WorkOrdRouting
+                WHERE RecID = @RecID AND tenant_id = @TenantId
+                """,
+                new { RecID = input.RecID, TenantId = tenantId });
+            if (currentOpRows.Count > 0)
+            {
+                var currentOp = currentOpRows[0];
+                var afterComplete = await _db.Ado.SqlQueryAsync<int>(
+                    """
+                    SELECT COUNT(1) FROM WorkOrdRouting
+                    WHERE WorkOrd = @WorkOrd
+                      AND CAST(IFNULL(OP, 0) AS SIGNED) > @CurrentOp
+                      AND IFNULL(QtyComplete, 0) > 0
+                      AND tenant_id = @TenantId
+                    """,
+                    new { WorkOrd = workOrd, CurrentOp = currentOp, TenantId = tenantId });
+                if (afterComplete[0] > 0)
+                    throw Oops.Oh("后序已有完成数量,不允许保存");
+            }
+        }
+
+        if (input.RecID == 0)
+        {
+            // ── 新增 ──
+            var nextIdRows = await _db.Ado.SqlQueryAsync<long>(
+                "SELECT IFNULL(MAX(RecID), 0) + 1 FROM WorkOrdRouting");
+            var newRecId = nextIdRows[0];
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO WorkOrdRouting (
+                    RecID, `Descr`, `Domain`, `ChargeCode`, `Machine`, `RunCrew`, `MilestoneOp`,
+                    `OP`, `ItemNum`, `WorkCtr`, `Setup`, `MachinesperOp`, `Labor`,
+                    `MachBdnRate`, `WorkCode`, `StdSetupTime`, `Engineer`,
+                    `WorkOrd`, `WorkOrdMasterRecID`, `ERPfld1`, `QtyOrded`,
+                    `ProcessOut`, `ProcessOutDay`, `ProcessOutSupp`,
+                    `IsActive`, `Status`, `ProdLine`, `ToolCode`,
+                    `CreateTime`, `CreateUser`, `tenant_id`, `CommentIndex`, `WaitTime`, `QtyComplete`, `ParentOp`
+                ) VALUES (
+                    @RecID, @Descr, @Domain, @ChargeCode, @Machine, @RunCrew, @MilestoneOp,
+                    @OP, @ItemNum, @WorkCtr, @Setup, @MachinesperOp, @Labor,
+                    @MachBdnRate, @WorkCode, @StdSetupTime, @Engineer,
+                    @WorkOrd, @MasterRecId, @ERPfld1, @QtyOrded,
+                    @ProcessOut, @ProcessOutDay, @ProcessOutSupp,
+                    b'1', 'r', @ProdLine, @ToolCode,
+                    @Now, @User, @TenantId, 0, 0, 0, 0
+                )
+                """,
+                new SugarParameter("@RecID", newRecId),
+                new SugarParameter("@Descr", (object?)input.Descr ?? DBNull.Value),
+                new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain),
+                new SugarParameter("@ChargeCode", (object?)input.ChargeCode ?? DBNull.Value),
+                new SugarParameter("@Machine", (object?)input.Machine ?? DBNull.Value),
+                new SugarParameter("@RunCrew", input.RunCrew),
+                new SugarParameter("@MilestoneOp", input.MilestoneOp ? 1 : 0),
+                new SugarParameter("@OP", input.OP.Trim()),
+                new SugarParameter("@ItemNum", (object?)input.ItemNum ?? DBNull.Value),
+                new SugarParameter("@WorkCtr", (object?)input.WorkCtr ?? DBNull.Value),
+                new SugarParameter("@Setup", input.Setup),
+                new SugarParameter("@MachinesperOp", input.MachinesperOp),
+                new SugarParameter("@Labor", input.Labor),
+                new SugarParameter("@MachBdnRate", input.MachBdnRate),
+                new SugarParameter("@WorkCode", (object?)input.WorkCode ?? DBNull.Value),
+                new SugarParameter("@StdSetupTime", input.StdSetupTime),
+                new SugarParameter("@Engineer", (object?)input.Engineer ?? DBNull.Value),
+                new SugarParameter("@WorkOrd", workOrd),
+                new SugarParameter("@MasterRecId", masterRecId),
+                new SugarParameter("@ERPfld1", erpfld1),
+                new SugarParameter("@QtyOrded", input.QtyOrded > 0 ? input.QtyOrded : qtyOrded),
+                new SugarParameter("@ProcessOut", input.ProcessOut ? 1 : 0),
+                new SugarParameter("@ProcessOutDay", input.ProcessOutDay),
+                new SugarParameter("@ProcessOutSupp", (object?)input.ProcessOutSupp ?? DBNull.Value),
+                new SugarParameter("@ProdLine", (object?)input.ProdLine ?? DBNull.Value),
+                new SugarParameter("@ToolCode", (object?)input.ToolCode ?? DBNull.Value),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                new SugarParameter("@TenantId", tenantId));
+
+            return new { message = "新增成功", recID = newRecId };
+        }
+        else
+        {
+            // ── 编辑 ──
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE WorkOrdRouting
+                SET `Descr` = @Descr, `ChargeCode` = @ChargeCode, `Machine` = @Machine,
+                    `RunCrew` = @RunCrew, `MilestoneOp` = @MilestoneOp, `OP` = @OP,
+                    `ItemNum` = @ItemNum, `WorkCtr` = @WorkCtr,
+                    `Setup` = @Setup, `MachinesperOp` = @MachinesperOp, `Labor` = @Labor,
+                    `MachBdnRate` = @MachBdnRate, `WorkCode` = @WorkCode,
+                    `StdSetupTime` = @StdSetupTime, `Engineer` = @Engineer,
+                    `QtyOrded` = @QtyOrded, `ProcessOut` = @ProcessOut,
+                    `ProcessOutDay` = @ProcessOutDay, `ProcessOutSupp` = @ProcessOutSupp,
+                    `ProdLine` = @ProdLine, `ToolCode` = @ToolCode,
+                    `UpdateUser` = @User, `UpdateTime` = @Now
+                WHERE RecID = @RecID AND tenant_id = @TenantId
+                """,
+                new SugarParameter("@Descr", (object?)input.Descr ?? DBNull.Value),
+                new SugarParameter("@ChargeCode", (object?)input.ChargeCode ?? DBNull.Value),
+                new SugarParameter("@Machine", (object?)input.Machine ?? DBNull.Value),
+                new SugarParameter("@RunCrew", input.RunCrew),
+                new SugarParameter("@MilestoneOp", input.MilestoneOp ? 1 : 0),
+                new SugarParameter("@OP", input.OP.Trim()),
+                new SugarParameter("@ItemNum", (object?)input.ItemNum ?? DBNull.Value),
+                new SugarParameter("@WorkCtr", (object?)input.WorkCtr ?? DBNull.Value),
+                new SugarParameter("@Setup", input.Setup),
+                new SugarParameter("@MachinesperOp", input.MachinesperOp),
+                new SugarParameter("@Labor", input.Labor),
+                new SugarParameter("@MachBdnRate", input.MachBdnRate),
+                new SugarParameter("@WorkCode", (object?)input.WorkCode ?? DBNull.Value),
+                new SugarParameter("@StdSetupTime", input.StdSetupTime),
+                new SugarParameter("@Engineer", (object?)input.Engineer ?? DBNull.Value),
+                new SugarParameter("@QtyOrded", input.QtyOrded),
+                new SugarParameter("@ProcessOut", input.ProcessOut ? 1 : 0),
+                new SugarParameter("@ProcessOutDay", input.ProcessOutDay),
+                new SugarParameter("@ProcessOutSupp", (object?)input.ProcessOutSupp ?? DBNull.Value),
+                new SugarParameter("@ProdLine", (object?)input.ProdLine ?? DBNull.Value),
+                new SugarParameter("@ToolCode", (object?)input.ToolCode ?? DBNull.Value),
+                new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@RecID", input.RecID),
+                new SugarParameter("@TenantId", tenantId));
+
+            return new { message = "保存成功" };
+        }
+    }
+
+    /// <summary>
+    /// 删除工单工序明细
+    /// </summary>
+    [DisplayName("删除工单工序明细")]
+    [HttpPost("scheduling/routing-delete")]
+    public async Task<object> DeleteRoutingDetail([FromBody] WorkOrdRoutingDeleteInput input)
+    {
+        if (input.RecID == 0)
+            throw Oops.Oh("主键不能为空");
+
+        var tenantId = _userManager.TenantId;
+
+        // 检查后序是否已有完成数量
+        var detailRows = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT r.WorkOrd, CAST(IFNULL(r.OP, 0) AS SIGNED) AS OP, IFNULL(r.`Domain`, '') AS Domain
+            FROM WorkOrdRouting r
+            WHERE r.RecID = @RecID AND r.tenant_id = @TenantId
+            """,
+            new { RecID = input.RecID, TenantId = tenantId });
+        if (detailRows == null || detailRows.Count == 0)
+            throw Oops.Oh("工序明细不存在");
+
+        var row = detailRows[0];
+        var workOrd = (string)row.WorkOrd;
+        var currentOp = (int)row.OP;
+        var domain = (string)row.Domain ?? "";
+
+        var afterComplete = await _db.Ado.SqlQueryAsync<int>(
+            """
+            SELECT COUNT(1) FROM WorkOrdRouting
+            WHERE WorkOrd = @WorkOrd
+              AND CAST(IFNULL(OP, 0) AS SIGNED) > @CurrentOp
+              AND IFNULL(QtyComplete, 0) > 0
+              AND tenant_id = @TenantId
+            """,
+            new { WorkOrd = workOrd, CurrentOp = currentOp, TenantId = tenantId });
+        if (afterComplete[0] > 0)
+            throw Oops.Oh("后序已有完成数量,不允许删除");
+
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM WorkOrdRouting WHERE RecID = @RecID AND tenant_id = @TenantId",
+            new SugarParameter("@RecID", input.RecID),
+            new SugarParameter("@TenantId", tenantId));
+
+        return new { message = "删除成功" };
+    }
+
+    // ══════════════════════════════════════════════════════════════
+    // 辅助方法
+    // ══════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 为物料明细创建或更新领料单NbrDetail
+    /// </summary>
+    private async Task CreateOrUpdateNbrDetailForMaterial(
+        string workOrd, string domain, string itemNum, decimal qtyRequired,
+        string um, string itemName, long tenantId, string account, DateTime now, bool isNew)
+    {
+        // 查找此工单此物料的SM类型领料单明细
+        var existRows = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT nd.RecID, nd.NbrRecID
+            FROM NbrDetail nd
+            WHERE nd.WorkOrd = @WorkOrd AND nd.ItemNum = @ItemNum AND nd.Type = 'SM'
+              AND nd.tenant_id = @TenantId
+            """,
+            new { WorkOrd = workOrd, ItemNum = itemNum, TenantId = tenantId });
+
+        if (existRows == null || existRows.Count == 0)
+        {
+            // 没有领料单明细,创建一条
+            // 先查找此工单是否有SM类型的领料单主记录
+            var nbrMasterRows = await _db.Ado.SqlQueryAsync<dynamic>(
+                """
+                SELECT RecID, Nbr FROM NbrMaster
+                WHERE WorkOrd = @WorkOrd AND Type = 'SM' AND tenant_id = @TenantId
+                ORDER BY RecID DESC LIMIT 1
+                """,
+                new { WorkOrd = workOrd, TenantId = tenantId });
+
+            long nbrRecId = 0;
+            string nbrNo = "";
+
+            if (nbrMasterRows == null || nbrMasterRows.Count == 0)
+            {
+                // 创建领料单主记录
+                var nextMasterIdRows = await _db.Ado.SqlQueryAsync<long>(
+                    "SELECT IFNULL(MAX(RecID), 0) + 1 FROM NbrMaster");
+                nbrRecId = nextMasterIdRows[0];
+                nbrNo = $"SM-{workOrd}-{DateTime.Now:yyyyMMddHHmmss}";
+
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO NbrMaster (
+                        RecID, `Domain`, Type, Nbr, WorkOrd, NbrDate,
+                        Status, IsActive, CreateUser, CreateTime, tenant_id
+                    ) VALUES (
+                        @RecID, @Domain, 'SM', @Nbr, @WorkOrd, @Now,
+                        '', 1, @User, @Now, @TenantId
+                    )
+                    """,
+                    new SugarParameter("@RecID", nbrRecId),
+                    new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain),
+                    new SugarParameter("@Nbr", nbrNo),
+                    new SugarParameter("@WorkOrd", workOrd),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                    new SugarParameter("@TenantId", tenantId));
+            }
+            else
+            {
+                nbrRecId = (long)nbrMasterRows[0].RecID;
+                nbrNo = (string)nbrMasterRows[0].Nbr;
+            }
+
+            // 创建领料单明细
+            var nextDetailIdRows = await _db.Ado.SqlQueryAsync<long>(
+                "SELECT IFNULL(MAX(RecID), 0) + 1 FROM NbrDetail");
+            var newDetailId = nextDetailIdRows[0];
+
+            var maxLineRows = await _db.Ado.SqlQueryAsync<short>(
+                "SELECT IFNULL(MAX(Line), 0) + 1 FROM NbrDetail WHERE NbrRecID = @NbrRecID AND tenant_id = @TenantId",
+                new { NbrRecID = nbrRecId, TenantId = tenantId });
+            var newLine = maxLineRows[0];
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO NbrDetail (
+                    RecID, `Domain`, Type, Nbr, Line, ItemNum, Dimension1, Dimension2,
+                    LocationFrom, LocationTo, QtyFrom, QtyTo, QtyOrd, QtyRec,
+                    CurrQtyOpened, UM, WorkOrd, ItemName, Status,
+                    IsActive, IsConfirm, IsChanged, BusinessID, NbrRecID,
+                    CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
+                ) VALUES (
+                    @RecID, @Domain, 'SM', @Nbr, @Line, @ItemNum, '', '',
+                    '', '', 0, 0, @QtyOrd, 0,
+                    @QtyOrd, @UM, @WorkOrd, @ItemName, '',
+                    1, 0, 1, 0, @NbrRecID,
+                    @User, @Now, @User, @Now, @TenantId
+                )
+                """,
+                new SugarParameter("@RecID", newDetailId),
+                new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain),
+                new SugarParameter("@Nbr", nbrNo),
+                new SugarParameter("@Line", newLine),
+                new SugarParameter("@ItemNum", itemNum),
+                new SugarParameter("@QtyOrd", qtyRequired),
+                new SugarParameter("@UM", string.IsNullOrEmpty(um) ? DBNull.Value : um),
+                new SugarParameter("@WorkOrd", workOrd),
+                new SugarParameter("@ItemName", string.IsNullOrEmpty(itemName) ? DBNull.Value : itemName),
+                new SugarParameter("@NbrRecID", nbrRecId),
+                new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", tenantId));
+        }
+        else
+        {
+            // 更新已有的领料单明细
+            var ndRecId = (long)existRows[0].RecID;
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE NbrDetail
+                SET QtyOrd = @QtyOrd, CurrQtyOpened = @QtyOrd,
+                    UM = @UM, ItemName = @ItemName,
+                    UpdateUser = @User, UpdateTime = @Now
+                WHERE RecID = @RecID AND tenant_id = @TenantId
+                """,
+                new SugarParameter("@QtyOrd", qtyRequired),
+                new SugarParameter("@UM", string.IsNullOrEmpty(um) ? DBNull.Value : um),
+                new SugarParameter("@ItemName", string.IsNullOrEmpty(itemName) ? DBNull.Value : itemName),
+                new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@RecID", ndRecId),
+                new SugarParameter("@TenantId", tenantId));
+        }
+    }
+
     // ══════════════════════════════════════════════════════════════
     // 加急 POST /api/Production/scheduling/urgent
     // ══════════════════════════════════════════════════════════════
@@ -959,6 +1713,58 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
         public int? Urgent { get; set; }
     }
 
+    // ══════════════════════════════════════════════════════════════
+    // Input/Output DTOs
+    // ══════════════════════════════════════════════════════════════
+
+    public class WorkOrdDetailSaveInput
+    {
+        public long RecID { get; set; }
+        public string? WorkOrd { get; set; }
+        public string? ItemNum { get; set; }
+        public string? Op { get; set; }
+        public decimal QtyRequired { get; set; }
+        public string? Location { get; set; }
+        public string? LotSerial { get; set; }
+    }
+
+    public class WorkOrdDetailDeleteInput
+    {
+        public long RecID { get; set; }
+    }
+
+    public class WorkOrdRoutingSaveInput
+    {
+        public long RecID { get; set; }
+        public string? WorkOrd { get; set; }
+        public string? ItemNum { get; set; }
+        public string? OP { get; set; }
+        public string? Descr { get; set; }
+        public string? WorkCtr { get; set; }
+        public string? ProdLine { get; set; }
+        public decimal StdSetupTime { get; set; }
+        public decimal QtyOrded { get; set; }
+        public bool MilestoneOp { get; set; } = true;
+        public bool ProcessOut { get; set; }
+        public string? ProcessOutSupp { get; set; }
+        public decimal ProcessOutDay { get; set; }
+        public string? Machine { get; set; }
+        public string? ToolCode { get; set; }
+        public decimal RunCrew { get; set; }
+        public string? Engineer { get; set; }
+        public decimal MachBdnRate { get; set; }
+        public string? WorkCode { get; set; }
+        public decimal Setup { get; set; }
+        public decimal MachinesperOp { get; set; }
+        public decimal Labor { get; set; }
+        public string? ChargeCode { get; set; }
+    }
+
+    public class WorkOrdRoutingDeleteInput
+    {
+        public long RecID { get; set; }
+    }
+
     private sealed class WorkOrderEditPreviewRow
     {
         public string? WorkOrd { get; set; }
@@ -986,6 +1792,7 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
 
     private sealed class WorkOrderViewRoutingRow
     {
+        public long RecID { get; set; }
         public string? Op { get; set; }
         public string? Descr { get; set; }
         public string? ParentOp { get; set; }
@@ -995,14 +1802,37 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
         public decimal QtyReject { get; set; }
         public decimal QtyScrap { get; set; }
         public string? Status { get; set; }
+        public string? WorkOrd { get; set; }
+        public string? WorkCtr { get; set; }
+        public string? ProdLine { get; set; }
+        public decimal StdSetupTime { get; set; }
+        public decimal QtyOrded { get; set; }
+        public int ProcessOut { get; set; }
+        public string? ProcessOutSupp { get; set; }
+        public decimal ProcessOutDay { get; set; }
+        public string? Machine { get; set; }
+        public string? ToolCode { get; set; }
+        public decimal RunCrew { get; set; }
+        public string? Engineer { get; set; }
+        public decimal MachBdnRate { get; set; }
+        public string? WorkCode { get; set; }
+        public decimal Setup { get; set; }
+        public decimal MachinesperOp { get; set; }
+        public decimal Labor { get; set; }
+        public string? ChargeCode { get; set; }
+        public string? ItemNum { get; set; }
     }
 
     private sealed class WorkOrderViewDetailRow
     {
+        public long RecID { get; set; }
         public string? ItemNum { get; set; }
         public string? Op { get; set; }
         public decimal? QtyRequired { get; set; }
         public decimal FrozenBOMQty { get; set; }
+        public string? Location { get; set; }
+        public string? LotSerial { get; set; }
+        public string? WorkOrd { get; set; }
     }
 
     private sealed class WorkOrderTraceMasterRow