Просмотр исходного кода

feat: 3级计划重排增强+工单编辑关联同步+排产异常只读化+交货单生成提示精简

后端:
- OrderReviewOrchestrationService: 3级计划重排增加物料编码变更校验、清理旧占用、领料单状态感知同步、sys_capacity_date更新、生产排程+MRP+交货单生成
- ProductionSchedulingActionService: 工单编辑数量变更同步WorkOrdDetail/WorkOrdRouting(QtyOrded)/NbrDetail/mes_morder/mes_moentry
- ProductionScheduleGenerationService: 排程相关调整

前端:
- scheduleExceptionList: 完全只读化,移除新增/编辑/删除/查看按钮,移除公司域名列
- deliveryScheduleList: 生成交货单成功后仅提示成功,不再显示生成结果摘要弹窗

版本号: Web 2.4.200, Server 1.0.204
Pengxy 9 часов назад
Родитель
Сommit
df40bd2d79

+ 1 - 1
Web/package.json

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

+ 1 - 189
Web/src/views/aidop/production/scheduleExceptionList.vue

@@ -17,7 +17,6 @@
 		</el-form>
 
 		<div class="toolbar toolbar-wrap">
-			<el-button type="primary" @click="openAdd">新增</el-button>
 			<el-popover placement="bottom-end" :width="280" trigger="click">
 				<template #reference>
 					<el-button>列显示</el-button>
@@ -53,14 +52,6 @@
 			<el-table-column v-if="colOn('itemNum')" prop="itemNum" label="物料编码" width="140" show-overflow-tooltip />
 			<el-table-column v-if="colOn('descr1')" prop="descr1" label="规格型号" min-width="140" show-overflow-tooltip />
 			<el-table-column v-if="colOn('remark')" prop="remark" label="备注" min-width="160" show-overflow-tooltip />
-			<el-table-column v-if="colOn('domain')" prop="domain" label="公司域名" width="100" show-overflow-tooltip />
-			<el-table-column label="操作" width="200" fixed="right" align="center">
-				<template #default="{ row }">
-					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
-					<el-button link type="danger" @click="onDelete(row)">删除</el-button>
-					<el-button link @click="openView(row)">查看</el-button>
-				</template>
-			</el-table-column>
 		</el-table>
 
 		<div class="pager">
@@ -75,63 +66,16 @@
 			/>
 		</div>
 
-		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close @closed="resetForm">
-			<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" :disabled="viewOnly">
-				<el-form-item label="公司域名" prop="domain">
-					<el-input v-model="form.domain" placeholder="如 8010" maxlength="8" show-word-limit />
-				</el-form-item>
-				<el-form-item label="工单编号" prop="workOrd">
-					<el-input v-model="form.workOrd" />
-				</el-form-item>
-				<el-form-item label="物料编码" prop="itemNum">
-					<el-input v-model="form.itemNum" />
-				</el-form-item>
-				<el-form-item label="操作类型" prop="type">
-					<el-input v-model="form.type" />
-				</el-form-item>
-				<el-form-item label="执行时间" prop="optTime">
-					<el-date-picker
-						v-model="form.optTime"
-						type="datetime"
-						value-format="YYYY-MM-DD HH:mm:ss"
-						style="width: 100%"
-						placeholder="选填,默认当前时间"
-					/>
-				</el-form-item>
-				<el-form-item v-if="editingId" label="创建时间" prop="createTime">
-					<el-date-picker
-						v-model="form.createTime"
-						type="datetime"
-						value-format="YYYY-MM-DD HH:mm:ss"
-						style="width: 100%"
-					/>
-				</el-form-item>
-				<el-form-item label="备注" prop="remark">
-					<el-input v-model="form.remark" type="textarea" :rows="3" />
-				</el-form-item>
-			</el-form>
-			<template #footer v-if="!viewOnly">
-				<el-button @click="dialogVisible = false">取消</el-button>
-				<el-button type="primary" :loading="saving" @click="onSubmit">保存</el-button>
-			</template>
-			<template #footer v-else>
-				<el-button type="primary" @click="dialogVisible = false">关闭</el-button>
-			</template>
-		</el-dialog>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopProductionScheduleExceptionList">
 import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage, ElMessageBox } from 'element-plus';
-import type { FormInstance, FormRules } from 'element-plus';
+import { ElMessage } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
 import {
-	deleteScheduleException,
-	fetchScheduleExceptionDetail,
 	fetchScheduleExceptionList,
-	saveScheduleException,
 	type ScheduleExceptionRow,
 } from '../api/scheduleException';
 
@@ -145,7 +89,6 @@ const allColumns = [
 	{ key: 'itemNum', label: '物料编码' },
 	{ key: 'descr1', label: '规格型号' },
 	{ key: 'remark', label: '备注' },
-	{ key: 'domain', label: '公司域名' },
 ];
 const visibleColKeys = ref<string[]>(allColumns.map((c) => c.key));
 
@@ -165,34 +108,6 @@ const loading = ref(false);
 const rows = ref<ScheduleExceptionRow[]>([]);
 const total = ref(0);
 
-const dialogVisible = ref(false);
-const viewOnly = ref(false);
-const editingId = ref<number | undefined>(undefined);
-const saving = ref(false);
-const formRef = ref<FormInstance>();
-
-const form = reactive({
-	domain: '',
-	workOrd: '',
-	itemNum: '',
-	remark: '' as string,
-	type: '' as string,
-	optTime: '' as string,
-	createTime: '' as string,
-});
-
-const rules: FormRules = {
-	domain: [{ required: true, message: '请输入公司域名', trigger: 'blur' }],
-	workOrd: [{ required: true, message: '请输入工单编号', trigger: 'blur' }],
-	itemNum: [{ required: true, message: '请输入物料编码', trigger: 'blur' }],
-};
-
-const dialogTitle = computed(() => {
-	if (viewOnly.value) return '查看排产异常';
-	if (editingId.value) return '编辑排产异常';
-	return '新增排产异常';
-});
-
 function fmtDt(v?: string | null) {
 	if (!v) return '—';
 	return String(v).replace('T', ' ').slice(0, 19);
@@ -230,109 +145,6 @@ async function loadList() {
 	}
 }
 
