浏览代码

feat(aidop): 工单下达租户、批号、完工日联动与保存顺序

- 下达/取号按 tenant_id;release 更新 DueDate 随开工日平移

- 保存:先 POST dispatch/release,再外部 ProduceWorkOrdKittingCheck(响应 ok 后提示成功)

- 列表齐套检查恢复 producedayplankittingcheck;表单传 userAccount

chore: bump Web 2.4.135 / server 1.0.95
Co-authored-by: Cursor <cursoragent@cursor.com>
Pengxy 1 月之前
父节点
当前提交
95e0984165

+ 1 - 1
Web/package.json

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

+ 108 - 18
Web/src/views/aidop/api/workOrderDispatch.ts

@@ -1,7 +1,20 @@
 import service from '/@/utils/request';
-import axios from 'axios';
 
-const EXTERNAL_BASE = 'http://123.60.180.165:8087';
+/** 与订单评审交期确认、工单排产「同步物料需求」同源的外部资源审查服务(不经后端转发)。 */
+const EXTERNAL_RESOURCE_EXAMINE_BASE = 'http://123.60.180.165:8087/api/business/resource-examine';
+
+/**
+ * GET 调用 resource-examine 接口;返回体须为 `ok`(忽略大小写),与 `confirmSeOrderDelivery` 约定一致。
+ * 使用 fetch,避免全局 axios 拦截器向外部地址附带 Authorization。
+ */
+async function getResourceExamineExpectOk(path: string, params: Record<string, string>): Promise<void> {
+	const qs = new URLSearchParams(params).toString();
+	const url = `${EXTERNAL_RESOURCE_EXAMINE_BASE}/${path}${qs ? `?${qs}` : ''}`;
+	const r = await fetch(url, { method: 'GET' });
+	if (!r.ok) throw new Error(`接口失败(HTTP ${r.status})`);
+	const body = String((await r.text()) ?? '').trim();
+	if (body.toLowerCase() !== 'ok') throw new Error(`接口返回异常:${body}`);
+}
 
 export interface Paged<T> {
 	total: number;
@@ -31,27 +44,71 @@ export interface WorkOrderDispatchRow {
 
 export interface WorkOrderReleaseParams {
 	workOrd: string;
-	domain: string;
+	/** 租户 ID(WorkOrdMaster.tenant_id);外部 ProduceWorkOrdKittingCheck 的 `domain`(factory_id) */
+	tenantId: number;
 	ordDate: string;
 	lotSerial?: string | null;
+	/** 登录账号,仅用于保存前调用外部齐套检查;不会提交给本系统 release 接口 */
+	userAccount: string;
 }
 
-/** 物料齐套检查(GET,直接调用外部接口) */
-export function kitCheck(domain: string, userAccount: string) {
-	return axios
-		.get(`${EXTERNAL_BASE}/api/business/resource-examine/producedayplankittingcheck`, {
-			params: { domain, userAccount },
-		})
-		.then((r) => r.data);
+/** 物料齐套检查(前端直连外部 GET)。`domain` 传租户 ID 字符串(与登录用户 currentTenantId / tenantId 一致)。 */
+export async function kitCheck(domain: string, userAccount: string): Promise<void> {
+	await getResourceExamineExpectOk('producedayplankittingcheck', { domain, userAccount });
+}
+
+/**
+ * 下达落库之后:外部工单齐套检查(与约定一致)。
+ * `GET .../ProduceWorkOrdKittingCheck?workord=&domain=&userAccount=`,响应体须为 `ok`(忽略大小写)。
+ */
+async function produceWorkOrdKittingCheckAfterRelease(payload: {
+	workord: string;
+	domain: string;
+	userAccount: string;
+}): Promise<void> {
+	await getResourceExamineExpectOk('ProduceWorkOrdKittingCheck', {
+		workord: payload.workord.trim(),
+		domain: payload.domain,
+		userAccount: (payload.userAccount ?? '').trim(),
+	});
+}
+
+/**
+ * GET 调用 resource-examine;仅校验 HTTP 成功(与工单排产 `syncMaterialRequirement` 一致)。
+ * 使用 fetch,避免全局 axios 拦截器向外部地址附带 Authorization。
+ */
+async function getResourceExamineHttpOk(path: string, params: Record<string, string>): Promise<void> {
+	const qs = new URLSearchParams(params).toString();
+	const url = `${EXTERNAL_RESOURCE_EXAMINE_BASE}/${path}${qs ? `?${qs}` : ''}`;
+	const r = await fetch(url, { method: 'GET' });
+	if (!r.ok) throw new Error(`接口失败(HTTP ${r.status})`);
 }
 
-/** 生成物料需求(GET,直接调用外部接口) */
-export function mrpGenerate(domain: string) {
-	return axios
-		.get(`${EXTERNAL_BASE}/api/business/resource-examine/AutomaticPrAdjustDate`, {
-			params: { domain },
+/** 生成物料需求(前端直连外部 GET)。`domain` 传租户 ID 字符串。 */
+export async function mrpGenerate(domain: string): Promise<void> {
+	await getResourceExamineHttpOk('AutomaticPrAdjustDate', { domain });
+}
+
+export interface NextLotSerialResult {
+	lotSerial: string;
+}
+
+/** 下一生产批号:本系统接口(按 tenant_id + 初始工单取号) */
+export function fetchNextLotSerial(payload: { tenantId: number; workOrd: string }) {
+	const params: Record<string, string | number> = {
+		workOrd: payload.workOrd.trim(),
+		tenantId: payload.tenantId,
+	};
+	return service
+		.get<NextLotSerialResult & { result?: NextLotSerialResult }>('/api/WorkOrder/dispatch/next-lot-serial', {
+			params,
 		})
-		.then((r) => r.data);
+		.then((res) => {
+			const body = res.data as NextLotSerialResult & { result?: NextLotSerialResult };
+			if (body && typeof body.lotSerial === 'string') return { lotSerial: body.lotSerial };
+			if (body?.result && typeof body.result.lotSerial === 'string') return { lotSerial: body.result.lotSerial };
+			throw new Error('获取生产批号返回格式异常');
+		});
 }
 
 export function fetchWorkOrderDispatchList(params: Record<string, unknown>) {
@@ -60,6 +117,39 @@ export function fetchWorkOrderDispatchList(params: Record<string, unknown>) {
 		.then((r) => r.data);
 }
 
-export function releaseWorkOrder(body: WorkOrderReleaseParams) {
-	return service.post('/api/WorkOrder/dispatch/release', body).then((r) => r.data);
+/** 下达成功后的响应体(统一包装或非统一 JSON) */
+export type WorkOrderReleaseResponse = Record<string, unknown>;
+
+/**
+ * 工单下达保存:先本系统 `POST .../dispatch/release`(开工日期、完工日平移、状态等落库),再直连外部 `ProduceWorkOrdKittingCheck`;
+ * 外部返回 `ok` 后本函数才 resolve,供调用方弹出「工单下达成功」。
+ */
+export async function releaseWorkOrder(body: WorkOrderReleaseParams): Promise<WorkOrderReleaseResponse> {
+	const { userAccount: _ua, ...releaseBody } = body;
+	const res = await service.post('/api/WorkOrder/dispatch/release', releaseBody);
+	const data = res.data as WorkOrderReleaseResponse | undefined;
+	if (data == null || typeof data !== 'object') {
+		throw new Error('工单下达返回异常');
+	}
+	if (Object.prototype.hasOwnProperty.call(data, 'code')) {
+		const code = Number(data.code);
+		if (!Number.isFinite(code) || code !== 200) {
+			const msg =
+				typeof data.message === 'string' && data.message.trim() ? data.message : '工单下达失败';
+			throw new Error(msg);
+		}
+	}
+	if (typeof data.type === 'string' && data.type && data.type !== 'success') {
+		const msg =
+			typeof data.message === 'string' && data.message.trim() ? data.message : '工单下达失败';
+		throw new Error(msg);
+	}
+
+	await produceWorkOrdKittingCheckAfterRelease({
+		workord: body.workOrd,
+		domain: String(body.tenantId),
+		userAccount: body.userAccount,
+	});
+
+	return data;
 }

+ 105 - 11
Web/src/views/aidop/business/workOrderDispatchForm.vue

@@ -8,8 +8,8 @@
 					</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 label="生产批号" prop="lotSerial" v-loading="lotSerialLoading">
+						<el-input v-model="form.lotSerial" placeholder="留空则保存前自动取号" />
 					</el-form-item>
 				</el-col>
 				<el-col :span="24">
@@ -18,6 +18,7 @@
 							v-model="form.ordDate"
 							type="date"
 							value-format="YYYY-MM-DD"
+							:disabled-date="disabledOrdDateBeforeToday"
 							placeholder="选择开工日期"
 							style="width: 100%"
 						/>
@@ -34,17 +35,42 @@
 </template>
 
 <script setup lang="ts">
-import { reactive, ref } from 'vue';
+import { onMounted, reactive, ref } from 'vue';
 import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
-import { releaseWorkOrder } from '../api/workOrderDispatch';
+import { fetchNextLotSerial, releaseWorkOrder } from '../api/workOrderDispatch';
+import { useUserInfo } from '/@/stores/userInfo';
+
+const userInfo = useUserInfo();
 
 const props = defineProps<{
 	workOrd: string;
-	domain: string;
+	/** 可选;不传或 0 时用当前登录用户租户 */
+	tenantId?: number;
 	initOrdDate?: string | null;
 	initLotSerial?: string | null;
 }>();
 
+function resolvedTenantId(): number {
+	if (props.tenantId != null && props.tenantId > 0) return props.tenantId;
+	const u = userInfo.userInfos;
+	const tid = u?.currentTenantId ?? u?.tenantId;
+	if (tid === undefined || tid === null) return 0;
+	const n = Number(tid);
+	return Number.isFinite(n) ? n : 0;
+}
+
+/** 本地日历「今天」零点,用于与所选日期比较 */
+function startOfLocalDay(d: Date): Date {
+	const x = new Date(d);
+	x.setHours(0, 0, 0, 0);
+	return x;
+}
+
+function disabledOrdDateBeforeToday(date: Date): boolean {
+	const today = startOfLocalDay(new Date());
+	return startOfLocalDay(date).getTime() < today.getTime();
+}
+
 const emit = defineEmits<{
 	(e: 'cancel'): void;
 	(e: 'saved'): void;
@@ -52,29 +78,97 @@ const emit = defineEmits<{
 
 const formRef = ref<FormInstance>();
 const saving = ref(false);
+const lotSerialLoading = ref(false);
 
 const form = reactive({
-	lotSerial: props.initLotSerial ?? '',
+	lotSerial: (props.initLotSerial ?? '').trim(),
 	ordDate: props.initOrdDate ? props.initOrdDate.slice(0, 10) : '',
 });
 
+async function ensureLotSerialIfEmpty() {
+	const cur = (form.lotSerial ?? '').trim();
+	if (cur) return;
+	const tid = resolvedTenantId();
+	if (!tid) {
+		ElMessage.error('无法解析租户信息,请重新登录后再试');
+		throw new Error('no tenant');
+	}
+	lotSerialLoading.value = true;
+	try {
+		const { lotSerial } = await fetchNextLotSerial({ tenantId: tid, workOrd: props.workOrd });
+		form.lotSerial = lotSerial;
+	} catch (e: any) {
+		ElMessage.error(e?.message || '获取生产批号失败,请手填后重试');
+		throw e;
+	} finally {
+		lotSerialLoading.value = false;
+	}
+}
+
 const rules: FormRules = {
-	ordDate: [{ required: true, message: '请选择开工日期', trigger: 'change' }],
+	ordDate: [
+		{ required: true, message: '请选择开工日期', trigger: 'change' },
+		{
+			validator: (_rule, value: string, callback) => {
+				if (!value) {
+					callback();
+					return;
+				}
+				const picked = startOfLocalDay(new Date(`${value}T12:00:00`));
+				const today = startOfLocalDay(new Date());
+				if (picked.getTime() < today.getTime()) {
+					callback(new Error('开工日期不能早于今天'));
+					return;
+				}
+				callback();
+			},
+			trigger: ['change', 'blur'],
+		},
+	],
 };
 
+onMounted(async () => {
+	const existing = (props.initLotSerial ?? '').trim();
+	if (existing) {
+		form.lotSerial = existing;
+		return;
+	}
+	try {
+		await ensureLotSerialIfEmpty();
+	} catch {
+		/* 已在 ensure 内提示 */
+	}
+});
+
 async function onSave() {
+	try {
+		await ensureLotSerialIfEmpty();
+	} catch {
+		return;
+	}
+
 	const valid = await formRef.value?.validate().catch(() => false);
 	if (!valid) return;
 
 	saving.value = true;
 	try {
-		await releaseWorkOrder({
+		const tid = resolvedTenantId();
+		if (!tid) {
+			ElMessage.error('无法解析租户信息,请重新登录后再试');
+			return;
+		}
+		const releaseRes = await releaseWorkOrder({
 			workOrd: props.workOrd,
-			domain: props.domain,
+			tenantId: tid,
 			ordDate: form.ordDate,
-			lotSerial: form.lotSerial || null,
+			lotSerial: form.lotSerial.trim() || null,
+			userAccount: userInfo.userInfos?.account ?? '',
 		});
-		ElMessage.success('工单下达成功');
+		const serverMsg =
+			typeof releaseRes.message === 'string' && releaseRes.message.trim()
+				? releaseRes.message.trim()
+				: '';
+		ElMessage.success(serverMsg || '工单下达成功');
 		emit('saved');
 	} catch (e: any) {
 		ElMessage.error(e?.message || '工单下达失败');

+ 29 - 31
Web/src/views/aidop/business/workOrderDispatchList.vue

@@ -85,7 +85,7 @@
 		<el-dialog v-model="releaseVisible" title="工单下达" width="480px" destroy-on-close>
 			<WorkOrderDispatchForm
 				:work-ord="releasingRow?.workOrd ?? ''"
-				:domain="releasingRow?.domain ?? ''"
+				:tenant-id="domainParamFromCurrentTenantNumber()"
 				:init-ord-date="releasingRow?.ordDate ?? ''"
 				:init-lot-serial="releasingRow?.lotSerial ?? ''"
 				@cancel="releaseVisible = false"
@@ -100,7 +100,7 @@
 <script setup lang="ts" name="aidopBusinessWorkOrderDispatchList">
 import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage, ElMessageBox } from 'element-plus';
+import { ElMessage } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
 import WorkOrderDispatchForm from './workOrderDispatchForm.vue';
 import { useUserInfo } from '/@/stores/userInfo';
@@ -158,6 +158,22 @@ function onSelectionChange(sel: WorkOrderDispatchRow[]) {
 	selectedRows.value = sel;
 }
 
+/** 外部 resource-examine 的 `domain` 参数:传当前用户租户 ID(如 AidopAdmin 对应 797403760988229) */
+function domainParamFromCurrentTenant(): string | null {
+	const u = userInfo.userInfos;
+	const tid = u?.currentTenantId ?? u?.tenantId;
+	if (tid === undefined || tid === null || String(tid).trim() === '') return null;
+	return String(tid);
+}
+
+/** 当前用户租户 ID(数字),用于工单下达 / 取生产批号 */
+function domainParamFromCurrentTenantNumber(): number {
+	const s = domainParamFromCurrentTenant();
+	if (!s) return 0;
+	const n = Number(s);
+	return Number.isFinite(n) ? n : 0;
+}
+
 function doSearch() {
 	query.page = 1;
 	loadList();
@@ -208,14 +224,18 @@ function onReleased() {
 const kitChecking = ref(false);
 
 async function onKitCheck() {
-	const domain = '8010';
-	const account = userInfo.userInfos?.account ?? '';
+	const domain = domainParamFromCurrentTenant();
+	if (!domain) {
+		ElMessage.error('当前用户无租户信息,无法执行齐套检查');
+		return;
+	}
 
+	const account = userInfo.userInfos?.account ?? '';
 	kitChecking.value = true;
 	try {
 		await kitCheck(domain, account);
 		ElMessage.success('齐套检查已执行');
-		loadList();
+		await loadList();
 	} catch (e: any) {
 		ElMessage.error(e?.message || '齐套检查失败');
 	} finally {
@@ -227,32 +247,9 @@ async function onKitCheck() {
 const mrpGenerating = ref(false);
 
 async function onMrpGenerate() {
-	let domain = '';
-	try {
-		const { value } = await ElMessageBox.prompt('请输入公司域名(domain)', '生成物料需求计划', {
-			confirmButtonText: '下一步',
-			cancelButtonText: '取消',
-			inputPlaceholder: '例如:8010',
-			inputValue: '8010',
-			inputPattern: /.+/,
-			inputErrorMessage: '域名不能为空',
-		});
-		domain = value;
-	} catch {
-		return;
-	}
-
-	try {
-		await ElMessageBox.confirm(
-			`确认为域名【${domain}】生成物料需求采购计划?此操作不可撤销。`,
-			'二次确认',
-			{
-				confirmButtonText: '确认生成',
-				cancelButtonText: '取消',
-				type: 'warning',
-			}
-		);
-	} catch {
+	const domain = domainParamFromCurrentTenant();
+	if (!domain) {
+		ElMessage.error('当前用户无租户信息,无法生成物料需求');
 		return;
 	}
 
@@ -260,6 +257,7 @@ async function onMrpGenerate() {
 	try {
 		await mrpGenerate(domain);
 		ElMessage.success('物料需求计划生成成功');
+		await loadList();
 	} catch (e: any) {
 		ElMessage.error(e?.message || '生成失败');
 	} finally {

+ 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.96</AssemblyVersion>
-    <FileVersion>1.0.96</FileVersion>
-    <Version>1.0.96</Version>
+    <AssemblyVersion>1.0.97</AssemblyVersion>
+    <FileVersion>1.0.97</FileVersion>
+    <Version>1.0.97</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

@@ -22,7 +22,7 @@ public class AdoS0LineMaster : ITenantIdFilter
     [SugarColumn(ColumnName = "Line", ColumnDescription = "生产线编码", Length = 100)]
     public string Line { get; set; } = string.Empty;
 
-    [SugarColumn(ColumnName = "line_describe", ColumnDescription = "生产线描述", Length = 255, IsNullable = true)]
+    [SugarColumn(ColumnName = "Describe", ColumnDescription = "生产线描述", Length = 255, IsNullable = true)]
     public string? Describe { get; set; }
 
     [SugarColumn(ColumnName = "LineType", ColumnDescription = "产线类型", Length = 50, IsNullable = true)]

+ 2 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/Dto/WorkOrderDispatchDto.cs

@@ -30,9 +30,8 @@ public class WorkOrderReleaseInput
     [Required(ErrorMessage = "工单编号不能为空")]
     public string WorkOrd { get; set; } = string.Empty;
 
-    /// <summary>公司域名</summary>
-    [Required(ErrorMessage = "域名不能为空")]
-    public string Domain { get; set; } = string.Empty;
+    /// <summary>租户 ID(与 WorkOrdMaster.tenant_id 一致);未传或 0 时使用当前登录用户租户</summary>
+    public long TenantId { get; set; }
 
     /// <summary>开工日期(yyyy-MM-dd)</summary>
     [Required(ErrorMessage = "开工日期不能为空")]

+ 13 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/Entity/WorkOrdMaster.cs

@@ -28,11 +28,14 @@ public class WorkOrdMaster
     [SugarColumn(ColumnName = "Typed", Length = 1, IsNullable = true)]
     public string? Typed { get; set; }
 
-    [SugarColumn(ColumnName = "QtyOrded", IsNullable = true)]
-    public decimal? QtyOrded { get; set; }
+    [SugarColumn(ColumnName = "QtyOrded", IsNullable = false)]
+    public decimal QtyOrded { get; set; }
 
-    [SugarColumn(ColumnName = "QtyCompleted", IsNullable = true)]
-    public decimal? QtyCompleted { get; set; }
+    [SugarColumn(ColumnName = "QtyCompleted", IsNullable = false)]
+    public decimal QtyCompleted { get; set; }
+
+    [SugarColumn(ColumnName = "LbrVar", IsNullable = false)]
+    public decimal LbrVar { get; set; }
 
     [SugarColumn(ColumnName = "QtyReject", IsNullable = true)]
     public decimal? QtyReject { get; set; }
@@ -70,8 +73,8 @@ public class WorkOrdMaster
     [SugarColumn(ColumnName = "Priority", ColumnDataType = "decimal(18,8)")]
     public decimal Priority { get; set; }
 
-    [SugarColumn(ColumnName = "Urgent", IsNullable = true)]
-    public int? Urgent { get; set; }
+    [SugarColumn(ColumnName = "Urgent", IsNullable = false)]
+    public int Urgent { get; set; }
 
     [SugarColumn(ColumnName = "CustNo", Length = 64, IsNullable = true)]
     public string? CustNo { get; set; }
@@ -127,16 +130,16 @@ public class WorkOrdMaster
     public string? FlowStatus { get; set; }
 
     // 审批字段
-    [SugarColumn(ColumnName = "BusinessID", IsNullable = true)]
-    public long? BusinessID { get; set; }
+    [SugarColumn(ColumnName = "BusinessID", IsNullable = false)]
+    public long BusinessID { get; set; }
 
-    [SugarColumn(ColumnName = "CreateUser", Length = 8, IsNullable = true)]
+    [SugarColumn(ColumnName = "CreateUser", Length = 80, IsNullable = true)]
     public string? CreateUser { get; set; }
 
     [SugarColumn(ColumnName = "CreateTime", IsNullable = true)]
     public DateTime? CreateTime { get; set; }
 
-    [SugarColumn(ColumnName = "UpdateUser", Length = 8, IsNullable = true)]
+    [SugarColumn(ColumnName = "UpdateUser", Length = 80, IsNullable = true)]
     public string? UpdateUser { get; set; }
 
     [SugarColumn(ColumnName = "UpdateTime", IsNullable = true)]

+ 110 - 31
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs

@@ -1,3 +1,5 @@
+using System.Globalization;
+
 namespace Admin.NET.Plugin.AiDOP.WorkOrder;
 
 /// <summary>
@@ -19,6 +21,31 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         _userManager = userManager;
     }
 
+    private long ResolveTenantId(long? requestTenantId)
+    {
+        var tid = requestTenantId is > 0 ? requestTenantId!.Value : _userManager.TenantId;
+        if (tid == 0)
+            throw Oops.Oh("租户ID无效,请传入 tenantId 或登录后重试");
+        return tid;
+    }
+
+    /// <summary>校验:本租户下存在处于初始状态(p)的该工单。</summary>
+    private async Task AssertInitialWorkOrderExistsAsync(long tenantId, string workOrd)
+    {
+        var wo = workOrd.Trim();
+        var cnt = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(*) FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId
+              AND WorkOrd = @WorkOrd
+              AND LOWER(TRIM(IFNULL(Status,''))) = 'p'
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", wo));
+        if (cnt == 0)
+            throw Oops.Oh("未找到处于初始状态(p)的本租户工单,请确认工单号与租户是否正确");
+    }
+
     // ══════════════════════════════════════════════════════════════
     // 列表 GET /api/WorkOrder/dispatch/list
     // ══════════════════════════════════════════════════════════════
@@ -55,6 +82,8 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
 
         var whereClause = "WHERE " + string.Join(" AND ", conditions);
 
+        // mes_morder 与工单的关联:历史上用 factory_id=Domain 直连,但部分库 factory_id 存租户/组织数值而 Domain 为工厂编码,
+        // 会导致 JOIN 匹配不到、齐套列为空。改为按工单号取齐套,优先 factory_id 与 Domain 文本一致的那条。
         var baseSql = $"""
             SELECT
                 a.RecID           AS Id,
@@ -74,16 +103,11 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                 IFNULL(nm1.Nbr,'') AS Nbr,
                 CONCAT(IFNULL(b.ItemNum,''), IFNULL(b.Descr,''), IFNULL(b.Descr1,'')) AS Wl
             FROM WorkOrdMaster a
-            LEFT JOIN ItemMaster b ON a.ItemNum COLLATE utf8mb4_general_ci = b.ItemNum COLLATE utf8mb4_general_ci
-            LEFT JOIN mes_morder m ON a.WorkOrd COLLATE utf8mb4_general_ci = m.morder_no COLLATE utf8mb4_general_ci
-                AND CAST(a.Domain AS CHAR(64)) COLLATE utf8mb4_general_ci = CAST(m.factory_id AS CHAR(64)) COLLATE utf8mb4_general_ci
-            LEFT JOIN NbrMaster nm
-                ON a.Domain COLLATE utf8mb4_general_ci = nm.Domain COLLATE utf8mb4_general_ci
-                AND a.WorkOrd COLLATE utf8mb4_general_ci = nm.WorkOrd COLLATE utf8mb4_general_ci
+            LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum 
+            LEFT JOIN mes_morder m ON a.WorkOrd = m.morder_no
+            LEFT JOIN NbrMaster nm ON a.WorkOrd= nm.WorkOrd
                 AND nm.Type = 'SM' AND nm.TransType = 'PrevProcess'
-            LEFT JOIN NbrMaster nm1
-                ON a.Domain COLLATE utf8mb4_general_ci = nm1.Domain COLLATE utf8mb4_general_ci
-                AND a.WorkOrd COLLATE utf8mb4_general_ci = nm1.WorkOrd COLLATE utf8mb4_general_ci
+            LEFT JOIN NbrMaster nm1 ON a.WorkOrd = nm1.WorkOrd
                 AND nm1.Type = 'SM' AND nm1.TransType = ''
             {whereClause}
             """;
@@ -98,57 +122,112 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         return new { total, page = input.Page, pageSize = input.PageSize, list };
     }
 
+    // ══════════════════════════════════════════════════════════════
+    // 下一生产批号 GET /api/WorkOrder/dispatch/next-lot-serial
+    // 规则:以服务器「当前日期」的 yyMMdd + 三位流水;同一 tenant_id 下按已占用流水递增,避免重复。
+    // 须存在:tenant_id + 工单号 + 状态 p。
+    // ══════════════════════════════════════════════════════════════
+    /// <summary>获取下一可用生产批号(当前日期 + 三位流水)🏭</summary>
+    [DisplayName("获取下一生产批号")]
+    [HttpGet("dispatch/next-lot-serial")]
+    public async Task<object> GetNextLotSerial([FromQuery] long? tenantId, [FromQuery] string workOrd)
+    {
+        var tid = ResolveTenantId(tenantId);
+        if (string.IsNullOrWhiteSpace(workOrd))
+            throw Oops.Oh("工单编号不能为空");
+
+        await AssertInitialWorkOrderExistsAsync(tid, workOrd);
+
+        var prefix = DateTime.Today.ToString("yyMMdd", CultureInfo.InvariantCulture);
+
+        var maxSql = """
+            SELECT IFNULL(MAX(CAST(RIGHT(TRIM(w.LotSerial), 3) AS UNSIGNED)), 0)
+            FROM WorkOrdMaster w
+            WHERE w.tenant_id = @TenantId
+              AND TRIM(w.LotSerial) REGEXP '^[0-9]{9}$'
+              AND LEFT(TRIM(w.LotSerial), 6) = @Prefix
+            """;
+
+        var maxSeq = await _db.Ado.GetIntAsync(maxSql,
+            new SugarParameter("@TenantId", tid),
+            new SugarParameter("@Prefix", prefix));
+        var next = maxSeq + 1;
+        if (next > 999)
+            throw Oops.Oh($"当日生产批号流水已超过 999(前缀 {prefix}),请联系管理员");
+
+        var lotSerial = prefix + next.ToString("D3", CultureInfo.InvariantCulture);
+        return new { lotSerial };
+    }
+
     // ══════════════════════════════════════════════════════════════
     // 工单下达 POST /api/WorkOrder/dispatch/release
     // ══════════════════════════════════════════════════════════════
-    /// <summary>工单下达(更新开工日期、生产批号,状态改为 r)🏭</summary>
+    /// <summary>工单下达(更新开工日期、生产批号,状态改为 r)。若原开工日、新开工日、原完工日均非空,则完工日按日历同天数平移。</summary>
     [DisplayName("工单下达")]
     [ApiDescriptionSettings(Name = "ReleaseWorkOrder"), HttpPost("dispatch/release")]
     public async Task<object> ReleaseWorkOrder([FromBody] WorkOrderReleaseInput input)
     {
-        // 参照 SysJobService.UpdateJobDetail:先查再改
-        var pars = new List<SugarParameter>
+        var tid = ResolveTenantId(input.TenantId > 0 ? input.TenantId : null);
+        var wo = input.WorkOrd.Trim();
+
+        await AssertInitialWorkOrderExistsAsync(tid, wo);
+
+        if (!string.IsNullOrWhiteSpace(input.LotSerial))
         {
-            new SugarParameter("@WorkOrd", input.WorkOrd),
-            new SugarParameter("@Domain",  input.Domain)
-        };
-        var exists = await _db.Ado.GetIntAsync(
-            "SELECT COUNT(*) FROM WorkOrdMaster WHERE WorkOrd = @WorkOrd AND Domain = @Domain AND Status = 'p'",
-            pars);
-        if (exists == 0)
-            throw Oops.Oh("工单不存在或状态不为初始(p),无法下达");
+            var ls = input.LotSerial.Trim();
+            var dup = await _db.Ado.GetIntAsync(
+                """
+                SELECT COUNT(*) FROM WorkOrdMaster
+                WHERE tenant_id = @TenantId
+                  AND TRIM(IFNULL(LotSerial,'')) = @LotSerial
+                  AND WorkOrd <> @WorkOrd
+                """,
+                new SugarParameter("@TenantId", tid),
+                new SugarParameter("@LotSerial", ls),
+                new SugarParameter("@WorkOrd", wo));
+            if (dup > 0)
+                throw Oops.Oh("生产批号已被本租户下其他工单占用,请重新获取或修改");
+        }
 
-        var ordDate = string.IsNullOrWhiteSpace(input.OrdDate)
-            ? (DateTime?)null
-            : DateTime.Parse(input.OrdDate);
+        // 开工日仅取日期部分;与库中原开工日做日历差,同天数平移 DueDate(保持原「完工−开工」间隔)
+        DateTime? ordDate = string.IsNullOrWhiteSpace(input.OrdDate)
+            ? null
+            : DateTime.Parse(input.OrdDate.Trim(), CultureInfo.InvariantCulture).Date;
         var releaseDate = DateTime.Now;
         var account = _userManager.Account ?? "system";
 
         var updatePars = new List<SugarParameter>
         {
             new SugarParameter("@Status",      "r"),
-            new SugarParameter("@OrdDate",     ordDate),
-            new SugarParameter("@LotSerial",   input.LotSerial ?? (object)DBNull.Value),
+            new SugarParameter("@OrdDate",     ordDate ?? (object)DBNull.Value),
+            new SugarParameter("@LotSerial",   string.IsNullOrWhiteSpace(input.LotSerial) ? (object)DBNull.Value : input.LotSerial.Trim()),
             new SugarParameter("@ReleaseDate", releaseDate),
             new SugarParameter("@UpdateUser",  account),
             new SugarParameter("@UpdateTime",  releaseDate),
-            new SugarParameter("@WorkOrd",     input.WorkOrd),
-            new SugarParameter("@Domain",      input.Domain)
+            new SugarParameter("@WorkOrd",     wo),
+            new SugarParameter("@TenantId",   tid)
         };
 
-        await _db.Ado.ExecuteCommandAsync(
+        var affected = await _db.Ado.ExecuteCommandAsync(
             """
             UPDATE WorkOrdMaster
-            SET Status      = @Status,
-                OrdDate     = @OrdDate,
+            SET OrdDate     = @OrdDate,
+                DueDate     = CASE
+                    WHEN OrdDate IS NOT NULL AND @OrdDate IS NOT NULL AND DueDate IS NOT NULL THEN
+                        DATE_ADD(DATE(DueDate), INTERVAL DATEDIFF(DATE(@OrdDate), DATE(OrdDate)) DAY)
+                    ELSE DueDate
+                END,
                 LotSerial   = @LotSerial,
                 ReleaseDate = @ReleaseDate,
                 UpdateUser  = @UpdateUser,
                 UpdateTime  = @UpdateTime
-            WHERE WorkOrd = @WorkOrd AND Domain = @Domain
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
             """,
             updatePars);
 
+        if (affected == 0)
+            throw Oops.Oh("工单下达失败:未更新到对应工单,请确认工单号、租户及状态仍为初始(p)");
+
         return new { message = "工单下达成功" };
     }