소스 검색

Merge branch 'master' of http://123.60.180.165:4647/ZZYDOP/AiDOPWarehouse

YY968XX 1 개월 전
부모
커밋
446c0675d4
21개의 변경된 파일3210개의 추가작업 그리고 9개의 파일을 삭제
  1. 1 1
      Web/package.json
  2. 164 0
      Web/src/views/aidop/s4/api/procurementExecution.ts
  3. 221 0
      Web/src/views/aidop/s4/execution-kanban/supplierShortageKanbanList.vue
  4. 194 0
      Web/src/views/aidop/s4/return/iqcReturnQueryList.vue
  5. 599 0
      Web/src/views/aidop/s4/return/purchaseReturnOrderForm.vue
  6. 220 0
      Web/src/views/aidop/s4/return/purchaseReturnOrderList.vue
  7. 1 1
      server/Admin.NET.Application/Configuration/Database.json
  8. 14 0
      server/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs
  9. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  10. 198 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMenuLinkSync.cs
  11. 50 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/IqcReturnQueryDto.cs
  12. 180 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/PurchaseReturnDto.cs
  13. 44 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/SupplierShortageKanbanDto.cs
  14. 182 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Entity/PurOrdRctDetail.cs
  15. 128 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Entity/PurOrdRctMaster.cs
  16. 148 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/IqcReturnQueryService.cs
  17. 595 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/PurchaseReturnService.cs
  18. 131 0
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShortageKanbanService.cs
  19. 2 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/PeriodSequenceDet.cs
  20. 2 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/ScheduleResultOpMaster.cs
  21. 133 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

+ 1 - 1
Web/package.json

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

+ 164 - 0
Web/src/views/aidop/s4/api/procurementExecution.ts

@@ -163,3 +163,167 @@ export function fetchSupplierShipmentLabelData(shddh: string) {
 		.then((r) => r.data);
 }
 
+// ── S4 采购退货单 ──
+export interface PurchaseReturnListRow {
+	id?: number;
+	detailRecId?: number;
+	receiver?: string;
+	suppName?: string;
+	rctDate?: string;
+	ordNbr?: string;
+	itemNum?: string;
+	descr?: string;
+	descr1?: string;
+	rctQty?: number;
+	isPlan?: boolean;
+}
+
+export interface PurchaseReturnKv {
+	value?: string;
+	label?: string;
+}
+
+export interface PurchaseReturnDetailRow {
+	recID?: number | null;
+	line?: number;
+	itemNum?: string;
+	ordNbr?: string;
+	ordLine?: number;
+	qtyOrded?: number;
+	qtyReceived?: number;
+	rctQty?: number;
+	lotSerial?: string;
+	um?: string;
+	location?: string;
+	qcDescr?: string;
+	remark?: string;
+	rctDate?: string;
+	typed?: string;
+	taxIn?: boolean;
+	taxClass?: string;
+	curr?: string;
+	potype?: string;
+	qtyReturn?: number;
+	rctNbr?: string;
+	rctLine?: number;
+	custPO?: string;
+	blanketLine?: number;
+	umConversion?: number;
+	poCost?: number;
+	supp?: string;
+	_edit?: boolean;
+}
+
+export function fetchPurchaseReturnList(params: any) {
+	return service.get<Paged<PurchaseReturnListRow>>('/api/ProcurementExecution/purchase-return/list', { params }).then((r) => r.data);
+}
+
+export function fetchPurchaseReturnDetail(id: number) {
+	return service.get<{ master: Record<string, any>; details: PurchaseReturnDetailRow[] }>(`/api/ProcurementExecution/purchase-return/${id}`).then((r) => r.data);
+}
+
+export function savePurchaseReturn(body: any) {
+	return service.post<{ id: number; receiver?: string }>('/api/ProcurementExecution/purchase-return/save', body).then((r) => r.data);
+}
+
+export function deletePurchaseReturn(id: number) {
+	return service.post<{ message?: string }>(`/api/ProcurementExecution/purchase-return/delete/${id}`).then((r) => r.data);
+}
+
+export function fetchPurchaseReturnSuppliers() {
+	return service.get<PurchaseReturnKv[]>('/api/ProcurementExecution/purchase-return/options/suppliers').then((r) => r.data);
+}
+
+export function fetchPurchaseReturnRcReceivers(supp: string) {
+	return service.get<PurchaseReturnKv[]>('/api/ProcurementExecution/purchase-return/options/rc-receivers', { params: { supp } }).then((r) => r.data);
+}
+
+export function fetchPurchaseReturnRcLines(receiver: string) {
+	return service
+		.get<{ head?: Record<string, any>; lines: PurchaseReturnDetailRow[] }>('/api/ProcurementExecution/purchase-return/rc-detail-lines', {
+			params: { receiver },
+		})
+		.then((r) => r.data);
+}
+
+export function fetchPurchaseReturnDepartments() {
+	return service.get<PurchaseReturnKv[]>('/api/ProcurementExecution/purchase-return/options/departments').then((r) => r.data);
+}
+
+export function fetchPurchaseReturnLocations() {
+	return service.get<PurchaseReturnKv[]>('/api/ProcurementExecution/purchase-return/options/locations').then((r) => r.data);
+}
+
+export function fetchPurchaseReturnQcReasons() {
+	return service.get<PurchaseReturnKv[]>('/api/ProcurementExecution/purchase-return/options/qc-reasons').then((r) => r.data);
+}
+
+export function fetchPurchaseReturnItemOptions(params: { keyword?: string; page?: number; pageSize?: number }) {
+	return service
+		.get<{ total: number; page: number; pageSize: number; list: PurchaseReturnKv[] }>(`/api/ProcurementExecution/purchase-return/item-options`, {
+			params,
+		})
+		.then((r) => r.data);
+}
+
+// ── S4 IQC退货查询(只读)──
+export interface IqcReturnListRow {
+	rowKey?: string;
+	entryId?: number;
+	fBillNo?: string;
+	ordNbr?: string;
+	pch?: string;
+	fMaterialCfg?: string;
+	sortName?: string;
+	supp?: string;
+	gysmc?: string;
+	wlmc?: string;
+	ggxh?: string;
+	frInsQty?: number;
+	bhgsl?: number;
+	clfs?: number;
+	thyy?: string;
+	fInspectStatus?: string;
+	fApplyTime?: string;
+	fInspeEndDate?: string;
+}
+
+export function fetchIqcReturnList(params: any) {
+	return service.get<Paged<IqcReturnListRow>>('/api/ProcurementExecution/iqc-return/list', { params }).then((r) => r.data);
+}
+
+// ── S4 供应商欠料看板(只读)──
+export interface SupplierShortageKanbanRow {
+	rowNum?: number;
+	supplier_Number?: string;
+	supplier_Name?: string;
+	itemNum?: string;
+	descr?: string;
+	descr1?: string;
+	d0?: number;
+	d1?: number;
+	d2?: number;
+	d3?: number;
+	d4?: number;
+	d5?: number;
+	d6?: number;
+	d7?: number;
+	d8?: number;
+	d9?: number;
+	d10?: number;
+	d11?: number;
+	d12?: number;
+	d13?: number;
+	d14?: number;
+}
+
+export function fetchSupplierShortageKanbanList(params: any) {
+	return service
+		.get<Paged<SupplierShortageKanbanRow>>('/api/ProcurementExecution/supplier-shortage-kanban/list', { params })
+		.then((r) => r.data);
+}
+
+export function refreshSupplierShortageKanban() {
+	return service.post<{ ok: boolean; elapsedMs?: number; message?: string }>('/api/ProcurementExecution/supplier-shortage-kanban/refresh').then((r) => r.data);
+}
+

+ 221 - 0
Web/src/views/aidop/s4/execution-kanban/supplierShortageKanbanList.vue