-function resetForm() {
-	editingId.value = undefined;
-	viewOnly.value = false;
-	Object.assign(form, {
-		domain: '',
-		workOrd: '',
-		itemNum: '',
-		remark: '',
-		type: '',
-		optTime: '',
-		createTime: '',
-	});
-}
-
-function openAdd() {
-	resetForm();
-	dialogVisible.value = true;
-}
-
-async function openEdit(row: ScheduleExceptionRow) {
-	resetForm();
-	viewOnly.value = false;
-	editingId.value = row.id;
-	dialogVisible.value = true;
-	try {
-		const d = await fetchScheduleExceptionDetail(row.id);
-		form.domain = d.domain ?? '';
-		form.workOrd = d.workOrd ?? '';
-		form.itemNum = d.itemNum ?? '';
-		form.remark = d.remark ?? '';
-		form.type = d.type ?? '';
-		form.optTime = d.optTime ? String(d.optTime).replace('T', ' ').slice(0, 19) : '';
-		form.createTime = d.createTime ? String(d.createTime).replace('T', ' ').slice(0, 19) : '';
-	} catch (e: any) {
-		ElMessage.error(e?.message || '加载详情失败');
-		dialogVisible.value = false;
-	}
-}
-
-async function openView(row: ScheduleExceptionRow) {
-	resetForm();
-	viewOnly.value = true;
-	editingId.value = row.id;
-	dialogVisible.value = true;
-	try {
-		const d = await fetchScheduleExceptionDetail(row.id);
-		form.domain = d.domain ?? '';
-		form.workOrd = d.workOrd ?? '';
-		form.itemNum = d.itemNum ?? '';
-		form.remark = d.remark ?? '';
-		form.type = d.type ?? '';
-		form.optTime = d.optTime ? String(d.optTime).replace('T', ' ').slice(0, 19) : '';
-		form.createTime = d.createTime ? String(d.createTime).replace('T', ' ').slice(0, 19) : '';
-	} catch (e: any) {
-		ElMessage.error(e?.message || '加载详情失败');
-		dialogVisible.value = false;
-	}
-}
-
-async function onDelete(row: ScheduleExceptionRow) {
-	try {
-		await ElMessageBox.confirm('确认删除该条排产异常记录?', '删除', { type: 'warning' });
-	} catch {
-		return;
-	}
-	try {
-		await deleteScheduleException(row.id);
-		ElMessage.success('已删除');
-		loadList();
-	} catch (e: any) {
-		ElMessage.error(e?.message || '删除失败');
-	}
-}
-
-async function onSubmit() {
-	try {
-		await formRef.value?.validate();
-	} catch {
-		return;
-	}
-
-	saving.value = true;
-	try {
-		await saveScheduleException({
-			id: editingId.value,
-			domain: form.domain.trim(),
-			workOrd: form.workOrd.trim(),
-			itemNum: form.itemNum.trim(),
-			remark: form.remark || undefined,
-			type: form.type || undefined,
-			optTime: form.optTime || undefined,
-			createTime: form.createTime || undefined,
-		});
-		ElMessage.success('保存成功');
-		dialogVisible.value = false;
-		loadList();
-	} catch (e: any) {
-		ElMessage.error(e?.message || '保存失败');
-	} finally {
-		saving.value = false;
-	}
-}
-
 onMounted(() => loadList());
 </script>
 

+ 4 - 68
Web/src/views/aidop/s3/supply/deliveryScheduleList.vue

@@ -144,8 +144,8 @@
 
 <script setup lang="ts" name="aidopS3SupplyDeliveryScheduleList">
 import { computed, h, onMounted, reactive, ref } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
-import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
+import { useRoute } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import DeliveryScheduleBatchForm from './deliveryScheduleBatchForm.vue';
 import { useUserInfo } from '/@/stores/userInfo';
