Quellcode durchsuchen

feat(aidop): add process outsource order workflow

Introduce process outsource order frontend/backend flow and menu seed entries.
Bump Web to 2.4.116 and server to 1.0.83 for this delivery.

Made-with: Cursor
Pengxy vor 1 Monat
Ursprung
Commit
ecab5242ae

+ 1 - 1
Web/package.json

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

+ 141 - 0
Web/src/views/aidop/s3/api/processOutsourceOrder.ts

@@ -0,0 +1,141 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface OptionRow {
+	value: string;
+	label: string;
+}
+
+export interface ProcessOutsourceOrderRow {
+	id: number;
+	purOrd?: string | null;
+	workOrd?: string | null;
+	suppName?: string | null;
+	buyer?: string | null;
+	itemNum?: string | null;
+	itemName?: string | null;
+	op?: string | null;
+	ordDate?: string | null;
+	qtyOrded?: number | null;
+	cumQtyBO?: number | null;
+	dueDate?: string | null;
+	flDate?: string | null;
+	status?: string | null;
+	supp?: string | null;
+	reqBy?: string | null;
+	department?: string | null;
+	usage?: string | null;
+	curr?: string | null;
+	remark?: string | null;
+	buyerCode?: string | null;
+}
+
+export interface ProcessOutsourceDetailRow {
+	id?: number | null;
+	line?: number | null;
+	itemNum?: string | null;
+	itemName?: string | null;
+	op?: number | null;
+	um?: string | null;
+	location?: string | null;
+	qtyOrded?: number | null;
+	qtyReceived?: number | null;
+	receiptQty?: number | null;
+	dueDate?: string | null;
+	lotSerial?: string | null;
+	potype?: string | null;
+	purOrd?: string | null;
+	purOrdRecID?: number | null;
+}
+
+export interface ProcessOutsourceMaster {
+	id: number;
+	purOrd?: string | null;
+	ordDate?: string | null;
+	supp?: string | null;
+	reqBy?: string | null;
+	workOrd?: string | null;
+	buyer?: string | null;
+	department?: string | null;
+	usage?: string | null;
+	curr?: string | null;
+	remark?: string | null;
+	status?: string | null;
+}
+
+export interface ProcessOutsourceSaveInput {
+	id: number;
+	purOrd?: string | null;
+	ordDate?: string | null;
+	supp?: string | null;
+	reqBy?: string | null;
+	workOrd?: string | null;
+	buyer?: string | null;
+	department?: string | null;
+	usage?: string | null;
+	curr?: string | null;
+	remark?: string | null;
+	details: ProcessOutsourceDetailRow[];
+}
+
+export interface UniversalWorkOrderRow {
+	id: number;
+	workOrd?: string | null;
+	itemNum?: string | null;
+	descr?: string | null;
+	ordDate?: string | null;
+}
+
+export function fetchProcessOutsourceOrderList(params: any) {
+	return service.get<Paged<ProcessOutsourceOrderRow>>('/api/Supply/process-outsource-order/list', { params }).then((r) => r.data);
+}
+
+export function fetchProcessOutsourceOrderDetail(id: number) {
+	return service.get<{ master: ProcessOutsourceMaster; details: ProcessOutsourceDetailRow[] }>(`/api/Supply/process-outsource-order/${id}`).then((r) => r.data);
+}
+
+export function createProcessOutsourceOrderByWorkOrd(workOrd: string) {
+	return service.post<{ id: number; purOrd: string; message: string }>('/api/Supply/process-outsource-order/create-by-workord', { workOrd }).then((r) => r.data);
+}
+
+export function saveProcessOutsourceOrder(body: ProcessOutsourceSaveInput) {
+	return service.post('/api/Supply/process-outsource-order/save', body).then((r) => r.data);
+}
+
+export function deleteProcessOutsourceOrder(id: number, purOrd: string) {
+	return service.post(`/api/Supply/process-outsource-order/delete/${id}`, null, { params: { purOrd } }).then((r) => r.data);
+}
+
+export function fetchProcessOutsourceBuyers() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/process-outsource-order/options/buyers').then((r) => r.data);
+}
+
+export function fetchProcessOutsourceSuppliers() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/process-outsource-order/options/suppliers').then((r) => r.data);
+}
+
+export function fetchProcessOutsourceDepartments() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/process-outsource-order/options/departments').then((r) => r.data);
+}
+
+export function fetchProcessOutsourceCurrencies() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/process-outsource-order/options/currencies').then((r) => r.data);
+}
+
+export function fetchProcessOutsourceLocations() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/process-outsource-order/options/locations').then((r) => r.data);
+}
+
+export function fetchProcessOutsourceOps(purOrdRecId: number) {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/process-outsource-order/options/ops', { params: { purOrdRecId } }).then((r) => r.data);
+}
+
+export function fetchUniversalWorkOrderPage(params: any) {
+	return service.get<Paged<UniversalWorkOrderRow>>('/api/Universal/work-order/page', { params }).then((r) => r.data);
+}

+ 330 - 0
Web/src/views/aidop/s3/supply/processOutsourceOrderForm.vue