@@ -0,0 +1,221 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="滚动显示未来 7-14 天生产工单欠料明细(只读)">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="物料编码">
+				<el-input v-model="query.itemNum" clearable style="width: 180px" placeholder="=" />
+			</el-form-item>
+			<el-form-item label="供应商名称">
+				<el-input v-model="query.supplierName" clearable style="width: 220px" placeholder="%like%" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="toolbar">
+			<el-button type="primary" :loading="refreshing" @click="onRefresh">刷新</el-button>
+			<el-popover placement="bottom" width="240" trigger="click">
+				<template #reference><el-button text>列设置</el-button></template>
+				<el-checkbox v-for="item in toggleItems" :key="item.key" :model-value="col[item.key]" @change="(v) => setColumnVisible(item.key, Boolean(v))">
+					{{ item.label }}
+				</el-checkbox>
+			</el-popover>
+		</div>
+
+		<el-table :data="rows" v-loading="loading" border stripe @sort-change="onSortChange">
+			<el-table-column v-if="col.rowNum" prop="rowNum" label="序号" width="70" fixed="left" sortable="custom" resizable />
+			<el-table-column v-if="col.supplier_number" prop="supplier_Number" label="供应商编码" width="140" fixed="left" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.supplier_name" prop="supplier_Name" label="供应商名称" min-width="160" fixed="left" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.itemNum" prop="itemNum" label="物料编码" width="140" fixed="left" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.descr" prop="descr" label="物料描述" min-width="160" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.descr1" prop="descr1" label="规格型号" min-width="140" sortable="custom" show-overflow-tooltip resizable />
+
+			<el-table-column
+				v-for="d in dayColumns"
+				:key="d.key"
+				v-show="col[d.key]"
+				:prop="d.key"
+				:label="d.label"
+				width="120"
+				align="right"
+				sortable="custom"
+				resizable
+			/>
+		</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>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS4SupplierShortageKanban">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchSupplierShortageKanbanList, refreshSupplierShortageKanban, type SupplierShortageKanbanRow } from '../api/procurementExecution';
+
+const route = useRoute();
+const pageTitle = computed(() => (route.meta?.title as string) || '供应商欠料看板');
+
+const query = reactive({
+	itemNum: '',
+	supplierName: '',
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+
+const loading = ref(false);
+const refreshing = ref(false);
+const rows = ref<SupplierShortageKanbanRow[]>([]);
+const total = ref(0);
+
+const dayColumns = computed(() => {
+	const base = new Date();
+	base.setHours(0, 0, 0, 0);
+	const fmt = (d: Date) => {
+		const y = d.getFullYear();
+		const m = String(d.getMonth() + 1).padStart(2, '0');
+		const dd = String(d.getDate()).padStart(2, '0');
+		return `${y}-${m}-${dd}`;
+	};
+	const cols: Array<{ key: DayKey; label: string }> = [];
+	for (let i = 0; i <= 14; i++) {
+		const d = new Date(base);
+		d.setDate(base.getDate() + i);
+		cols.push({ key: (`d${i}` as DayKey), label: fmt(d) });
+	}
+	return cols;
+});
+
+const col = reactive({
+	rowNum: true,
+	supplier_number: true,
+	supplier_name: true,
+	itemNum: true,
+	descr: true,
+	descr1: true,
+	d0: true,
+	d1: true,
+	d2: true,
+	d3: true,
+	d4: true,
+	d5: true,
+	d6: true,
+	d7: true,
+	d8: true,
+	d9: true,
+	d10: true,
+	d11: true,
+	d12: true,
+	d13: true,
+	d14: true,
+});
+
+type ColumnKey = keyof typeof col;
+type DayKey = `d${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14}`;
+
+const toggleItems: Array<{ key: ColumnKey; label: string }> = [
+	{ key: 'rowNum', label: '序号' },
+	{ key: 'supplier_number', label: '供应商编码' },
+	{ key: 'supplier_name', label: '供应商名称' },
+	{ key: 'itemNum', label: '物料编码' },
+	{ key: 'descr', label: '物料描述' },
+	{ key: 'descr1', label: '规格型号' },
+	{ key: 'd0', label: '今天(D0)' },
+	{ key: 'd1', label: '第1天(D1)' },
+	{ key: 'd2', label: '第2天(D2)' },
+	{ key: 'd3', label: '第3天(D3)' },
+	{ key: 'd4', label: '第4天(D4)' },
+	{ key: 'd5', label: '第5天(D5)' },
+	{ key: 'd6', label: '第6天(D6)' },
+	{ key: 'd7', label: '第7天(D7)' },
+	{ key: 'd8', label: '第8天(D8)' },
+	{ key: 'd9', label: '第9天(D9)' },
+	{ key: 'd10', label: '第10天(D10)' },
+	{ key: 'd11', label: '第11天(D11)' },
+	{ key: 'd12', label: '第12天(D12)' },
+	{ key: 'd13', label: '第13天(D13)' },
+	{ key: 'd14', label: '第14天(D14)' },
+];
+
+function setColumnVisible(key: ColumnKey, visible: boolean) {
+	col[key] = visible;
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchSupplierShortageKanbanList({ ...query });
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function onRefresh() {
+	if (refreshing.value) return;
+	refreshing.value = true;
+	try {
+		const r = await refreshSupplierShortageKanban();
+		if (r?.ok) ElMessage.success(`刷新完成(${r.elapsedMs ?? 0}ms)`);
+		else ElMessage.error(r?.message || '刷新失败');
+		await loadList();
+	} finally {
+		refreshing.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.itemNum = '';
+	query.supplierName = '';
+	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
+}
+
+onMounted(() => loadList());
+</script>
+
+<style scoped lang="scss">
+.mb12 {
+	margin-bottom: 12px;
+}
+.toolbar {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 8px;
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>
+

+ 194 - 0
Web/src/views/aidop/s4/return/iqcReturnQueryList.vue

@@ -0,0 +1,194 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="只读列表">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="采购单号"><el-input v-model="query.ordNbr" clearable style="width: 140px" placeholder="OrdNbr" /></el-form-item>
+			<el-form-item label="批次号"><el-input v-model="query.pch" clearable style="width: 120px" /></el-form-item>
+			<el-form-item label="物料编码">
+				<el-input v-model="query.fMaterialCfg" clearable style="width: 160px" placeholder="精确" />
+			</el-form-item>
+			<el-form-item label="供应商名称"><el-input v-model="query.sortName" clearable style="width: 160px" placeholder="LIKE" /></el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="toolbar">
+			<el-popover placement="bottom" width="220" trigger="click">
+				<template #reference><el-button text>列设置</el-button></template>
+				<el-checkbox
+					v-for="item in toggleItems"
+					:key="item.key"
+					:model-value="col[item.key]"
+					@change="(v: unknown) => setColumnVisible(item.key, Boolean(v))"
+				>
+					{{ item.label }}
+				</el-checkbox>
+			</el-popover>
+		</div>
+
+		<el-table :data="rows" row-key="rowKey" v-loading="loading" border stripe @sort-change="onSortChange">
+			<el-table-column v-if="col.fBillNo" prop="fBillNo" label="单据编号" width="200" fixed="left" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.ordNbr" prop="ordNbr" label="采购单号" width="120" sortable="custom" resizable />
+			<el-table-column v-if="col.pch" prop="pch" label="批次" width="120" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.fMaterialCfg" prop="fMaterialCfg" label="物料编号" width="140" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.sortName" prop="sortName" label="供应商" min-width="160" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.supp" prop="supp" label="供应商编码" width="120" sortable="custom" resizable />
+			<el-table-column v-if="col.gysmc" prop="gysmc" label="供应商名称" min-width="140" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.wlmc" prop="wlmc" label="物料名称" min-width="140" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.ggxh" prop="ggxh" label="规格型号" min-width="120" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.frInsQty" prop="frInsQty" label="检验数量" width="150" align="right" sortable="custom" resizable />
+			<el-table-column v-if="col.bhgsl" prop="bhgsl" label="不合格" width="120" align="right" sortable="custom" resizable />
+			<el-table-column v-if="col.clfs" prop="clfs" label="处理方式" width="130" sortable="custom" resizable />
+			<el-table-column v-if="col.thyy" prop="thyy" label="退货原因" min-width="140" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.fInspectStatus" prop="fInspectStatus" label="检验状态" width="120" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.fApplyTime" prop="fApplyTime" label="申请时间" width="160" sortable="custom" resizable>
+				<template #default="{ row }">{{ fmtDateTime(row.fApplyTime) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.fInspeEndDate" prop="fInspeEndDate" label="检验完成" width="120" sortable="custom" resizable>
+				<template #default="{ row }">{{ fmtDate(row.fInspeEndDate) }}</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>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS4IqcReturnQuery">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchIqcReturnList, type IqcReturnListRow } from '../api/procurementExecution';
+
+const route = useRoute();
+const pageTitle = computed(() => (route.meta?.title as string) || 'IQC退货查询');
+
+const query = reactive({
+	ordNbr: '',
+	pch: '',
+	fMaterialCfg: '',
+	sortName: '',
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+
+const loading = ref(false);
+const rows = ref<IqcReturnListRow[]>([]);
+const total = ref(0);
+
+const col = reactive({
+	fBillNo: true,
+	ordNbr: true,
+	pch: true,
+	fMaterialCfg: true,
+	sortName: true,
+	supp: true,
+	gysmc: true,
+	wlmc: true,
+	ggxh: true,
+	frInsQty: true,
+	bhgsl: true,
+	clfs: true,
+	thyy: true,
+	fInspectStatus: true,
+	fApplyTime: true,
+	fInspeEndDate: true,
+});
+
+type ColumnKey = keyof typeof col;
+const toggleItems: Array<{ key: ColumnKey; label: string }> = [
+	{ key: 'fBillNo', label: '单据编号(FBILLNO)' },
+	{ key: 'ordNbr', label: '采购单号' },
+	{ key: 'pch', label: '批次' },
+	{ key: 'fMaterialCfg', label: '物料' },
+	{ key: 'sortName', label: '供应商' },
+	{ key: 'supp', label: '供应商编码' },
+	{ key: 'gysmc', label: '供应商名称' },
+	{ key: 'wlmc', label: '物料名称' },
+	{ key: 'ggxh', label: '规格型号' },
+	{ key: 'frInsQty', label: '检验数量' },
+	{ key: 'bhgsl', label: '不合格数量' },
+	{ key: 'clfs', label: '处理方式' },
+	{ key: 'thyy', label: '退货原因' },
+	{ key: 'fInspectStatus', label: '检验状态' },
+	{ key: 'fApplyTime', label: '申请时间' },
+	{ key: 'fInspeEndDate', label: '检验完成' },
+];
+
+function fmtDate(v?: string | null) {
+	return v ? String(v).slice(0, 10) : '';
+}
+function fmtDateTime(v?: string | null) {
+	if (!v) return '';
+	const s = String(v);
+	return s.length > 19 ? s.slice(0, 19).replace('T', ' ') : s;
+}
+
+function setColumnVisible(key: ColumnKey, visible: boolean) {
+	col[key] = visible;
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchIqcReturnList({ ...query });
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.ordNbr = '';
+	query.pch = '';
+	query.fMaterialCfg = '';
+	query.sortName = '';
+	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
+}
+
+onMounted(() => loadList());
+</script>
+
+<style scoped lang="scss">
+.mb12 {
+	margin-bottom: 12px;
+}
+.toolbar {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 8px;
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 599 - 0
Web/src/views/aidop/s4/return/purchaseReturnOrderForm.vue

@@ -0,0 +1,599 @@
+<template>
+	<AidopDemoShell :title="titleText">
+		<div v-loading="loading">
+			<el-divider content-position="left">表头</el-divider>
+			<el-form :model="head" label-width="120px" class="form-grid">
+				<el-row :gutter="12">
+					<el-col :span="12">
+						<el-form-item label="退货单号">
+							<el-input v-model="head.receiver" disabled placeholder="保存后自动生成" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="退货日期" required>
+							<el-date-picker v-model="head.rctDate" 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="供应商" required>
+							<el-select
+								v-model="head.supp"
+								filterable
+								clearable
+								style="width: 100%"
+								:disabled="isView || isEdit"
+								@change="onSuppChanged"
+							>
+								<el-option v-for="x in supplierOpts" :key="x.value" :label="x.label || x.value" :value="x.value || ''" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="收货单号" required>
+							<el-select
+								v-model="head.ordNbr"
+								filterable
+								style="width: 100%"
+								:disabled="isView || isEdit || !head.supp"
+								placeholder="先选供应商"
+								@change="onRcReceiverChanged"
+							>
+								<el-option v-for="x in rcOpts" :key="x.value" :label="x.label || x.value" :value="x.value || ''" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="部门" required>
+							<el-select v-model="head.department" filterable clearable style="width: 100%" :disabled="isView">
+								<el-option v-for="x in deptOpts" :key="x.value" :label="x.label || x.value" :value="x.value || ''" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12"><el-form-item label="类别"><el-input v-model="head.terms" disabled /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="币别"><el-input v-model="head.curr" disabled /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="税类型"><el-input v-model="head.taxClass" disabled /></el-form-item></el-col>
+					<el-col :span="12">
+						<el-form-item label="是否含税"><el-input v-model="taxInDisplay" disabled /></el-form-item>
+					</el-col>
+					<el-col :span="24">
+						<el-form-item label="备注"><el-input v-model="head.remark" type="textarea" :rows="2" :disabled="isView" /></el-form-item>
+					</el-col>
+				</el-row>
+			</el-form>
+
+			<el-divider content-position="left">退货明细</el-divider>
+			<div v-if="!isView" class="sub-toolbar">
+				<el-button type="primary" @click="addDetailRow">添加</el-button>
+			</div>
+			<el-table :data="details" border stripe>
+				<el-table-column label="行号" width="60">
+					<template #default="{ row, $index }">{{ row.line ?? $index + 1 }}</template>
+				</el-table-column>
+				<el-table-column label="物料编号" min-width="150">
+					<template #default="{ row, $index }">
+						<div v-if="canEditDetail($index)" class="picker-cell">
+							<el-input v-model="row.itemNum" placeholder="物料" readonly>
+								<template #append><el-button :disabled="isView" @click="openItemPick($index)">选择</el-button></template>
+							</el-input>
+						</div>
+						<span v-else>{{ row.itemNum }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="采购订单" width="120">
+					<template #default="{ row, $index }">
+						<el-input v-if="canEditDetail($index)" v-model="row.ordNbr" />
+						<span v-else>{{ row.ordNbr }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="采购单行" width="90">
+					<template #default="{ row, $index }">
+						<el-input-number v-if="canEditDetail($index)" v-model="row.ordLine" :controls="false" class="w100num" />
+						<span v-else>{{ row.ordLine }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="订单数量" width="110">
+					<template #default="{ row, $index }">
+						<el-input-number v-if="canEditDetail($index)" v-model="row.qtyOrded" :controls="false" class="w100num" />
+						<span v-else>{{ row.qtyOrded }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="退货数量" width="110">
+					<template #default="{ row, $index }">
+						<el-input-number v-if="canEditDetail($index)" v-model="row.qtyReceived" :controls="false" class="w100num" />
+						<span v-else>{{ row.qtyReceived }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="已退数量" width="110">
+					<template #default="{ row, $index }">
+						<el-input-number v-if="canEditDetail($index)" v-model="row.rctQty" :controls="false" class="w100num" />
+						<span v-else>{{ row.rctQty }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="生产批号" width="130">
+					<template #default="{ row, $index }">
+						<el-input v-if="canEditDetail($index)" v-model="row.lotSerial" />
+						<span v-else>{{ row.lotSerial }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="单位" width="90">
+					<template #default="{ row, $index }">
+						<el-input v-if="canEditDetail($index)" v-model="row.um" />
+						<span v-else>{{ row.um }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="退出库位" min-width="160">
+					<template #default="{ row, $index }">
+						<el-select v-if="canEditDetail($index)" v-model="row.location" filterable clearable class="w100">
+							<el-option v-for="x in locOpts" :key="x.value" :label="x.label || x.value" :value="x.value || ''" />
+						</el-select>
+						<span v-else>{{ row.location }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="退货原因" min-width="180">
+					<template #default="{ row, $index }">
+						<el-select v-if="canEditDetail($index)" v-model="row.qcDescr" filterable clearable class="w100">
+							<el-option v-for="x in qcOpts" :key="x.value" :label="x.label || x.value" :value="x.value || ''" />
+						</el-select>
+						<span v-else>{{ row.qcDescr }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column label="备注" min-width="140">
+					<template #default="{ row, $index }">
+						<el-input v-if="canEditDetail($index)" v-model="row.remark" />
+						<span v-else>{{ row.remark }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column v-if="!isView" label="操作" width="140" fixed="right">
+					<template #default="{ $index }">
+						<el-button link type="primary" @click="toggleEditRow($index)">{{ editingIdx === $index ? '完成' : '编辑' }}</el-button>
+						<el-button link type="danger" @click="removeDetailRow($index)">删除</el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+
+			<div class="footer-actions">
+				<el-button @click="goBack">取消</el-button>
+				<el-button v-if="!isView" type="primary" :loading="saving" @click="submit">保存</el-button>
+			</div>
+		</div>
+
+		<el-dialog v-model="itemDlg.visible" title="选择物料" width="720px" destroy-on-close @open="reloadItems">
+			<el-input v-model="itemDlg.keyword" class="mb8" placeholder="关键字" clearable @keyup.enter="reloadItems(true)" />
+			<el-table v-loading="itemDlg.loading" :data="itemDlg.rows" border max-height="400" @row-dblclick="handleItemPickRow">
+				<el-table-column prop="value" label="物料编号" width="160" />
+				<el-table-column prop="label" label="描述" />
+			</el-table>
+			<div class="pager-inline">
+				<el-pagination
+					layout="total, prev, pager, next"
+					:total="itemDlg.total"
+					v-model:current-page="itemDlg.page"
+					:page-size="itemDlg.pageSize"
+					@current-change="reloadItems()"
+				/>
+			</div>
+			<template #footer>
+				<el-button @click="itemDlg.visible = false">关闭</el-button>
+			</template>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS4PurchaseReturnOrderForm">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import {
+	fetchPurchaseReturnDepartments,
+	fetchPurchaseReturnDetail,
+	fetchPurchaseReturnItemOptions,
+	fetchPurchaseReturnLocations,
+	fetchPurchaseReturnQcReasons,
+	fetchPurchaseReturnRcLines,
+	fetchPurchaseReturnRcReceivers,
+	fetchPurchaseReturnSuppliers,
+	savePurchaseReturn,
+	type PurchaseReturnDetailRow,
+	type PurchaseReturnKv,
+} from '../api/procurementExecution';
+
+const route = useRoute();
+const router = useRouter();
+const mode = computed(() => String(route.query.mode || 'create') as 'create' | 'edit' | 'view');
+const id = computed(() => Number(route.query.id || 0));
+const isView = computed(() => mode.value === 'view');
+const isEdit = computed(() => mode.value === 'edit');
+const titleText = computed(() =>
+	mode.value === 'create' ? '采购退货单-新增' : mode.value === 'edit' ? '采购退货单-编辑' : '采购退货单-查看',
+);
+
+const loading = ref(false);
+const saving = ref(false);
+const head = reactive({
+	id: null as number | null,
+	receiver: '',
+	rctDate: '',
+	supp: '',
+	ordNbr: '',
+	department: '',
+	terms: '',
+	curr: '',
+	taxClass: '',
+	taxIn: false as boolean,
+	remark: '',
+	termsofTrade: '',
+	domain: '',
+});
+
+const taxInDisplay = computed(() => (head.taxIn ? 'True' : 'False'));
+
+const supplierOpts = ref<PurchaseReturnKv[]>([]);
+const rcOpts = ref<PurchaseReturnKv[]>([]);
+const deptOpts = ref<PurchaseReturnKv[]>([]);
+const locOpts = ref<PurchaseReturnKv[]>([]);
+const qcOpts = ref<PurchaseReturnKv[]>([]);
+
+const details = ref<PurchaseReturnDetailRow[]>([]);
+const editingIdx = ref<number | null>(null);
+
+const itemDlg = reactive({
+	visible: false,
+	keyword: '',
+	loading: false,
+	rows: [] as PurchaseReturnKv[],
+	total: 0,
+	page: 1,
+	pageSize: 10,
+	rowIndexForPick: null as number | null,
+});
+
+function canEditDetail(idx: number) {
+	return !isView.value && editingIdx.value === idx;
+}
+
+function toggleEditRow(idx: number) {
+	editingIdx.value = editingIdx.value === idx ? null : idx;
+}
+
+function goBack() {
+	router.push('/aidop/s4/return-mgmt/purchase-return-order');
+}
+
+async function bootstrapOptions() {
+	const [su, dept, loc, qc] = await Promise.all([
+		fetchPurchaseReturnSuppliers(),
+		fetchPurchaseReturnDepartments(),
+		fetchPurchaseReturnLocations(),
+		fetchPurchaseReturnQcReasons(),
+	]);
+	supplierOpts.value = normalizeKv(su);
+	deptOpts.value = normalizeKv(dept);
+	locOpts.value = normalizeKv(loc);
+	qcOpts.value = normalizeKv(qc);
+}
+
+function normalizeKv(arr: PurchaseReturnKv[] | null | undefined) {
+	if (!Array.isArray(arr)) return [];
+	return arr.map((x: any) => ({ value: x.value ?? x.Value, label: x.label ?? x.Label }));
+}
+
+async function onSuppChanged() {
+	rcOpts.value = [];
+	head.ordNbr = '';
+	if (!head.supp) return;
+	const rc = await fetchPurchaseReturnRcReceivers(head.supp);
+	rcOpts.value = normalizeKv(rc as any);
+}
+
+async function onRcReceiverChanged() {
+	if (!head.ordNbr) return;
+	editingIdx.value = null;
+	loading.value = true;
+	try {
+		const pack = await fetchPurchaseReturnRcLines(head.ordNbr);
+		const line0 = (pack.lines || [])[0] as Record<string, any> | undefined;
+		const h = pack.head || line0 || {};
+		head.domain = String((h as any).domain ?? line0?.domain ?? head.domain ?? '');
+		head.department = (h as any).department ?? head.department;
+		head.terms = (h as any).terms ?? '';
+		head.termsofTrade = (h as any).termsofTrade ?? '';
+		head.curr = (h as any).curr ?? '';
+		head.taxClass = (h as any).taxClass ?? '';
+		head.taxIn = Boolean((h as any).taxIn);
+		const lines = (pack.lines || []) as PurchaseReturnDetailRow[];
+		let i = 0;
+		details.value = lines.map((x: any, idx: number) => ({
+			recID: x.recID ?? x.recId,
+			line: typeof x.line === 'number' ? x.line : idx + 1,
+			itemNum: x.itemNum,
+			ordNbr: x.ordNbr ?? '',
+			ordLine: Number(x.ordLine ?? 0),
+			qtyOrded: Number(x.qtyOrded ?? 0),
+			qtyReceived: Number(x.qtyReceived ?? x.qtyReturn ?? 0),
+			rctQty: Number(x.rctQty ?? 0),
+			lotSerial: x.lotSerial,
+			um: x.um,
+			location: String(x.location ?? ''),
+			qcDescr: x.qcDescr,
+			remark: x.remark,
+			rctDate: x.rctDate,
+			typed: x.typed || 'C',
+			taxIn: Boolean(x.taxIn),
+			taxClass: x.taxClass ?? '',
+			curr: x.curr ?? '',
+			potype: x.potype ?? 'P',
+			qtyReturn: Number(x.qtyReturn ?? x.qtyReceived ?? 0),
+			rctNbr: x.rctNbr,
+			rctLine: Number(x.rctLine ?? 0),
+			custPO: x.custPO,
+			blanketLine: Number(x.blanketLine ?? 0),
+			umConversion: Number(x.umConversion ?? 0),
+			poCost: Number(x.poCost ?? 0),
+			supp: x.supp ?? head.supp,
+		}));
+		// eslint fix for unused
+		void i;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function blankDetail(idx: number): PurchaseReturnDetailRow {
+	return {
+		line: idx,
+		itemNum: '',
+		ordNbr: '',
+		ordLine: 0,
+		qtyOrded: 0,
+		qtyReceived: 0,
+		rctQty: 0,
+		lotSerial: '',
+		um: '',
+		location: '',
+		qcDescr: '',
+		remark: '',
+		rctDate: head.rctDate,
+		typed: 'C',
+		taxIn: head.taxIn,
+		taxClass: head.taxClass,
+		curr: head.curr,
+		potype: 'P',
+		qtyReturn: 0,
+		rctNbr: head.ordNbr,
+		rctLine: 0,
+		custPO: '',
+		blanketLine: 0,
+		umConversion: 0,
+		poCost: 0,
+		supp: head.supp,
+	};
+}
+
+function addDetailRow() {
+	const idx = details.value.length;
+	details.value.push(blankDetail(idx + 1));
+	editingIdx.value = idx;
+}
+
+function removeDetailRow(i: number) {
+	details.value.splice(i, 1);
+	if (editingIdx.value !== null && editingIdx.value >= details.value.length) editingIdx.value = null;
+	reassignLines();
+}
+
+function reassignLines() {
+	details.value.forEach((d, i) => {
+		d.line = i + 1;
+	});
+}
+
+function openItemPick(rowIndex: number) {
+	editingIdx.value = rowIndex;
+	itemDlg.visible = true;
+	itemDlg.keyword = '';
+	itemDlg.page = 1;
+	itemDlg.rowIndexForPick = rowIndex;
+}
+
+function handleItemPickRow(row: PurchaseReturnKv) {
+	pickItemConfirm(row);
+}
+
+async function reloadItems(reset?: boolean) {
+	if (reset) itemDlg.page = 1;
+	itemDlg.loading = true;
+	try {
+		const r = await fetchPurchaseReturnItemOptions({
+			keyword: itemDlg.keyword,
+			page: itemDlg.page,
+			pageSize: itemDlg.pageSize,
+		});
+		itemDlg.rows = normalizeKv(r.list as any).map((x) => ({
+			value: x.value ?? '',
+			label: x.label ?? '',
+		}));
+		itemDlg.total = r.total;
+	} finally {
+		itemDlg.loading = false;
+	}
+}
+
+function pickItemConfirm(row: PurchaseReturnKv) {
+	const ix = itemDlg.rowIndexForPick ?? editingIdx.value;
+	if (ix == null || ix < 0) return;
+	if (details.value[ix]) {
+		details.value[ix].itemNum = row.value || '';
+		itemDlg.visible = false;
+	}
+}
+
+async function loadExisting() {
+	if (!id.value) return;
+	loading.value = true;
+	try {
+		const data = await fetchPurchaseReturnDetail(id.value);
+		const m = data.master || {};
+		head.id = m.recID ?? m.recId ?? id.value;
+		head.receiver = m.receiver ?? '';
+		head.rctDate = sliceDate(m.rctDate);
+		head.supp = m.supp ?? '';
+		head.ordNbr = m.ordNbr ?? '';
+		head.department = m.department ?? '';
+		head.terms = m.terms ?? '';
+		head.curr = m.curr ?? '';
+		head.taxClass = m.taxClass ?? '';
+		head.taxIn = Boolean(m.taxIn);
+		head.remark = m.remark ?? '';
+		head.termsofTrade = m.termsofTrade ?? '';
+		head.domain = m.domain ?? '';
+		await onSuppChanged();
+		details.value = (data.details || []).map((x: any, idx: number) => ({
+			recID: x.recID ?? x.recId ?? null,
+			line: x.line ?? idx + 1,
+			itemNum: x.itemNum,
+			ordNbr: x.ordNbr,
+			ordLine: Number(x.ordLine ?? 0),
+			qtyOrded: Number(x.qtyOrded ?? 0),
+			qtyReceived: Number(x.qtyReceived ?? 0),
+			rctQty: Number(x.rctQty ?? 0),
+			lotSerial: x.lotSerial,
+			um: x.um,
+			location: x.location,
+			qcDescr: x.qcDescr,
+			remark: x.remark,
+			rctDate: sliceDate(x.rctDate),
+			typed: x.typed || 'C',
+			taxIn: Boolean(x.taxIn),
+			taxClass: x.taxClass,
+			curr: x.curr,
+			potype: x.potype ?? 'P',
+			qtyReturn: Number(x.qtyReturn ?? 0),
+			rctNbr: x.rctNbr,
+			rctLine: Number(x.rctLine ?? 0),
+			custPO: x.custPO,
+			blanketLine: Number(x.blanketLine ?? 0),
+			umConversion: Number(x.umConversion ?? 0),
+			poCost: Number(x.poCost ?? 0),
+			supp: x.supp ?? head.supp,
+		}));
+	} finally {
+		loading.value = false;
+	}
+}
+
+function sliceDate(v: unknown) {
+	if (!v) return '';
+	return String(v).slice(0, 10);
+}
+
+function buildSaveBody() {
+	return {
+		id: mode.value === 'create' ? undefined : head.id ?? id.value ?? undefined,
+		rctDate: head.rctDate,
+		supp: head.supp,
+		ordNbr: head.ordNbr,
+		department: head.department,
+		remark: head.remark,
+		terms: head.terms,
+		curr: head.curr,
+		taxClass: head.taxClass,
+		taxIn: head.taxIn,
+		termsofTrade: head.termsofTrade,
+		domain: head.domain,
+		details: details.value.map((d, idx) => ({
+			recID: d.recID != null && d.recID > 0 ? d.recID : undefined,
+			line: d.line ?? idx + 1,
+			itemNum: d.itemNum,
+			ordNbr: d.ordNbr,
+			ordLine: d.ordLine ?? 0,
+			qtyOrded: Number(d.qtyOrded ?? 0),
+			qtyReceived: Number(d.qtyReceived ?? 0),
+			rctQty: Number(d.rctQty ?? 0),
+			lotSerial: d.lotSerial,
+			um: d.um,
+			location: d.location ?? '',
+			qcDescr: d.qcDescr,
+			remark: d.remark,
+			rctDate: head.rctDate,
+			typed: d.typed ?? 'C',
+			taxIn: d.taxIn ?? head.taxIn,
+			taxClass: d.taxClass ?? head.taxClass,
+			curr: d.curr ?? head.curr,
+			potype: d.potype ?? 'P',
+			isActive: true,
+			isConfirm: false,
+			umConversion: Number(d.umConversion ?? 0),
+			poCost: Number(d.poCost ?? 0),
+			rctNbr: d.rctNbr ?? head.ordNbr,
+			rctLine: d.rctLine ?? 0,
+			custPO: d.custPO,
+			blanketLine: d.blanketLine ?? 0,
+			qtyReturn: Number(d.qtyReturn ?? d.qtyReceived ?? 0),
+			supp: d.supp ?? head.supp,
+		})),
+	};
+}
+
+async function submit() {
+	if (!head.rctDate) {
+		ElMessage.warning('请填写退货日期');
+		return;
+	}
+	if (!head.supp || !head.ordNbr || !head.department) {
+		ElMessage.warning('请完善供应商 / 收货单号 / 部门');
+		return;
+	}
+	const bad = details.value.find((x) => !x.itemNum);
+	if (!details.value.length || bad) {
+		ElMessage.warning('请填写明细物料');
+		return;
+	}
+	saving.value = true;
+	try {
+		const res = await savePurchaseReturn(buildSaveBody());
+		ElMessage.success('保存成功');
+		if (!head.receiver && res.receiver) head.receiver = res.receiver;
+		if (res.id && !head.id) head.id = res.id as any;
+		router.replace({ query: { mode: 'edit', id: String(res.id ?? head.id) } });
+		editingIdx.value = null;
+	} finally {
+		saving.value = false;
+	}
+}
+
+onMounted(async () => {
+	head.rctDate = new Date().toISOString().slice(0, 10);
+	await bootstrapOptions();
+	if (mode.value !== 'create' && id.value) await loadExisting();
+	else if (!isView.value && head.supp) await onSuppChanged();
+});
+</script>
+
+<style scoped>
+.form-grid :deep(.el-form-item) {
+	margin-bottom: 12px;
+}
+.mb8 {
+	margin-bottom: 8px;
+}
+.sub-toolbar {
+	margin-bottom: 10px;
+}
+.footer-actions {
+	margin-top: 16px;
+	display: flex;
+	gap: 8px;
+}
+.w100num {
+	width: 100%;
+}
+.w100 {
+	width: 100%;
+}
+.pager-inline {
+	margin-top: 8px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 220 - 0
Web/src/views/aidop/s4/return/purchaseReturnOrderList.vue

@@ -0,0 +1,220 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="采购退货单">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="退货单号"><el-input v-model="query.receiver" clearable style="width: 150px" /></el-form-item>
+			<el-form-item label="供应商">
+				<el-select v-model="query.supp" clearable filterable placeholder="全部" style="width: 220px">
+					<el-option v-for="x in suppOpts" :key="x.value" :label="x.label || x.value" :value="x.value || ''" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="收货单号"><el-input v-model="query.ordNbr" clearable style="width: 150px" /></el-form-item>
+			<el-form-item label="物料编号"><el-input v-model="query.itemNum" clearable style="width: 150px" /></el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="toolbar">
+			<el-button type="primary" @click="openForm('create')">添加</el-button>
+			<el-popover placement="bottom" width="220" trigger="click">
+				<template #reference><el-button text>列设置</el-button></template>
+				<el-checkbox v-for="item in toggleItems" :key="item.key" :model-value="col[item.key]" @change="(v) => setColumnVisible(item.key, Boolean(v))">
+					{{ item.label }}
+				</el-checkbox>
+			</el-popover>
+		</div>
+
+		<el-table :data="rows" v-loading="loading" border stripe @sort-change="onSortChange">
+			<el-table-column v-if="col.receiver" prop="receiver" label="退货单号" width="130" fixed="left" sortable="custom" resizable />
+			<el-table-column v-if="col.suppName" prop="suppName" label="供应商" min-width="160" show-overflow-tooltip sortable="custom" resizable />
+			<el-table-column v-if="col.rctDate" prop="rctDate" label="退货日期" width="120" sortable="custom" resizable>
+				<template #default="{ row }">{{ fmtDate(row.rctDate) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.ordNbr" prop="ordNbr" label="收货单号" width="130" sortable="custom" resizable />
+			<el-table-column v-if="col.itemNum" prop="itemNum" label="物料编号" width="130" sortable="custom" resizable />
+			<el-table-column v-if="col.descr" prop="descr" label="物料名称" min-width="140" show-overflow-tooltip sortable="custom" resizable />
+			<el-table-column v-if="col.descr1" prop="descr1" label="规格型号" min-width="120" show-overflow-tooltip resizable />
+			<el-table-column v-if="col.rctQty" prop="rctQty" label="退货数量" width="110" align="right" sortable="custom" resizable />
+			<el-table-column v-if="col.isPlan" prop="isPlan" label="销售出货类型(IsPlan)" width="150" resizable>
+				<template #default="{ row }">{{ row.isPlan ? '1' : '0' }}</template>
+			</el-table-column>
+			<el-table-column label="操作" width="200" fixed="right" resizable>
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openForm('view', row)">查看</el-button>
+					<el-button v-if="truthy(row.isPlan)" link type="warning" @click="openForm('edit', row)">编辑</el-button>
+					<el-button v-if="truthy(row.isPlan)" link type="danger" @click="onDelete(row)">删除</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>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS4PurchaseReturnOrderList">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import {
+	deletePurchaseReturn,
+	fetchPurchaseReturnList,
+	fetchPurchaseReturnSuppliers,
+	type PurchaseReturnKv,
+	type PurchaseReturnListRow,
+} from '../api/procurementExecution';
+
+const route = useRoute();
+const router = useRouter();
+const pageTitle = computed(() => (route.meta?.title as string) || '采购退货单');
+
+const query = reactive({
+	receiver: '',
+	supp: '',
+	ordNbr: '',
+	itemNum: '',
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+
+const loading = ref(false);
+const rows = ref<PurchaseReturnListRow[]>([]);
+const total = ref(0);
+const suppOpts = ref<PurchaseReturnKv[]>([]);
+
+const col = reactive({
+	receiver: true,
+	suppName: true,
+	rctDate: true,
+	ordNbr: true,
+	itemNum: true,
+	descr: true,
+	descr1: true,
+	rctQty: true,
+	isPlan: false,
+});
+
+type ColumnKey = keyof typeof col;
+const toggleItems: Array<{ key: ColumnKey; label: string }> = [
+	{ key: 'receiver', label: '退货单号' },
+	{ key: 'suppName', label: '供应商' },
+	{ key: 'rctDate', label: '退货日期' },
+	{ key: 'ordNbr', label: '收货单号' },
+	{ key: 'itemNum', label: '物料编号' },
+	{ key: 'descr', label: '物料名称' },
+	{ key: 'descr1', label: '规格型号' },
+	{ key: 'rctQty', label: '退货数量' },
+	{ key: 'isPlan', label: '销售出货类型(IsPlan)' },
+];
+
+function fmtDate(v?: string | null) {
+	return v ? String(v).slice(0, 10) : '';
+}
+
+function truthy(v: unknown) {
+	if (v === true || v === 1) return true;
+	if (v === '1' || v === 'true') return true;
+	return false;
+}
+
+function setColumnVisible(key: ColumnKey, visible: boolean) {
+	col[key] = visible;
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+async function loadSuppliers() {
+	try {
+		const data = await fetchPurchaseReturnSuppliers();
+		suppOpts.value = Array.isArray(data) ? data.map((x: any) => ({ value: x.value, label: x.label })) : [];
+	} catch {
+		suppOpts.value = [];
+	}
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchPurchaseReturnList({ ...query });
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.receiver = '';
+	query.supp = '';
+	query.ordNbr = '';
+	query.itemNum = '';
+	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
+}
+
+function openForm(mode: 'create' | 'edit' | 'view', row?: PurchaseReturnListRow) {
+	const id = row?.id && mode !== 'create' ? row.id : 0;
+	router.push({
+		path: '/aidop/s4/return-mgmt/purchase-return-order-form',
+		query: { mode, id: id ? String(id) : '' },
+	});
+}
+
+function onDelete(row: PurchaseReturnListRow) {
+	if (!row?.id) return;
+	ElMessageBox.confirm('确认删除该退货单?将把主表设为无效。', '提示', { type: 'warning' })
+		.then(async () => {
+			await deletePurchaseReturn(row.id!);
+			ElMessage.success('已删除');
+			loadList();
+		})
+		.catch(() => undefined);
+}
+
+onMounted(() => {
+	loadSuppliers();
+	loadList();
+});
+</script>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+}
+.toolbar {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+	align-items: center;
+	margin-bottom: 10px;
+}
+.pager {
+	display: flex;
+	justify-content: flex-end;
+	margin-top: 12px;
+}
+</style>

+ 1 - 1
server/Admin.NET.Application/Configuration/Database.json

@@ -44,7 +44,7 @@
           "InitTableRetryCount": 3, // 表结构初始化失败重试次数
           "InitTableRetryIntervalMs": 2000, // 表结构初始化失败重试间隔(毫秒)
           "InitTableCommandTimeoutSeconds": 120, // 表结构初始化命令超时时间(秒)
-          // "ContinueInitTableOnEntityFailure": false // 单实体 CodeFirst 失败时是否跳过并继续(默认 false;救急可 true,或环境变量 AIDOP_CODEFIRST_CONTINUE_ON_ENTITY_ERROR=1)
+          "ContinueInitTableOnEntityFailure": true // 单实体 CodeFirst 失败时是否跳过并继续(默认 false;救急可 true,或环境变量 AIDOP_CODEFIRST_CONTINUE_ON_ENTITY_ERROR=1)
         },
         "SeedSettings": {
           "EnableInitSeed": false, // 启用种子初始化(需要初始化字典/菜单数据时改 true)

+ 14 - 0
server/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs

@@ -620,6 +620,20 @@ public static class SqlSugarSetup
             }
             catch (Exception ex)
             {
+                // MySQL 上部分 DDL 具备幂等语义,但不同版本/元数据读取差异可能导致 CodeFirst 重复生成 DDL。
+                // 典型:表已有主键时仍尝试 ADD PRIMARY KEY -> Multiple primary key defined。
+                // 这类问题不应阻塞启动与其余实体初始化(联调环境更常见)。
+                var msg = ex.ToString();
+                if (msg.Contains("Multiple primary key defined", StringComparison.OrdinalIgnoreCase)
+                    || msg.Contains("Duplicate key name", StringComparison.OrdinalIgnoreCase)
+                    || msg.Contains("Duplicate column name", StringComparison.OrdinalIgnoreCase)
+                    || msg.Contains("All parts of a PRIMARY KEY must be NOT NULL", StringComparison.OrdinalIgnoreCase)
+                    || msg.Contains("Can't DROP", StringComparison.OrdinalIgnoreCase) && msg.Contains("check that column/key exists", StringComparison.OrdinalIgnoreCase))
+                {
+                    Log.Warning($"初始化表结构遇到可忽略的 DDL 冲突,已跳过:Entity={label} Table={tableName} ConfigId={config.ConfigId} — {ex.Message}");
+                    return;
+                }
+
                 Log.Error($"初始化表结构失败 Entity={label} Table={tableName} ConfigId={config.ConfigId}: {ex.Message}", ex);
                 if (continueOnEntityFailure)
                 {

+ 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.87</AssemblyVersion>
-    <FileVersion>1.0.87</FileVersion>
-    <Version>1.0.87</Version>
+    <AssemblyVersion>1.0.89</AssemblyVersion>
+    <FileVersion>1.0.89</FileVersion>
+    <Version>1.0.89</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 198 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMenuLinkSync.cs

@@ -23,6 +23,7 @@ public static class AidopMenuLinkSync
         var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
         // Database.json 常关闭 EnableInitSeed/EnableIncreSeed,新增种子菜单不会入库;先补插 S2 三级结构再挂租户/角色。
         EnsureS2ManufacturingCollaborationSeedMenus(db);
+        EnsureS4ProcurementExecutionSeedMenus(db);
         NormalizeMaterialSubstitutionMenu(db);
         NormalizeS1OrderWorkOrderParents(db);
         NormalizeS2ManufacturingParents(db);
@@ -31,6 +32,8 @@ public static class AidopMenuLinkSync
         NormalizeS2WorkOrderProgressKanbanMenu(db);
         NormalizeS3SupplyMenus(db);
         NormalizeS4DeliveryMenus(db);
+        NormalizeS4ReturnMenus(db);
+        NormalizeS4TopLevelMenus(db);
         EnsureLinkagePlanMenu(db);
         RemoveDeprecatedS8DashboardChildMenu(db);
         NormalizeS8MenuParents(db);
@@ -122,6 +125,53 @@ public static class AidopMenuLinkSync
             db.Insertable(roleRows).ExecuteCommand();
     }
 
+    /// <summary>
+    /// S4「采购执行」:数据库关闭种子时菜单不入库,侧栏只能看到历史遗留项(常见仅“供应商交货管理”)。
+    /// 从 <see cref="SysMenuSeedData.HasData"/> 取完整行按父链补插,确保交货/退货/看板目录与子菜单齐全。
+    /// </summary>
+    private static void EnsureS4ProcurementExecutionSeedMenus(ISqlSugarClient db)
+    {
+        const long aidopRootId = 1320990000101L;
+
+        var fullSeed = new global::Admin.NET.Plugin.AiDOP.SysMenuSeedData().HasData().ToList();
+        var byId = fullSeed.GroupBy(m => m.Id).ToDictionary(g => g.Key, g => g.First());
+
+        var orderedIds = new long[]
+        {
+            aidopRootId,
+            1321000005000L,     // S4 一级目录
+            1322000000012L,     // 交货管理(Dir)
+            1322000000013L,     // 退货管理(Dir)
+            1322000000014L,     // 采购执行看板(Dir)
+            1329004100001L,     // 供应商交货管理
+            1329004100002L,     // 供应商发货单
+            1329004100003L,     // 发货单表单(隐藏)
+            1329004200001L,     // 采购退货单
+            1329004200002L,     // 采购退货单表单(隐藏)
+            1329004200003L,     // IQC 退货查询
+            1329004300001L,     // 采购执行看板主页
+            1329004300002L,     // 供应商欠料看板
+        };
+
+        var existing = db.Queryable<SysMenu>().Where(m => orderedIds.Contains(m.Id)).Select(m => m.Id).ToList().ToHashSet();
+        var cumulativeIds = new HashSet<long>(existing);
+        var toInsert = new List<SysMenu>();
+        foreach (var id in orderedIds)
+        {
+            if (cumulativeIds.Contains(id))
+                continue;
+            if (!byId.TryGetValue(id, out var row))
+                continue;
+            if (row.Pid != 0 && !cumulativeIds.Contains(row.Pid))
+                continue;
+            toInsert.Add(row);
+            cumulativeIds.Add(id);
+        }
+
+        if (toInsert.Count > 0)
+            db.Insertable(toInsert).ExecuteCommand();
+    }
+
     /// <summary>
     /// S2「制造协同」二级目录与三级子菜单:<c>Database.json</c> 关闭种子时不会入库,导致侧栏无「工单工序排程」等项。
     /// 从 <see cref="SysMenuSeedData.HasData"/> 取完整行一次性补插(含目录 Redirect)。
@@ -726,6 +776,154 @@ public static class AidopMenuLinkSync
             .ExecuteCommand();
     }
 
+    private static void NormalizeS4ReturnMenus(ISqlSugarClient db)
+    {
+        const long s4RootId = 1321000005000L;
+        const long returnDirId = 1322000000013L;
+        if (!db.Queryable<SysMenu>().Any(m => m.Id == returnDirId))
+            return;
+
+        var dir = db.Queryable<SysMenu>().First(m => m.Id == returnDirId);
+        if (dir != null)
+        {
+            var needFix = dir.Pid != s4RootId
+                          || dir.Title != "退货管理"
+                          || dir.Path != "/aidop/s4/return-mgmt"
+                          || dir.Name != "aidopS4ReturnMgmt"
+                          || dir.Component != "Layout"
+                          || dir.Type != MenuTypeEnum.Dir
+                          || dir.Icon != "ele-Folder"
+                          || dir.Redirect != "/aidop/s4/return-mgmt/purchase-return-order";
+            if (needFix)
+            {
+                dir.Pid = s4RootId;
+                dir.Title = "退货管理";
+                dir.Path = "/aidop/s4/return-mgmt";
+                dir.Name = "aidopS4ReturnMgmt";
+                dir.Component = "Layout";
+                dir.Type = MenuTypeEnum.Dir;
+                dir.Icon = "ele-Folder";
+                dir.Redirect = "/aidop/s4/return-mgmt/purchase-return-order";
+                dir.Remark = "S4 退货管理";
+                db.Updateable(dir)
+                    .UpdateColumns(m => new { m.Pid, m.Title, m.Path, m.Name, m.Component, m.Type, m.Icon, m.Redirect, m.Remark })
+                    .ExecuteCommand();
+            }
+        }
+
+        var ct = DateTime.Parse("2022-02-10 00:00:00");
+        EnsureS3LeafMenu(
+            db, ct, 1329004200001L, returnDirId, "采购退货单",
+            "/aidop/s4/return-mgmt/purchase-return-order", "aidopS4PurchaseReturnOrder",
+            "/aidop/s4/return/purchaseReturnOrderList", "ele-Document", 10, "S4 采购退货单");
+
+        EnsureS3LeafMenu(
+            db, ct, 1329004200003L, returnDirId, "IQC 退货查询",
+            "/aidop/s4/return-mgmt/iqc-return-query", "aidopS4IqcReturnQuery",
+            "/aidop/s4/return/iqcReturnQueryList", "ele-Search", 20, "S4 IQC 退货查询(只读列表)");
+
+        var formMenu = db.Queryable<SysMenu>().First(m => m.Id == 1329004200002L);
+        if (formMenu == null)
+        {
+            db.Insertable(new SysMenu
+            {
+                Id = 1329004200002L,
+                Pid = returnDirId,
+                Title = "采购退货单表单",
+                Path = "/aidop/s4/return-mgmt/purchase-return-order-form",
+                Name = "aidopS4PurchaseReturnOrderForm",
+                Component = "/aidop/s4/return/purchaseReturnOrderForm",
+                Icon = "ele-Document",
+                Type = MenuTypeEnum.Menu,
+                IsHide = true,
+                CreateTime = ct,
+                OrderNo = 90,
+                Status = StatusEnum.Enable,
+                Remark = "S4 采购退货单新增/编辑/查看表单"
+            }).ExecuteCommand();
+        }
+        else
+        {
+            var formNeedFix = formMenu.Pid != returnDirId
+                              || formMenu.Path != "/aidop/s4/return-mgmt/purchase-return-order-form"
+                              || formMenu.Name != "aidopS4PurchaseReturnOrderForm"
+                              || formMenu.Component != "/aidop/s4/return/purchaseReturnOrderForm"
+                              || formMenu.Title != "采购退货单表单"
+                              || formMenu.Icon != "ele-Document"
+                              || formMenu.Type != MenuTypeEnum.Menu
+                              || formMenu.OrderNo != 90
+                              || formMenu.IsHide != true;
+            if (formNeedFix)
+            {
+                formMenu.Pid = returnDirId;
+                formMenu.Path = "/aidop/s4/return-mgmt/purchase-return-order-form";
+                formMenu.Name = "aidopS4PurchaseReturnOrderForm";
+                formMenu.Component = "/aidop/s4/return/purchaseReturnOrderForm";
+                formMenu.Title = "采购退货单表单";
+                formMenu.Icon = "ele-Document";
+                formMenu.Type = MenuTypeEnum.Menu;
+                formMenu.OrderNo = 90;
+                formMenu.IsHide = true;
+                formMenu.Remark = "S4 采购退货单新增/编辑/查看表单";
+                db.Updateable(formMenu)
+                    .UpdateColumns(m => new { m.Pid, m.Path, m.Name, m.Component, m.Title, m.Icon, m.Type, m.OrderNo, m.IsHide, m.Remark })
+                    .ExecuteCommand();
+            }
+        }
+    }
+
+    /// <summary>S4:隐藏规划占位「采购管理」;纠正「采购执行看板」路径与组件。</summary>
+    private static void NormalizeS4TopLevelMenus(ISqlSugarClient db)
+    {
+        const long purchaseMgmtPlaceholderId = 1322000000011L;
+        const long executionKanbanId = 1322000000014L;
+
+        if (db.Queryable<SysMenu>().Any(m => m.Id == purchaseMgmtPlaceholderId))
+        {
+            var proc = db.Queryable<SysMenu>().First(m => m.Id == purchaseMgmtPlaceholderId);
+            if (proc != null && proc.IsHide != true)
+            {
+                proc.IsHide = true;
+                db.Updateable(proc).UpdateColumns(m => new { m.IsHide }).ExecuteCommand();
+            }
+        }
+
+        if (!db.Queryable<SysMenu>().Any(m => m.Id == executionKanbanId))
+            return;
+        var kb = db.Queryable<SysMenu>().First(m => m.Id == executionKanbanId);
+
+        var needFix = kb.Path != "/aidop/s4/execution-kanban"
+                      || kb.Name != "aidopS4ExecutionKanban"
+                      || kb.Component != "Layout"
+                      || kb.Type != MenuTypeEnum.Dir
+                      || kb.Icon != "ele-Folder"
+                      || kb.Redirect != "/aidop/s4/execution-kanban/dashboard";
+        if (needFix)
+        {
+            kb.Path = "/aidop/s4/execution-kanban";
+            kb.Name = "aidopS4ExecutionKanban";
+            kb.Component = "Layout";
+            kb.Type = MenuTypeEnum.Dir;
+            kb.Icon = "ele-Folder";
+            kb.Redirect = "/aidop/s4/execution-kanban/dashboard";
+            kb.Remark = "S4 采购执行看板目录(含主页 + 供应商欠料看板)";
+            db.Updateable(kb)
+                .UpdateColumns(m => new { m.Path, m.Name, m.Component, m.Type, m.Icon, m.Redirect, m.Remark })
+                .ExecuteCommand();
+        }
+
+        var ct = DateTime.Parse("2022-02-10 00:00:00");
+        EnsureS3LeafMenu(
+            db, ct, 1329004300001L, executionKanbanId, "采购执行看板主页",
+            "/aidop/s4/execution-kanban/dashboard", "aidopS4ExecutionKanbanDashboard",
+            "/aidop/kanban/s4", "ele-DataBoard", 10, "S4 采购执行看板主页(/aidop/kanban/s4)");
+
+        EnsureS3LeafMenu(
+            db, ct, 1329004300002L, executionKanbanId, "供应商欠料看板",
+            "/aidop/s4/execution-kanban/supplier-shortage-kanban", "aidopS4SupplierShortageKanban",
+            "/aidop/s4/execution-kanban/supplierShortageKanbanList", "ele-List", 20, "S4 供应商欠料看板(WorkOrdDetailTotalKB)");
+    }
+
     private static void EnsureS3LeafMenu(
         ISqlSugarClient db,
         DateTime ct,

+ 50 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/IqcReturnQueryDto.cs

@@ -0,0 +1,50 @@
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
+
+/// <summary>S4 IQC 退货查询 — 列表入参。</summary>
+public class IqcReturnListInput
+{
+    /// <summary>采购单号/来源订单号 LIKE(PurOrdDetail / FSRCORDERNUM)</summary>
+    public string OrdNbr { get; set; }
+
+    /// <summary>批次号 pch LIKE</summary>
+    public string Pch { get; set; }
+
+    /// <summary>物料编号 FMATERIALCFG = 精确</summary>
+    public string FMaterialCfg { get; set; }
+
+    /// <summary>供应商 SortName LIKE(SuppMaster,含无采购明细时的名称兜底)</summary>
+    public string SortName { get; set; }
+
+    public string SortField { get; set; }
+    public string SortOrder { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+}
+
+/// <summary>S4 IQC 退货查询 — 分页行。</summary>
+public class IqcReturnListRow
+{
+    /// <summary>前端 row-key(避免 bigint 精度问题)</summary>
+    public string RowKey { get; set; }
+
+    public long EntryId { get; set; }
+
+    /// <summary>单据编号(申请单号优先,其次检验单号)</summary>
+    public string FBillNo { get; set; }
+
+    public string OrdNbr { get; set; }
+    public string Pch { get; set; }
+    public string FMaterialCfg { get; set; }
+    public string SortName { get; set; }
+    public string Supp { get; set; }
+    public string Gysmc { get; set; }
+    public string Wlmc { get; set; }
+    public string Ggxh { get; set; }
+    public decimal? FrInsQty { get; set; }
+    public decimal? Bhgsl { get; set; }
+    public long? Clfs { get; set; }
+    public string Thyy { get; set; }
+    public string FInspectStatus { get; set; }
+    public DateTime? FApplyTime { get; set; }
+    public DateTime? FInspeEndDate { get; set; }
+}

+ 180 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/PurchaseReturnDto.cs

@@ -0,0 +1,180 @@
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
+
+public class PurchaseReturnListInput
+{
+    public string Receiver { get; set; }
+    public string Supp { get; set; }
+    public string OrdNbr { get; set; }
+    public string ItemNum { get; set; }
+    public string SortField { get; set; }
+    public string SortOrder { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+}
+
+public class PurchaseReturnListRow
+{
+    public int Id { get; set; }
+    public int DetailRecId { get; set; }
+    public string RctType { get; set; }
+    public string Receiver { get; set; }
+    public DateTime? RctDate { get; set; }
+    public string OrdNbr { get; set; }
+    public string TaxClass { get; set; }
+    public bool TaxIn { get; set; }
+    public string Curr { get; set; }
+    public string Terms { get; set; }
+    public bool IsPlan { get; set; }
+    public string DepartmentDescr { get; set; }
+    public string SuppName { get; set; }
+    public string ItemNum { get; set; }
+    public string Descr { get; set; }
+    public string Descr1 { get; set; }
+    public decimal RctQty { get; set; }
+}
+
+public class PurchaseReturnKvItem
+{
+    public string Value { get; set; }
+    public string Label { get; set; }
+}
+
+public class PurchaseReturnRcLineRow
+{
+    public string Domain { get; set; }
+    public string Department { get; set; }
+    public string Terms { get; set; }
+    public string TermsofTrade { get; set; }
+    public string ItemNum { get; set; }
+    public string UM { get; set; }
+    public string OrdNbr { get; set; }
+    public int OrdLine { get; set; }
+    public decimal QtyOrded { get; set; }
+    public string Supp { get; set; }
+    public string LotSerial { get; set; }
+    public string Location { get; set; }
+    public decimal QtyReceived { get; set; }
+    public string Potype { get; set; }
+    public string Typed { get; set; }
+    public string TaxClass { get; set; }
+    public bool TaxIn { get; set; }
+    public string Curr { get; set; }
+    public string RctDate { get; set; }
+    public decimal UMConversion { get; set; }
+    public decimal POCost { get; set; }
+    public decimal QtyReturn { get; set; }
+    public string RctNbr { get; set; }
+    public int RctLine { get; set; }
+    public string CustPO { get; set; }
+    public int BlanketLine { get; set; }
+    public decimal RctQty { get; set; }
+    public int Line { get; set; }
+}
+
+public class PurchaseReturnMasterDto
+{
+    public int RecID { get; set; }
+    public string Domain { get; set; }
+    public string Receiver { get; set; }
+    public string RctDate { get; set; }
+    public string Supp { get; set; }
+    public string OrdNbr { get; set; }
+    public string Department { get; set; }
+    public string Terms { get; set; }
+    public string Curr { get; set; }
+    public string TaxClass { get; set; }
+    public bool TaxIn { get; set; }
+    public string TermsofTrade { get; set; }
+    public string Remark { get; set; }
+    public bool IsPlan { get; set; }
+}
+
+public class PurchaseReturnDetailDto
+{
+    public int? RecID { get; set; }
+    public short Line { get; set; }
+    public string ItemNum { get; set; }
+    public string OrdNbr { get; set; }
+    public int OrdLine { get; set; }
+    public decimal QtyOrded { get; set; }
+    public decimal QtyReceived { get; set; }
+    public decimal RctQty { get; set; }
+    public string LotSerial { get; set; }
+    public string UM { get; set; }
+    public string Location { get; set; }
+    public string QCDescr { get; set; }
+    public string Remark { get; set; }
+    public string RctDate { get; set; }
+    public string Typed { get; set; }
+    public bool TaxIn { get; set; }
+    public string TaxClass { get; set; }
+    public string Curr { get; set; }
+    public string Potype { get; set; }
+    public bool IsActive { get; set; }
+    public bool IsConfirm { get; set; }
+    public decimal UMConversion { get; set; }
+    public decimal POCost { get; set; }
+    public string RctNbr { get; set; }
+    public int RctLine { get; set; }
+    public string CustPO { get; set; }
+    public int BlanketLine { get; set; }
+    public decimal QtyReturn { get; set; }
+    public string Supp { get; set; }
+}
+
+public class PurchaseReturnSaveInput
+{
+    public int? Id { get; set; }
+    public string RctDate { get; set; }
+    public string Supp { get; set; }
+    public string OrdNbr { get; set; }
+    public string Department { get; set; }
+    public string Remark { get; set; }
+    public string Terms { get; set; }
+    public string Curr { get; set; }
+    public string TaxClass { get; set; }
+    public bool TaxIn { get; set; }
+    public string TermsofTrade { get; set; }
+    public string Domain { get; set; }
+    public List<PurchaseReturnDetailInput> Details { get; set; } = new();
+}
+
+public class PurchaseReturnDetailInput
+{
+    public int? RecID { get; set; }
+    public short Line { get; set; }
+    public string ItemNum { get; set; }
+    public string OrdNbr { get; set; }
+    public int OrdLine { get; set; }
+    public decimal QtyOrded { get; set; }
+    public decimal QtyReceived { get; set; }
+    public decimal RctQty { get; set; }
+    public string LotSerial { get; set; }
+    public string UM { get; set; }
+    public string Location { get; set; }
+    public string QCDescr { get; set; }
+    public string Remark { get; set; }
+    public string RctDate { get; set; }
+    public string Typed { get; set; }
+    public bool TaxIn { get; set; }
+    public string TaxClass { get; set; }
+    public string Curr { get; set; }
+    public string Potype { get; set; }
+    public bool IsActive { get; set; } = true;
+    public bool IsConfirm { get; set; }
+    public decimal UMConversion { get; set; }
+    public decimal POCost { get; set; }
+    public string RctNbr { get; set; }
+    public int RctLine { get; set; }
+    public string CustPO { get; set; }
+    public int BlanketLine { get; set; }
+    public decimal QtyReturn { get; set; }
+    public string Supp { get; set; }
+}
+
+public class KeywordPageInput
+{
+    public string Keyword { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}

+ 44 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/SupplierShortageKanbanDto.cs

@@ -0,0 +1,44 @@
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
+
+/// <summary>S4 供应商欠料看板 — 列表入参。</summary>
+public class SupplierShortageKanbanListInput
+{
+    /// <summary>物料编码 =</summary>
+    public string ItemNum { get; set; }
+
+    /// <summary>供应商名称 LIKE</summary>
+    public string SupplierName { get; set; }
+
+    public string SortField { get; set; }
+    public string SortOrder { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+}
+
+/// <summary>S4 供应商欠料看板 — 分页行。</summary>
+public class SupplierShortageKanbanListRow
+{
+    public long RowNum { get; set; }
+    public string Supplier_Number { get; set; }
+    public string Supplier_Name { get; set; }
+    public string ItemNum { get; set; }
+    public string Descr { get; set; }
+    public string Descr1 { get; set; }
+
+    public decimal? D0 { get; set; }
+    public decimal? D1 { get; set; }
+    public decimal? D2 { get; set; }
+    public decimal? D3 { get; set; }
+    public decimal? D4 { get; set; }
+    public decimal? D5 { get; set; }
+    public decimal? D6 { get; set; }
+    public decimal? D7 { get; set; }
+    public decimal? D8 { get; set; }
+    public decimal? D9 { get; set; }
+    public decimal? D10 { get; set; }
+    public decimal? D11 { get; set; }
+    public decimal? D12 { get; set; }
+    public decimal? D13 { get; set; }
+    public decimal? D14 { get; set; }
+}
+

+ 182 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Entity/PurOrdRctDetail.cs

@@ -0,0 +1,182 @@
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
+
+/// <summary>
+/// PurOrdRctDetail(aidopdev 采购收货/退货明细)
+/// </summary>
+[SugarTable("PurOrdRctDetail")]
+public class PurOrdRctDetail
+{
+    [SugarColumn(ColumnName = "RecID", IsPrimaryKey = true, IsIdentity = false)]
+    public int RecID { get; set; }
+
+    [SugarColumn(ColumnName = "Domain", Length = 24, IsNullable = true)]
+    public string Domain { get; set; }
+
+    [SugarColumn(ColumnName = "RctType", Length = 8, IsNullable = false)]
+    public string RctType { get; set; }
+
+    [SugarColumn(ColumnName = "Receiver", Length = 24, IsNullable = false)]
+    public string Receiver { get; set; }
+
+    [SugarColumn(ColumnName = "Line")]
+    public short Line { get; set; }
+
+    [SugarColumn(ColumnName = "ItemNum", Length = 60, IsNullable = true)]
+    public string ItemNum { get; set; }
+
+    [SugarColumn(ColumnName = "Dimension1", Length = 8, IsNullable = false)]
+    public string Dimension1 { get; set; }
+
+    [SugarColumn(ColumnName = "Dimension2", Length = 8, IsNullable = false)]
+    public string Dimension2 { get; set; }
+
+    [SugarColumn(ColumnName = "OrdNbr", Length = 24, IsNullable = false)]
+    public string OrdNbr { get; set; }
+
+    [SugarColumn(ColumnName = "RctDate", IsNullable = true)]
+    public DateTime? RctDate { get; set; }
+
+    [SugarColumn(ColumnName = "POCost", ColumnDataType = "decimal(15,6)")]
+    public decimal POCost { get; set; }
+
+    [SugarColumn(ColumnName = "UM", Length = 8, IsNullable = true)]
+    public string UM { get; set; }
+
+    [SugarColumn(ColumnName = "PerformDate", IsNullable = true)]
+    public DateTime? PerformDate { get; set; }
+
+    [SugarColumn(ColumnName = "QtyOrded", ColumnDataType = "decimal(15,5)")]
+    public decimal QtyOrded { get; set; }
+
+    [SugarColumn(ColumnName = "RctQty", ColumnDataType = "decimal(15,5)")]
+    public decimal RctQty { get; set; }
+
+    [SugarColumn(ColumnName = "Site", Length = 24, IsNullable = true)]
+    public string Site { get; set; }
+
+    [SugarColumn(ColumnName = "Supp", Length = 20, IsNullable = true)]
+    public string Supp { get; set; }
+
+    [SugarColumn(ColumnName = "Location", Length = 8, IsNullable = false)]
+    public string Location { get; set; }
+
+    [SugarColumn(ColumnName = "LotSerial", Length = 120, IsNullable = true)]
+    public string LotSerial { get; set; }
+
+    [SugarColumn(ColumnName = "Refs", Length = 8, IsNullable = true)]
+    public string Refs { get; set; }
+
+    [SugarColumn(ColumnName = "QtyReceived", ColumnDataType = "decimal(15,5)")]
+    public decimal QtyReceived { get; set; }
+
+    [SugarColumn(ColumnName = "Potype", Length = 8, IsNullable = false)]
+    public string Potype { get; set; }
+
+    [SugarColumn(ColumnName = "BusinessID")]
+    public long BusinessID { get; set; }
+
+    [SugarColumn(ColumnName = "CreateUser", Length = 24, IsNullable = true)]
+    public string CreateUser { get; set; }
+
+    [SugarColumn(ColumnName = "UpdateUser", Length = 24, IsNullable = true)]
+    public string UpdateUser { get; set; }
+
+    [SugarColumn(ColumnName = "CreateTime", IsNullable = true)]
+    public DateTime? CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "UpdateTime", IsNullable = true)]
+    public DateTime? UpdateTime { get; set; }
+
+    [SugarColumn(ColumnName = "IsActive", ColumnDataType = "bit(1)")]
+    public bool IsActive { get; set; }
+
+    [SugarColumn(ColumnName = "IsConfirm", ColumnDataType = "bit(1)")]
+    public bool IsConfirm { get; set; }
+
+    [SugarColumn(ColumnName = "Typed", Length = 4, IsNullable = true)]
+    public string Typed { get; set; }
+
+    [SugarColumn(ColumnName = "QC", Length = 8, IsNullable = true)]
+    public string QC { get; set; }
+
+    [SugarColumn(ColumnName = "QCDescr", ColumnDataType = "text", IsNullable = true)]
+    public string QCDescr { get; set; }
+
+    [SugarColumn(ColumnName = "QCDate", IsNullable = true)]
+    public DateTime? QCDate { get; set; }
+
+    [SugarColumn(ColumnName = "Remark", Length = 200, IsNullable = true)]
+    public string Remark { get; set; }
+
+    [SugarColumn(ColumnName = "IsChanged", ColumnDataType = "bit(1)")]
+    public bool IsChanged { get; set; }
+
+    [SugarColumn(ColumnName = "TaxClass", Length = 20, IsNullable = true)]
+    public string TaxClass { get; set; }
+
+    [SugarColumn(ColumnName = "TaxIn", ColumnDataType = "bit(1)")]
+    public bool TaxIn { get; set; }
+
+    [SugarColumn(ColumnName = "UMConversion", ColumnDataType = "decimal(12,7)")]
+    public decimal UMConversion { get; set; }
+
+    [SugarColumn(ColumnName = "Curr", Length = 3, IsNullable = true)]
+    public string Curr { get; set; }
+
+    [SugarColumn(ColumnName = "IsClosed", ColumnDataType = "bit(1)")]
+    public bool IsClosed { get; set; }
+
+    [SugarColumn(ColumnName = "Delivery", Length = 30, IsNullable = true)]
+    public string Delivery { get; set; }
+
+    [SugarColumn(ColumnName = "InvShelf", Length = 24, IsNullable = true)]
+    public string InvShelf { get; set; }
+
+    [SugarColumn(ColumnName = "EffTime", IsNullable = true)]
+    public DateTime? EffTime { get; set; }
+
+    [SugarColumn(ColumnName = "OrdLine")]
+    public short OrdLine { get; set; }
+
+    [SugarColumn(ColumnName = "QCNbr", Length = 60, IsNullable = true)]
+    public string QCNbr { get; set; }
+
+    [SugarColumn(ColumnName = "StatusByQC", Length = 12, IsNullable = true)]
+    public string StatusByQC { get; set; }
+
+    [SugarColumn(ColumnName = "IsRounding", ColumnDataType = "bit(1)")]
+    public bool IsRounding { get; set; }
+
+    [SugarColumn(ColumnName = "CustPO", Length = 100, IsNullable = true)]
+    public string CustPO { get; set; }
+
+    [SugarColumn(ColumnName = "BarCode", Length = 80, IsNullable = true)]
+    public string BarCode { get; set; }
+
+    [SugarColumn(ColumnName = "BlanketLine")]
+    public int BlanketLine { get; set; }
+
+    [SugarColumn(ColumnName = "ProjectLine")]
+    public int ProjectLine { get; set; }
+
+    [SugarColumn(ColumnName = "RctNbr", Length = 30, IsNullable = true)]
+    public string RctNbr { get; set; }
+
+    [SugarColumn(ColumnName = "RctLine")]
+    public short RctLine { get; set; }
+
+    [SugarColumn(ColumnName = "QtyReturn", ColumnDataType = "decimal(15,5)")]
+    public decimal QtyReturn { get; set; }
+
+    [SugarColumn(ColumnName = "PurOrdRctRecID")]
+    public long PurOrdRctRecID { get; set; }
+
+    [SugarColumn(ColumnName = "QcQty", ColumnDataType = "decimal(18,5)")]
+    public decimal QcQty { get; set; }
+
+    [SugarColumn(ColumnName = "ERPfld1", Length = 200, IsNullable = true)]
+    public string ERPfld1 { get; set; }
+
+    [SugarColumn(ColumnName = "ERPfld2", Length = 200, IsNullable = true)]
+    public string ERPfld2 { get; set; }
+}

+ 128 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Entity/PurOrdRctMaster.cs

@@ -0,0 +1,128 @@
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
+
+/// <summary>
+/// PurOrdRctMaster(aidopdev 采购收货/退货类单头)
+/// </summary>
+[SugarTable("PurOrdRctMaster")]
+public class PurOrdRctMaster
+{
+    [SugarColumn(ColumnName = "RecID", IsPrimaryKey = true, IsIdentity = false)]
+    public int RecID { get; set; }
+
+    [SugarColumn(ColumnName = "Domain", Length = 24, IsNullable = true)]
+    public string Domain { get; set; }
+
+    [SugarColumn(ColumnName = "RctType", Length = 8, IsNullable = false)]
+    public string RctType { get; set; }
+
+    [SugarColumn(ColumnName = "Receiver", Length = 24, IsNullable = false)]
+    public string Receiver { get; set; }
+
+    [SugarColumn(ColumnName = "RctDate", IsNullable = true)]
+    public DateTime? RctDate { get; set; }
+
+    [SugarColumn(ColumnName = "OrdNbr", Length = 24, IsNullable = true)]
+    public string OrdNbr { get; set; }
+
+    [SugarColumn(ColumnName = "PerformDate", IsNullable = true)]
+    public DateTime? PerformDate { get; set; }
+
+    [SugarColumn(ColumnName = "Site", Length = 24, IsNullable = true)]
+    public string Site { get; set; }
+
+    [SugarColumn(ColumnName = "BusinessID")]
+    public long BusinessID { get; set; }
+
+    [SugarColumn(ColumnName = "CreateUser", Length = 24, IsNullable = true)]
+    public string CreateUser { get; set; }
+
+    [SugarColumn(ColumnName = "UpdateUser", Length = 24, IsNullable = true)]
+    public string UpdateUser { get; set; }
+
+    [SugarColumn(ColumnName = "CreateTime", IsNullable = true)]
+    public DateTime? CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "UpdateTime", IsNullable = true)]
+    public DateTime? UpdateTime { get; set; }
+
+    [SugarColumn(ColumnName = "EffTime", IsNullable = true)]
+    public DateTime? EffTime { get; set; }
+
+    [SugarColumn(ColumnName = "IsActive", ColumnDataType = "bit(1)")]
+    public bool IsActive { get; set; }
+
+    [SugarColumn(ColumnName = "IsConfirm", ColumnDataType = "bit(1)")]
+    public bool IsConfirm { get; set; }
+
+    [SugarColumn(ColumnName = "Typed", Length = 4, IsNullable = true)]
+    public string Typed { get; set; }
+
+    [SugarColumn(ColumnName = "Department", Length = 8, IsNullable = true)]
+    public string Department { get; set; }
+
+    [SugarColumn(ColumnName = "Supp", Length = 20, IsNullable = true)]
+    public string Supp { get; set; }
+
+    [SugarColumn(ColumnName = "Print", ColumnDataType = "bit(1)")]
+    public bool Print { get; set; }
+
+    [SugarColumn(ColumnName = "IsChanged", ColumnDataType = "bit(1)")]
+    public bool IsChanged { get; set; }
+
+    [SugarColumn(ColumnName = "TaxClass", Length = 20, IsNullable = true)]
+    public string TaxClass { get; set; }
+
+    [SugarColumn(ColumnName = "TaxIn", ColumnDataType = "bit(1)")]
+    public bool TaxIn { get; set; }
+
+    [SugarColumn(ColumnName = "Remark", Length = 200, IsNullable = true)]
+    public string Remark { get; set; }
+
+    [SugarColumn(ColumnName = "CustAddress", Length = 200, IsNullable = true)]
+    public string CustAddress { get; set; }
+
+    [SugarColumn(ColumnName = "CustContact", Length = 50, IsNullable = true)]
+    public string CustContact { get; set; }
+
+    [SugarColumn(ColumnName = "CustPhone", Length = 30, IsNullable = true)]
+    public string CustPhone { get; set; }
+
+    [SugarColumn(ColumnName = "Delivery", Length = 60, IsNullable = true)]
+    public string Delivery { get; set; }
+
+    [SugarColumn(ColumnName = "InvoiceNumber", Length = 100, IsNullable = true)]
+    public string InvoiceNumber { get; set; }
+
+    [SugarColumn(ColumnName = "Curr", Length = 8, IsNullable = true)]
+    public string Curr { get; set; }
+
+    [SugarColumn(ColumnName = "IsExport", ColumnDataType = "bit(1)")]
+    public bool IsExport { get; set; }
+
+    [SugarColumn(ColumnName = "Terms", Length = 20, IsNullable = true)]
+    public string Terms { get; set; }
+
+    [SugarColumn(ColumnName = "IsPlan", ColumnDataType = "bit(1)")]
+    public bool IsPlan { get; set; }
+
+    [SugarColumn(ColumnName = "RoleNo", Length = 20, IsNullable = true)]
+    public string RoleNo { get; set; }
+
+    [SugarColumn(ColumnName = "TermsofTrade", Length = 20, IsNullable = true)]
+    public string TermsofTrade { get; set; }
+
+    [SugarColumn(ColumnName = "OrdType", Length = 30, IsNullable = true)]
+    public string OrdType { get; set; }
+
+    [SugarColumn(ColumnName = "Ufld1", Length = 30, IsNullable = true)]
+    public string Ufld1 { get; set; }
+
+    [SugarColumn(ColumnName = "Ufld2", Length = 30, IsNullable = true)]
+    public string Ufld2 { get; set; }
+
+    [SugarColumn(ColumnName = "IsBackwash", ColumnDataType = "bit(1)")]
+    public bool IsBackwash { get; set; }
+
+    [SugarColumn(ColumnName = "ERPfld1", Length = 200, IsNullable = true)]
+    public string ERPfld1 { get; set; }
+}

+ 148 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/IqcReturnQueryService.cs

@@ -0,0 +1,148 @@
+using Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
+
+/// <summary>
+/// S4 IQC 退货查询(只读列表)。数据:qms_qcp_insappnentry + qms_qcp_inspecapplyn + qms_qcp_inspbill,可选 PurOrdDetail / SuppMaster。
+/// 路由:/api/ProcurementExecution/iqc-return/list
+/// </summary>
+[ApiDescriptionSettings(Order = 322, Description = "S4 IQC退货查询")]
+[Route("api/ProcurementExecution")]
+[AllowAnonymous]
+[NonUnify]
+public class IqcReturnQueryService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public IqcReturnQueryService(ISqlSugarClient db) => _db = db;
+
+    /// <summary>
+    /// 退货相关处理方式:NULL 视作 0;与 aidopdev 中 clfs 枚举一致时可再收紧为仅 clfs=3。
+    /// </summary>
+    private const string ReturnClfsPredicate = "(IFNULL(a.clfs, 0) IN (0, 3))";
+
+    private static string NormalizeOrderBy(string field, string order)
+    {
+        var col = (field ?? "").Trim().ToLowerInvariant();
+        var ord = string.Equals(order?.Trim(), "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC";
+        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["fbillno"] = "t.FBillNo",
+            ["ordnbr"] = "t.OrdNbr",
+            ["pch"] = "t.Pch",
+            ["fmaterialcfg"] = "t.FMaterialCfg",
+            ["sortname"] = "t.SortName",
+            ["supp"] = "t.Supp",
+            ["gysmc"] = "t.Gysmc",
+            ["wlmc"] = "t.Wlmc",
+            ["ggxh"] = "t.Ggxh",
+            ["frinsqty"] = "t.FrInsQty",
+            ["bhgsl"] = "t.Bhgsl",
+            ["clfs"] = "t.Clfs",
+            ["finspectstatus"] = "t.FInspectStatus",
+            ["fapplytime"] = "t.FApplyTime",
+            ["finspeenddate"] = "t.FInspeEndDate",
+        };
+        if (!map.TryGetValue(col, out var sqlCol))
+            sqlCol = "t.FApplyTime";
+        return $"ORDER BY {sqlCol} {ord}, t.EntryId ASC";
+    }
+
+    [DisplayName("IQC退货查询分页列表")]
+    [HttpGet("iqc-return/list")]
+    public async Task<object> GetList([FromQuery] IqcReturnListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var cond = new List<string> { ReturnClfsPredicate };
+        var pars = new List<SugarParameter>();
+
+        if (!string.IsNullOrWhiteSpace(input.OrdNbr))
+        {
+            cond.Add("(TRIM(IFNULL(c.FSRCORDERNUM,'')) LIKE @OrdNbr OR TRIM(IFNULL(pod.PurOrd,'')) LIKE @OrdNbr)");
+            pars.Add(new SugarParameter("@OrdNbr", $"%{input.OrdNbr.Trim()}%"));
+        }
+
+        if (!string.IsNullOrWhiteSpace(input.Pch))
+        {
+            cond.Add("TRIM(IFNULL(COALESCE(a.pch, c.FLOTNUMBER, ''),'')) LIKE @Pch");
+            pars.Add(new SugarParameter("@Pch", $"%{input.Pch.Trim()}%"));
+        }
+
+        if (!string.IsNullOrWhiteSpace(input.FMaterialCfg))
+        {
+            cond.Add("TRIM(IFNULL(COALESCE(c.FMATERIALCFG, a.FMATERIALCFG, pod.ItemNum),'')) = TRIM(@FMat)");
+            pars.Add(new SugarParameter("@FMat", input.FMaterialCfg.Trim()));
+        }
+
+        if (!string.IsNullOrWhiteSpace(input.SortName))
+        {
+            cond.Add("""
+                (
+                  EXISTS (
+                    SELECT 1 FROM SuppMaster s
+                    WHERE TRIM(IFNULL(c.FSUPPLIER,'')) <> ''
+                      AND s.Supp = TRIM(IFNULL(c.FSUPPLIER,''))
+                      AND (pod.Domain IS NULL OR s.Domain = pod.Domain)
+                      AND TRIM(IFNULL(s.SortName,'')) LIKE @SortName
+                    LIMIT 1
+                  )
+                  OR TRIM(IFNULL(COALESCE(c.gysmc, a.gysmc, ''),'')) LIKE @SortName
+                )
+                """);
+            pars.Add(new SugarParameter("@SortName", $"%{input.SortName.Trim()}%"));
+        }
+
+        var where = string.Join(" AND ", cond);
+
+        var baseSql = $"""
+            SELECT
+              CAST(c.id AS CHAR(32)) AS RowKey,
+              c.id AS EntryId,
+              TRIM(IFNULL(
+                NULLIF(TRIM(CAST(IFNULL(b.FBILLNO,'') AS CHAR(512))), ''),
+                TRIM(IFNULL(a.FBILLNO,''))
+              )) AS FBillNo,
+              TRIM(IFNULL(c.FSRCORDERNUM,'')) AS OrdNbr,
+              TRIM(IFNULL(COALESCE(a.pch, c.FLOTNUMBER, ''),'')) AS Pch,
+              TRIM(IFNULL(COALESCE(c.FMATERIALCFG, a.FMATERIALCFG, pod.ItemNum),'')) AS FMaterialCfg,
+              (SELECT TRIM(IFNULL(s.SortName,''))
+               FROM SuppMaster s
+               WHERE TRIM(IFNULL(c.FSUPPLIER,'')) <> ''
+                 AND s.Supp = TRIM(IFNULL(c.FSUPPLIER,''))
+                 AND (pod.Domain IS NULL OR s.Domain = pod.Domain)
+               LIMIT 1) AS SortName,
+              TRIM(IFNULL(c.FSUPPLIER,'')) AS Supp,
+              TRIM(IFNULL(COALESCE(c.gysmc, a.gysmc, ''),'')) AS Gysmc,
+              TRIM(IFNULL(COALESCE(c.wlmc, a.wlmc, ''),'')) AS Wlmc,
+              TRIM(IFNULL(COALESCE(c.ggxh, a.ggxh, ''),'')) AS Ggxh,
+              CAST(IFNULL(a.FRINSQTY, 0) AS DECIMAL(18,4)) AS FrInsQty,
+              CAST(IFNULL(a.bhgsl, 0) AS DECIMAL(18,4)) AS Bhgsl,
+              a.clfs AS Clfs,
+              TRIM(IFNULL(a.thyy,'')) AS Thyy,
+              TRIM(IFNULL(c.FINSPECTSTATUS,'')) AS FInspectStatus,
+              b.FAPPLYTIME AS FApplyTime,
+              a.FINSPEENDDATE AS FInspeEndDate
+            FROM qms_qcp_insappnentry c
+            INNER JOIN qms_qcp_inspecapplyn b ON b.id = c.glid
+            LEFT JOIN qms_qcp_inspbill a
+              ON BINARY TRIM(IFNULL(a.lydjbh,'')) = BINARY TRIM(CAST(IFNULL(b.FBILLNO,'') AS CHAR(512)))
+            LEFT JOIN PurOrdDetail pod
+              ON TRIM(IFNULL(c.FSRCORDERNUM,'')) <> ''
+              AND pod.PurOrd = TRIM(IFNULL(c.FSRCORDERNUM,''))
+              AND TRIM(IFNULL(c.FSRCBILLENTRYSEQ,'')) <> ''
+              AND CAST(pod.Line AS CHAR(32)) = TRIM(IFNULL(c.FSRCBILLENTRYSEQ,''))
+            WHERE {where}
+            """;
+
+        var orderBy = NormalizeOrderBy(input.SortField, input.SortOrder);
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseSql}) t", pars);
+        var list = await _db.Ado.SqlQueryAsync<IqcReturnListRow>(
+            $"SELECT * FROM ({baseSql}) t {orderBy} LIMIT {pageSize} OFFSET {offset}", pars);
+
+        return new { total, page, pageSize, list };
+    }
+}

+ 595 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/PurchaseReturnService.cs

@@ -0,0 +1,595 @@
+using Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
+using Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
+
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
+
+/// <summary>
+/// S4 采购退货单(PurOrdRctMaster / PurOrdRctDetail,RctType=pt)
+/// 路由:/api/ProcurementExecution/purchase-return/...
+/// </summary>
+[ApiDescriptionSettings(Order = 321, Description = "S4采购退货单")]
+[Route("api/ProcurementExecution")]
+[AllowAnonymous]
+[NonUnify]
+public class PurchaseReturnService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly SqlSugarRepository<PurOrdRctMaster> _masterRep;
+    private readonly SqlSugarRepository<PurOrdRctDetail> _detailRep;
+    private readonly UserManager _userManager;
+
+    public PurchaseReturnService(
+        ISqlSugarClient db,
+        SqlSugarRepository<PurOrdRctMaster> masterRep,
+        SqlSugarRepository<PurOrdRctDetail> detailRep,
+        UserManager userManager)
+    {
+        _db = db;
+        _masterRep = masterRep;
+        _detailRep = detailRep;
+        _userManager = userManager;
+    }
+
+    private static string NormalizeOrderBy(string field, string order)
+    {
+        var col = (field ?? "").Trim();
+        var ord = string.Equals(order?.Trim(), "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC";
+        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+        {
+            // 注意:列表 SQL 外层包了 SELECT * FROM (...) t,因此 ORDER BY 只能引用外层可见列名
+            ["receiver"] = "Receiver",
+            ["rctdate"] = "RctDate",
+            ["ordnbr"] = "OrdNbr",
+            ["itemnum"] = "ItemNum",
+            ["rctqty"] = "RctQty",
+            ["suppname"] = "SuppName",
+        };
+        if (!map.TryGetValue(col, out var sqlCol))
+            sqlCol = "Id";
+        return $"ORDER BY {sqlCol} {ord}, DetailRecId ASC";
+    }
+
+    [DisplayName("采购退货单分页列表")]
+    [HttpGet("purchase-return/list")]
+    public async Task<object> GetList([FromQuery] PurchaseReturnListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var cond = new List<string> { "p.RctType='pt'", "p.IsActive=1" };
+        var pars = new List<SugarParameter>();
+        if (!string.IsNullOrWhiteSpace(input.Receiver))
+        {
+            cond.Add("p.Receiver LIKE @Receiver");
+            pars.Add(new SugarParameter("@Receiver", $"%{input.Receiver.Trim()}%"));
+        }
+
+        if (!string.IsNullOrWhiteSpace(input.Supp))
+        {
+            cond.Add("p.Supp=@Supp");
+            pars.Add(new SugarParameter("@Supp", input.Supp.Trim()));
+        }
+
+        if (!string.IsNullOrWhiteSpace(input.OrdNbr))
+        {
+            cond.Add("p.OrdNbr LIKE @OrdNbr");
+            pars.Add(new SugarParameter("@OrdNbr", $"%{input.OrdNbr.Trim()}%"));
+        }
+
+        if (!string.IsNullOrWhiteSpace(input.ItemNum))
+        {
+            cond.Add("d1.ItemNum LIKE @ItemNum");
+            pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
+        }
+
+        var where = string.Join(" AND ", cond);
+        var baseSql = $"""
+            SELECT
+              p.RecID AS Id,
+              IFNULL(d1.RecID, 0) AS DetailRecId,
+              p.RctType AS RctType,
+              p.Receiver AS Receiver,
+              p.RctDate AS RctDate,
+              p.OrdNbr AS OrdNbr,
+              p.TaxClass AS TaxClass,
+              IFNULL(p.TaxIn, 0) AS TaxIn,
+              p.Curr AS Curr,
+              p.Terms AS Terms,
+              IFNULL(p.IsPlan, 0) AS IsPlan,
+              TRIM(CONCAT(TRIM(IFNULL(p.Department,'')),' ',TRIM(IFNULL(d.Descr,'')))) AS DepartmentDescr,
+              TRIM(CONCAT(TRIM(IFNULL(p.Supp,'')),' ',TRIM(IFNULL(s.SortName,'')))) AS SuppName,
+              d1.ItemNum AS ItemNum,
+              IFNULL(i.Descr,'') AS Descr,
+              IFNULL(i.Descr1,'') AS Descr1,
+              IFNULL(d1.RctQty, 0) AS RctQty
+            FROM PurOrdRctMaster p
+            LEFT JOIN PurOrdRctDetail d1 ON p.RecID = d1.PurOrdRctRecID AND d1.RctType='pt' AND d1.IsActive=1
+            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 ItemMaster i ON d1.Domain = i.Domain AND d1.ItemNum = i.ItemNum
+            WHERE {where}
+            """;
+
+        var orderBy = NormalizeOrderBy(input.SortField, input.SortOrder);
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseSql}) t", pars);
+        var list = await _db.Ado.SqlQueryAsync<PurchaseReturnListRow>(
+            $"SELECT * FROM ({baseSql}) t {orderBy} LIMIT {pageSize} OFFSET {offset}", pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("采购退货供应商下拉")]
+    [HttpGet("purchase-return/options/suppliers")]
+    public async Task<object> GetSuppliers()
+    {
+        var rows = await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
+            """
+            SELECT Supp AS `Value`,
+                   TRIM(CONCAT(TRIM(IFNULL(Supp,'')),' ',TRIM(IFNULL(SortName,'')))) AS `Label`
+            FROM SuppMaster
+            ORDER BY Supp
+            """);
+        return rows;
+    }
+
+    [DisplayName("收货单号下拉(rc)")]
+    [HttpGet("purchase-return/options/rc-receivers")]
+    public async Task<object> GetRcReceivers([FromQuery] string supp)
+    {
+        if (string.IsNullOrWhiteSpace(supp))
+            return Array.Empty<object>();
+        var rows = await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
+            """
+            SELECT p.Receiver AS `Value`,
+                   TRIM(CONCAT(TRIM(IFNULL(p.Receiver,'')),' ',
+                              TRIM(IFNULL(d.LotSerial,'')),' ',
+                              TRIM(IFNULL(d.ItemNum,'')),' ',
+                              TRIM(IFNULL(i.Descr,'')))) AS `Label`
+            FROM PurOrdRctMaster p
+            LEFT JOIN PurOrdRctDetail d
+              ON p.Domain = d.Domain AND p.RctType = d.RctType AND p.Receiver = d.Receiver
+            LEFT JOIN ItemMaster i ON d.Domain = i.Domain AND d.ItemNum = i.ItemNum
+            WHERE p.RctType = 'rc' AND p.Supp = @Supp AND d.RecID > 0 AND IFNULL(p.TermsofTrade,'') <> ''
+            GROUP BY p.Receiver, d.LotSerial, d.ItemNum, i.Descr, p.Domain
+            ORDER BY p.Receiver DESC
+            """,
+            new SugarParameter("@Supp", supp.Trim()));
+        return rows;
+    }
+
+    [DisplayName("按收货单加载明细模板")]
+    [HttpGet("purchase-return/rc-detail-lines")]
+    public async Task<object> GetRcDetailLines([FromQuery] string receiver)
+    {
+        if (string.IsNullOrWhiteSpace(receiver))
+            throw Oops.Oh("请先选择收货单号");
+        var rows = await _db.Ado.SqlQueryAsync<PurchaseReturnRcLineRow>(
+            """
+            SELECT
+              p.Domain AS Domain,
+              p.Department AS Department,
+              p.Terms AS Terms,
+              p.TermsofTrade AS TermsofTrade,
+              d.ItemNum AS ItemNum,
+              d.UM AS UM,
+              d.OrdNbr AS OrdNbr,
+              CAST(d.OrdLine AS SIGNED) AS OrdLine,
+              d.QtyOrded AS QtyOrded,
+              d.Supp AS Supp,
+              d.LotSerial AS LotSerial,
+              CASE WHEN UPPER(IFNULL(d.Potype,'')) = 'PW' THEN d.Supp ELSE d.Location END AS Location,
+              d.QtyReceived AS QtyReceived,
+              d.Potype AS Potype,
+              d.Typed AS Typed,
+              p.TaxClass AS TaxClass,
+              IFNULL(p.TaxIn, 0) AS TaxIn,
+              p.Curr AS Curr,
+              DATE_FORMAT(CURDATE(), '%Y-%m-%d') AS RctDate,
+              d.UMConversion AS UMConversion,
+              d.POCost AS POCost,
+              d.QtyReceived AS QtyReturn,
+              d.Receiver AS RctNbr,
+              CAST(d.Line AS SIGNED) AS RctLine,
+              d.CustPO AS CustPO,
+              d.BlanketLine AS BlanketLine,
+              IFNULL(d.RctQty, 0) AS RctQty,
+              CAST(d.Line AS SIGNED) AS Line
+            FROM PurOrdRctMaster p
+            LEFT JOIN PurOrdRctDetail d
+              ON p.Domain = d.Domain AND p.RctType = d.RctType AND p.Receiver = d.Receiver
+            WHERE p.RctType = 'rc' AND p.Receiver = @Receiver AND d.RecID > 0
+            ORDER BY d.Line
+            """,
+            new SugarParameter("@Receiver", receiver.Trim()));
+
+        PurchaseReturnRcLineRow head = null;
+        if (rows.Count > 0)
+            head = rows[0];
+        return new { head, lines = rows };
+    }
+
+    [DisplayName("部门下拉")]
+    [HttpGet("purchase-return/options/departments")]
+    public async Task<object> GetDepartments()
+    {
+        return await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
+            """
+            SELECT Department AS `Value`,
+                   TRIM(CONCAT(TRIM(IFNULL(Department,'')),' ',TRIM(IFNULL(Descr,'')))) AS `Label`
+            FROM DepartmentMaster
+            ORDER BY Department
+            """);
+    }
+
+    [DisplayName("库位下拉")]
+    [HttpGet("purchase-return/options/locations")]
+    public async Task<object> GetLocations()
+    {
+        return await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
+            """
+            SELECT Location AS `Value`,
+                   TRIM(CONCAT(TRIM(IFNULL(Location,'')),' ',TRIM(IFNULL(Descr,'')))) AS `Label`
+            FROM LocationMaster
+            WHERE IFNULL(Typed,'') <> 'Supp'
+            ORDER BY Location
+            """);
+    }
+
+    [DisplayName("退货原因下拉")]
+    [HttpGet("purchase-return/options/qc-reasons")]
+    public async Task<object> GetQcReasons()
+    {
+        return await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
+            """
+            SELECT Val AS `Value`,
+                   CONCAT(IFNULL(Val,''),'_',IFNULL(Comments,'')) AS `Label`
+            FROM GeneralizedCodeMaster
+            WHERE FldName = 'SAPTransReason'
+              AND IFNULL(Ufld1,'') <> ''
+              AND LOCATE('122', IFNULL(Ufld1,'')) > 0
+            ORDER BY Val
+            """);
+    }
+
+    [DisplayName("物料选择分页")]
+    [HttpGet("purchase-return/item-options")]
+    public async Task<object> GetItems([FromQuery] KeywordPageInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 20 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+        var pars = new List<SugarParameter>();
+        var where = "1=1";
+        if (!string.IsNullOrWhiteSpace(input.Keyword))
+        {
+            where += " AND (ItemNum LIKE @Kw OR Descr LIKE @Kw OR Descr1 LIKE @Kw)";
+            pars.Add(new SugarParameter("@Kw", $"%{input.Keyword.Trim()}%"));
+        }
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ItemMaster WHERE {where}", pars);
+        var list = await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
+            $"""
+             SELECT ItemNum AS `Value`,
+                    TRIM(CONCAT(IFNULL(Descr,''),' ',IFNULL(Descr1,''))) AS `Label`
+             FROM ItemMaster
+             WHERE {where}
+             ORDER BY ItemNum
+             LIMIT {pageSize} OFFSET {offset}
+             """,
+            pars);
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("采购退货单详情")]
+    [HttpGet("purchase-return/{id:int}")]
+    public async Task<object> GetDetail(int id)
+    {
+        var master = await _masterRep.GetFirstAsync(m => m.RecID == id && m.RctType == "pt")
+                     ?? throw Oops.Oh("单据不存在");
+
+        var m = await _db.Ado.SqlQueryAsync<PurchaseReturnMasterDto>(
+            """
+            SELECT
+              m.RecID AS RecID,
+              m.Domain AS Domain,
+              m.Receiver AS Receiver,
+              DATE_FORMAT(m.RctDate,'%Y-%m-%d') AS RctDate,
+              m.Supp AS Supp,
+              m.OrdNbr AS OrdNbr,
+              m.Department AS Department,
+              IFNULL(m.Terms,'') AS Terms,
+              IFNULL(m.Curr,'') AS Curr,
+              IFNULL(m.TaxClass,'') AS TaxClass,
+              IFNULL(m.TaxIn, 0) AS TaxIn,
+              IFNULL(m.TermsofTrade,'') AS TermsofTrade,
+              IFNULL(m.Remark,'') AS Remark,
+              IFNULL(m.IsPlan, 0) AS IsPlan
+            FROM PurOrdRctMaster m
+            WHERE m.RecID = @Id
+            LIMIT 1
+            """,
+            new SugarParameter("@Id", id));
+        var head = m.FirstOrDefault() ?? throw Oops.Oh("单据不存在");
+
+        var detailRows = await _db.Queryable<PurOrdRctDetail>()
+            .Where(d => d.PurOrdRctRecID == id && d.RctType == "pt" && d.IsActive)
+            .OrderBy(d => d.Line)
+            .ToListAsync();
+
+        var details = detailRows.Select(ToDetailDto).ToList();
+
+        return new { master = head, details };
+    }
+
+    private static PurchaseReturnDetailDto ToDetailDto(PurOrdRctDetail d)
+    {
+        return new PurchaseReturnDetailDto
+        {
+            RecID = d.RecID,
+            Line = d.Line,
+            ItemNum = d.ItemNum,
+            OrdNbr = d.OrdNbr,
+            OrdLine = d.OrdLine,
+            QtyOrded = d.QtyOrded,
+            QtyReceived = d.QtyReceived,
+            RctQty = d.RctQty,
+            LotSerial = d.LotSerial,
+            UM = d.UM,
+            Location = d.Location,
+            QCDescr = d.QCDescr,
+            Remark = d.Remark,
+            RctDate = d.RctDate?.ToString("yyyy-MM-dd"),
+            Typed = d.Typed,
+            TaxIn = d.TaxIn,
+            TaxClass = d.TaxClass,
+            Curr = d.Curr,
+            Potype = d.Potype,
+            IsActive = d.IsActive,
+            IsConfirm = d.IsConfirm,
+            UMConversion = d.UMConversion,
+            POCost = d.POCost,
+            RctNbr = d.RctNbr,
+            RctLine = d.RctLine,
+            CustPO = d.CustPO,
+            BlanketLine = d.BlanketLine,
+            QtyReturn = d.QtyReturn,
+            Supp = d.Supp,
+        };
+    }
+
+    [DisplayName("保存采购退货单")]
+    [HttpPost("purchase-return/save")]
+    public async Task<object> Save([FromBody] PurchaseReturnSaveInput input)
+    {
+        if (input == null) throw Oops.Oh("参数无效");
+        if (string.IsNullOrWhiteSpace(input.Supp)) throw Oops.Oh("请选择供应商");
+        if (string.IsNullOrWhiteSpace(input.OrdNbr)) throw Oops.Oh("请选择收货单号");
+        if (string.IsNullOrWhiteSpace(input.Department)) throw Oops.Oh("请选择部门");
+        if (input.Details == null || input.Details.Count == 0) throw Oops.Oh("请维护退货明细");
+
+        var now = DateTime.Now;
+        var account = _userManager.Account ?? "system";
+
+        var domain = string.IsNullOrWhiteSpace(input.Domain)
+            ? await ResolveRcDomainAsync(input.OrdNbr.Trim())
+            : input.Domain.Trim();
+        if (string.IsNullOrWhiteSpace(domain))
+            domain = "100";
+
+        if (input.Id is null or 0)
+        {
+            var receiver = await AllocReceiverAsync();
+            var rctDate = ParseDate(input.RctDate) ?? now.Date;
+
+            var master = new PurOrdRctMaster
+            {
+                Domain = domain,
+                RctType = "pt",
+                Receiver = receiver,
+                RctDate = rctDate,
+                OrdNbr = input.OrdNbr.Trim(),
+                Supp = input.Supp.Trim(),
+                Department = input.Department.Trim(),
+                Remark = input.Remark?.Trim(),
+                Terms = input.Terms?.Trim(),
+                Curr = input.Curr?.Trim(),
+                TaxClass = input.TaxClass?.Trim(),
+                TaxIn = input.TaxIn,
+                TermsofTrade = input.TermsofTrade?.Trim(),
+                Typed = "C",
+                IsActive = true,
+                IsConfirm = false,
+                IsPlan = true,
+                CreateUser = account,
+                CreateTime = now,
+                UpdateUser = account,
+                UpdateTime = now,
+            };
+
+            var newId = await _db.Insertable(master).ExecuteReturnIdentityAsync();
+            master.RecID = Convert.ToInt32(newId);
+
+            await SaveDetailsAsync(master.RecID, domain, receiver, "pt", input.Details, account, now);
+
+            return new { id = master.RecID, receiver, message = "新增成功" };
+        }
+
+        var existing = await _masterRep.GetFirstAsync(m => m.RecID == input.Id!.Value && m.RctType == "pt")
+                       ?? throw Oops.Oh("单据不存在");
+        if (!existing.IsPlan)
+            throw Oops.Oh("该单据不允许修改");
+
+        existing.RctDate = ParseDate(input.RctDate) ?? existing.RctDate;
+        existing.OrdNbr = input.OrdNbr.Trim();
+        existing.Supp = input.Supp.Trim();
+        existing.Department = input.Department.Trim();
+        existing.Remark = input.Remark?.Trim();
+        existing.Terms = input.Terms?.Trim();
+        existing.Curr = input.Curr?.Trim();
+        existing.TaxClass = input.TaxClass?.Trim();
+        existing.TaxIn = input.TaxIn;
+        existing.TermsofTrade = input.TermsofTrade?.Trim();
+        existing.Domain = domain;
+        existing.UpdateUser = account;
+        existing.UpdateTime = now;
+
+        await _masterRep.UpdateAsync(existing);
+
+        await SaveDetailsAsync(existing.RecID, existing.Domain, existing.Receiver, "pt", input.Details, account, now);
+
+        return new { id = existing.RecID, receiver = existing.Receiver, message = "保存成功" };
+    }
+
+    private async Task SaveDetailsAsync(
+        int masterRecId,
+        string domain,
+        string receiver,
+        string rctType,
+        List<PurchaseReturnDetailInput> entries,
+        string account,
+        DateTime now)
+    {
+        var dbRows = await _detailRep.GetListAsync(d =>
+            d.PurOrdRctRecID == masterRecId && d.RctType == rctType);
+        var dbById = dbRows.ToDictionary(d => d.RecID);
+        var inputIds = new HashSet<int>(entries.Where(e => e.RecID is > 0).Select(e => e.RecID!.Value));
+
+        for (var i = 0; i < entries.Count; i++)
+        {
+            var e = entries[i];
+            var lineNo = e.Line > 0 ? e.Line : (short)(i + 1);
+
+            if (string.IsNullOrWhiteSpace(e.ItemNum))
+                throw Oops.Oh($"第{i + 1}行:请填写物料");
+
+            var loc = string.IsNullOrWhiteSpace(e.Location) ? "00" : e.Location.Trim();
+
+            if (e.RecID is > 0 && dbById.TryGetValue(e.RecID.Value, out var row))
+            {
+                ApplyDetailInput(row, e, domain, receiver, masterRecId, account, now, lineNo, loc);
+                row.UpdateUser = account;
+                row.UpdateTime = now;
+                await _detailRep.UpdateAsync(row);
+            }
+            else
+            {
+                var detailNew = new PurOrdRctDetail();
+                ApplyDetailInput(detailNew, e, domain, receiver, masterRecId, account, now, lineNo, loc);
+                detailNew.CreateUser = account;
+                detailNew.CreateTime = now;
+                detailNew.UpdateUser = account;
+                detailNew.UpdateTime = now;
+                await _detailRep.InsertAsync(detailNew);
+            }
+        }
+
+        foreach (var del in dbRows.Where(d => !inputIds.Contains(d.RecID)))
+            await _detailRep.DeleteAsync(d => d.RecID == del.RecID);
+    }
+
+    private static void ApplyDetailInput(
+        PurOrdRctDetail row,
+        PurchaseReturnDetailInput e,
+        string domain,
+        string receiver,
+        int masterRecId,
+        string account,
+        DateTime now,
+        short lineNo,
+        string location)
+    {
+        row.Domain = domain;
+        row.RctType = "pt";
+        row.Receiver = receiver;
+        row.PurOrdRctRecID = masterRecId;
+        row.Line = lineNo;
+        row.Dimension1 = "";
+        row.Dimension2 = "";
+        row.ItemNum = e.ItemNum?.Trim();
+        row.OrdNbr = string.IsNullOrWhiteSpace(e.OrdNbr) ? "" : e.OrdNbr.Trim();
+        row.OrdLine = (short)e.OrdLine;
+        row.RctDate = ParseDate(e.RctDate);
+        row.UM = e.UM;
+        row.QtyOrded = e.QtyOrded;
+        row.QtyReceived = e.QtyReceived;
+        row.RctQty = e.RctQty;
+        row.LotSerial = e.LotSerial;
+        row.Location = location;
+        row.QCDescr = e.QCDescr;
+        row.Remark = e.Remark;
+        row.Typed = string.IsNullOrWhiteSpace(e.Typed) ? "C" : e.Typed.Trim();
+        row.TaxIn = e.TaxIn;
+        row.TaxClass = e.TaxClass;
+        row.Curr = e.Curr;
+        row.Potype = string.IsNullOrWhiteSpace(e.Potype) ? "P" : e.Potype.Trim();
+        row.IsActive = true;
+        row.IsConfirm = e.IsConfirm;
+        row.UMConversion = e.UMConversion;
+        row.POCost = e.POCost;
+        row.RctNbr = e.RctNbr;
+        row.RctLine = (short)e.RctLine;
+        row.CustPO = e.CustPO;
+        row.BlanketLine = e.BlanketLine;
+        row.QtyReturn = e.QtyReturn;
+        row.Supp = e.Supp;
+    }
+
+    [DisplayName("删除采购退货单(软删除)")]
+    [HttpPost("purchase-return/delete/{id:int}")]
+    public async Task<object> SoftDelete(int id)
+    {
+        var m = await _masterRep.GetFirstAsync(x => x.RecID == id && x.RctType == "pt")
+                ?? throw Oops.Oh("单据不存在");
+        if (!m.IsPlan)
+            throw Oops.Oh("该单据不允许删除");
+
+        m.IsActive = false;
+        m.UpdateUser = _userManager.Account ?? "system";
+        m.UpdateTime = DateTime.Now;
+        await _masterRep.UpdateAsync(m);
+
+        await _db.Ado.ExecuteCommandAsync(
+            "UPDATE PurOrdRctDetail SET IsActive=0, UpdateUser=@U, UpdateTime=@T WHERE PurOrdRctRecID=@Id AND RctType='pt'",
+            new SugarParameter("@U", m.UpdateUser),
+            new SugarParameter("@T", m.UpdateTime),
+            new SugarParameter("@Id", id));
+
+        return new { message = "已删除" };
+    }
+
+    private async Task<string> ResolveRcDomainAsync(string rcReceiver)
+    {
+        var d = await _db.Ado.GetStringAsync(
+            "SELECT Domain FROM PurOrdRctMaster WHERE RctType='rc' AND Receiver=@R LIMIT 1",
+            new SugarParameter("@R", rcReceiver));
+        return d;
+    }
+
+    private async Task<string> AllocReceiverAsync()
+    {
+        for (var i = 0; i < 20; i++)
+        {
+            var suffix = (DateTime.UtcNow.Ticks ^ Random.Shared.Next()).ToString("x");
+            if (suffix.Length > 12)
+                suffix = suffix[..12];
+            var cand = $"PT{DateTime.Now:yyMMdd}{suffix}";
+            if (cand.Length > 24)
+                cand = cand[..24];
+            var n = await _db.Ado.GetIntAsync(
+                "SELECT COUNT(1) FROM PurOrdRctMaster WHERE Receiver=@R",
+                new SugarParameter("@R", cand));
+            if (n == 0)
+                return cand;
+        }
+
+        return $"PT{Guid.NewGuid():N}"[..24];
+    }
+
+    private static DateTime? ParseDate(string raw)
+    {
+        if (string.IsNullOrWhiteSpace(raw)) return null;
+        return DateTime.TryParse(raw.Trim(), out var d) ? d.Date : null;
+    }
+}

+ 131 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShortageKanbanService.cs

@@ -0,0 +1,131 @@
+using Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
+
+/// <summary>
+/// S4 供应商欠料看板(只读列表 + 刷新)
+/// 数据表:WorkOrdDetailTotalKB
+/// 存储过程:pr_MES_Report_DayWorkOrdDetailTotal_New
+/// 路由:/api/ProcurementExecution/supplier-shortage-kanban/...
+/// </summary>
+[ApiDescriptionSettings(Order = 323, Description = "S4供应商欠料看板")]
+[Route("api/ProcurementExecution")]
+[AllowAnonymous]
+[NonUnify]
+public class SupplierShortageKanbanService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public SupplierShortageKanbanService(ISqlSugarClient db) => _db = db;
+
+    private static string NormalizeOrderBy(string field, string order)
+    {
+        var col = (field ?? "").Trim().ToLowerInvariant();
+        var ord = string.Equals(order?.Trim(), "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC";
+        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["rownum"] = "t.RowNum",
+            ["supplier_number"] = "t.supplier_number",
+            ["supplier_name"] = "t.supplier_name",
+            ["itemnum"] = "t.ItemNum",
+            ["descr"] = "t.Descr",
+            ["descr1"] = "t.Descr1",
+            ["d0"] = "t.D0",
+            ["d1"] = "t.D1",
+            ["d2"] = "t.D2",
+            ["d3"] = "t.D3",
+            ["d4"] = "t.D4",
+            ["d5"] = "t.D5",
+            ["d6"] = "t.D6",
+            ["d7"] = "t.D7",
+            ["d8"] = "t.D8",
+            ["d9"] = "t.D9",
+            ["d10"] = "t.D10",
+            ["d11"] = "t.D11",
+            ["d12"] = "t.D12",
+            ["d13"] = "t.D13",
+            ["d14"] = "t.D14",
+        };
+        if (!map.TryGetValue(col, out var sqlCol))
+            sqlCol = "t.RowNum";
+        return $"ORDER BY {sqlCol} {ord}";
+    }
+
+    [DisplayName("供应商欠料看板分页列表")]
+    [HttpGet("supplier-shortage-kanban/list")]
+    public async Task<object> GetList([FromQuery] SupplierShortageKanbanListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var cond = new List<string> { "1=1" };
+        var pars = new List<SugarParameter>();
+
+        if (!string.IsNullOrWhiteSpace(input.ItemNum))
+        {
+            cond.Add("t.ItemNum = @ItemNum");
+            pars.Add(new SugarParameter("@ItemNum", input.ItemNum.Trim()));
+        }
+
+        if (!string.IsNullOrWhiteSpace(input.SupplierName))
+        {
+            cond.Add("t.supplier_name LIKE @SupplierName");
+            pars.Add(new SugarParameter("@SupplierName", $"%{input.SupplierName.Trim()}%"));
+        }
+
+        var where = string.Join(" AND ", cond);
+        var baseSql = $"""
+            SELECT
+              IFNULL(RowNum, 0) AS RowNum,
+              IFNULL(supplier_number, '') AS Supplier_Number,
+              IFNULL(supplier_name, '') AS Supplier_Name,
+              IFNULL(ItemNum, '') AS ItemNum,
+              IFNULL(Descr, '') AS Descr,
+              IFNULL(Descr1, '') AS Descr1,
+              IFNULL(D0, 0) AS D0,
+              IFNULL(D1, 0) AS D1,
+              IFNULL(D2, 0) AS D2,
+              IFNULL(D3, 0) AS D3,
+              IFNULL(D4, 0) AS D4,
+              IFNULL(D5, 0) AS D5,
+              IFNULL(D6, 0) AS D6,
+              IFNULL(D7, 0) AS D7,
+              IFNULL(D8, 0) AS D8,
+              IFNULL(D9, 0) AS D9,
+              IFNULL(D10, 0) AS D10,
+              IFNULL(D11, 0) AS D11,
+              IFNULL(D12, 0) AS D12,
+              IFNULL(D13, 0) AS D13,
+              IFNULL(D14, 0) AS D14
+            FROM WorkOrdDetailTotalKB t
+            WHERE {where}
+            """;
+
+        var orderBy = NormalizeOrderBy(input.SortField, input.SortOrder);
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseSql}) x", pars);
+        var list = await _db.Ado.SqlQueryAsync<SupplierShortageKanbanListRow>(
+            $"SELECT * FROM ({baseSql}) t {orderBy} LIMIT {pageSize} OFFSET {offset}", pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("刷新欠料看板数据")]
+    [HttpPost("supplier-shortage-kanban/refresh")]
+    public async Task<object> Refresh()
+    {
+        var sw = System.Diagnostics.Stopwatch.StartNew();
+        try
+        {
+            // 说明:存储过程签名原为 @Params varchar(max) = '',MySQL 侧通常为一个 IN 参数;这里按空参数调用。
+            await _db.Ado.ExecuteCommandAsync("CALL pr_MES_Report_DayWorkOrdDetailTotal_New('')");
+            return new { ok = true, elapsedMs = sw.ElapsedMilliseconds };
+        }
+        catch (Exception ex)
+        {
+            return new { ok = false, elapsedMs = sw.ElapsedMilliseconds, message = ex.Message };
+        }
+    }
+}
+

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/PeriodSequenceDet.cs

@@ -7,10 +7,10 @@ namespace Admin.NET.Plugin.AiDOP.Production;
 public class PeriodSequenceDet
 {
     /// <summary>自增列</summary>
-    [SugarColumn(ColumnName = "RecID", IsPrimaryKey = true, IsIdentity = true)]
+    [SugarColumn(ColumnName = "RecID", IsPrimaryKey = true, IsIdentity = false)]
     public int RecID { get; set; }
 
-    [SugarColumn(ColumnName = "Domain", Length = 8, IsNullable = true)]
+    [SugarColumn(ColumnName = "Domain", Length = 80, IsNullable = true)]
     public string? Domain { get; set; }
 
     [SugarColumn(ColumnName = "Site", Length = 8, IsNullable = true)]

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/ScheduleResultOpMaster.cs

@@ -7,10 +7,10 @@ namespace Admin.NET.Plugin.AiDOP.Production;
 public class ScheduleResultOpMaster
 {
     /// <summary>主键</summary>
-    [SugarColumn(ColumnName = "RecID", IsPrimaryKey = true, IsIdentity = true)]
+    [SugarColumn(ColumnName = "RecID", IsPrimaryKey = true, IsIdentity = false)]
     public long RecID { get; set; }
 
-    [SugarColumn(ColumnName = "Domain", Length = 8, IsNullable = true)]
+    [SugarColumn(ColumnName = "Domain", Length = 80, IsNullable = true)]
     public string? Domain { get; set; }
 
     [SugarColumn(ColumnName = "WorkOrd", Length = 64, IsNullable = true)]

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

@@ -102,6 +102,10 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             list.Add(m);
         foreach (var m in BuildS4DeliveryMenus(ct))
             list.Add(m);
+        foreach (var m in BuildS4ReturnMenus(ct))
+            list.Add(m);
+        foreach (var m in BuildS4ExecutionKanbanMenus(ct))
+            list.Add(m);
         foreach (var m in BuildS1SalesKanbanMenus(ct))
             list.Add(m);
         foreach (var m in BuildS8CollaborationMenus(ct))
@@ -141,6 +145,41 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         if (s4Delivery != null)
             s4Delivery.Redirect = "/aidop/s4/delivery/supplier-delivery-management";
 
+        var s4Return = list.FirstOrDefault(x => x.Id == 1322000000013L);
+        if (s4Return != null)
+        {
+            s4Return.Title = "退货管理";
+            s4Return.Path = "/aidop/s4/return-mgmt";
+            s4Return.Name = "aidopS4ReturnMgmt";
+            s4Return.Component = "Layout";
+            s4Return.Icon = "ele-Folder";
+            s4Return.Type = MenuTypeEnum.Dir;
+            s4Return.Redirect = "/aidop/s4/return-mgmt/purchase-return-order";
+            s4Return.Remark = "S4 退货管理";
+        }
+
+        // S4 规划项「采购管理」与子模块 S3「采购管理」重叠,不在侧栏展示(仍保留种子 Id 兼容历史)
+        var s4ProcMgmt = list.FirstOrDefault(x => x.Id == 1322000000011L);
+        if (s4ProcMgmt != null)
+        {
+            s4ProcMgmt.IsHide = true;
+            s4ProcMgmt.Remark = $"{s4ProcMgmt.Remark}|侧栏隐藏:采购业务入口以 S3 采购管理为准";
+        }
+
+        // S4 采购执行看板 → 列表页路由与组件对齐看板 Vue
+        var s4Kanban = list.FirstOrDefault(x => x.Id == 1322000000014L);
+        if (s4Kanban != null)
+        {
+            s4Kanban.Path = "/aidop/s4/execution-kanban";
+            s4Kanban.Name = "aidopS4ExecutionKanban";
+            s4Kanban.Component = "Layout";
+            s4Kanban.Icon = "ele-Folder";
+            s4Kanban.Title = "采购执行看板";
+            s4Kanban.Type = MenuTypeEnum.Dir;
+            s4Kanban.Redirect = "/aidop/s4/execution-kanban/dashboard";
+            s4Kanban.Remark = "S4 采购执行看板目录(含主页 + 供应商欠料看板)";
+        }
+
         // S8:复用自动生成的首项菜单位,直接作为「异常监控看板」页,避免再出现中间层「异常管理」目录
         var s8Dashboard = list.FirstOrDefault(x => x.Id == 1322000000027L);
         if (s8Dashboard != null)
@@ -234,6 +273,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
     private static readonly Dictionary<(string Mod, int Leaf), string> ComponentOverrides = new()
     {
         { ("S1", 3), "/aidop/kanban/s1" },
+        { ("S4", 4), "/aidop/kanban/s4" },
     };
 
     /// <summary>S1 等模块下叶子菜单 path 覆盖(目录化后使用语义路径)。</summary>
@@ -247,6 +287,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         { ("S2", 3), "/aidop/s2/collaboration-kanban" },
         { ("S3", 1), "/aidop/s3/material-plan" },
         { ("S4", 2), "/aidop/s4/delivery" },
+        { ("S4", 3), "/aidop/s4/return-mgmt" },
+        { ("S4", 4), "/aidop/s4/execution-kanban" },
     };
 
     /// <summary>S1 等模块下叶子菜单 route name 覆盖。</summary>
@@ -259,6 +301,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         { ("S2", 3), "aidopS2CollaborationKanban" },
         { ("S3", 1), "aidopS3MaterialPlan" },
         { ("S4", 2), "aidopS4Delivery" },
+        { ("S4", 3), "aidopS4ReturnMgmt" },
+        { ("S4", 4), "aidopS4ExecutionKanban" },
     };
 
     /// <summary>
@@ -275,6 +319,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         { ("S2", 3) }, // 制造协同看板 → 目录(工单执行进度看板)
         { ("S3", 1) }, // 物料计划 → 目录(物料需求计划)
         { ("S4", 2) }, // 交货管理 → 目录(供应商交货管理、供应商发货单)
+        { ("S4", 3) }, // 退货管理 → 目录(采购退货单)
     };
 
     private static IEnumerable<SysMenu> BuildS4DeliveryMenus(DateTime ct)
@@ -330,6 +375,94 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         };
     }
 
+    private static IEnumerable<SysMenu> BuildS4ReturnMenus(DateTime ct)
+    {
+        const long returnDirId = 1322000000013L;
+        const long baseId = 1329004200000L;
+
+        yield return new SysMenu
+        {
+            Id = baseId + 1,
+            Pid = returnDirId,
+            Title = "采购退货单",
+            Path = "/aidop/s4/return-mgmt/purchase-return-order",
+            Name = "aidopS4PurchaseReturnOrder",
+            Component = "/aidop/s4/return/purchaseReturnOrderList",
+            Icon = "ele-Document",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 10,
+            Remark = "S4 采购退货单"
+        };
+
+        yield return new SysMenu
+        {
+            Id = baseId + 2,
+            Pid = returnDirId,
+            Title = "采购退货单表单",
+            Path = "/aidop/s4/return-mgmt/purchase-return-order-form",
+            Name = "aidopS4PurchaseReturnOrderForm",
+            Component = "/aidop/s4/return/purchaseReturnOrderForm",
+            Icon = "ele-Document",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 90,
+            IsHide = true,
+            Remark = "S4 采购退货单表单(新增/编辑/查看)"
+        };
+
+        yield return new SysMenu
+        {
+            Id = baseId + 3,
+            Pid = returnDirId,
+            Title = "IQC 退货查询",
+            Path = "/aidop/s4/return-mgmt/iqc-return-query",
+            Name = "aidopS4IqcReturnQuery",
+            Component = "/aidop/s4/return/iqcReturnQueryList",
+            Icon = "ele-Search",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 20,
+            Remark = "S4 IQC 退货查询(只读列表)"
+        };
+    }
+
+    private static IEnumerable<SysMenu> BuildS4ExecutionKanbanMenus(DateTime ct)
+    {
+        const long kanbanDirId = 1322000000014L;
+        const long baseId = 1329004300000L;
+
+        yield return new SysMenu
+        {
+            Id = baseId + 1,
+            Pid = kanbanDirId,
+            Title = "采购执行看板主页",
+            Path = "/aidop/s4/execution-kanban/dashboard",
+            Name = "aidopS4ExecutionKanbanDashboard",
+            Component = "/aidop/kanban/s4",
+            Icon = "ele-DataBoard",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 10,
+            Remark = "S4 采购执行看板主页(/aidop/kanban/s4)"
+        };
+
+        yield return new SysMenu
+        {
+            Id = baseId + 2,
+            Pid = kanbanDirId,
+            Title = "供应商欠料看板",
+            Path = "/aidop/s4/execution-kanban/supplier-shortage-kanban",
+            Name = "aidopS4SupplierShortageKanban",
+            Component = "/aidop/s4/execution-kanban/supplierShortageKanbanList",
+            Icon = "ele-List",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 20,
+            Remark = "S4 供应商欠料看板(WorkOrdDetailTotalKB)"
+        };
+    }
+
     private static string BuildRemark(string code, (string Title, string Desc, string Complexity, string Days, string Note) leaf)
     {
         var notePart = string.IsNullOrWhiteSpace(leaf.Note) ? "" : $" | {leaf.Note}";