@@ -157,7 +157,6 @@ import {
 	generateDeliverySchedule,
 	publishDeliverySchedule,
 	saveS3DeliveryGenerateRule,
-	type DeliveryScheduleGenerateResult,
 	type DeliveryScheduleRow,
 	type RuleConfigSnapshot,
 	type S3DeliveryGenerateRuleOptions,
@@ -165,7 +164,6 @@ import {
 } from '../api/deliverySchedule';
 
 const route = useRoute();
-const router = useRouter();
 const userInfo = useUserInfo();
 const pageTitle = computed(() => (route.meta?.title as string) || '物料交货计划');
 
@@ -312,9 +310,8 @@ function resetQuery() {
 async function onGenerate() {
 	generating.value = true;
 	try {
-		const result = await generateDeliverySchedule();
-		ElMessage.success(result?.message || `生成交货单完成,共生成 ${result?.createdCount ?? 0} 条`);
-		await showGenerateResult(result);
+		await generateDeliverySchedule();
+		ElMessage.success('生成交货单调用成功');
 		await loadList();
 	} catch (e: any) {
 		ElMessage.error(e?.message ? `生成交货单失败:${e.message}` : '生成交货单失败');
@@ -359,67 +356,6 @@ async function onProcurementPipeline() {
 	}
 }
 
-async function showGenerateResult(result: DeliveryScheduleGenerateResult) {
-	const rows = [
-		['需求计划数', result?.demandCount],
-		['候选交货单数', result?.candidateCount],
-		['已生成交货单数', result?.createdCount],
-		['自动生成 PR 数', result?.purchaseRequestCount],
-		['PR 合并减少数', result?.purchaseRequestMergeReducedCount],
-		['生成 DO/PO 单数', result?.purchaseOrderCount],
-		['生成 DO/PO 明细数', result?.purchaseOrderLineCount],
-		['写 QadTracking 数', result?.qadTrackingCount],
-		['异常数', result?.exceptionCount],
-		['跳过数', result?.skippedCount],
-	].filter(([, value]) => value !== undefined && value !== null);
-
-	await ElMessageBox.alert(
-		h('div', { class: 'generate-result-summary' }, [
-			h('p', { class: 'generate-result-message' }, result?.message || '生成交货单完成'),
-			h(
-				'ul',
-				{ class: 'generate-result-list' },
-				rows.map(([label, value]) => h('li', { key: label }, [h('span', label as string), h('strong', String(value))]))
-			),
-			(result?.exceptionCount ?? 0) > 0
-				? h('p', { class: 'generate-result-warning' }, '本次生成存在异常,请到“交货单异常记录”查看明细。')
-				: null,
-			h('p', { class: 'generate-result-tip' }, '本次执行摘要和规则快照已写入系统操作日志。'),
-			h('div', { class: 'generate-result-actions' }, [
-				(result?.exceptionCount ?? 0) > 0
-					? h(
-							ElButton,
-							{
-								type: 'warning',
-								size: 'small',
-								onClick: () => router.push('/aidop/s3/material-plan/delivery-exception'),
-							},
-							() => '查看异常记录'
-						)
-					: null,
-				h(
-					ElButton,
-					{
-						size: 'small',
-						onClick: () => {
-							router.push({
-								path: '/log/oplog',
-								query: {
-									controllerName: 'S3',
-									actionName: 'S3_DELIVERY_GENERATE',
-								},
-							});
-						},
-					},
-					() => '查看操作日志'
-				),
-			]),
-		]),
-		'生成结果摘要',
-		{ confirmButtonText: '知道了' }
-	);
-}
-
 async function onViewRules() {
 	viewingRules.value = true;
 	try {

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

@@ -10,10 +10,10 @@
     <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
-    <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.203</AssemblyVersion>
-    <FileVersion>1.0.203</FileVersion>
-    <Version>1.0.203</Version>
+    <Description>Admin.NET 通锟斤拷权锟睫匡拷锟斤拷平台</Description>
+    <AssemblyVersion>1.0.204</AssemblyVersion>
+    <FileVersion>1.0.204</FileVersion>
+    <Version>1.0.204</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

@@ -1,4 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Production;
+using Admin.NET.Plugin.AiDOP.Supply;
 using Admin.NET.Plugin.AiDOP.WorkOrder;
 
 namespace Admin.NET.Plugin.AiDOP.Order;
@@ -23,6 +25,8 @@ public class OrderReviewOrchestrationService : ITransient
     private readonly WorkOrderRoutingSyncService _routingSync;
     private readonly S1MdpSyncTransformService _mdpSync;
     private readonly AidopActionRunLogWriter _runLog;
+    private readonly ProductionScheduleGenerationService _scheduleGen;
+    private readonly ProcurementPipelineService _pipeline;
 
     public OrderReviewOrchestrationService(
         ISqlSugarClient db,
@@ -32,7 +36,9 @@ public class OrderReviewOrchestrationService : ITransient
         WorkOrderMaterialDetailSyncService materialDetailSync,
         WorkOrderRoutingSyncService routingSync,
         S1MdpSyncTransformService mdpSync,
-        AidopActionRunLogWriter runLog)
+        AidopActionRunLogWriter runLog,
+        ProductionScheduleGenerationService scheduleGen,
+        ProcurementPipelineService pipeline)
     {
         _db = db;
         _userManager = userManager;
@@ -42,6 +48,8 @@ public class OrderReviewOrchestrationService : ITransient
         _routingSync = routingSync;
         _mdpSync = mdpSync;
         _runLog = runLog;
+        _scheduleGen = scheduleGen;
+        _pipeline = pipeline;
     }
 
     public Task<SeOrderReviewExecuteResult> ReviewAsync(IReadOnlyList<long> orderIds) =>
@@ -53,10 +61,25 @@ public class OrderReviewOrchestrationService : ITransient
     public Task<SeOrderReviewExecuteResult> RefreshPlanAsync(long orderId, string? reason) =>
         ExecuteSingleAsync(ActionRefresh, orderId, async (order, result, warnings, account) =>
         {
+            // ── 第0步:物料编码变更校验 ──
+            await ValidateMaterialNotChangedAsync(order.Id, order.TenantId);
+
+            // ── 第1步:清理旧占用记录,确保跨工单库存递减重新计算 ──
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
+                new SugarParameter("@TenantId", order.TenantId),
+                new SugarParameter("@BangId", ReviewBangId));
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
+                new SugarParameter("@TenantId", order.TenantId),
+                new SugarParameter("@BangId", ReviewBangId));
+
+            // ── 第2步:加载可重排明细行(确认 / 再评审) ──
             var entries = await LoadReviewableEntriesAsync(order.Id, order.TenantId, ["3", "0"]);
             if (entries.Count == 0)
                 throw Oops.Oh("订单没有可重排的确认/再评审明细行");
 
+            // ── 第3步:逐条处理(工单 + 资源检查 + 领料单 + 交期更新) ──
             foreach (var entry in entries)
             {
                 ValidateEntryForResourceCheck(entry);
@@ -71,9 +94,44 @@ public class OrderReviewOrchestrationService : ITransient
 
                 await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account);
                 await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account);
+
+                // 当工单状态为下达/投产/暂停(R、W、S)时,更新对应领料单数据
+                await UpdatePickingListForActiveWorkOrderAsync(entry.TenantId, wo.WorkOrd, account, warnings);
+
+                // 根据资源检查结果更新明细行系统建议交期
+                await UpdateEntrySysCapacityDateAsync(entry.Id, check.KittingTime, account);
             }
 
-            await UpdateEntriesProgressAsync(entries.Select(e => e.Id).ToList(), "0", account);
+            // ── 第4步:更新明细行进度为3 ──
+            await UpdateEntriesProgressAsync(entries.Select(e => e.Id).ToList(), "3", account);
+
+            // ── 第4.5步:重新进行生产排程(在事务提交前) ──
+            try
+            {
+                var scheduleResult = await _scheduleGen.GenerateAsync(order.TenantId, order.TenantId.ToString(), account);
+                if (!string.IsNullOrWhiteSpace(scheduleResult.Message))
+                    warnings.Add($"生产排程:{scheduleResult.Message}");
+            }
+            catch (Exception ex)
+            {
+                warnings.Add($"生产排程失败:{ex.Message}");
+            }
+
+            // ── 第4.5步:同步物料需求(MRP → PR → 采购闭环) ──
+            try
+            {
+                var mrResult = await _pipeline.ExecuteCoreAsync(order.TenantId, account, createFromShortage: true);
+                if (!string.IsNullOrWhiteSpace(mrResult.Message))
+                    warnings.Add($"物料需求同步:{mrResult.Message}");
+            }
+            catch (Exception ex)
+            {
+                warnings.Add($"物料需求同步失败:{ex.Message}");
+            }
+
+            // ── 第4.5步:生成交货单(出货计划) ──
+            await GenerateShippingPlanFromOrderAsync(order, entries, account, warnings);
+
             result.EntryCount = entries.Count;
             result.Message = string.IsNullOrWhiteSpace(reason)
                 ? "3级计划重排完成"
@@ -538,4 +596,428 @@ public class OrderReviewOrchestrationService : ITransient
         return warnings;
     }
 
+    // ══════════════════════════════════════════════════════════════
+    // 3级计划重排 — 辅助方法
+    // ══════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 校验订单明细行的物料编码是否与已生成工单的物料编码一致。
+    /// 若不一致则说明物料信息已变更,不允许重排。
+    /// </summary>
+    private async Task ValidateMaterialNotChangedAsync(long orderId, long tenantId)
+    {
+        var mismatches = await _db.Ado.SqlQueryAsync<dynamic>(
+            """
+            SELECT e.entry_seq AS EntrySeq,
+                   e.item_number AS EntryItemNum,
+                   w.ItemNum AS WoItemNum
+            FROM crm_seorderentry e
+            INNER JOIN WorkOrdMaster w
+                ON w.BusinessID = e.Id AND w.tenant_id = e.tenant_id
+               AND IFNULL(w.IsActive, 0) = 1
+               AND LOWER(TRIM(IFNULL(w.Status, ''))) <> 'c'
+            WHERE e.seorder_id = @OrderId AND e.tenant_id = @TenantId AND e.IsDeleted = 0
+              AND TRIM(IFNULL(e.item_number, '')) <> ''
+              AND TRIM(IFNULL(w.ItemNum, '')) <> ''
+              AND TRIM(e.item_number) <> TRIM(w.ItemNum)
+            """,
+            new SugarParameter("@OrderId", orderId),
+            new SugarParameter("@TenantId", tenantId));
+
+        if (mismatches.Count > 0)
+            throw Oops.Oh("此订单行的物料信息有变更无法重排");
+    }
+
+    /// <summary>
+    /// 当工单状态为下达(R)/投产(W)/暂停(S)时,更新对应领料单明细数据。
+    /// 重新从 WorkOrdDetail 汇总物料需求,覆盖 NbrDetail 中的 QtyOrd。
+    /// </summary>
+    private async Task UpdatePickingListForActiveWorkOrderAsync(
+        long tenantId, string workOrd, string account, List<string> warnings)
+    {
+        // 获取工单状态
+        var status = await _db.Ado.GetStringAsync(
+            """
+            SELECT IFNULL(LOWER(TRIM(Status)), '') FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        // 仅处理 下达(R)/投产(W)/暂停(S) 状态的工单
+        if (status is not ("r" or "w" or "s"))
+            return;
+
+        // 查找领料单主记录
+        var nbrRows = await _db.Ado.SqlQueryAsync<NbrMasterRow>(
+            """
+            SELECT RecID, Nbr, `Domain` FROM NbrMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM'
+              AND IFNULL(TransType, '') = ''
+              AND IFNULL(IsActive, 0) = 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        if (nbrRows.Count == 0)
+        {
+            warnings.Add($"工单 {workOrd} 状态为 {status.ToUpper()} 但无领料单,跳过领料单更新");
+            return;
+        }
+
+        var now = DateTime.Now;
+
+        // 从 WorkOrdDetail 汇总最新物料需求
+        var details = await _db.Ado.SqlQueryAsync<PickDetailRow>(
+            """
+            SELECT
+                d.ItemNum,
+                SUM(d.QtyRequired) AS QtyRequired,
+                MAX(IFNULL(d.UM, im.Um)) AS Unit,
+                MAX(im.Descr) AS ItemName
+            FROM WorkOrdDetail d
+            LEFT JOIN ItemMaster im ON d.ItemNum = im.ItemNum AND im.tenant_id = d.tenant_id
+            WHERE d.tenant_id = @TenantId AND d.WorkOrd = @WorkOrd AND IFNULL(d.IsActive, 0) = 1
+            GROUP BY d.ItemNum
+            HAVING SUM(d.QtyRequired) > 0
+            ORDER BY d.ItemNum
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        foreach (var nbr in nbrRows)
+        {
+            var domain = string.IsNullOrWhiteSpace(nbr.Domain) ? tenantId.ToString() : nbr.Domain!.Trim();
+            if (domain.Length > 8) domain = domain[..8];
+
+            // 加载当前领料单明细行(仅未关闭的)
+            var existingDetails = await _db.Ado.SqlQueryAsync<NbrDetailRow>(
+                """
+                SELECT RecID, ItemNum, QtyOrd, QtyRec, CurrQtyOpened, Line
+                FROM NbrDetail
+                WHERE tenant_id = @TenantId AND Nbr = @Nbr AND Type = 'SM'
+                  AND IFNULL(IsActive, 0) = 1
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@Nbr", nbr.Nbr));
+
+            var detailMap = details.ToDictionary(d => d.ItemNum.Trim(), d => d);
+            var existingMap = existingDetails.ToDictionary(d => (d.ItemNum ?? "").Trim(), d => d);
+
+            // ── 1. 处理已有明细行:按 ItemNum 匹配 ──
+            foreach (var existing in existingDetails)
+            {
+                var key = (existing.ItemNum ?? "").Trim();
+                if (detailMap.TryGetValue(key, out var newDetail))
+                {
+                    // 物料在工单明细中存在
+                    var newQty = newDetail.QtyRequired;
+                    if (existing.QtyRec > 0)
+                    {
+                        // 已发料:判断新需求数是否大于已发料数
+                        if (newQty > existing.QtyRec)
+                        {
+                            // 新需求 > 已发料 → 更新需求数
+                            await _db.Ado.ExecuteCommandAsync(
+                                """
+                                UPDATE NbrDetail
+                                SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened,
+                                    UM = @UM, ItemName = @ItemName,
+                                    UpdateUser = @User, UpdateTime = @Now
+                                WHERE RecID = @RecId
+                                """,
+                                new SugarParameter("@QtyOrd", newQty),
+                                new SugarParameter("@CurrQtyOpened", newQty),
+                                new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value),
+                                new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value),
+                                new SugarParameter("@User", account),
+                                new SugarParameter("@Now", now),
+                                new SugarParameter("@RecId", existing.RecID));
+                        }
+                        else
+                        {
+                            // 新需求 <= 已发料 → 关闭当前行
+                            await _db.Ado.ExecuteCommandAsync(
+                                """
+                                UPDATE NbrDetail
+                                SET Status = 'C', IsActive = 0,
+                                    UpdateUser = @User, UpdateTime = @Now
+                                WHERE RecID = @RecId
+                                """,
+                                new SugarParameter("@User", account),
+                                new SugarParameter("@Now", now),
+                                new SugarParameter("@RecId", existing.RecID));
+                        }
+                    }
+                    else
+                    {
+                        // 未发料 → 直接修改需求数
+                        await _db.Ado.ExecuteCommandAsync(
+                            """
+                            UPDATE NbrDetail
+                            SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened,
+                                UM = @UM, ItemName = @ItemName,
+                                UpdateUser = @User, UpdateTime = @Now
+                            WHERE RecID = @RecId
+                            """,
+                            new SugarParameter("@QtyOrd", newQty),
+                            new SugarParameter("@CurrQtyOpened", newQty),
+                            new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value),
+                            new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value),
+                            new SugarParameter("@User", account),
+                            new SugarParameter("@Now", now),
+                            new SugarParameter("@RecId", existing.RecID));
+                    }
+                }
+                else
+                {
+                    // 物料明细中没有,但领料单明细有 → 关闭当前领料单明细行
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE NbrDetail
+                        SET Status = 'C', IsActive = 0,
+                            UpdateUser = @User, UpdateTime = @Now
+                        WHERE RecID = @RecId
+                        """,
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", now),
+                        new SugarParameter("@RecId", existing.RecID));
+                }
+            }
+
+            // ── 2. 新增:物料明细中有,领料单明细没有的 ──
+            var nextDetailId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM NbrDetail");
+            short newLine = (short)(existingDetails.Count > 0 ? existingDetails.Max(d => d.Line) + 1 : 1);
+
+            foreach (var d in details)
+            {
+                var key = d.ItemNum.Trim();
+                if (existingMap.ContainsKey(key))
+                    continue; // 已处理
+
+                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,
+                        @CurrQtyOpened, @UM, @WorkOrd, @ItemName, '',
+                        1, 0, 1, 0, @NbrRecId,
+                        @User, @Now, @User, @Now, @TenantId
+                    )
+                    """,
+                    new SugarParameter("@RecId", nextDetailId++),
+                    new SugarParameter("@Domain", domain),
+                    new SugarParameter("@Nbr", nbr.Nbr),
+                    new SugarParameter("@Line", newLine++),
+                    new SugarParameter("@ItemNum", d.ItemNum),
+                    new SugarParameter("@QtyOrd", d.QtyRequired),
+                    new SugarParameter("@CurrQtyOpened", d.QtyRequired),
+                    new SugarParameter("@UM", (object?)d.Unit ?? DBNull.Value),
+                    new SugarParameter("@WorkOrd", workOrd),
+                    new SugarParameter("@ItemName", (object?)d.ItemName ?? DBNull.Value),
+                    new SugarParameter("@NbrRecId", nbr.RecID),
+                    new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@TenantId", tenantId));
+            }
+
+            // ── 3. 更新领料单主记录的更新时间和数量 ──
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE NbrMaster
+                SET QtyOrd = (SELECT IFNULL(SUM(QtyOrd), 0) FROM NbrDetail
+                              WHERE Nbr = @Nbr AND Type = 'SM' AND IFNULL(IsActive, 1) = 1),
+                    UpdateUser = @User, UpdateTime = @Now
+                WHERE RecID = @RecId
+                """,
+                new SugarParameter("@Nbr", nbr.Nbr),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@RecId", nbr.RecID));
+        }
+    }
+
+    /// <summary>
+    /// 根据资源检查结果更新明细行系统建议交期(sys_capacity_date)。
+    /// </summary>
+    private async Task UpdateEntrySysCapacityDateAsync(long entryId, DateTime? kittingTime, string account)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE crm_seorderentry
+            SET sys_capacity_date = @CapacityDate,
+                update_time = @Now
+            WHERE Id = @Id AND IsDeleted = 0
+            """,
+            new SugarParameter("@CapacityDate", kittingTime ?? (object)DBNull.Value),
+            new SugarParameter("@Now", DateTime.Now),
+            new SugarParameter("@Id", entryId));
+    }
+
+    /// <summary>
+    /// 根据订单及明细行生成交货单(出货计划 ShippingPlan / ShippingPlanDetail)。
+    /// 若该订单已存在出货计划则更新明细,否则新建。
+    /// </summary>
+    private async Task GenerateShippingPlanFromOrderAsync(
+        OrderWorkOrderGenerationService.OrderHeader order,
+        List<OrderWorkOrderGenerationService.OrderEntryLine> entries,
+        string account,
+        List<string> warnings)
+    {
+        // 加载订单额外字段(客户名称、国家、日期)
+        var orderInfo = await _db.Ado.SqlQueryAsync<OrderShippingInfoRow>(
+            """
+            SELECT custom_name AS CustomName, country AS Country, date AS OrderDate
+            FROM crm_seorder
+            WHERE Id = @Id AND tenant_id = @TenantId AND IsDeleted = 0
+            LIMIT 1
+            """,
+            new SugarParameter("@Id", order.Id),
+            new SugarParameter("@TenantId", order.TenantId));
+        var info = orderInfo.FirstOrDefault() ?? new OrderShippingInfoRow();
+
+        // 检查是否已存在该订单的出货计划明细
+        var existingPlanId = await _db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(MAX(plan_id), 0) FROM ShippingPlanDetail
+            WHERE tenant_id = @TenantId AND seorder_id = @OrderId AND IFNULL(IsActive, 1) = 1
+            """,
+            new SugarParameter("@TenantId", order.TenantId),
+            new SugarParameter("@OrderId", order.Id));
+
+        var now = DateTime.Now;
+        var domain = order.TenantId.ToString();
+        if (domain.Length > 8) domain = domain[..8];
+
+        if (existingPlanId == 0)
+        {
+            // ── 新建出货计划主表 ──
+            var planId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM ShippingPlan");
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ShippingPlan (
+                    RecID, `Domain`, LotSerial, ShippingDate, ShippingSite,
+                    Consignee, Priority, Status, Remark,
+                    IsActive, IsConfirm, CreateUser, CreateTime, tenant_id
+                ) VALUES (
+                    @PlanId, @Domain, @LotSerial, @ShippingDate, '',
+                    @Consignee, 0, '', '3级计划重排自动生成',
+                    1, 1, @User, @Now, @TenantId
+                )
+                """,
+                new SugarParameter("@PlanId", planId),
+                new SugarParameter("@Domain", domain),
+                new SugarParameter("@LotSerial", order.BillNo ?? string.Empty),
+                new SugarParameter("@ShippingDate", info.OrderDate ?? (object)DBNull.Value),
+                new SugarParameter("@Consignee", (object?)info.CustomName ?? DBNull.Value),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", order.TenantId));
+
+            // ── 新建出货计划明细 ──
+            var nextDetailId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM ShippingPlanDetail");
+            foreach (var entry in entries)
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO ShippingPlanDetail (
+                        RecID, `Domain`, plan_id, OrdNbr, bill_no,
+                        ItemNum, ItemName, Specification, Qty,
+                        OrdDate, Country, CustomNo, CustomName,
+                        seorder_id, sentry_id, Remark, Status,
+                        IsActive, IsConfirm, CreateUser, CreateTime, tenant_id
+                    ) VALUES (
+                        @RecId, @Domain, @PlanId, @OrdNbr, @BillNo,
+                        @ItemNum, @ItemName, @Spec, @Qty,
+                        @OrdDate, @Country, @CustomNo, @CustomName,
+                        @SeOrderId, @SentryId, '', '',
+                        1, 1, @User, @Now, @TenantId
+                    )
+                    """,
+                    new SugarParameter("@RecId", nextDetailId++),
+                    new SugarParameter("@Domain", domain),
+                    new SugarParameter("@PlanId", planId),
+                    new SugarParameter("@OrdNbr", order.BillNo ?? string.Empty),
+                    new SugarParameter("@BillNo", entry.BillNo ?? order.BillNo ?? string.Empty),
+                    new SugarParameter("@ItemNum", (object?)entry.ItemNumber ?? DBNull.Value),
+                    new SugarParameter("@ItemName", (object?)entry.ItemName ?? DBNull.Value),
+                    new SugarParameter("@Spec", (object?)entry.Specification ?? DBNull.Value),
+                    new SugarParameter("@Qty", entry.Qty ?? 0),
+                    new SugarParameter("@OrdDate", entry.PlanDate ?? (object)DBNull.Value),
+                    new SugarParameter("@Country", (object?)info.Country ?? DBNull.Value),
+                    new SugarParameter("@CustomNo", (object?)order.CustomNo ?? DBNull.Value),
+                    new SugarParameter("@CustomName", (object?)info.CustomName ?? DBNull.Value),
+                    new SugarParameter("@SeOrderId", order.Id),
+                    new SugarParameter("@SentryId", entry.Id),
+                    new SugarParameter("@User", account),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@TenantId", order.TenantId));
+            }
+
+            warnings.Add($"已自动生成出货计划(ID={planId}),共 {entries.Count} 行明细");
+        }
+        else
+        {
+            // ── 更新已有出货计划明细 ──
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE ShippingPlanDetail
+                SET CustomName = @CustomName, Country = @Country,
+                    UpdateUser = @User, UpdateTime = @Now
+                WHERE tenant_id = @TenantId AND seorder_id = @OrderId AND IFNULL(IsActive, 1) = 1
+                """,
+                new SugarParameter("@CustomName", (object?)info.CustomName ?? DBNull.Value),
+                new SugarParameter("@Country", (object?)info.Country ?? DBNull.Value),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", order.TenantId),
+                new SugarParameter("@OrderId", order.Id));
+
+            warnings.Add($"已更新出货计划(ID={existingPlanId})的明细数据");
+        }
+    }
+
+    // ══════════════════════════════════════════════════════════════
+    // 3级计划重排 — 内部 DTO
+    // ══════════════════════════════════════════════════════════════
+
+    private sealed class NbrMasterRow
+    {
+        public int RecID { get; set; }
+        public string Nbr { get; set; } = string.Empty;
+        public string? Domain { get; set; }
+    }
+
+    private sealed class NbrDetailRow
+    {
+        public int RecID { get; set; }
+        public string? ItemNum { get; set; }
+        public decimal QtyOrd { get; set; }
+        public decimal QtyRec { get; set; }
+        public decimal CurrQtyOpened { get; set; }
+        public short Line { get; set; }
+    }
+
+    private sealed class PickDetailRow
+    {
+        public string ItemNum { get; set; } = string.Empty;
+        public decimal QtyRequired { get; set; }
+        public string? Unit { get; set; }
+        public string? ItemName { get; set; }
+    }
+
+    private sealed class OrderShippingInfoRow
+    {
+        public string? CustomName { get; set; }
+        public string? Country { get; set; }
+        public DateTime? OrderDate { get; set; }
+    }
+
 }