@@ -0,0 +1,330 @@
+<template>
+	<div v-loading="loading">
+		<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
+			<template v-if="isCreate">
+				<el-row :gutter="12">
+					<el-col :span="24">
+						<el-form-item label="工单号" prop="workOrd">
+							<div class="pick-wrap">
+								<el-input v-model="form.workOrd" readonly placeholder="请选择工单" />
+								<el-button @click="openWorkOrderDialog">选择</el-button>
+							</div>
+						</el-form-item>
+					</el-col>
+				</el-row>
+			</template>
+
+			<template v-else>
+				<el-row :gutter="12">
+					<el-col :span="12"><el-form-item label="采购单号"><el-input v-model="form.purOrd" disabled readonly /></el-form-item></el-col>
+					<el-col :span="12">
+						<el-form-item label="订单日期" prop="ordDate">
+							<el-date-picker v-model="form.ordDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" :disabled="isView" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="供应商" prop="supp">
+							<el-select v-model="form.supp" filterable :disabled="isView" style="width: 100%">
+								<el-option v-for="o in supplierOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="订单类型">
+							<el-select v-model="form.reqBy" style="width: 100%" :disabled="isView">
+								<el-option label="外协采购订单" value="PO" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="工单号" prop="workOrd">
+							<div class="pick-wrap">
+								<el-input v-model="form.workOrd" readonly :disabled="isView" />
+								<el-button :disabled="isView" @click="openWorkOrderDialog">选择</el-button>
+							</div>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12"><el-form-item label="采购组"><el-input v-model="form.buyer" disabled readonly /></el-form-item></el-col>
+					<el-col :span="12">
+						<el-form-item label="部门" prop="department">
+							<el-select v-model="form.department" filterable :disabled="isView" style="width: 100%">
+								<el-option v-for="o in departmentOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="供应类别">
+							<el-select v-model="form.usage" style="width: 100%" :disabled="isView">
+								<el-option label="外协" value="外协" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="币别">
+							<el-select v-model="form.curr" filterable :disabled="isView" style="width: 100%">
+								<el-option v-for="o in currencyOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" :disabled="isView" /></el-form-item></el-col>
+				</el-row>
+
+				<el-divider content-position="left">明细</el-divider>
+				<div class="sub-toolbar" v-if="!isView">
+					<el-button type="primary" @click="addDetail">添加</el-button>
+				</div>
+				<el-table :data="form.details" border stripe>
+					<el-table-column label="行号" width="80" align="center">
+						<template #default="{ row, $index }">{{ row.line || $index + 1 }}</template>
+					</el-table-column>
+					<el-table-column label="物料编号" min-width="160">
+						<template #default="{ row }">
+							<div class="pick-wrap">
+								<el-input v-model="row.itemNum" readonly :disabled="isView" />
+								<el-button :disabled="isView" @click="openItemDialog(row)">选择</el-button>
+							</div>
+						</template>
+					</el-table-column>
+					<el-table-column label="工序" width="160">
+						<template #default="{ row }">
+							<el-select v-model="row.op" filterable :disabled="isView" style="width: 100%">
+								<el-option v-for="o in opOptions" :key="o.value" :label="o.label" :value="Number(o.value)" />
+							</el-select>
+						</template>
+					</el-table-column>
+					<el-table-column label="单位" width="100"><template #default="{ row }"><el-input v-model="row.um" :disabled="isView" /></template></el-table-column>
+					<el-table-column label="收货库位" width="160">
+						<template #default="{ row }">
+							<el-select v-model="row.location" filterable :disabled="isView" style="width: 100%">
+								<el-option v-for="o in locationOptions" :key="o.value" :label="o.label" :value="o.value" />
+							</el-select>
+						</template>
+					</el-table-column>
+					<el-table-column label="订单数量" width="130"><template #default="{ row }"><el-input-number v-model="row.qtyOrded" :disabled="isView" :min="0" :controls="false" style="width: 100%" /></template></el-table-column>
+					<el-table-column label="收货数量" width="130"><template #default="{ row }"><el-input-number v-model="row.qtyReceived" :disabled="isView" :min="0" :controls="false" style="width: 100%" /></template></el-table-column>
+					<el-table-column label="暂收数量" width="130"><template #default="{ row }"><el-input-number v-model="row.receiptQty" :disabled="isView" :min="0" :controls="false" style="width: 100%" /></template></el-table-column>
+					<el-table-column label="交货日期" width="140">
+						<template #default="{ row }">
+							<el-date-picker v-model="row.dueDate" type="date" value-format="YYYY-MM-DD" :disabled="isView" style="width: 100%" />
+						</template>
+					</el-table-column>
+					<el-table-column label="生产批号" width="130"><template #default="{ row }"><el-input v-model="row.lotSerial" :disabled="isView" /></template></el-table-column>
+					<el-table-column v-if="!isView" label="操作" width="90" fixed="right" align="center">
+						<template #default="{ $index }"><el-button type="danger" link @click="removeDetail($index)">删除</el-button></template>
+					</el-table-column>
+				</el-table>
+			</template>
+		</el-form>
+
+		<div class="footer">
+			<el-button @click="$emit('cancel')">取消</el-button>
+			<el-button v-if="!isView" type="primary" :loading="saving" @click="onSave">保存</el-button>
+		</div>
+	</div>
+
+	<el-dialog v-model="workOrderVisible" title="选择工单" width="75%" append-to-body destroy-on-close>
+		<select-process-work-order @picked="onPickedWorkOrder" />
+	</el-dialog>
+
+	<el-dialog v-model="itemVisible" title="选择物料" width="75%" append-to-body destroy-on-close>
+		<select-item-master @picked="onPickedItem" />
+	</el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
+import SelectItemMaster from './selectItemMaster.vue';
+import SelectProcessWorkOrder from './selectProcessWorkOrder.vue';
+import {
+	createProcessOutsourceOrderByWorkOrd,
+	fetchProcessOutsourceCurrencies,
+	fetchProcessOutsourceDepartments,
+	fetchProcessOutsourceOps,
+	fetchProcessOutsourceOrderDetail,
+	fetchProcessOutsourceLocations,
+	fetchProcessOutsourceSuppliers,
+	saveProcessOutsourceOrder,
+	type OptionRow,
+	type ProcessOutsourceDetailRow,
+	type UniversalWorkOrderRow,
+} from '../api/processOutsourceOrder';
+import { type ItemRow } from '../api/outsourceOrder';
+
+const props = defineProps<{ mode: 'create' | 'edit' | 'view'; id: number | null }>();
+const emit = defineEmits<{ (e: 'saved'): void; (e: 'cancel'): void }>();
+
+const isCreate = computed(() => props.mode === 'create');
+const isView = computed(() => props.mode === 'view');
+const loading = ref(false);
+const saving = ref(false);
+const formRef = ref<FormInstance>();
+
+const supplierOptions = ref<OptionRow[]>([]);
+const departmentOptions = ref<OptionRow[]>([]);
+const currencyOptions = ref<OptionRow[]>([]);
+const locationOptions = ref<OptionRow[]>([]);
+const opOptions = ref<OptionRow[]>([]);
+
+const form = reactive({
+	id: 0,
+	purOrd: '',
+	ordDate: '',
+	supp: '',
+	reqBy: 'PO',
+	workOrd: '',
+	buyer: '110',
+	department: '',
+	usage: '外协',
+	curr: '',
+	remark: '',
+	details: [] as ProcessOutsourceDetailRow[],
+});
+
+const rules = computed<FormRules>(() => ({
+	workOrd: [{ required: true, message: '请选择工单', trigger: 'change' }],
+	ordDate: isCreate.value ? [] : [{ required: true, message: '请选择订单日期', trigger: 'change' }],
+	supp: isCreate.value ? [] : [{ required: true, message: '请选择供应商', trigger: 'change' }],
+	department: isCreate.value ? [] : [{ required: true, message: '请选择部门', trigger: 'change' }],
+}));
+
+const workOrderVisible = ref(false);
+function openWorkOrderDialog() {
+	workOrderVisible.value = true;
+}
+function onPickedWorkOrder(row: UniversalWorkOrderRow) {
+	form.workOrd = row.workOrd || '';
+	workOrderVisible.value = false;
+}
+
+const itemVisible = ref(false);
+let itemTargetRow: ProcessOutsourceDetailRow | null = null;
+function openItemDialog(row: ProcessOutsourceDetailRow) {
+	itemTargetRow = row;
+	itemVisible.value = true;
+}
+function onPickedItem(row: ItemRow) {
+	if (!itemTargetRow) return;
+	itemTargetRow.itemNum = row.itemNum || '';
+	itemTargetRow.um = row.um || '';
+	itemTargetRow.location = row.location || '';
+	itemVisible.value = false;
+}
+
+function addDetail() {
+	form.details.push({
+		line: form.details.length + 1,
+		itemNum: '',
+		op: undefined,
+		um: '',
+		location: '',
+		qtyOrded: 0,
+		qtyReceived: 0,
+		receiptQty: 0,
+		dueDate: '',
+		lotSerial: '',
+		potype: 'PW',
+		purOrd: form.purOrd,
+		purOrdRecID: form.id || undefined,
+	});
+}
+
+function removeDetail(index: number) {
+	form.details.splice(index, 1);
+}
+
+async function loadOptions() {
+	const [sup, dep, curr, loc] = await Promise.all([
+		fetchProcessOutsourceSuppliers(),
+		fetchProcessOutsourceDepartments(),
+		fetchProcessOutsourceCurrencies(),
+		fetchProcessOutsourceLocations(),
+	]);
+	supplierOptions.value = sup.list || [];
+	departmentOptions.value = dep.list || [];
+	currencyOptions.value = curr.list || [];
+	locationOptions.value = loc.list || [];
+}
+
+async function loadDetail() {
+	if (!props.id || isCreate.value) return;
+	loading.value = true;
+	try {
+		const data = await fetchProcessOutsourceOrderDetail(props.id);
+		const m = data.master;
+		form.id = m.id;
+		form.purOrd = m.purOrd || '';
+		form.ordDate = m.ordDate ? String(m.ordDate).slice(0, 10) : '';
+		form.supp = m.supp || '';
+		form.reqBy = m.reqBy || 'PO';
+		form.workOrd = m.workOrd || '';
+		form.buyer = m.buyer || '110';
+		form.department = m.department || '';
+		form.usage = m.usage || '外协';
+		form.curr = m.curr || '';
+		form.remark = m.remark || '';
+		form.details = (data.details || []).map((x, i) => ({ ...x, line: x.line ?? i + 1 }));
+
+		const opRet = await fetchProcessOutsourceOps(form.id);
+		opOptions.value = opRet.list || [];
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function onSave() {
+	await formRef.value?.validate();
+	saving.value = true;
+	try {
+		if (isCreate.value) {
+			await createProcessOutsourceOrderByWorkOrd(form.workOrd);
+		} else {
+			await saveProcessOutsourceOrder({
+				id: form.id,
+				purOrd: form.purOrd,
+				ordDate: form.ordDate || undefined,
+				supp: form.supp || undefined,
+				reqBy: form.reqBy || undefined,
+				workOrd: form.workOrd || undefined,
+				buyer: form.buyer || undefined,
+				department: form.department || undefined,
+				usage: form.usage || undefined,
+				curr: form.curr || undefined,
+				remark: form.remark || undefined,
+				details: form.details.map((x, i) => ({
+					...x,
+					line: x.line || i + 1,
+					potype: 'PW',
+					purOrd: form.purOrd,
+					purOrdRecID: form.id,
+				})),
+			});
+		}
+		ElMessage.success('保存成功');
+		emit('saved');
+	} finally {
+		saving.value = false;
+	}
+}
+
+onMounted(async () => {
+	await loadOptions();
+	await loadDetail();
+});
+</script>
+
+<style scoped>
+.pick-wrap {
+	display: flex;
+	gap: 6px;
+}
+.sub-toolbar {
+	margin-bottom: 8px;
+}
+.footer {
+	display: flex;
+	justify-content: flex-end;
+	gap: 10px;
+	margin-top: 12px;
+}
+</style>

+ 203 - 0
Web/src/views/aidop/s3/supply/processOutsourceOrderList.vue

@@ -0,0 +1,203 @@
+<template>
+	<div class="aidop-page">
+		<aidop-demo-shell title="工序外协订单">
+			<template #query>
+				<el-form :inline="true" :model="query" @submit.prevent>
+					<el-form-item label="采购单号"><el-input v-model="query.purOrd" clearable style="width: 170px" /></el-form-item>
+					<el-form-item label="工单"><el-input v-model="query.workOrd" clearable style="width: 170px" /></el-form-item>
+					<el-form-item label="采购组">
+						<el-select v-model="query.buyer" clearable filterable style="width: 170px">
+							<el-option v-for="o in buyerOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+					<el-form-item label="供应商">
+						<el-select v-model="query.supp" clearable filterable style="width: 190px">
+							<el-option v-for="o in supplierOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+					<el-form-item label="状态">
+						<el-select v-model="query.status" clearable style="width: 140px">
+							<el-option label="新增" value="R" />
+							<el-option label="审核中" value="A" />
+							<el-option label="同意" value="B" />
+							<el-option label="关闭" value="C" />
+						</el-select>
+					</el-form-item>
+					<el-form-item>
+						<el-button type="primary" @click="doSearch">查询</el-button>
+						<el-button @click="doReset">重置</el-button>
+					</el-form-item>
+				</el-form>
+			</template>
+			<template #actions>
+				<el-button type="primary" @click="openForm('create', null)">添加</el-button>
+			</template>
+			<template #default>
+				<el-table :data="rows" v-loading="loading" border stripe @sort-change="onSortChange">
+					<el-table-column prop="workOrd" label="工单" min-width="150" fixed="left" sortable="custom" />
+					<el-table-column prop="status" label="状态" width="100" fixed="left" sortable="custom">
+						<template #default="{ row }">{{ statusText(row.status) }}</template>
+					</el-table-column>
+					<el-table-column prop="purOrd" label="采购单号" width="160" sortable="custom" />
+					<el-table-column prop="suppName" label="供应商" min-width="190" sortable="custom" />
+					<el-table-column prop="buyer" label="采购组" width="160" sortable="custom" />
+					<el-table-column prop="itemNum" label="物料编号" width="140" sortable="custom" />
+					<el-table-column prop="itemName" label="物料名称" min-width="160" sortable="custom" show-overflow-tooltip />
+					<el-table-column prop="op" label="工序" width="130" sortable="custom" />
+					<el-table-column prop="ordDate" label="订单日期" width="120" sortable="custom">
+						<template #default="{ row }">{{ fmtDate(row.ordDate) }}</template>
+					</el-table-column>
+					<el-table-column prop="qtyOrded" label="订单数量" width="120" align="right" sortable="custom" />
+					<el-table-column prop="cumQtyBO" label="发料数" width="120" align="right" sortable="custom" />
+					<el-table-column prop="dueDate" label="交付日期" width="120" sortable="custom">
+						<template #default="{ row }">{{ fmtDate(row.dueDate) }}</template>
+					</el-table-column>
+					<el-table-column prop="flDate" label="发料日期" width="120" sortable="custom">
+						<template #default="{ row }">{{ fmtDate(row.flDate) }}</template>
+					</el-table-column>
+					<el-table-column label="操作" width="200" fixed="right">
+						<template #default="{ row }">
+							<el-button type="primary" link @click="openForm('edit', row.id)">编辑</el-button>
+							<el-button type="danger" link @click="onDelete(row)">删除</el-button>
+							<el-button type="primary" link @click="openForm('view', row.id)">查看</el-button>
+						</template>
+					</el-table-column>
+				</el-table>
+				<div class="pager">
+					<el-pagination
+						v-model:current-page="query.page"
+						v-model:page-size="query.pageSize"
+						:total="total"
+						:page-sizes="[10, 20, 50]"
+						layout="total, sizes, prev, pager, next"
+						@current-change="loadList"
+						@size-change="loadList"
+					/>
+				</div>
+			</template>
+		</aidop-demo-shell>
+
+		<el-dialog v-model="formVisible" :title="formTitle" width="88%" destroy-on-close append-to-body>
+			<process-outsource-order-form :mode="formMode" :id="editingId" @saved="onSaved" @cancel="formVisible = false" />
+		</el-dialog>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import ProcessOutsourceOrderForm from './processOutsourceOrderForm.vue';
+import {
+	deleteProcessOutsourceOrder,
+	fetchProcessOutsourceBuyers,
+	fetchProcessOutsourceOrderList,
+	fetchProcessOutsourceSuppliers,
+	type OptionRow,
+	type ProcessOutsourceOrderRow,
+} from '../api/processOutsourceOrder';
+
+const query = reactive({
+	page: 1,
+	pageSize: 10,
+	purOrd: '',
+	workOrd: '',
+	buyer: '',
+	supp: '',
+	status: '',
+	sortField: '',
+	sortOrder: '',
+});
+
+const rows = ref<ProcessOutsourceOrderRow[]>([]);
+const total = ref(0);
+const loading = ref(false);
+const buyerOptions = ref<OptionRow[]>([]);
+const supplierOptions = ref<OptionRow[]>([]);
+
+const formVisible = ref(false);
+const formMode = ref<'create' | 'edit' | 'view'>('create');
+const editingId = ref<number | null>(null);
+const formTitle = computed(() => (formMode.value === 'create' ? '新增工序外协订单' : formMode.value === 'edit' ? '编辑工序外协订单' : '查看工序外协订单'));
+
+function fmtDate(v?: string | null) {
+	return v ? String(v).slice(0, 10) : '';
+}
+
+function statusText(v?: string | null) {
+	if (v === 'A') return '审核中';
+	if (v === 'B') return '同意';
+	if (v === 'C') return '关闭';
+	return '新增';
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchProcessOutsourceOrderList({ ...query });
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function doReset() {
+	query.purOrd = '';
+	query.workOrd = '';
+	query.buyer = '';
+	query.supp = '';
+	query.status = '';
+	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+function openForm(mode: 'create' | 'edit' | 'view', id: number | null) {
+	formMode.value = mode;
+	editingId.value = id;
+	formVisible.value = true;
+}
+
+async function onDelete(row: ProcessOutsourceOrderRow) {
+	await ElMessageBox.confirm('确认删除该工序外协订单?', '提示', { type: 'warning' });
+	await deleteProcessOutsourceOrder(row.id, row.purOrd || '');
+	ElMessage.success('删除成功');
+	loadList();
+}
+
+function onSaved() {
+	formVisible.value = false;
+	loadList();
+}
+
+onMounted(async () => {
+	const [buyers, suppliers] = await Promise.all([fetchProcessOutsourceBuyers(), fetchProcessOutsourceSuppliers()]);
+	buyerOptions.value = buyers.list || [];
+	supplierOptions.value = suppliers.list || [];
+	await loadList();
+});
+</script>
+
+<style scoped>
+.aidop-page {
+	padding: 8px;
+}
+.pager {
+	margin-top: 10px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 109 - 0
Web/src/views/aidop/s3/supply/selectProcessWorkOrder.vue

@@ -0,0 +1,109 @@
+<template>
+	<div>
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="工单号">
+				<el-input v-model="query.workOrd" clearable style="width: 180px" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="reset">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table :data="rows" v-loading="loading" border stripe height="420" @sort-change="onSortChange" @row-dblclick="onDblClick">
+			<el-table-column prop="workOrd" label="工单号" width="180" sortable="custom" />
+			<el-table-column prop="itemNum" label="物料编号" width="140" sortable="custom" />
+			<el-table-column prop="descr" label="物料名称" min-width="180" sortable="custom" show-overflow-tooltip />
+			<el-table-column prop="ordDate" label="开工日期" width="140" sortable="custom">
+				<template #default="{ row }">{{ fmtDate(row.ordDate) }}</template>
+			</el-table-column>
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[10, 20, 50]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue';
+import { fetchUniversalWorkOrderPage, type UniversalWorkOrderRow } from '../api/processOutsourceOrder';
+
+const emit = defineEmits<{ (e: 'picked', row: UniversalWorkOrderRow): void }>();
+
+const query = reactive({
+	workOrd: '',
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+const loading = ref(false);
+const rows = ref<UniversalWorkOrderRow[]>([]);
+const total = ref(0);
+
+function fmtDate(v?: string | null) {
+	return v ? String(v).slice(0, 10) : '';
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchUniversalWorkOrderPage({
+			workOrd: query.workOrd || undefined,
+			page: query.page,
+			pageSize: query.pageSize,
+			sortField: query.sortField || undefined,
+			sortOrder: query.sortOrder || undefined,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function reset() {
+	query.workOrd = '';
+	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+function onDblClick(row: UniversalWorkOrderRow) {
+	emit('picked', row);
+}
+
+onMounted(loadList);
+</script>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 10px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

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

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

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

@@ -1291,6 +1291,21 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             Remark = "S3 委外加工订单(PurOrdMaster/PW)"
         };
 
+        yield return new SysMenu
+        {
+            Id = 1329003100014L,
+            Pid = s3ProcurementDirId,
+            Title = "工序外协订单",
+            Path = "/aidop/s3/procurement/process-outsource-order",
+            Name = "aidopS3ProcessOutsourceOrder",
+            Component = "/aidop/s3/supply/processOutsourceOrderList",
+            Icon = "ele-List",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 40,
+            Remark = "S3 工序外协订单(PurOrdMaster/PW)"
+        };
+
         yield return new SysMenu
         {
             Id = 1329003100010L,

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Dto/ProcessOutsourceOrderDto.cs

@@ -0,0 +1,51 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+public class ProcessOutsourceOrderListInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+    public string? PurOrd { get; set; }
+    public string? WorkOrd { get; set; }
+    public string? Buyer { get; set; }
+    public string? Supp { get; set; }
+    public string? Status { get; set; }
+    public string? SortField { get; set; }
+    public string? SortOrder { get; set; }
+}
+
+public class ProcessOutsourceOrderCreateInput
+{
+    public string WorkOrd { get; set; } = string.Empty;
+}
+
+public class ProcessOutsourceOrderSaveInput
+{
+    public int Id { get; set; }
+    public string? PurOrd { get; set; }
+    public string? OrdDate { get; set; }
+    public string? Supp { get; set; }
+    public string? ReqBy { get; set; }
+    public string? WorkOrd { get; set; }
+    public string? Buyer { get; set; }
+    public string? Department { get; set; }
+    public string? Usage { get; set; }
+    public string? Curr { get; set; }
+    public string? Remark { get; set; }
+    public List<ProcessOutsourceOrderDetailInput> Details { get; set; } = new();
+}
+
+public class ProcessOutsourceOrderDetailInput
+{
+    public int? Id { get; set; }
+    public int? Line { get; set; }
+    public string? ItemNum { get; set; }
+    public int? Op { get; set; }
+    public string? UM { get; set; }
+    public string? Location { get; set; }
+    public decimal? QtyOrded { get; set; }
+    public decimal? QtyReceived { get; set; }
+    public decimal? ReceiptQty { get; set; }
+    public string? DueDate { get; set; }
+    public string? LotSerial { get; set; }
+    public string? Potype { get; set; }
+}

+ 558 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/ProcessOutsourceOrderService.cs

@@ -0,0 +1,558 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 工序外协订单服务
+/// </summary>
+[ApiDescriptionSettings(Order = 311, Description = "工序外协订单")]
+[Route("api/Supply")]
+[AllowAnonymous]
+[NonUnify]
+public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public ProcessOutsourceOrderService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    [DisplayName("工序外协订单列表")]
+    [HttpGet("process-outsource-order/list")]
+    public async Task<object> GetList([FromQuery] ProcessOutsourceOrderListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var where = new List<string> { "p.Potype='PW'", "p.IsActive<=1" };
+        var pars = new List<SugarParameter>();
+        if (!string.IsNullOrWhiteSpace(input.PurOrd))
+        {
+            where.Add("p.PurOrd LIKE @PurOrd");
+            pars.Add(new SugarParameter("@PurOrd", $"%{input.PurOrd.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.WorkOrd))
+        {
+            where.Add("p.WorkOrd LIKE @WorkOrd");
+            pars.Add(new SugarParameter("@WorkOrd", $"%{input.WorkOrd.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Buyer))
+        {
+            where.Add("p.Buyer=@Buyer");
+            pars.Add(new SugarParameter("@Buyer", input.Buyer.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Supp))
+        {
+            where.Add("p.Supp=@Supp");
+            pars.Add(new SugarParameter("@Supp", input.Supp.Trim()));
+        }
+
+        var baseSql = $"""
+            SELECT
+                p.RecID AS Id,
+                p.PurOrd AS PurOrd,
+                p.WorkOrd AS WorkOrd,
+                p.ERPWorkOrd AS ERPWorkOrd,
+                CONCAT(TRIM(IFNULL(p.Supp,'')),' ',TRIM(IFNULL(s.SortName,''))) AS SuppName,
+                p.Buyer AS BuyerCode,
+                CONCAT(TRIM(IFNULL(p.Buyer,'')),' ',TRIM(IFNULL(e.Name,''))) AS Buyer,
+                f.ItemNum AS ItemNum,
+                i.Descr AS ItemName,
+                CONCAT(TRIM(CAST(IFNULL(r.Op,0) AS CHAR(20))),' ',TRIM(IFNULL(r.Descr,''))) AS Op,
+                p.OrdDate AS OrdDate,
+                f.QtyOrded AS QtyOrded,
+                f.CumQtyBO AS CumQtyBO,
+                f.DueDate AS DueDate,
+                n.`Date` AS FlDate,
+                CASE
+                    WHEN IFNULL(LENGTH(p.Status),0)=0 OR p.Buyer IS NULL THEN 'R'
+                    ELSE p.Status
+                END AS Status,
+                p.Supp AS Supp,
+                p.ReqBy AS ReqBy,
+                p.Department AS Department,
+                p.`Usage` AS `Usage`,
+                p.Curr AS Curr,
+                p.Remark AS Remark
+            FROM PurOrdMaster p
+            LEFT JOIN SuppMaster s ON p.Domain=s.Domain AND p.Supp=s.Supp
+            LEFT JOIN DepartmentMaster d ON p.Domain=d.Domain AND p.Department=d.Department
+            LEFT JOIN EmployeeMaster e ON p.Domain=e.Domain AND p.Buyer=e.Employee
+            LEFT JOIN PurOrdDetail f ON p.PurOrd=f.PurOrd
+            LEFT JOIN RoutingOpDetail r ON f.Op=r.Op AND f.ItemNum=r.RoutingCode
+            LEFT JOIN ItemMaster i ON f.ItemNum=i.ItemNum
+            LEFT JOIN PurOrdRctMaster o ON o.OrdNbr=p.PurOrd
+            LEFT JOIN NbrMaster n ON p.WorkOrd=n.WorkOrd AND n.Type='CA'
+            WHERE {string.Join(" AND ", where)}
+            """;
+
+        var outerWhere = string.Empty;
+        if (!string.IsNullOrWhiteSpace(input.Status))
+        {
+            outerWhere = "WHERE t.Status=@Status";
+            pars.Add(new SugarParameter("@Status", input.Status.Trim()));
+        }
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseSql}) t {outerWhere}", pars);
+        var list = await _db.Ado.SqlQueryAsync<ProcessOutsourceOrderListRow>(
+            $"SELECT * FROM ({baseSql}) t {outerWhere} ORDER BY {BuildOrderBy(input.SortField, input.SortOrder)} LIMIT {pageSize} OFFSET {offset}",
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("工序外协订单详情")]
+    [HttpGet("process-outsource-order/{id:int}")]
+    public async Task<object> GetDetail(int id)
+    {
+        var master = (await _db.Ado.SqlQueryAsync<ProcessOutsourceOrderMasterRow>(
+            """
+            SELECT
+                p.RecID AS Id,p.PurOrd AS PurOrd,p.OrdDate AS OrdDate,p.Supp AS Supp,p.ReqBy AS ReqBy,p.WorkOrd AS WorkOrd,
+                p.Buyer AS Buyer,p.Department AS Department,p.`Usage` AS `Usage`,p.Curr AS Curr,p.Remark AS Remark,
+                CASE WHEN IFNULL(LENGTH(p.Status),0)=0 OR p.Buyer IS NULL THEN 'R' ELSE p.Status END AS Status
+            FROM PurOrdMaster p
+            WHERE p.RecID=@Id
+            LIMIT 1
+            """,
+            new SugarParameter("@Id", id))).FirstOrDefault();
+        if (master == null) throw Oops.Oh("记录不存在");
+
+        var details = await _db.Ado.SqlQueryAsync<ProcessOutsourceOrderDetailRow>(
+            """
+            SELECT
+                d.RecID AS Id,
+                d.Line AS Line,
+                d.ItemNum AS ItemNum,
+                IFNULL(i.Descr,'') AS ItemName,
+                d.Op AS Op,
+                d.UM AS UM,
+                d.Location AS Location,
+                d.QtyOrded AS QtyOrded,
+                d.QtyReceived AS QtyReceived,
+                d.ReceiptQty AS ReceiptQty,
+                d.DueDate AS DueDate,
+                d.LotSerial AS LotSerial,
+                d.Potype AS Potype,
+                d.PurOrd AS PurOrd,
+                d.PurOrdRecID AS PurOrdRecID
+            FROM PurOrdDetail d
+            LEFT JOIN ItemMaster i ON d.ItemNum=i.ItemNum
+            WHERE d.PurOrdRecID=@PurOrdRecID
+            ORDER BY d.Line,d.RecID
+            """,
+            new SugarParameter("@PurOrdRecID", id));
+
+        return new { master, details };
+    }
+
+    [DisplayName("按工单生成工序外协订单")]
+    [HttpPost("process-outsource-order/create-by-workord")]
+    public async Task<object> CreateByWorkOrd([FromBody] ProcessOutsourceOrderCreateInput input)
+    {
+        if (string.IsNullOrWhiteSpace(input.WorkOrd)) throw Oops.Oh("请选择工单");
+        var workOrd = input.WorkOrd.Trim();
+
+        await _db.Ado.ExecuteCommandAsync(
+            "CALL pr_MES_GeneratePW(@WorkOrd)",
+            new SugarParameter("@WorkOrd", workOrd));
+
+        var created = (await _db.Ado.SqlQueryAsync<IdPurOrdRow>(
+            """
+            SELECT RecID AS Id, PurOrd AS PurOrd
+            FROM PurOrdMaster
+            WHERE WorkOrd=@WorkOrd AND Potype='PW'
+            ORDER BY RecID DESC
+            LIMIT 1
+            """,
+            new SugarParameter("@WorkOrd", workOrd))).FirstOrDefault();
+        if (created == null) throw Oops.Oh("工序外协订单生成失败");
+
+        return new { id = created.Id, purOrd = created.PurOrd, message = "生成成功" };
+    }
+
+    [DisplayName("保存工序外协订单")]
+    [HttpPost("process-outsource-order/save")]
+    public async Task<object> Save([FromBody] ProcessOutsourceOrderSaveInput input)
+    {
+        if (input.Id <= 0) throw Oops.Oh("缺少主键");
+        if (string.IsNullOrWhiteSpace(input.PurOrd)) throw Oops.Oh("采购单号不能为空");
+        if (string.IsNullOrWhiteSpace(input.Supp)) throw Oops.Oh("供应商不能为空");
+        if (string.IsNullOrWhiteSpace(input.Department)) throw Oops.Oh("部门不能为空");
+
+        var exists = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM PurOrdMaster WHERE RecID=@Id", new SugarParameter("@Id", input.Id));
+        if (exists <= 0) throw Oops.Oh("记录不存在");
+
+        try
+        {
+            _db.Ado.BeginTran();
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE PurOrdMaster
+                SET OrdDate=@OrdDate,Supp=@Supp,ReqBy=@ReqBy,WorkOrd=@WorkOrd,Buyer=@Buyer,Department=@Department,`Usage`=@Usage,Curr=@Curr,Remark=@Remark,UpdateUser=@UpdateUser,UpdateTime=@UpdateTime
+                WHERE RecID=@Id
+                """,
+                new SugarParameter("@Id", input.Id),
+                new SugarParameter("@OrdDate", ParseDate(input.OrdDate)),
+                new SugarParameter("@Supp", input.Supp!.Trim()),
+                new SugarParameter("@ReqBy", string.IsNullOrWhiteSpace(input.ReqBy) ? "PO" : input.ReqBy.Trim()),
+                new SugarParameter("@WorkOrd", input.WorkOrd?.Trim()),
+                new SugarParameter("@Buyer", string.IsNullOrWhiteSpace(input.Buyer) ? "110" : input.Buyer.Trim()),
+                new SugarParameter("@Department", input.Department!.Trim()),
+                new SugarParameter("@Usage", string.IsNullOrWhiteSpace(input.Usage) ? "外协" : input.Usage.Trim()),
+                new SugarParameter("@Curr", input.Curr?.Trim()),
+                new SugarParameter("@Remark", input.Remark?.Trim()),
+                new SugarParameter("@UpdateUser", _userManager.Account),
+                new SugarParameter("@UpdateTime", DateTime.Now)
+            );
+
+            await SaveDetailsAsync(input.Id, input.PurOrd!.Trim(), input.WorkOrd, input.Details);
+
+            _db.Ado.CommitTran();
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+
+        return new { id = input.Id, message = "保存成功" };
+    }
+
+    /// <summary>
+    /// 明细三路合并:
+    /// ① DB有且入参有(按 Id 匹配)→ 更新
+    /// ② DB无但入参有              → 新增
+    /// ③ DB有但入参无              → 删除
+    /// </summary>
+    private async Task SaveDetailsAsync(int masterId, string purOrd, string? workOrd, List<ProcessOutsourceOrderDetailInput> details)
+    {
+        var dbDetails = await _db.Ado.SqlQueryAsync<DbDetailRow>(
+            "SELECT RecID AS Id FROM PurOrdDetail WHERE PurOrdRecID=@PurOrdRecID",
+            new SugarParameter("@PurOrdRecID", masterId));
+        var dbById = dbDetails.ToDictionary(x => x.Id);
+        var inputIds = new HashSet<int>(details.Where(x => x.Id.HasValue && x.Id.Value > 0).Select(x => x.Id!.Value));
+
+        var maxLine = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(Line),0) FROM PurOrdDetail WHERE PurOrdRecID=@PurOrdRecID", new SugarParameter("@PurOrdRecID", masterId));
+        var lineSeed = maxLine;
+
+        for (var i = 0; i < details.Count; i++)
+        {
+            var item = details[i];
+            if (string.IsNullOrWhiteSpace(item.ItemNum)) continue;
+
+            var line = item.Line ?? (i + 1);
+            if (line <= 0)
+            {
+                lineSeed++;
+                line = lineSeed;
+            }
+
+            var itemInfo = (await _db.Ado.SqlQueryAsync<ItemLookupRow>(
+                """
+                SELECT ItemNum,Descr,UM,Location
+                FROM ItemMaster
+                WHERE ItemNum=@ItemNum
+                LIMIT 1
+                """,
+                new SugarParameter("@ItemNum", item.ItemNum.Trim()))).FirstOrDefault();
+
+            if (item.Id.HasValue && item.Id.Value > 0 && dbById.ContainsKey(item.Id.Value))
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE PurOrdDetail
+                    SET Line=@Line,ItemNum=@ItemNum,Descr=@Descr,Op=@Op,UM=@UM,Location=@Location,QtyOrded=@QtyOrded,QtyReceived=@QtyReceived,ReceiptQty=@ReceiptQty,DueDate=@DueDate,LotSerial=@LotSerial,Potype='PW',PurOrd=@PurOrd,PurOrdRecID=@PurOrdRecID,WorkOrd=@WorkOrd,UpdateUser=@UpdateUser,UpdateTime=@UpdateTime
+                    WHERE RecID=@Id
+                    """,
+                    new SugarParameter("@Id", item.Id.Value),
+                    new SugarParameter("@Line", line),
+                    new SugarParameter("@ItemNum", item.ItemNum.Trim()),
+                    new SugarParameter("@Descr", itemInfo?.Descr),
+                    new SugarParameter("@Op", item.Op ?? 0),
+                    new SugarParameter("@UM", string.IsNullOrWhiteSpace(item.UM) ? itemInfo?.UM : item.UM!.Trim()),
+                    new SugarParameter("@Location", string.IsNullOrWhiteSpace(item.Location) ? itemInfo?.Location : item.Location!.Trim()),
+                    new SugarParameter("@QtyOrded", item.QtyOrded ?? 0),
+                    new SugarParameter("@QtyReceived", item.QtyReceived ?? 0),
+                    new SugarParameter("@ReceiptQty", item.ReceiptQty ?? 0),
+                    new SugarParameter("@DueDate", ParseDate(item.DueDate)),
+                    new SugarParameter("@LotSerial", item.LotSerial?.Trim()),
+                    new SugarParameter("@PurOrd", purOrd),
+                    new SugarParameter("@PurOrdRecID", masterId),
+                    new SugarParameter("@WorkOrd", workOrd?.Trim()),
+                    new SugarParameter("@UpdateUser", _userManager.Account),
+                    new SugarParameter("@UpdateTime", DateTime.Now)
+                );
+            }
+            else
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO PurOrdDetail
+                    (PurOrd,Line,ItemNum,Descr,Op,UM,Location,QtyOrded,QtyReceived,ReceiptQty,DueDate,LotSerial,Potype,PurOrdRecID,WorkOrd,Status,CreateUser,CreateTime,UpdateUser,UpdateTime,tenant_id)
+                    VALUES
+                    (@PurOrd,@Line,@ItemNum,@Descr,@Op,@UM,@Location,@QtyOrded,@QtyReceived,@ReceiptQty,@DueDate,@LotSerial,'PW',@PurOrdRecID,@WorkOrd,'R',@CreateUser,@CreateTime,@UpdateUser,@UpdateTime,@TenantId)
+                    """,
+                    new SugarParameter("@PurOrd", purOrd),
+                    new SugarParameter("@Line", line),
+                    new SugarParameter("@ItemNum", item.ItemNum.Trim()),
+                    new SugarParameter("@Descr", itemInfo?.Descr),
+                    new SugarParameter("@Op", item.Op ?? 0),
+                    new SugarParameter("@UM", string.IsNullOrWhiteSpace(item.UM) ? itemInfo?.UM : item.UM!.Trim()),
+                    new SugarParameter("@Location", string.IsNullOrWhiteSpace(item.Location) ? itemInfo?.Location : item.Location!.Trim()),
+                    new SugarParameter("@QtyOrded", item.QtyOrded ?? 0),
+                    new SugarParameter("@QtyReceived", item.QtyReceived ?? 0),
+                    new SugarParameter("@ReceiptQty", item.ReceiptQty ?? 0),
+                    new SugarParameter("@DueDate", ParseDate(item.DueDate)),
+                    new SugarParameter("@LotSerial", item.LotSerial?.Trim()),
+                    new SugarParameter("@PurOrdRecID", masterId),
+                    new SugarParameter("@WorkOrd", workOrd?.Trim()),
+                    new SugarParameter("@CreateUser", _userManager.Account),
+                    new SugarParameter("@CreateTime", DateTime.Now),
+                    new SugarParameter("@UpdateUser", _userManager.Account),
+                    new SugarParameter("@UpdateTime", DateTime.Now),
+                    new SugarParameter("@TenantId", _userManager.TenantId <= 0 ? null : _userManager.TenantId)
+                );
+            }
+        }
+
+        foreach (var toDelete in dbDetails.Where(x => !inputIds.Contains(x.Id)))
+        {
+            await _db.Ado.ExecuteCommandAsync("DELETE FROM PurOrdDetail WHERE RecID=@Id", new SugarParameter("@Id", toDelete.Id));
+        }
+    }
+
+    [DisplayName("删除工序外协订单")]
+    [HttpPost("process-outsource-order/delete/{id:int}")]
+    public async Task<object> Delete(int id, [FromQuery] string purOrd)
+    {
+        if (string.IsNullOrWhiteSpace(purOrd)) throw Oops.Oh("缺少采购单号");
+        var ordNo = purOrd.Trim();
+
+        var hasReceipt = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1)
+            FROM PurOrdRctDetail
+            WHERE OrdNbr=@PurOrd AND IFNULL(QtyReceived,0)>0
+            """,
+            new SugarParameter("@PurOrd", ordNo));
+        if (hasReceipt > 0) throw Oops.Oh("存在收货数量,不允许删除");
+
+        var hasShip = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM scm_shdzb WHERE po_bill=@PurOrd",
+            new SugarParameter("@PurOrd", ordNo));
+        if (hasShip > 0) throw Oops.Oh("存在发货单,不允许删除");
+
+        try
+        {
+            _db.Ado.BeginTran();
+            await _db.Ado.ExecuteCommandAsync("DELETE FROM PurOrdDetail WHERE PurOrd=@PurOrd", new SugarParameter("@PurOrd", ordNo));
+            await _db.Ado.ExecuteCommandAsync("DELETE FROM PurOrdMaster WHERE RecID=@Id AND PurOrd=@PurOrd", new SugarParameter("@Id", id), new SugarParameter("@PurOrd", ordNo));
+            _db.Ado.CommitTran();
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+
+        return new { message = "删除成功" };
+    }
+
+    [DisplayName("采购组下拉")]
+    [HttpGet("process-outsource-order/options/buyers")]
+    public async Task<object> GetBuyerOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Employee AS Value, CONCAT(TRIM(Employee),' ',TRIM(IFNULL(Name,''))) AS Label
+            FROM EmployeeMaster
+            ORDER BY Employee
+            """);
+        return new { list };
+    }
+
+    [DisplayName("供应商下拉")]
+    [HttpGet("process-outsource-order/options/suppliers")]
+    public async Task<object> GetSupplierOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Supp AS Value, CONCAT(TRIM(Supp),' ',TRIM(IFNULL(SortName,''))) AS Label
+            FROM SuppMaster
+            ORDER BY Supp
+            """);
+        return new { list };
+    }
+
+    [DisplayName("部门下拉")]
+    [HttpGet("process-outsource-order/options/departments")]
+    public async Task<object> GetDepartmentOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Department AS Value, CONCAT(TRIM(Department),' ',TRIM(IFNULL(Descr,''))) AS Label
+            FROM DepartmentMaster
+            ORDER BY Department
+            """);
+        return new { list };
+    }
+
+    [DisplayName("币别下拉")]
+    [HttpGet("process-outsource-order/options/currencies")]
+    public async Task<object> GetCurrencyOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Val AS Value, CONCAT(TRIM(Val),' ',TRIM(IFNULL(Comments,''))) AS Label
+            FROM GeneralizedCodeMaster
+            WHERE FldName='Curr'
+            ORDER BY Val
+            """);
+        return new { list };
+    }
+
+    [DisplayName("库位下拉")]
+    [HttpGet("process-outsource-order/options/locations")]
+    public async Task<object> GetLocationOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Location AS Value, CONCAT(TRIM(Location),' ',TRIM(IFNULL(Descr,''))) AS Label
+            FROM LocationMaster
+            WHERE IFNULL(Typed,'')<>'Supp'
+            ORDER BY Location
+            """);
+        return new { list };
+    }
+
+    [DisplayName("工序下拉")]
+    [HttpGet("process-outsource-order/options/ops")]
+    public async Task<object> GetOpOptions([FromQuery] int purOrdRecId)
+    {
+        if (purOrdRecId <= 0) return new { list = new List<OptionRow>() };
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT DISTINCT CAST(Op AS CHAR(50)) AS Value, CONCAT(TRIM(CAST(Op AS CHAR(50))),' ',TRIM(IFNULL(Descr,''))) AS Label
+            FROM RoutingOpDetail
+            WHERE UDeci5!=0
+              AND RoutingCode IN (SELECT ItemNum FROM PurOrdDetail WHERE PurOrdRecID=@PurOrdRecID)
+            ORDER BY Op
+            """,
+            new SugarParameter("@PurOrdRecID", purOrdRecId));
+        return new { list };
+    }
+
+    private static DateTime? ParseDate(string? v) => DateTime.TryParse(v, out var d) ? d : null;
+
+    private static string BuildOrderBy(string? sortField, string? sortOrder)
+    {
+        var dir = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return sortField?.ToLowerInvariant() switch
+        {
+            "purord" => $"t.PurOrd {dir}",
+            "workord" => $"t.WorkOrd {dir}",
+            "suppname" => $"t.SuppName {dir}",
+            "buyer" => $"t.Buyer {dir}",
+            "itemnum" => $"t.ItemNum {dir}",
+            "itemname" => $"t.ItemName {dir}",
+            "op" => $"t.Op {dir}",
+            "orddate" => $"t.OrdDate {dir}",
+            "qtyorded" => $"t.QtyOrded {dir}",
+            "cumqtybo" => $"t.CumQtyBO {dir}",
+            "duedate" => $"t.DueDate {dir}",
+            "fldate" => $"t.FlDate {dir}",
+            "status" => $"t.Status {dir}",
+            _ => "t.Id DESC"
+        };
+    }
+
+    private sealed class ProcessOutsourceOrderListRow
+    {
+        public int Id { get; set; }
+        public string? PurOrd { get; set; }
+        public string? WorkOrd { get; set; }
+        public string? ERPWorkOrd { get; set; }
+        public string? SuppName { get; set; }
+        public string? Buyer { get; set; }
+        public string? BuyerCode { get; set; }
+        public string? ItemNum { get; set; }
+        public string? ItemName { get; set; }
+        public string? Op { get; set; }
+        public DateTime? OrdDate { get; set; }
+        public decimal? QtyOrded { get; set; }
+        public decimal? CumQtyBO { get; set; }
+        public DateTime? DueDate { get; set; }
+        public DateTime? FlDate { get; set; }
+        public string? Status { get; set; }
+        public string? Supp { get; set; }
+        public string? ReqBy { get; set; }
+        public string? Department { get; set; }
+        public string? Usage { get; set; }
+        public string? Curr { get; set; }
+        public string? Remark { get; set; }
+    }
+
+    private sealed class ProcessOutsourceOrderMasterRow
+    {
+        public int Id { get; set; }
+        public string? PurOrd { get; set; }
+        public DateTime? OrdDate { get; set; }
+        public string? Supp { get; set; }
+        public string? ReqBy { get; set; }
+        public string? WorkOrd { get; set; }
+        public string? Buyer { get; set; }
+        public string? Department { get; set; }
+        public string? Usage { get; set; }
+        public string? Curr { get; set; }
+        public string? Remark { get; set; }
+        public string? Status { get; set; }
+    }
+
+    private sealed class ProcessOutsourceOrderDetailRow
+    {
+        public int Id { get; set; }
+        public int? Line { get; set; }
+        public string? ItemNum { get; set; }
+        public string? ItemName { get; set; }
+        public int? Op { get; set; }
+        public string? UM { get; set; }
+        public string? Location { get; set; }
+        public decimal? QtyOrded { get; set; }
+        public decimal? QtyReceived { get; set; }
+        public decimal? ReceiptQty { get; set; }
+        public DateTime? DueDate { get; set; }
+        public string? LotSerial { get; set; }
+        public string? Potype { get; set; }
+        public string? PurOrd { get; set; }
+        public int? PurOrdRecID { get; set; }
+    }
+
+    private sealed class OptionRow
+    {
+        public string? Value { get; set; }
+        public string? Label { get; set; }
+    }
+
+    private sealed class IdPurOrdRow
+    {
+        public int Id { get; set; }
+        public string? PurOrd { get; set; }
+    }
+
+    private sealed class DbDetailRow
+    {
+        public int Id { get; set; }
+    }
+
+    private sealed class ItemLookupRow
+    {
+        public string? ItemNum { get; set; }
+        public string? Descr { get; set; }
+        public string? UM { get; set; }
+        public string? Location { get; set; }
+    }
+}

+ 31 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/Dto/UniversalWorkOrderDto.cs

@@ -0,0 +1,31 @@
+namespace Admin.NET.Plugin.AiDOP.Universal;
+
+public class UniversalWorkOrderPageInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+    public string? WorkOrd { get; set; }
+    public string? SortField { get; set; }
+    public string? SortOrder { get; set; }
+}
+
+public class UniversalWorkOrderOutput
+{
+    public int Id { get; set; }
+    public string? Batch { get; set; }
+    public string? Drawing { get; set; }
+    public string? Typed { get; set; }
+    public string? WorkOrd { get; set; }
+    public DateTime? OrdDate { get; set; }
+    public DateTime? DueDate { get; set; }
+    public string? ItemNum { get; set; }
+    public string? Project { get; set; }
+    public decimal? QtyOrded { get; set; }
+    public decimal? QtyCompleted { get; set; }
+    public string? Remark { get; set; }
+    public string? Status { get; set; }
+    public string? WoTyped { get; set; }
+    public string? Descr { get; set; }
+    public string? Descr1 { get; set; }
+    public decimal? Lbrvar { get; set; }
+}

+ 87 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/UniversalWorkOrderService.cs

@@ -0,0 +1,87 @@
+namespace Admin.NET.Plugin.AiDOP.Universal;
+
+/// <summary>
+/// 通用工单选择服务
+/// </summary>
+[ApiDescriptionSettings(Order = 281, Description = "通用-工单选择")]
+[Route("api/Universal")]
+[AllowAnonymous]
+[NonUnify]
+public class UniversalWorkOrderService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public UniversalWorkOrderService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    [DisplayName("获取工单选择列表")]
+    [HttpGet("work-order/page")]
+    public async Task<object> GetPage([FromQuery] UniversalWorkOrderPageInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var where = new List<string>
+        {
+            "a.Status!='c'",
+            "a.Workord in (select Workord from WorkOrdRouting where ProcessOut=1 and WorkOrd not in (SELECT WorkOrd from PurOrdMaster where Potype='PW' and WorkOrd is not null))"
+        };
+        var pars = new List<SugarParameter>();
+        if (!string.IsNullOrWhiteSpace(input.WorkOrd))
+        {
+            where.Add("a.WorkOrd LIKE @WorkOrd");
+            pars.Add(new SugarParameter("@WorkOrd", $"%{input.WorkOrd.Trim()}%"));
+        }
+
+        var fromSql = $"""
+            FROM WorkOrdMaster a
+            LEFT JOIN ItemMaster b ON a.ItemNum=b.ItemNum
+            WHERE {string.Join(" AND ", where)}
+            """;
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<UniversalWorkOrderOutput>(
+            $"""
+            SELECT
+                a.recid AS Id,
+                a.Batch AS Batch,
+                a.Drawing AS Drawing,
+                a.Typed AS Typed,
+                a.WorkOrd AS WorkOrd,
+                a.OrdDate AS OrdDate,
+                a.DueDate AS DueDate,
+                a.ItemNum AS ItemNum,
+                a.Project AS Project,
+                a.QtyOrded AS QtyOrded,
+                a.QtyCompleted AS QtyCompleted,
+                a.Remark AS Remark,
+                LOWER(a.Status) AS Status,
+                a.WoTyped AS WoTyped,
+                b.Descr AS Descr,
+                b.Descr1 AS Descr1,
+                a.lbrvar AS Lbrvar
+            {fromSql}
+            ORDER BY {BuildOrderBy(input.SortField, input.SortOrder)}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    private static string BuildOrderBy(string? sortField, string? sortOrder)
+    {
+        var dir = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return sortField?.ToLowerInvariant() switch
+        {
+            "workord" => $"a.WorkOrd {dir}",
+            "itemnum" => $"a.ItemNum {dir}",
+            "descr" => $"b.Descr {dir}",
+            "orddate" => $"a.OrdDate {dir}",
+            _ => "a.RecID DESC"
+        };
+    }
+}