+ 15 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ProductionScheduleGenerationService.cs

@@ -294,11 +294,22 @@ public class ProductionScheduleGenerationService : ITransient
     {
         return await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
             """
-            SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent
+            SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent,
+                   IFNULL(Status, '') AS Status
             FROM WorkOrdMaster
             WHERE tenant_id = @TenantId
-              AND LOWER(TRIM(IFNULL(Status,''))) IN ('p', 'r')
-            ORDER BY IFNULL(Urgent, 0) DESC, IFNULL(Priority, 0) DESC, DueDate, WorkOrd
+              AND LOWER(TRIM(IFNULL(Status,''))) IN ('w', 's', 'r', 'p')
+            ORDER BY
+              CASE LOWER(TRIM(IFNULL(Status,'')))
+                WHEN 'w' THEN 1
+                WHEN 's' THEN 1
+                WHEN 'r' THEN 2
+                WHEN 'p' THEN 3
+                ELSE 4
+              END,
+              IFNULL(Urgent, 0) DESC,
+              IFNULL(Priority, 0) ASC,
+              DueDate, WorkOrd
             """,
             new SugarParameter("@TenantId", tenantId));
     }
@@ -462,6 +473,7 @@ public class ProductionScheduleGenerationService : ITransient
         public DateTime? DueDate { get; set; }
         public decimal? Priority { get; set; }
         public int? Urgent { get; set; }
+        public string Status { get; set; } = string.Empty;
     }
 
     private sealed class RoutingRow

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

@@ -1,6 +1,7 @@
 using System.Globalization;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Order;
+using Admin.NET.Plugin.AiDOP.WorkOrder;
 
 namespace Admin.NET.Plugin.AiDOP.Production;
 
@@ -15,6 +16,8 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
     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(
@@ -22,12 +25,16 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
         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;
     }
 
@@ -74,7 +81,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
     [HttpPost("scheduling/update-priority-and-recheck")]
     public async Task<object> UpdatePriorityAndRecheck([FromBody] WorkOrderPriorityRecheckInput input)
     {
-        var tenantId = ResolveTenantId(input.Domain);
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
         var account = string.IsNullOrWhiteSpace(input.UserAccount)
             ? (_userManager.Account ?? "system")
             : input.UserAccount.Trim();
@@ -132,6 +139,10 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
                     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;
@@ -139,6 +150,33 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
             var priorityChanged = !string.IsNullOrWhiteSpace(input.Priority)
                 && !string.Equals(before?.Priority?.Trim(), input.Priority.Trim(), StringComparison.Ordinal);
 
+            // 数量变更时同步更新 mes_morder 和 mes_moentry
+            if (qtyChanged && qty.HasValue)
+            {
+                await UpdateMesOrderQuantityAsync(tenantId, workOrd, qty.Value, account);
+
+                // 同步更新 WorkOrdRouting.QtyOrded(EnsureFromRoutingAsync 已有数据时不会更新数量)
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE WorkOrdRouting
+                    SET QtyOrded = @Qty,
+                        UpdateUser = @User, UpdateTime = @Now
+                    WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+                      AND IFNULL(IsActive, 0) = 1
+                    """,
+                    new SugarParameter("@Qty", qty.Value),
+                    new SugarParameter("@User", account),
+                    new SugarParameter("@Now", DateTime.Now),
+                    new SugarParameter("@TenantId", tenantId),
+                    new SugarParameter("@WorkOrd", workOrd));
+            }
+
+            // 数量变更时同步更新领料单明细(NbrDetail)
+            if (qtyChanged)
+            {
+                await SyncPickingListDetailsAsync(tenantId, workOrd, account, warnings);
+            }
+
             ProductionScheduleGenerationService.ScheduleGenerationResult? reschedule = null;
             if (qtyChanged || dueChanged)
             {
@@ -201,6 +239,307 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
         return AidopTenantHelper.Resolve(App.HttpContext);
     }
 
+    /// <summary>
+    /// 数量变更时同步更新 mes_morder(制造工单)和 mes_moentry(工单明细)的数量字段。
+    /// </summary>
+    private async Task UpdateMesOrderQuantityAsync(long tenantId, string workOrd, decimal newQty, string account)
+    {
+        var now = DateTime.Now;
+
+        // 更新 mes_morder
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mes_morder
+            SET need_number = @Qty,
+                morder_production_number = @Qty,
+                update_by_name = @User,
+                update_time = @Now
+            WHERE morder_no = @MorderNo
+              AND tenant_id = @TenantId
+              AND IsDeleted = 0
+            """,
+            new SugarParameter("@Qty", newQty),
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@MorderNo", workOrd),
+            new SugarParameter("@TenantId", tenantId));
+
+        // 更新 mes_moentry(remaining_number 仅在未开始生产时才更新)
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mes_moentry
+            SET need_number = @Qty,
+                morder_production_number = @Qty,
+                remaining_number = CASE
+                    WHEN IFNULL(remaining_number, 0) = IFNULL(need_number, 0)
+                         OR IFNULL(morder_production_number, 0) = 0
+                    THEN @Qty
+                    ELSE remaining_number
+                END,
+                update_by_name = @User,
+                update_time = @Now
+            WHERE moentry_mono = @Mono
+              AND tenant_id = @TenantId
+              AND IsDeleted = 0
+            """,
+            new SugarParameter("@Qty", newQty),
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@Mono", workOrd),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    /// <summary>
+    /// 数量变更后同步领料单明细(NbrDetail),按物料逐行比对更新。
+    /// 规则:
+    ///   已发料(QtyRec>0) + 新需求>已发料 → 更新 QtyOrd/CurrQtyOpened
+    ///   已发料(QtyRec>0) + 新需求≤已发料 → 关闭该行
+    ///   未发料 → 直接修改 QtyOrd/CurrQtyOpened
+    ///   工单明细有但领料单无 → 新增
+    ///   领料单有但工单明细无 → 关闭
+    /// </summary>
+    private async Task SyncPickingListDetailsAsync(
+        long tenantId, string workOrd, string account, List<string> warnings)
+    {
+        // 获取工单状态
+        var status = await _db.Ado.GetStringAsync(
+            """
+            SELECT IFNULL(LOWER(TRIM(Status)), '') FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        // 仅处理 下达(R)/投产(W)/暂停(S) 状态的工单
+        if (status is not ("r" or "w" or "s"))
+        {
+            // 初始(P)状态无领料单,无需同步
+            return;
+        }
+
+        // 查找领料单主记录
+        var nbrRows = await _db.Ado.SqlQueryAsync<NbrMasterRow>(
+            """
+            SELECT RecID, Nbr, `Domain` FROM NbrMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM'
+              AND IFNULL(TransType, '') = ''
+              AND IFNULL(IsActive, 0) = 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        if (nbrRows.Count == 0)
+        {
+            warnings.Add($"工单 {workOrd} 状态为 {status.ToUpper()} 但无领料单,跳过领料单同步");
+            return;
+        }
+
+        var now = DateTime.Now;
+
+        // 从 WorkOrdDetail 汇总最新物料需求
+        var details = await _db.Ado.SqlQueryAsync<PickDetailRow>(
+            """
+            SELECT
+                d.ItemNum,
+                SUM(d.QtyRequired) AS QtyRequired,
+                MAX(IFNULL(d.UM, im.Um)) AS Unit,
+                MAX(im.Descr) AS ItemName
+            FROM WorkOrdDetail d
+            LEFT JOIN ItemMaster im ON d.ItemNum = im.ItemNum AND im.tenant_id = d.tenant_id
+            WHERE d.tenant_id = @TenantId AND d.WorkOrd = @WorkOrd AND IFNULL(d.IsActive, 0) = 1
+            GROUP BY d.ItemNum
+            HAVING SUM(d.QtyRequired) > 0
+            ORDER BY d.ItemNum
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        foreach (var nbr in nbrRows)
+        {
+            var domain = string.IsNullOrWhiteSpace(nbr.Domain) ? tenantId.ToString() : nbr.Domain!.Trim();
+            if (domain.Length > 8) domain = domain[..8];
+
+            // 加载当前领料单明细行(仅未关闭的)
+            var existingDetails = await _db.Ado.SqlQueryAsync<NbrDetailRow>(
+                """
+                SELECT RecID, ItemNum, QtyOrd, QtyRec, CurrQtyOpened, Line
+                FROM NbrDetail
+                WHERE tenant_id = @TenantId AND Nbr = @Nbr AND Type = 'SM'
+                  AND IFNULL(IsActive, 0) = 1
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@Nbr", nbr.Nbr));
+
+            var detailMap = details.ToDictionary(d => d.ItemNum.Trim(), d => d);
+            var existingMap = existingDetails.ToDictionary(d => (d.ItemNum ?? "").Trim(), d => d);
+
+            // ── 1. 处理已有明细行:按 ItemNum 匹配 ──
+            foreach (var existing in existingDetails)
+            {
+                var key = (existing.ItemNum ?? "").Trim();
+                if (detailMap.TryGetValue(key, out var newDetail))
+                {
+                    var newQty = newDetail.QtyRequired;
+                    if (existing.QtyRec > 0)
+                    {
+                        if (newQty > existing.QtyRec)
+                        {
+                            // 新需求 > 已发料 → 更新需求数
+                            await _db.Ado.ExecuteCommandAsync(
+                                """
+                                UPDATE NbrDetail
+                                SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened,
+                                    UM = @UM, ItemName = @ItemName,
+                                    UpdateUser = @User, UpdateTime = @Now
+                                WHERE RecID = @RecId
+                                """,
+                                new SugarParameter("@QtyOrd", newQty),
+                                new SugarParameter("@CurrQtyOpened", newQty),
+                                new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value),
+                                new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value),
+                                new SugarParameter("@User", account),
+                                new SugarParameter("@Now", now),
+                                new SugarParameter("@RecId", existing.RecID));
+                        }
+                        else
+                        {
+                            // 新需求 <= 已发料 → 关闭当前行
+                            await _db.Ado.ExecuteCommandAsync(
+                                """
+                                UPDATE NbrDetail
+                                SET Status = 'C', IsActive = 0,
+                                    UpdateUser = @User, UpdateTime = @Now
+                                WHERE RecID = @RecId
+                                """,
+                                new SugarParameter("@User", account),
+                                new SugarParameter("@Now", now),
+                                new SugarParameter("@RecId", existing.RecID));
+                        }
+                    }
+                    else
+                    {
+                        // 未发料 → 直接修改需求数
+                        await _db.Ado.ExecuteCommandAsync(
+                            """
+                            UPDATE NbrDetail
+                            SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened,
+                                UM = @UM, ItemName = @ItemName,
+                                UpdateUser = @User, UpdateTime = @Now
+                            WHERE RecID = @RecId
+                            """,
+                            new SugarParameter("@QtyOrd", newQty),
+                            new SugarParameter("@CurrQtyOpened", newQty),
+                            new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value),
+                            new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value),
+                            new SugarParameter("@User", account),
+                            new SugarParameter("@Now", now),
+                            new SugarParameter("@RecId", existing.RecID));
+                    }
+                }
+                else
+                {
+                    // 物料明细中没有,但领料单明细有 → 关闭当前领料单明细行
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE NbrDetail
+                        SET Status = 'C', IsActive = 0,
+                            UpdateUser = @User, UpdateTime = @Now
+                        WHERE RecID = @RecId
+                        """,
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", now),
+                        new SugarParameter("@RecId", existing.RecID));
+                }
+            }
+
+            // ── 2. 新增:物料明细中有,领料单明细没有的 ──
+            var nextDetailId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM NbrDetail");
+            short newLine = (short)(existingDetails.Count > 0 ? existingDetails.Max(d => d.Line) + 1 : 1);
+
+            foreach (var d in details)
+            {
+                var key = d.ItemNum.Trim();
+                if (existingMap.ContainsKey(key))
+                    continue;
+
+                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,
+                        @CurrQtyOpened, @UM, @WorkOrd, @ItemName, '',
+                        1, 0, 1, 0, @NbrRecId,
+                        @User, @Now, @User, @Now, @TenantId
+                    )
+                    """,
+                    new SugarParameter("@RecId", nextDetailId++),
+                    new SugarParameter("@Domain", domain),
+                    new SugarParameter("@Nbr", nbr.Nbr),
+                    new SugarParameter("@Line", newLine++),
+                    new SugarParameter("@ItemNum", d.ItemNum),
+                    new SugarParameter("@QtyOrd", d.QtyRequired),
+                    new SugarParameter("@CurrQtyOpened", d.QtyRequired),
+                    new SugarParameter("@UM", (object?)d.Unit ?? DBNull.Value),
+                    new SugarParameter("@WorkOrd", workOrd),
+                    new SugarParameter("@ItemName", (object?)d.ItemName ?? DBNull.Value),
+                    new SugarParameter("@NbrRecId", nbr.RecID),
+                    new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@TenantId", tenantId));
+            }
+
+            // ── 3. 更新领料单主记录的更新时间和数量 ──
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE NbrMaster
+                SET QtyOrd = (SELECT IFNULL(SUM(QtyOrd), 0) FROM NbrDetail
+                              WHERE Nbr = @Nbr AND Type = 'SM' AND IFNULL(IsActive, 1) = 1),
+                    UpdateUser = @User, UpdateTime = @Now
+                WHERE RecID = @RecId
+                """,
+                new SugarParameter("@Nbr", nbr.Nbr),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@RecId", nbr.RecID));
+        }
+    }
+
+    // ══════════════════════════════════════════════════════════════
+    // 内部 DTO
+    // ══════════════════════════════════════════════════════════════
+
+    private sealed class NbrMasterRow
+    {
+        public int RecID { get; set; }
+        public string Nbr { get; set; } = string.Empty;
+        public string? Domain { get; set; }
+    }
+
+    private sealed class NbrDetailRow
+    {
+        public int RecID { get; set; }
+        public string? ItemNum { get; set; }
+        public decimal QtyOrd { get; set; }
+        public decimal QtyRec { get; set; }
+        public decimal CurrQtyOpened { get; set; }
+        public short Line { get; set; }
+    }
+
+    private sealed class PickDetailRow
+    {
+        public string ItemNum { get; set; } = string.Empty;
+        public decimal QtyRequired { get; set; }
+        public string? Unit { get; set; }
+        public string? ItemName { get; set; }
+    }
+
     private async Task<WorkOrderSnapshotRow?> LoadWorkOrderSnapshotAsync(long tenantId, string workOrd)
     {
         var rows = await _db.Ado.SqlQueryAsync<WorkOrderSnapshotRow>(