Browse Source

feat(aidop): add S3 purchase request and align schema

Implement S3 purchase request and source-list frontend/backend flows, normalize related S3 menus, and align NbrControl entity mapping to aidopdev to prevent runtime field mismatches. Bump frontend and backend versions to keep this delivery traceable.

Made-with: Cursor
Pengxy 2 weeks ago
parent
commit
d557f7c69d

+ 1 - 1
Web/package.json

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

+ 30 - 0
Web/src/views/aidop/s3/api/deliveryException.ts

@@ -0,0 +1,30 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface DeliveryExceptionRow {
+	id: number;
+	domain?: string | null;
+	icdsId?: number | null;
+	optTime?: string | null;
+	itemNum?: string | null;
+	descr?: string | null;
+	needQty?: number | null;
+	remark?: string | null;
+}
+
+export interface DeliveryExceptionListParams {
+	page: number;
+	pageSize: number;
+	optTime?: string;
+	itemNum?: string;
+}
+
+export function fetchDeliveryExceptionList(params: DeliveryExceptionListParams) {
+	return service.get<Paged<DeliveryExceptionRow>>('/api/Supply/delivery-exception/list', { params }).then((r) => r.data);
+}

+ 79 - 0
Web/src/views/aidop/s3/api/purchaseRequest.ts

@@ -0,0 +1,79 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface PurchaseRequestRow {
+	id: number;
+	prBillNo?: string | null;
+	purOrd?: string | null;
+	prType?: number | null;
+	supplierType?: string | null;
+	isRequireGoods?: number | null;
+	state?: number | null;
+	number?: string | null;
+	name?: string | null;
+	model?: string | null;
+	prUnit?: string | null;
+	prPurchaseNumber?: string | null;
+	prPurchaseName?: string | null;
+	prSqty?: number | null;
+	prAqty?: number | null;
+	stockQty?: number | null;
+	prSsendDate?: string | null;
+	prSarriveDate?: string | null;
+	prPurchaser?: string | null;
+	createTime?: string | null;
+}
+
+export interface PurchaseRequestSaveInput {
+	id?: number | null;
+	prBillNo?: string | null;
+	icitemId?: number | null;
+	icitemName?: string | null;
+	prUnit?: string | null;
+	prPurchaseNumber?: string | null;
+	prPurchaseName?: string | null;
+	supplierType?: string | null;
+	prAqty?: number | null;
+	prSsendDate?: string | null;
+	prSarriveDate?: string | null;
+	prType?: number | null;
+	state?: number | null;
+	prPurchaseId?: number | null;
+	isRequireGoods?: number | null;
+	prPurchaserNum?: string | null;
+	prPurchaser?: string | null;
+	currencyType?: number | null;
+}
+
+export interface PurchaseRequestListParams {
+	page: number;
+	pageSize: number;
+	prBillNo?: string;
+	itemNumber?: string;
+	supplierName?: string;
+	state?: number;
+	sortField?: string;
+	sortOrder?: string;
+}
+
+export function fetchPurchaseRequestList(params: PurchaseRequestListParams) {
+	return service.get<Paged<PurchaseRequestRow>>('/api/Supply/purchase-request/list', { params }).then((r) => r.data);
+}
+
+export function fetchPurchaseRequestDetail(id: number) {
+	return service.get<PurchaseRequestSaveInput>(`/api/Supply/purchase-request/${id}`).then((r) => r.data);
+}
+
+export function savePurchaseRequest(body: PurchaseRequestSaveInput) {
+	return service.post('/api/Supply/purchase-request/save', body).then((r) => r.data);
+}
+
+export function deletePurchaseRequest(id: number) {
+	return service.post(`/api/Supply/purchase-request/delete/${id}`).then((r) => r.data);
+}

+ 60 - 0
Web/src/views/aidop/s3/api/sourceList.ts

@@ -0,0 +1,60 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface SourceListRow {
+	id: number;
+	tenantId?: number | null;
+	factoryId?: number | null;
+	icitemId?: string | null;
+	number?: string | null;
+	icitemName?: string | null;
+	itemType?: string | null;
+	model?: string | null;
+	unit?: string | null;
+	supplierType?: string | null;
+	isActive?: number | null;
+	supplierId?: number | null;
+	supplierName?: string | null;
+	supplierNumber?: string | null;
+	orderPrice?: number | null;
+	currencyType?: string | null;
+	taxRate?: number | null;
+	tariff?: number | null;
+	freight?: number | null;
+	priceTerms?: string | null;
+	effectiveDate?: string | null;
+	expiringDate?: string | null;
+	quotaRate?: number | null;
+	leadTime?: number | null;
+	qtyMin?: number | null;
+	packagingQty?: number | null;
+	orderRectorName?: string | null;
+	orderRectorNum?: string | null;
+	icitem?: string | null;
+	supplier?: string | null;
+	isRequireGoods?: number | null;
+	location?: string | null;
+	um?: string | null;
+	rev?: string | null;
+	drawing?: string | null;
+}
+
+export interface SourceListPageParams {
+	page: number;
+	pageSize: number;
+	number?: string;
+	icitemName?: string;
+	supplierNumber?: string;
+	sortField?: string;
+	sortOrder?: string;
+}
+
+export function fetchSourceListPage(params: SourceListPageParams) {
+	return service.get<Paged<SourceListRow>>('/api/Universal/source-list/page', { params }).then((r) => r.data);
+}

+ 191 - 0
Web/src/views/aidop/s3/supply/deliveryExceptionList.vue

@@ -0,0 +1,191 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="交货单异常记录">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="执行时间">
+				<el-input v-model="query.optTime" clearable placeholder="模糊查询" style="width: 180px" />
+			</el-form-item>
+			<el-form-item label="物料编码">
+				<el-input v-model="query.itemNum" clearable placeholder="精确查询" style="width: 160px" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" :loading="loading" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="toolbar">
+			<el-popover placement="bottom-end" :width="220" trigger="click">
+				<template #reference>
+					<el-button>列显示</el-button>
+				</template>
+				<el-checkbox-group v-model="visibleColKeys" class="col-toggle">
+					<div v-for="c in allColumns" :key="c.key">
+						<el-checkbox :label="c.key">{{ c.label }}</el-checkbox>
+					</div>
+				</el-checkbox-group>
+			</el-popover>
+		</div>
+
+		<el-table
+			:data="rows"
+			v-loading="loading"
+			border
+			stripe
+			style="width: 100%"
+			table-layout="auto"
+			empty-text="暂无交货单异常记录"
+		>
+			<el-table-column
+				v-if="colOn('optTime')"
+				prop="optTime"
+				label="执行时间"
+				width="170"
+				fixed="left"
+				resizable
+				show-overflow-tooltip
+			>
+				<template #default="{ row }">{{ fmtDt(row.optTime) }}</template>
+			</el-table-column>
+			<el-table-column
+				v-if="colOn('itemNum')"
+				prop="itemNum"
+				label="物料编码"
+				width="140"
+				fixed="left"
+				resizable
+				show-overflow-tooltip
+			/>
+			<el-table-column
+				v-if="colOn('descr')"
+				prop="descr"
+				label="物料描述"
+				min-width="180"
+				resizable
+				show-overflow-tooltip
+			/>
+			<el-table-column
+				v-if="colOn('needQty')"
+				prop="needQty"
+				label="缺料数量"
+				width="120"
+				align="right"
+				resizable
+			/>
+			<el-table-column
+				v-if="colOn('remark')"
+				prop="remark"
+				label="异常描述"
+				min-width="220"
+				resizable
+				show-overflow-tooltip
+			/>
+		</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, 100]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS3SupplyDeliveryExceptionList">
+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 {
+	fetchDeliveryExceptionList,
+	type DeliveryExceptionRow,
+} from '../api/deliveryException';
+
+const route = useRoute();
+const pageTitle = computed(() => (route.meta?.title as string) || '交货单异常记录');
+
+const query = reactive({
+	optTime: '',
+	itemNum: '',
+	page: 1,
+	pageSize: 20,
+});
+const loading = ref(false);
+const rows = ref<DeliveryExceptionRow[]>([]);
+const total = ref(0);
+
+const allColumns = [
+	{ key: 'optTime', label: '执行时间' },
+	{ key: 'itemNum', label: '物料编码' },
+	{ key: 'descr', label: '物料描述' },
+	{ key: 'needQty', label: '缺料数量' },
+	{ key: 'remark', label: '异常描述' },
+];
+const visibleColKeys = ref<string[]>(allColumns.map((c) => c.key));
+
+function colOn(key: string) {
+	return visibleColKeys.value.includes(key);
+}
+
+function fmtDt(v?: string | null) {
+	if (!v) return '—';
+	return String(v).replace('T', ' ').slice(0, 19);
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.optTime = '';
+	query.itemNum = '';
+	query.page = 1;
+	loadList();
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchDeliveryExceptionList({
+			page: query.page,
+			pageSize: query.pageSize,
+			optTime: query.optTime || undefined,
+			itemNum: query.itemNum || undefined,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} catch (e: any) {
+		ElMessage.error(e?.message || '加载失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+onMounted(loadList);
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/aidop/styles/aidop-demo.scss';
+
+.mb12 {
+	margin-bottom: 12px;
+}
+
+.toolbar {
+	margin-bottom: 8px;
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 205 - 0
Web/src/views/aidop/s3/supply/purchaseRequestForm.vue

@@ -0,0 +1,205 @@
+<template>
+	<div>
+		<el-form ref="formRef" :model="form" :rules="rules" label-width="130px" v-loading="loading">
+			<el-row :gutter="16">
+				<el-col :span="12">
+					<el-form-item label="采购申请单号">
+						<el-input v-model="form.prBillNo" disabled placeholder="保存后自动生成" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="物料名称" prop="icitemName">
+						<el-input v-model="form.icitemName" readonly placeholder="请选择货源清单">
+							<template #append>
+								<el-button :disabled="isView" @click="sourceDlgVisible = true">选择</el-button>
+							</template>
+						</el-input>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="单位">
+						<el-input v-model="form.prUnit" disabled />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="供应商编码">
+						<el-input v-model="form.prPurchaseNumber" disabled />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="供应商名称">
+						<el-input v-model="form.prPurchaseName" disabled />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="供应类别">
+						<el-input v-model="form.supplierType" disabled />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="申请数量" prop="prAqty">
+						<el-input-number v-model="form.prAqty" :disabled="isView" :min="0" :precision="4" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="下单日期" prop="prSsendDate">
+						<el-date-picker v-model="form.prSsendDate" :disabled="isView" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="到货日期" prop="prSarriveDate">
+						<el-date-picker v-model="form.prSarriveDate" :disabled="isView" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+		</el-form>
+
+		<div class="footer">
+			<el-button @click="$emit('cancel')">取消</el-button>
+			<el-button type="primary" :disabled="isView" :loading="saving" @click="onSave">保存</el-button>
+		</div>
+
+		<el-dialog v-model="sourceDlgVisible" title="选择货源清单" width="980px" append-to-body destroy-on-close>
+			<SelectSourceList @picked="onPickSource" />
+		</el-dialog>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopS3SupplyPurchaseRequestForm">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
+import SelectSourceList from './selectSourceList.vue';
+import {
+	fetchPurchaseRequestDetail,
+	savePurchaseRequest,
+	type PurchaseRequestSaveInput,
+} from '../api/purchaseRequest';
+import type { SourceListRow } from '../api/sourceList';
+
+const props = defineProps<{
+	mode: 'create' | 'edit' | 'view';
+	requestId: number | null;
+}>();
+const emit = defineEmits<{
+	(e: 'cancel'): void;
+	(e: 'saved'): void;
+}>();
+
+const isView = computed(() => props.mode === 'view');
+const loading = ref(false);
+const saving = ref(false);
+const sourceDlgVisible = ref(false);
+const formRef = ref<FormInstance>();
+
+const form = reactive<PurchaseRequestSaveInput>({
+	id: null,
+	prBillNo: '',
+	icitemId: null,
+	icitemName: '',
+	prUnit: '',
+	prPurchaseNumber: '',
+	prPurchaseName: '',
+	supplierType: '',
+	prAqty: 0,
+	prSsendDate: new Date().toISOString().slice(0, 10),
+	prSarriveDate: '',
+	prType: 3,
+	state: 1,
+	prPurchaseId: null,
+	isRequireGoods: 0,
+	prPurchaserNum: '',
+	prPurchaser: '',
+	currencyType: 1,
+});
+
+const rules: FormRules = {
+	icitemName: [{ required: true, message: '请选择货源清单', trigger: 'change' }],
+	prAqty: [{ required: true, message: '请输入申请数量', trigger: 'blur' }],
+	prSsendDate: [{ required: true, message: '请选择下单日期', trigger: 'change' }],
+};
+
+function toDateString(dt: Date) {
+	return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
+}
+
+function onPickSource(row: SourceListRow) {
+	form.prPurchaseId = row.supplierId ?? null;
+	form.icitemId = row.icitemId ? Number(row.icitemId) : null;
+	form.icitemName = row.icitemName ?? '';
+	form.prPurchaseNumber = row.supplierNumber ?? '';
+	form.prPurchaseName = row.supplierName ?? '';
+	form.supplierType = row.supplierType ?? '';
+	form.prPurchaser = row.orderRectorName ?? '';
+	form.prPurchaserNum = row.orderRectorNum ?? '';
+	form.prUnit = row.unit ?? '';
+	form.isRequireGoods = row.isRequireGoods ?? 0;
+	if ((row.leadTime ?? 0) !== 0) {
+		const d = new Date();
+		d.setDate(d.getDate() + Number(row.leadTime));
+		form.prSarriveDate = toDateString(d);
+	}
+	sourceDlgVisible.value = false;
+}
+
+async function loadDetail() {
+	if (!props.requestId) return;
+	loading.value = true;
+	try {
+		const d = await fetchPurchaseRequestDetail(props.requestId);
+		Object.assign(form, {
+			id: props.requestId,
+			prBillNo: d.prBillNo ?? '',
+			icitemId: d.icitemId ?? null,
+			icitemName: d.icitemName ?? '',
+			prUnit: d.prUnit ?? '',
+			prPurchaseNumber: d.prPurchaseNumber ?? '',
+			prPurchaseName: d.prPurchaseName ?? '',
+			supplierType: d.supplierType ?? '',
+			prAqty: d.prAqty ?? 0,
+			prSsendDate: d.prSsendDate ? String(d.prSsendDate).slice(0, 10) : '',
+			prSarriveDate: d.prSarriveDate ? String(d.prSarriveDate).slice(0, 10) : '',
+			prType: d.prType ?? 3,
+			state: d.state ?? 1,
+			prPurchaseId: d.prPurchaseId ?? null,
+			isRequireGoods: d.isRequireGoods ?? 0,
+			prPurchaserNum: d.prPurchaserNum ?? '',
+			prPurchaser: d.prPurchaser ?? '',
+			currencyType: d.currencyType ?? 1,
+		});
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function onSave() {
+	await formRef.value?.validate();
+	saving.value = true;
+	try {
+		await savePurchaseRequest({
+			...form,
+			prType: 3,
+			state: 1,
+			currencyType: 1,
+		});
+		ElMessage.success('保存成功');
+		emit('saved');
+	} finally {
+		saving.value = false;
+	}
+}
+
+onMounted(() => {
+	if (props.mode !== 'create') {
+		loadDetail();
+	}
+});
+</script>
+
+<style scoped lang="scss">
+.footer {
+	margin-top: 14px;
+	display: flex;
+	justify-content: flex-end;
+	gap: 10px;
+}
+</style>

+ 322 - 0
Web/src/views/aidop/s3/supply/purchaseRequestList.vue

@@ -0,0 +1,322 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="物料采购申请">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="采购申请单号">
+				<el-input v-model="query.prBillNo" clearable placeholder="模糊查询" style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="物料编码">
+				<el-input v-model="query.itemNumber" clearable placeholder="模糊查询" style="width: 150px" />
+			</el-form-item>
+			<el-form-item label="供应商名称">
+				<el-input v-model="query.supplierName" clearable placeholder="模糊查询" style="width: 150px" />
+			</el-form-item>
+			<el-form-item label="状态">
+				<el-select v-model="query.state" clearable style="width: 140px">
+					<el-option v-for="s in stateOptions" :key="s.value" :label="s.label" :value="s.value" />
+				</el-select>
+			</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" plain @click="openCreate">添加</el-button>
+			<el-button @click="onExport">导出</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 style="width: 100%" @sort-change="onSortChange">
+			<el-table-column v-if="col.prBillNo" prop="prBillNo" label="采购申请单号" width="160" fixed="left" sortable="custom" />
+			<el-table-column v-if="col.state" label="状态" width="120" fixed="left" sortable="custom">
+				<template #default="{ row }">
+					<el-tag size="small" :type="stateTag(row.state)">{{ stateText(row.state) }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column v-if="col.purOrd" prop="purOrd" label="采购订单" width="140" sortable="custom" />
+			<el-table-column v-if="col.prType" label="订单类型" width="110" sortable="custom">
+				<template #default="{ row }">{{ prTypeText(row.prType) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.supplierType" prop="supplierType" label="供应类别" width="100" />
+			<el-table-column v-if="col.isRequireGoods" label="采购类型" width="100">
+				<template #default="{ row }">{{ goodsTypeText(row.isRequireGoods) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.number" prop="number" label="物料编码" width="140" sortable="custom" />
+			<el-table-column v-if="col.name" prop="name" label="物料描述" min-width="140" show-overflow-tooltip />
+			<el-table-column v-if="col.model" prop="model" label="规格型号" min-width="140" show-overflow-tooltip />
+			<el-table-column v-if="col.prUnit" prop="prUnit" label="单位" width="90" />
+			<el-table-column v-if="col.prPurchaseNumber" prop="prPurchaseNumber" label="供应商代码" width="130" />
+			<el-table-column v-if="col.prPurchaseName" prop="prPurchaseName" label="供应商名称" min-width="150" show-overflow-tooltip />
+			<el-table-column v-if="col.prSqty" prop="prSqty" label="建议数量" width="110" align="right" sortable="custom" />
+			<el-table-column v-if="col.prAqty" prop="prAqty" label="申请数量" width="110" align="right" sortable="custom" />
+			<el-table-column v-if="col.stockQty" prop="stockQty" label="库存数量" width="110" align="right" sortable="custom" />
+			<el-table-column v-if="col.prSsendDate" label="建议下单日期" width="120" sortable="custom">
+				<template #default="{ row }">{{ fmtDate(row.prSsendDate) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.prSarriveDate" label="建议到货日期" width="120" sortable="custom">
+				<template #default="{ row }">{{ fmtDate(row.prSarriveDate) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.prPurchaser" prop="prPurchaser" label="采购员" width="100" />
+			<el-table-column v-if="col.createTime" label="创建时间" width="120" sortable="custom">
+				<template #default="{ row }">{{ fmtDate(row.createTime) }}</template>
+			</el-table-column>
+			<el-table-column label="行操作" width="170" fixed="right" align="center">
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+					<el-button link type="danger" @click="onDelete(row)">删除</el-button>
+					<el-button link @click="openView(row)">查看</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<div class="pager">
+			<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>
+
+		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="960px" destroy-on-close>
+			<PurchaseRequestForm
+				:mode="formMode"
+				:request-id="editingId"
+				@cancel="dialogVisible = false"
+				@saved="onSaved"
+			/>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS3SupplyPurchaseRequestList">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import PurchaseRequestForm from './purchaseRequestForm.vue';
+import { deletePurchaseRequest, fetchPurchaseRequestList, type PurchaseRequestRow } from '../api/purchaseRequest';
+
+const route = useRoute();
+const pageTitle = computed(() => (route.meta?.title as string) || '物料采购申请');
+
+const stateOptions = [
+	{ value: 0, label: '关闭' },
+	{ value: 1, label: '新增' },
+	{ value: 2, label: '提交' },
+	{ value: 3, label: '未通过' },
+	{ value: 4, label: '评审通过' },
+];
+
+const query = reactive({
+	prBillNo: '',
+	itemNumber: '',
+	supplierName: '',
+	state: undefined as number | undefined,
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+
+const loading = ref(false);
+const rows = ref<PurchaseRequestRow[]>([]);
+const total = ref(0);
+
+const col = reactive({
+	prBillNo: true,
+	purOrd: true,
+	prType: true,
+	supplierType: true,
+	isRequireGoods: true,
+	state: true,
+	number: true,
+	name: true,
+	model: true,
+	prUnit: true,
+	prPurchaseNumber: true,
+	prPurchaseName: true,
+	prSqty: true,
+	prAqty: true,
+	stockQty: true,
+	prSsendDate: true,
+	prSarriveDate: true,
+	prPurchaser: true,
+	createTime: true,
+});
+
+type ColumnKey = keyof typeof col;
+const toggleItems: Array<{ key: ColumnKey; label: string }> = [
+	{ key: 'prBillNo', label: '采购申请单号' },
+	{ key: 'state', label: '状态' },
+	{ key: 'purOrd', label: '采购订单' },
+	{ key: 'prType', label: '订单类型' },
+	{ key: 'supplierType', label: '供应类别' },
+	{ key: 'isRequireGoods', label: '采购类型' },
+	{ key: 'number', label: '物料编码' },
+	{ key: 'name', label: '物料描述' },
+	{ key: 'model', label: '规格型号' },
+	{ key: 'prUnit', label: '单位' },
+	{ key: 'prPurchaseNumber', label: '供应商代码' },
+	{ key: 'prPurchaseName', label: '供应商名称' },
+	{ key: 'prSqty', label: '建议数量' },
+	{ key: 'prAqty', label: '申请数量' },
+	{ key: 'stockQty', label: '库存数量' },
+	{ key: 'prSsendDate', label: '建议下单日期' },
+	{ key: 'prSarriveDate', label: '建议到货日期' },
+	{ key: 'prPurchaser', label: '采购员' },
+	{ key: 'createTime', label: '创建时间' },
+];
+
+const dialogVisible = ref(false);
+const editingId = ref<number | null>(null);
+const formMode = ref<'create' | 'edit' | 'view'>('create');
+const dialogTitle = computed(() =>
+	formMode.value === 'create' ? '添加物料采购申请' : formMode.value === 'edit' ? '编辑物料采购申请' : '查看物料采购申请'
+);
+
+function stateText(v?: number | null) {
+	return stateOptions.find((s) => s.value === Number(v))?.label ?? String(v ?? '');
+}
+function stateTag(v?: number | null) {
+	if (Number(v) === 4) return 'success';
+	if (Number(v) === 3) return 'danger';
+	if (Number(v) === 2) return 'warning';
+	return 'info';
+}
+function prTypeText(v?: number | null) {
+	return Number(v) === 2 ? '委外订单' : Number(v) === 3 ? '采购申请单' : '';
+}
+function goodsTypeText(v?: number | null) {
+	return Number(v) === 1 ? '要货令' : '采购申请';
+}
+function fmtDate(v?: string | null) {
+	return v ? String(v).slice(0, 10) : '';
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchPurchaseRequestList({
+			page: query.page,
+			pageSize: query.pageSize,
+			prBillNo: query.prBillNo || undefined,
+			itemNumber: query.itemNumber || undefined,
+			supplierName: query.supplierName || undefined,
+			state: query.state,
+			sortField: query.sortField || undefined,
+			sortOrder: query.sortOrder || undefined,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+function resetQuery() {
+	query.prBillNo = '';
+	query.itemNumber = '';
+	query.supplierName = '';
+	query.state = undefined;
+	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
+}
+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();
+}
+
+function openCreate() {
+	editingId.value = null;
+	formMode.value = 'create';
+	dialogVisible.value = true;
+}
+function openEdit(row: PurchaseRequestRow) {
+	editingId.value = row.id;
+	formMode.value = 'edit';
+	dialogVisible.value = true;
+}
+function openView(row: PurchaseRequestRow) {
+	editingId.value = row.id;
+	formMode.value = 'view';
+	dialogVisible.value = true;
+}
+async function onDelete(row: PurchaseRequestRow) {
+	await ElMessageBox.confirm('确认删除该条采购申请?', '提示', { type: 'warning' });
+	await deletePurchaseRequest(row.id);
+	ElMessage.success('删除成功');
+	await loadList();
+}
+async function onSaved() {
+	dialogVisible.value = false;
+	await loadList();
+}
+
+function onExport() {
+	const headers = [
+		'采购申请单号', '采购订单', '订单类型', '供应类别', '采购类型', '状态', '物料编码', '物料描述',
+		'规格型号', '单位', '供应商代码', '供应商名称', '建议数量', '申请数量', '库存数量',
+		'建议下单日期', '建议到货日期', '采购员', '创建时间',
+	];
+	const lines = rows.value.map((r) => [
+		r.prBillNo ?? '', r.purOrd ?? '', prTypeText(r.prType), r.supplierType ?? '', goodsTypeText(r.isRequireGoods),
+		stateText(r.state), r.number ?? '', r.name ?? '', r.model ?? '', r.prUnit ?? '', r.prPurchaseNumber ?? '',
+		r.prPurchaseName ?? '', r.prSqty ?? '', r.prAqty ?? '', r.stockQty ?? '', fmtDate(r.prSsendDate), fmtDate(r.prSarriveDate),
+		r.prPurchaser ?? '', fmtDate(r.createTime),
+	]);
+	const csv = [headers, ...lines]
+		.map((row) => row.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`).join(','))
+		.join('\n');
+	const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' });
+	const a = document.createElement('a');
+	a.href = URL.createObjectURL(blob);
+	a.download = `物料采购申请_${new Date().toISOString().slice(0, 10)}.csv`;
+	a.click();
+	URL.revokeObjectURL(a.href);
+}
+
+onMounted(loadList);
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/aidop/styles/aidop-demo.scss';
+.mb12 {
+	margin-bottom: 12px;
+}
+.toolbar {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 133 - 0
Web/src/views/aidop/s3/supply/selectSourceList.vue

@@ -0,0 +1,133 @@
+<template>
+	<div>
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="物料编号">
+				<el-input v-model="query.number" clearable placeholder="模糊查询" style="width: 170px" />
+			</el-form-item>
+			<el-form-item label="物料名称">
+				<el-input v-model="query.icitemName" clearable placeholder="模糊查询" style="width: 170px" />
+			</el-form-item>
+			<el-form-item label="供应商编号">
+				<el-input v-model="query.supplierNumber" clearable placeholder="模糊查询" style="width: 170px" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="reset">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table
+			:data="rows"
+			v-loading="loading"
+			border
+			stripe
+			height="420"
+			style="width: 100%"
+			@sort-change="onSortChange"
+			@row-dblclick="onDblClick"
+		>
+			<el-table-column prop="number" label="物料编码" width="140" sortable="custom" />
+			<el-table-column prop="icitemName" label="物料名称" min-width="160" show-overflow-tooltip sortable="custom" />
+			<el-table-column prop="supplierType" label="供应类别" width="100" sortable="custom" />
+			<el-table-column prop="supplierNumber" label="供应商编码" width="130" sortable="custom" />
+			<el-table-column prop="supplierName" label="供应商名称" min-width="140" show-overflow-tooltip sortable="custom" />
+			<el-table-column prop="leadTime" label="采购提前期(天)" width="120" align="right" sortable="custom" />
+			<el-table-column prop="isRequireGoods" label="采购类别" width="110">
+				<template #default="{ row }">{{ goodsTypeText(row.isRequireGoods) }}</template>
+			</el-table-column>
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[10, 20, 50]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopS3SupplySelectSourceList">
+import { onMounted, reactive, ref } from 'vue';
+import { fetchSourceListPage, type SourceListRow } from '../api/sourceList';
+
+const emit = defineEmits<{ (e: 'picked', v: SourceListRow): void }>();
+
+const query = reactive({
+	number: '',
+	icitemName: '',
+	supplierNumber: '',
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+const loading = ref(false);
+const rows = ref<SourceListRow[]>([]);
+const total = ref(0);
+
+function goodsTypeText(v?: number | null) {
+	return Number(v) === 1 ? '要货令' : '采购申请';
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchSourceListPage({
+			page: query.page,
+			pageSize: query.pageSize,
+			number: query.number || undefined,
+			icitemName: query.icitemName || undefined,
+			supplierNumber: query.supplierNumber || undefined,
+			sortField: query.sortField || undefined,
+			sortOrder: query.sortOrder || undefined,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function reset() {
+	query.number = '';
+	query.icitemName = '';
+	query.supplierNumber = '';
+	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
+	loadList();
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+function onDblClick(row: SourceListRow) {
+	emit('picked', row);
+}
+
+onMounted(loadList);
+</script>
+
+<style scoped lang="scss">
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 10px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

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

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

+ 81 - 33
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0NbrControl.cs

@@ -7,8 +7,26 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 [SugarIndex("uk_NbrControl_domain_nbrtype", nameof(DomainCode), OrderByType.Asc, nameof(NbrType), OrderByType.Asc, IsUnique = true)]
 public class AdoS0NbrControl : ITenantIdFilter
 {
-    [SugarColumn(ColumnName = "rec_id", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
-    public long Id { get; set; }
+    [SugarColumn(ColumnName = "RecID", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "int")]
+    public int Id { get; set; }
+
+    [SugarColumn(ColumnName = "NbrType", ColumnDescription = "单号类型编码", Length = 50, IsNullable = true)]
+    public string? NbrType { get; set; }
+
+    [SugarColumn(ColumnName = "Description", ColumnDescription = "描述", Length = 50, IsNullable = true)]
+    public string? Description { get; set; }
+
+    [SugarColumn(ColumnName = "NbrPre1", ColumnDescription = "前缀1", Length = 50, IsNullable = true)]
+    public string? NbrPre1 { get; set; }
+
+    [SugarColumn(ColumnName = "NbrPre2", ColumnDescription = "前缀2", Length = 50, IsNullable = true)]
+    public string? NbrPre2 { get; set; }
+
+    [SugarColumn(ColumnName = "NbrPre3", ColumnDescription = "前缀3", Length = 50, IsNullable = true)]
+    public string? NbrPre3 { get; set; }
+
+    [SugarColumn(ColumnName = "NextValue", ColumnDescription = "下一值", IsNullable = true)]
+    public int? NextValue { get; set; }
 
     [SugarColumn(ColumnName = "company_ref_id", ColumnDescription = "关联公司 ID", ColumnDataType = "bigint")]
     public long CompanyRefId { get; set; }
@@ -19,57 +37,87 @@ public class AdoS0NbrControl : ITenantIdFilter
     [SugarColumn(ColumnName = "domain_code", ColumnDescription = "工厂域编码", Length = 50)]
     public string DomainCode { get; set; } = string.Empty;
 
-    [SugarColumn(ColumnName = "nbr_type", ColumnDescription = "单号类型编码", Length = 100)]
-    public string NbrType { get; set; } = string.Empty;
+    [SugarColumn(ColumnName = "IniValue", ColumnDescription = "初始值", IsNullable = true)]
+    public int? IniValue { get; set; }
 
-    [SugarColumn(ColumnName = "description", ColumnDescription = "描述", Length = 255, IsNullable = true)]
-    public string? Description { get; set; }
+    [SugarColumn(ColumnName = "MinValue", ColumnDescription = "最小值", IsNullable = true)]
+    public int? MinValue { get; set; }
 
-    [SugarColumn(ColumnName = "nbr_pre1", ColumnDescription = "前缀1", Length = 50, IsNullable = true)]
-    public string? NbrPre1 { get; set; }
+    [SugarColumn(ColumnName = "MaxValue", ColumnDescription = "最大值", IsNullable = true)]
+    public int? MaxValue { get; set; }
 
-    [SugarColumn(ColumnName = "nbr_pre2", ColumnDescription = "前缀2", Length = 50, IsNullable = true)]
-    public string? NbrPre2 { get; set; }
+    [SugarColumn(ColumnName = "ResetValue", ColumnDescription = "重置值", IsNullable = true)]
+    public int? ResetValue { get; set; }
 
-    [SugarColumn(ColumnName = "nbr_pre3", ColumnDescription = "前缀3", Length = 50, IsNullable = true)]
-    public string? NbrPre3 { get; set; }
+    [SugarColumn(ColumnName = "AllowReset", ColumnDescription = "允许重置", ColumnDataType = "bit(1)", IsNullable = true)]
+    public bool? AllowReset { get; set; }
 
-    [SugarColumn(ColumnName = "ini_value", ColumnDescription = "初始值", IsNullable = true)]
-    public int? IniValue { get; set; }
+    [SugarColumn(ColumnName = "AllowSkip", ColumnDescription = "允许跳号", ColumnDataType = "bit(1)", IsNullable = true)]
+    public bool? AllowSkip { get; set; }
 
-    [SugarColumn(ColumnName = "min_value", ColumnDescription = "最小值", IsNullable = true)]
-    public int? MinValue { get; set; }
+    [SugarColumn(ColumnName = "AllowManual", ColumnDescription = "允许手动", ColumnDataType = "bit(1)", IsNullable = true)]
+    public bool? AllowManual { get; set; }
 
-    [SugarColumn(ColumnName = "max_value", ColumnDescription = "最大值", IsNullable = true)]
-    public int? MaxValue { get; set; }
+    [SugarColumn(ColumnName = "DateType", ColumnDescription = "日期类型", Length = 50, IsNullable = true)]
+    public string? DateType { get; set; }
 
-    [SugarColumn(ColumnName = "allow_reset", ColumnDescription = "允许重置", ColumnDataType = "boolean")]
-    public bool AllowReset { get; set; }
+    [SugarColumn(ColumnName = "EffectiveDate", ColumnDescription = "生效日期", IsNullable = true)]
+    public DateTime? EffectiveDate { get; set; }
 
-    [SugarColumn(ColumnName = "allow_skip", ColumnDescription = "允许跳号", ColumnDataType = "boolean")]
-    public bool AllowSkip { get; set; }
+    [SugarColumn(ColumnName = "ExpireDate", ColumnDescription = "失效日期", IsNullable = true)]
+    public DateTime? ExpireDate { get; set; }
 
-    [SugarColumn(ColumnName = "allow_manual", ColumnDescription = "允许手动", ColumnDataType = "boolean")]
-    public bool AllowManual { get; set; }
+    [SugarColumn(ColumnName = "ResetByDate", ColumnDescription = "是否按日期重置", ColumnDataType = "boolean", IsNullable = true)]
+    public bool? ResetByDate { get; set; }
 
-    [SugarColumn(ColumnName = "date_type", ColumnDescription = "日期类型", Length = 50, IsNullable = true)]
-    public string? DateType { get; set; }
+    [SugarColumn(ColumnName = "ArrayMethod", ColumnDescription = "排序方式", Length = 50, IsNullable = true)]
+    public string? ArrayMethod { get; set; }
+
+    [SugarColumn(ColumnName = "IsDateType", ColumnDescription = "是否含日期", ColumnDataType = "bit(1)", IsNullable = true)]
+    public bool? IsDateType { get; set; }
+
+    [SugarColumn(ColumnName = "BusinessID", ColumnDescription = "业务ID", IsNullable = true)]
+    public long? BusinessId { get; set; }
 
-    [SugarColumn(ColumnName = "is_date_type", ColumnDescription = "是否含日期", ColumnDataType = "boolean")]
-    public bool IsDateType { get; set; }
+    [SugarColumn(ColumnName = "IsActive", ColumnDescription = "是否启用", ColumnDataType = "boolean", IsNullable = true)]
+    public bool? IsActive { get; set; }
 
-    [SugarColumn(ColumnName = "create_user", ColumnDescription = "创建人", Length = 100, IsNullable = true)]
+    [SugarColumn(ColumnName = "IsConfirm", ColumnDescription = "是否确认", ColumnDataType = "boolean", IsNullable = true)]
+    public bool? IsConfirm { get; set; }
+
+    [SugarColumn(ColumnName = "Domain", ColumnDescription = "域", Length = 50, IsNullable = true)]
+    public string? Domain { get; set; }
+
+    [SugarColumn(ColumnName = "AddDate", ColumnDescription = "新增日期", IsNullable = true)]
+    public DateTime? AddDate { get; set; }
+
+    [SugarColumn(ColumnName = "IniDate", ColumnDescription = "初始日期", IsNullable = true)]
+    public DateTime? IniDate { get; set; }
+
+    [SugarColumn(ColumnName = "IniNextValue", ColumnDescription = "初始下一值", IsNullable = true)]
+    public int? IniNextValue { get; set; }
+
+    [SugarColumn(ColumnName = "IsStart", ColumnDescription = "是否起始", ColumnDataType = "boolean", IsNullable = true)]
+    public bool? IsStart { get; set; }
+
+    [SugarColumn(ColumnName = "IsChanged", ColumnDescription = "是否已变更", ColumnDataType = "boolean", IsNullable = true)]
+    public bool? IsChanged { get; set; }
+
+    [SugarColumn(ColumnName = "CreateUser", ColumnDescription = "创建人", Length = 24, IsNullable = true)]
     public string? CreateUser { get; set; }
 
-    [SugarColumn(ColumnName = "create_time", ColumnDescription = "创建时间")]
-    public DateTime CreateTime { get; set; } = DateTime.Now;
+    [SugarColumn(ColumnName = "CreateTime", ColumnDescription = "创建时间", IsNullable = true)]
+    public DateTime? CreateTime { get; set; }
 
-    [SugarColumn(ColumnName = "update_user", ColumnDescription = "更新人", Length = 100, IsNullable = true)]
+    [SugarColumn(ColumnName = "UpdateUser", ColumnDescription = "更新人", Length = 24, IsNullable = true)]
     public string? UpdateUser { get; set; }
 
-    [SugarColumn(ColumnName = "update_time", ColumnDescription = "更新时间", IsNullable = true)]
+    [SugarColumn(ColumnName = "UpdateTime", ColumnDescription = "更新时间", IsNullable = true)]
     public DateTime? UpdateTime { get; set; }
 
+    [SugarColumn(ColumnName = "EffectiveTime", ColumnDescription = "生效时间", IsNullable = true)]
+    public DateTime? EffectiveTime { get; set; }
+
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
 }

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

@@ -29,6 +29,7 @@ public static class AidopMenuLinkSync
         NormalizeS2ScheduleExceptionMenu(db);
         NormalizeS2OperationPlanLeafMenus(db);
         NormalizeS2WorkOrderProgressKanbanMenu(db);
+        NormalizeS3SupplyMenus(db);
         EnsureLinkagePlanMenu(db);
         RemoveDeprecatedS8DashboardChildMenu(db);
         NormalizeS8MenuParents(db);
@@ -465,4 +466,200 @@ public static class AidopMenuLinkSync
             .UpdateColumns(x => new { x.Component, x.Remark })
             .ExecuteCommand();
     }
+
+    /// <summary>
+    /// S3「物料计划」下三项菜单:种子关闭时补插,已存在时纠偏到真实组件页。
+    /// </summary>
+    private static void NormalizeS3SupplyMenus(ISqlSugarClient db)
+    {
+        const long s3RootId = 1321000003000L;
+        const long parentId = 1322000000009L;
+        const long procurementDirId = 1322000000010L;
+
+        if (!db.Queryable<SysMenu>().Any(m => m.Id == parentId) || !db.Queryable<SysMenu>().Any(m => m.Id == procurementDirId))
+            return;
+
+        var procurementDir = db.Queryable<SysMenu>().First(m => m.Id == procurementDirId);
+        if (procurementDir != null)
+        {
+            var procurementNeedFix = procurementDir.Pid != s3RootId
+                                     || procurementDir.Title != "采购管理"
+                                     || procurementDir.Path != "/aidop/s3/procurement"
+                                     || procurementDir.Name != "aidopS3Procurement"
+                                     || procurementDir.Component != "Layout"
+                                     || procurementDir.Type != MenuTypeEnum.Dir
+                                     || procurementDir.Icon != "ele-Folder"
+                                     || procurementDir.Redirect != "/aidop/s3/procurement/purchase-request"
+                                     || procurementDir.Remark != "S3 采购管理";
+            if (procurementNeedFix)
+            {
+                procurementDir.Pid = s3RootId;
+                procurementDir.Title = "采购管理";
+                procurementDir.Path = "/aidop/s3/procurement";
+                procurementDir.Name = "aidopS3Procurement";
+                procurementDir.Component = "Layout";
+                procurementDir.Type = MenuTypeEnum.Dir;
+                procurementDir.Icon = "ele-Folder";
+                procurementDir.Redirect = "/aidop/s3/procurement/purchase-request";
+                procurementDir.Remark = "S3 采购管理";
+                db.Updateable(procurementDir)
+                    .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");
+        var defs = new (long Id, string Title, string Path, string Name, string Component, string Icon, int OrderNo, string Remark)[]
+        {
+            (
+                1329003100001L,
+                "物料需求计划",
+                "/aidop/s3/material-plan/demand-schedule",
+                "aidopS3DemandSchedule",
+                "/aidop/s3/supply/demandScheduleList",
+                "ele-Calendar",
+                10,
+                "S3 物料需求计划(ic_demandschedule)"
+            ),
+            (
+                1329003100002L,
+                "物料交货计划",
+                "/aidop/s3/material-plan/delivery-schedule",
+                "aidopS3DeliverySchedule",
+                "/aidop/s3/supply/deliveryScheduleList",
+                "ele-Document",
+                20,
+                "S3 物料交货计划(srm_polist_ds)"
+            ),
+            (
+                1329003100003L,
+                "交货单异常记录",
+                "/aidop/s3/material-plan/delivery-exception",
+                "aidopS3DeliveryException",
+                "/aidop/s3/supply/deliveryExceptionList",
+                "ele-Warning",
+                30,
+                "S3 交货单异常记录(DeliveryExceptionMaster)"
+            ),
+        };
+
+        foreach (var def in defs)
+        {
+            var row = db.Queryable<SysMenu>().First(m => m.Id == def.Id);
+            if (row == null)
+            {
+                db.Insertable(new SysMenu
+                {
+                    Id = def.Id,
+                    Pid = parentId,
+                    Title = def.Title,
+                    Path = def.Path,
+                    Name = def.Name,
+                    Component = def.Component,
+                    Icon = def.Icon,
+                    Type = MenuTypeEnum.Menu,
+                    CreateTime = ct,
+                    OrderNo = def.OrderNo,
+                    Status = StatusEnum.Enable,
+                    Remark = def.Remark
+                }).ExecuteCommand();
+                continue;
+            }
+
+            var needFix = row.Pid != parentId
+                          || row.Path != def.Path
+                          || row.Name != def.Name
+                          || row.Component != def.Component
+                          || row.Title != def.Title
+                          || row.Icon != def.Icon
+                          || row.OrderNo != def.OrderNo
+                          || row.Type != MenuTypeEnum.Menu
+                          || row.Remark != def.Remark;
+            if (!needFix)
+                continue;
+
+            row.Pid = parentId;
+            row.Path = def.Path;
+            row.Name = def.Name;
+            row.Component = def.Component;
+            row.Title = def.Title;
+            row.Icon = def.Icon;
+            row.OrderNo = def.OrderNo;
+            row.Type = MenuTypeEnum.Menu;
+            row.Remark = def.Remark;
+            db.Updateable(row)
+                .UpdateColumns(m => new { m.Pid, m.Path, m.Name, m.Component, m.Title, m.Icon, m.OrderNo, m.Type, m.Remark })
+                .ExecuteCommand();
+        }
+
+        EnsureS3LeafMenu(
+            db, ct, 1329003100011L, procurementDirId, "物料采购申请",
+            "/aidop/s3/procurement/purchase-request", "aidopS3PurchaseRequest",
+            "/aidop/s3/supply/purchaseRequestList", "ele-Document", 10, "S3 物料采购申请(srm_pr_main)");
+
+        EnsureS3LeafMenu(
+            db, ct, 1329003100010L, s3RootId, "供应协同看板",
+            "/aidop/s3/supply-kanban", "aidopS3SupplyKanban",
+            "/aidop/kanban/s3", "ele-DataBoard", 102, "S3 供应协同看板");
+    }
+
+    private static void EnsureS3LeafMenu(
+        ISqlSugarClient db,
+        DateTime ct,
+        long id,
+        long pid,
+        string title,
+        string path,
+        string name,
+        string component,
+        string icon,
+        int orderNo,
+        string remark)
+    {
+        var row = db.Queryable<SysMenu>().First(m => m.Id == id);
+        if (row == null)
+        {
+            db.Insertable(new SysMenu
+            {
+                Id = id,
+                Pid = pid,
+                Title = title,
+                Path = path,
+                Name = name,
+                Component = component,
+                Icon = icon,
+                Type = MenuTypeEnum.Menu,
+                CreateTime = ct,
+                OrderNo = orderNo,
+                Status = StatusEnum.Enable,
+                Remark = remark
+            }).ExecuteCommand();
+            return;
+        }
+
+        var needFix = row.Pid != pid
+                      || row.Title != title
+                      || row.Path != path
+                      || row.Name != name
+                      || row.Component != component
+                      || row.Icon != icon
+                      || row.Type != MenuTypeEnum.Menu
+                      || row.OrderNo != orderNo
+                      || row.Remark != remark;
+        if (!needFix)
+            return;
+
+        row.Pid = pid;
+        row.Title = title;
+        row.Path = path;
+        row.Name = name;
+        row.Component = component;
+        row.Icon = icon;
+        row.Type = MenuTypeEnum.Menu;
+        row.OrderNo = orderNo;
+        row.Remark = remark;
+        db.Updateable(row)
+            .UpdateColumns(m => new { m.Pid, m.Title, m.Path, m.Name, m.Component, m.Icon, m.Type, m.OrderNo, m.Remark })
+            .ExecuteCommand();
+    }
 }

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

@@ -123,6 +123,18 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         var s3MaterialPlan = list.FirstOrDefault(x => x.Id == 1322000000009L);
         if (s3MaterialPlan != null)
             s3MaterialPlan.Redirect = "/aidop/s3/material-plan/demand-schedule";
+        var s3Procurement = list.FirstOrDefault(x => x.Id == 1322000000010L);
+        if (s3Procurement != null)
+        {
+            s3Procurement.Title = "采购管理";
+            s3Procurement.Path = "/aidop/s3/procurement";
+            s3Procurement.Name = "aidopS3Procurement";
+            s3Procurement.Component = "Layout";
+            s3Procurement.Icon = "ele-Folder";
+            s3Procurement.Type = MenuTypeEnum.Dir;
+            s3Procurement.Redirect = "/aidop/s3/procurement/purchase-request";
+            s3Procurement.Remark = "S3 采购管理";
+        }
 
         // S8:复用自动生成的首项菜单位,直接作为「异常监控看板」页,避免再出现中间层「异常管理」目录
         var s8Dashboard = list.FirstOrDefault(x => x.Id == 1322000000027L);
@@ -1186,6 +1198,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
     private static IEnumerable<SysMenu> BuildS3SupplyMenus(DateTime ct)
     {
         const long s3MaterialPlanDirId = 1322000000009L;
+        const long s3ProcurementDirId = 1322000000010L;
+        const long s3RootDirId = 1321000003000L;
 
         yield return new SysMenu
         {
@@ -1216,6 +1230,51 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             OrderNo = 20,
             Remark = "S3 物料交货计划(srm_polist_ds)"
         };
+
+        yield return new SysMenu
+        {
+            Id = 1329003100003L,
+            Pid = s3MaterialPlanDirId,
+            Title = "交货单异常记录",
+            Path = "/aidop/s3/material-plan/delivery-exception",
+            Name = "aidopS3DeliveryException",
+            Component = "/aidop/s3/supply/deliveryExceptionList",
+            Icon = "ele-Warning",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 30,
+            Remark = "S3 交货单异常记录(DeliveryExceptionMaster)"
+        };
+
+        yield return new SysMenu
+        {
+            Id = 1329003100011L,
+            Pid = s3ProcurementDirId,
+            Title = "物料采购申请",
+            Path = "/aidop/s3/procurement/purchase-request",
+            Name = "aidopS3PurchaseRequest",
+            Component = "/aidop/s3/supply/purchaseRequestList",
+            Icon = "ele-Document",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 10,
+            Remark = "S3 物料采购申请(srm_pr_main)"
+        };
+
+        yield return new SysMenu
+        {
+            Id = 1329003100010L,
+            Pid = s3RootDirId,
+            Title = "供应协同看板",
+            Path = "/aidop/s3/supply-kanban",
+            Name = "aidopS3SupplyKanban",
+            Component = "/aidop/kanban/s3",
+            Icon = "ele-DataBoard",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 102,
+            Remark = "S3 供应协同看板"
+        };
     }
 
     private static readonly (string Code, string L1, (string Title, string Desc, string Complexity, string Days, string Note)[] Leaves)[] ModuleDefinitions =

+ 79 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/DeliveryExceptionService.cs

@@ -0,0 +1,79 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 交货单异常记录服务
+/// </summary>
+[ApiDescriptionSettings(Order = 307, Description = "交货单异常记录")]
+[Route("api/Supply")]
+[AllowAnonymous]
+[NonUnify]
+public class DeliveryExceptionService : IDynamicApiController, ITransient
+{
+    private const string C = "utf8mb4_general_ci";
+    private readonly ISqlSugarClient _db;
+
+    public DeliveryExceptionService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>交货单异常记录分页列表</summary>
+    [DisplayName("交货单异常记录列表")]
+    [HttpGet("delivery-exception/list")]
+    public async Task<object> GetList([FromQuery] DeliveryExceptionListInput 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 = new List<string> { "1=1" };
+
+        if (!string.IsNullOrWhiteSpace(input.OptTime))
+        {
+            where.Add($"(CAST(dm.OptTime AS CHAR(50)) COLLATE {C}) LIKE @OptTimeLike");
+            pars.Add(new SugarParameter("@OptTimeLike", $"%{input.OptTime.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.ItemNum))
+        {
+            where.Add($"(dm.ItemNum COLLATE {C}) = @ItemNum");
+            pars.Add(new SugarParameter("@ItemNum", input.ItemNum.Trim()));
+        }
+
+        var baseSql = BuildListBaseSql(string.Join(" AND ", where));
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(*) FROM ({baseSql}) AS t", pars);
+        var list = await _db.Ado.SqlQueryAsync<DeliveryExceptionListRow>(
+            $"SELECT * FROM ({baseSql}) AS t ORDER BY t.Id DESC LIMIT {pageSize} OFFSET {offset}", pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    private static string BuildListBaseSql(string innerWhere) => $"""
+        SELECT
+            dm.RecID                     AS Id,
+            dm.Domain                    AS Domain,
+            dm.Icdsid                    AS IcdsId,
+            dm.OptTime                   AS OptTime,
+            dm.ItemNum                   AS ItemNum,
+            im.Descr                     AS Descr,
+            dm.Remark                    AS Remark,
+            IFNULL(dm.NeedQty, 0)        AS NeedQty
+        FROM DeliveryExceptionMaster dm
+        LEFT JOIN ItemMaster im
+            ON (dm.Domain COLLATE {C}) = (im.Domain COLLATE {C})
+            AND (dm.ItemNum COLLATE {C}) = (im.ItemNum COLLATE {C})
+        WHERE {innerWhere}
+        """;
+
+    private sealed class DeliveryExceptionListRow
+    {
+        public long Id { get; set; }
+        public string? Domain { get; set; }
+        public long? IcdsId { get; set; }
+        public DateTime? OptTime { get; set; }
+        public string? ItemNum { get; set; }
+        public string? Descr { get; set; }
+        public decimal? NeedQty { get; set; }
+        public string? Remark { get; set; }
+    }
+}

+ 16 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Dto/DeliveryExceptionDto.cs

@@ -0,0 +1,16 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 交货单异常记录分页查询
+/// </summary>
+public class DeliveryExceptionListInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+
+    /// <summary>执行时间(模糊匹配)</summary>
+    public string? OptTime { get; set; }
+
+    /// <summary>物料编码(精确匹配)</summary>
+    public string? ItemNum { get; set; }
+}

+ 35 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Dto/PurchaseRequestDto.cs

@@ -0,0 +1,35 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+public class PurchaseRequestListInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+    public string? PrBillNo { get; set; }
+    public string? ItemNumber { get; set; }
+    public string? SupplierName { get; set; }
+    public int? State { get; set; }
+    public string? SortField { get; set; }
+    public string? SortOrder { get; set; }
+}
+
+public class PurchaseRequestSaveInput
+{
+    public long? Id { get; set; }
+    public string? PrBillNo { get; set; }
+    public long? IcitemId { get; set; }
+    public string? IcitemName { get; set; }
+    public string? PrUnit { get; set; }
+    public string? PrPurchaseNumber { get; set; }
+    public string? PrPurchaseName { get; set; }
+    public string? SupplierType { get; set; }
+    public decimal? PrAqty { get; set; }
+    public string? PrSsendDate { get; set; }
+    public string? PrSarriveDate { get; set; }
+    public int? PrType { get; set; }
+    public int? State { get; set; }
+    public long? PrPurchaseId { get; set; }
+    public int? IsRequireGoods { get; set; }
+    public string? PrPurchaserNum { get; set; }
+    public string? PrPurchaser { get; set; }
+    public long? CurrencyType { get; set; }
+}

+ 32 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Entity/DeliveryExceptionMaster.cs

@@ -0,0 +1,32 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 交货单异常记录(DeliveryExceptionMaster)
+/// </summary>
+[SugarTable("DeliveryExceptionMaster", "交货单异常记录")]
+public class DeliveryExceptionMaster
+{
+    [SugarColumn(ColumnName = "RecID", IsIdentity = false)]
+    public long RecID { get; set; }
+
+    [SugarColumn(ColumnName = "Domain", Length = 8, IsNullable = true)]
+    public string? Domain { get; set; }
+
+    [SugarColumn(ColumnName = "Icdsid", IsNullable = true)]
+    public long? IcdsId { get; set; }
+
+    [SugarColumn(ColumnName = "OptTime", IsNullable = true)]
+    public DateTime? OptTime { get; set; }
+
+    [SugarColumn(ColumnName = "ItemNum", Length = 30, IsNullable = true)]
+    public string? ItemNum { get; set; }
+
+    [SugarColumn(ColumnName = "Remark", Length = 512, IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnName = "NeedQty", IsNullable = true)]
+    public decimal? NeedQty { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
+    public long? TenantId { get; set; }
+}

+ 104 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Entity/PurchaseRequestMain.cs

@@ -0,0 +1,104 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 物料采购申请单(srm_pr_main)
+/// </summary>
+[SugarTable("srm_pr_main", "物料采购申请单")]
+public class PurchaseRequestMain
+{
+    [SugarColumn(ColumnName = "voucher", IsPrimaryKey = true, IsIdentity = true)]
+    public long Voucher { get; set; }
+
+    [SugarColumn(ColumnName = "Id")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "pr_billno", IsNullable = true)]
+    public string? PrBillNo { get; set; }
+
+    [SugarColumn(ColumnName = "pr_purchaseid")]
+    public long PrPurchaseId { get; set; }
+
+    [SugarColumn(ColumnName = "pr_purchasenumber", IsNullable = true)]
+    public string? PrPurchaseNumber { get; set; }
+
+    [SugarColumn(ColumnName = "pr_purchasename", IsNullable = true)]
+    public string? PrPurchaseName { get; set; }
+
+    [SugarColumn(ColumnName = "pr_purchaser", IsNullable = true)]
+    public string? PrPurchaser { get; set; }
+
+    [SugarColumn(ColumnName = "pr_purchaser_num", IsNullable = true)]
+    public string? PrPurchaserNum { get; set; }
+
+    [SugarColumn(ColumnName = "pr_rqty", IsNullable = true)]
+    public decimal? PrRqty { get; set; }
+
+    [SugarColumn(ColumnName = "pr_aqty", IsNullable = true)]
+    public decimal? PrAqty { get; set; }
+
+    [SugarColumn(ColumnName = "pr_sqty", IsNullable = true)]
+    public decimal? PrSqty { get; set; }
+
+    [SugarColumn(ColumnName = "icitem_id")]
+    public long IcitemId { get; set; }
+
+    [SugarColumn(ColumnName = "icitem_name", IsNullable = true)]
+    public string? IcitemName { get; set; }
+
+    [SugarColumn(ColumnName = "pr_ssend_date", IsNullable = true)]
+    public DateTime? PrSsendDate { get; set; }
+
+    [SugarColumn(ColumnName = "pr_sarrive_date", IsNullable = true)]
+    public DateTime? PrSarriveDate { get; set; }
+
+    [SugarColumn(ColumnName = "pr_unit", IsNullable = true)]
+    public string? PrUnit { get; set; }
+
+    [SugarColumn(ColumnName = "state", IsNullable = true)]
+    public int? State { get; set; }
+
+    [SugarColumn(ColumnName = "pr_type", IsNullable = true)]
+    public int? PrType { get; set; }
+
+    [SugarColumn(ColumnName = "currencytype")]
+    public long CurrencyType { get; set; }
+
+    [SugarColumn(ColumnName = "create_by", IsNullable = true)]
+    public long? CreateBy { get; set; }
+
+    [SugarColumn(ColumnName = "create_by_name", IsNullable = true)]
+    public string? CreateByName { get; set; }
+
+    [SugarColumn(ColumnName = "create_time", IsNullable = true)]
+    public DateTime? CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_by", IsNullable = true)]
+    public long? UpdateBy { get; set; }
+
+    [SugarColumn(ColumnName = "update_by_name", IsNullable = true)]
+    public string? UpdateByName { get; set; }
+
+    [SugarColumn(ColumnName = "update_time", IsNullable = true)]
+    public DateTime? UpdateTime { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id", IsNullable = true)]
+    public long? FactoryId { get; set; }
+
+    [SugarColumn(ColumnName = "org_id", IsNullable = true)]
+    public long? OrgId { get; set; }
+
+    [SugarColumn(ColumnName = "IsDeleted")]
+    public bool IsDeleted { get; set; }
+
+    [SugarColumn(ColumnName = "company_id", IsNullable = true)]
+    public long? CompanyId { get; set; }
+
+    [SugarColumn(ColumnName = "IsRequireGoods")]
+    public int IsRequireGoods { get; set; }
+
+    [SugarColumn(ColumnName = "supplier_type", IsNullable = true)]
+    public string? SupplierType { get; set; }
+}

+ 365 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestService.cs

@@ -0,0 +1,365 @@
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 物料采购申请服务
+/// </summary>
+[ApiDescriptionSettings(Order = 308, Description = "物料采购申请")]
+[Route("api/Supply")]
+[AllowAnonymous]
+[NonUnify]
+public class PurchaseRequestService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public PurchaseRequestService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    [DisplayName("物料采购申请列表")]
+    [HttpGet("purchase-request/list")]
+    public async Task<object> GetList([FromQuery] PurchaseRequestListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var pars = new List<SugarParameter>();
+        var where = new List<string>
+        {
+            "IFNULL(pm.IsDeleted,0)=0",
+            "IFNULL(pm.state,0)<>0",
+            "IFNULL(pm.analogcalcversion,'')=''"
+        };
+
+        if (_userManager.TenantId > 0)
+        {
+            where.Add("pm.tenant_id=@TenantId");
+            pars.Add(new SugarParameter("@TenantId", _userManager.TenantId));
+        }
+        if (!string.IsNullOrWhiteSpace(input.PrBillNo))
+        {
+            where.Add("pm.pr_billno LIKE @PrBillNo");
+            pars.Add(new SugarParameter("@PrBillNo", $"%{input.PrBillNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.ItemNumber))
+        {
+            where.Add("ic.number LIKE @ItemNumber");
+            pars.Add(new SugarParameter("@ItemNumber", $"%{input.ItemNumber.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.SupplierName))
+        {
+            where.Add("pm.pr_purchasename LIKE @SupplierName");
+            pars.Add(new SugarParameter("@SupplierName", $"%{input.SupplierName.Trim()}%"));
+        }
+        if (input.State.HasValue)
+        {
+            where.Add("pm.state=@State");
+            pars.Add(new SugarParameter("@State", input.State.Value));
+        }
+
+        var orderBy = BuildOrderBy(input.SortField, input.SortOrder);
+        var fromSql = $"""
+            FROM srm_pr_main pm
+            LEFT JOIN ic_item ic ON pm.icitem_id=ic.Id
+            LEFT JOIN srm_purchase pur ON pm.icitem_id=pur.icitem_id AND pm.pr_purchaseid = pur.supplier_id
+            LEFT JOIN ic_item_stock its ON ic.Id=its.icitem_id
+            LEFT JOIN PurOrdDetail pod ON pm.pr_billno=pod.Req OR pm.SAP_pr_billno=pod.Req
+            WHERE {string.Join(" AND ", where)}
+            """;
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM (SELECT DISTINCT pm.Id {fromSql}) t", pars);
+        var list = await _db.Ado.SqlQueryAsync<PurchaseRequestListRow>(
+            $"""
+            SELECT
+                pm.id AS Id,
+                pm.pr_billno AS PrBillNo,
+                pod.PurOrd AS PurOrd,
+                pm.pr_type AS PrType,
+                pm.supplier_type AS SupplierType,
+                pm.IsRequireGoods AS IsRequireGoods,
+                pm.state AS State,
+                ic.number AS Number,
+                ic.name AS Name,
+                ic.model AS Model,
+                pm.pr_unit AS PrUnit,
+                pm.pr_purchasenumber AS PrPurchaseNumber,
+                pm.pr_purchasename AS PrPurchaseName,
+                pm.pr_sqty AS PrSqty,
+                pm.pr_aqty AS PrAqty,
+                its.sqty AS StockQty,
+                pm.pr_ssend_date AS PrSsendDate,
+                pm.pr_sarrive_date AS PrSarriveDate,
+                pm.pr_purchaser AS PrPurchaser,
+                pm.create_time AS CreateTime
+            {fromSql}
+            ORDER BY {orderBy}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("获取物料采购申请详情")]
+    [HttpGet("purchase-request/{id:long}")]
+    public async Task<object> GetDetail(long id)
+    {
+        var row = (await _db.Ado.SqlQueryAsync<PurchaseRequestDetailRow>(
+            """
+            SELECT
+                pm.id AS Id,
+                pm.pr_billno AS PrBillNo,
+                pm.icitem_id AS IcitemId,
+                pm.icitem_name AS IcitemName,
+                pm.pr_unit AS PrUnit,
+                pm.pr_purchasenumber AS PrPurchaseNumber,
+                pm.pr_purchasename AS PrPurchaseName,
+                pm.supplier_type AS SupplierType,
+                pm.pr_aqty AS PrAqty,
+                pm.pr_ssend_date AS PrSsendDate,
+                pm.pr_sarrive_date AS PrSarriveDate,
+                pm.pr_type AS PrType,
+                pm.state AS State,
+                pm.pr_purchaseid AS PrPurchaseId,
+                pm.IsRequireGoods AS IsRequireGoods,
+                pm.pr_purchaser_num AS PrPurchaserNum,
+                pm.pr_purchaser AS PrPurchaser,
+                pm.currencytype AS CurrencyType
+            FROM srm_pr_main pm
+            WHERE pm.Id=@Id AND IFNULL(pm.IsDeleted,0)=0
+            LIMIT 1
+            """,
+            new SugarParameter("@Id", id))).FirstOrDefault();
+
+        return row ?? throw Oops.Oh("记录不存在");
+    }
+
+    [DisplayName("保存物料采购申请")]
+    [ApiDescriptionSettings(Name = "SavePurchaseRequest"), HttpPost("purchase-request/save")]
+    public async Task<object> Save([FromBody] PurchaseRequestSaveInput input)
+    {
+        if (!input.IcitemId.HasValue || input.IcitemId.Value <= 0) throw Oops.Oh("请选择物料");
+        if (input.PrPurchaseId is null or <= 0) throw Oops.Oh("请选择供应商");
+        if (!input.PrAqty.HasValue || input.PrAqty.Value <= 0) throw Oops.Oh("申请数量必须大于0");
+
+        var now = DateTime.Now;
+        var aqty = input.PrAqty.Value;
+        var prSsendDate = ParseDate(input.PrSsendDate) ?? now.Date;
+        var prSarriveDate = ParseDate(input.PrSarriveDate);
+        var prType = input.PrType ?? 3;
+        var state = input.State ?? 1;
+        var currency = input.CurrencyType ?? 1;
+        var companyId = _userManager.OrgId > 0 ? _userManager.OrgId : 1000;
+
+        if (input.Id is null or 0)
+        {
+            var newId = YitIdHelper.NextId();
+            var billNo = string.IsNullOrWhiteSpace(input.PrBillNo) ? GenerateBillNo() : input.PrBillNo.Trim();
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO srm_pr_main
+                (Id,pr_billno,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
+                 pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
+                 create_by,create_by_name,create_time,update_by,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,IsRequireGoods,supplier_type)
+                VALUES
+                (@Id,@PrBillNo,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
+                 @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
+                 @CreateBy,@CreateByName,@CreateTime,@UpdateBy,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,@IsDeleted,@CompanyId,@IsRequireGoods,@SupplierType)
+                """,
+                new SugarParameter("@Id", newId),
+                new SugarParameter("@PrBillNo", billNo),
+                new SugarParameter("@PrPurchaseId", input.PrPurchaseId!.Value),
+                new SugarParameter("@PrPurchaseNumber", input.PrPurchaseNumber?.Trim()),
+                new SugarParameter("@PrPurchaseName", input.PrPurchaseName?.Trim()),
+                new SugarParameter("@PrPurchaser", input.PrPurchaser?.Trim()),
+                new SugarParameter("@PrPurchaserNum", input.PrPurchaserNum?.Trim()),
+                new SugarParameter("@PrRqty", aqty),
+                new SugarParameter("@PrAqty", aqty),
+                new SugarParameter("@PrSqty", aqty),
+                new SugarParameter("@IcitemId", input.IcitemId!.Value),
+                new SugarParameter("@IcitemName", input.IcitemName?.Trim()),
+                new SugarParameter("@PrSsendDate", prSsendDate),
+                new SugarParameter("@PrSarriveDate", prSarriveDate),
+                new SugarParameter("@PrUnit", input.PrUnit?.Trim()),
+                new SugarParameter("@State", state),
+                new SugarParameter("@PrType", prType),
+                new SugarParameter("@CurrencyType", currency),
+                new SugarParameter("@CreateBy", _userManager.UserId),
+                new SugarParameter("@CreateByName", _userManager.Account),
+                new SugarParameter("@CreateTime", now),
+                new SugarParameter("@UpdateBy", _userManager.UserId),
+                new SugarParameter("@UpdateByName", _userManager.Account),
+                new SugarParameter("@UpdateTime", now),
+                new SugarParameter("@TenantId", _userManager.TenantId),
+                new SugarParameter("@FactoryId", _userManager.OrgId),
+                new SugarParameter("@OrgId", _userManager.OrgId),
+                new SugarParameter("@IsDeleted", 0),
+                new SugarParameter("@CompanyId", companyId),
+                new SugarParameter("@IsRequireGoods", input.IsRequireGoods ?? 0),
+                new SugarParameter("@SupplierType", input.SupplierType?.Trim())
+            );
+
+            return new { id = newId, prBillNo = billNo, message = "新增成功" };
+        }
+        else
+        {
+            var exist = await _db.Ado.GetIntAsync(
+                "SELECT COUNT(1) FROM srm_pr_main WHERE Id=@Id AND IFNULL(IsDeleted,0)=0",
+                new SugarParameter("@Id", input.Id.Value));
+            if (exist <= 0) throw Oops.Oh("记录不存在");
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE srm_pr_main
+                SET
+                    pr_purchaseid=@PrPurchaseId,
+                    pr_purchasenumber=@PrPurchaseNumber,
+                    pr_purchasename=@PrPurchaseName,
+                    pr_purchaser=@PrPurchaser,
+                    pr_purchaser_num=@PrPurchaserNum,
+                    pr_rqty=@PrRqty,
+                    pr_aqty=@PrAqty,
+                    pr_sqty=@PrSqty,
+                    icitem_id=@IcitemId,
+                    icitem_name=@IcitemName,
+                    pr_ssend_date=@PrSsendDate,
+                    pr_sarrive_date=@PrSarriveDate,
+                    pr_unit=@PrUnit,
+                    state=@State,
+                    pr_type=@PrType,
+                    currencytype=@CurrencyType,
+                    update_by=@UpdateBy,
+                    update_by_name=@UpdateByName,
+                    update_time=@UpdateTime,
+                    IsRequireGoods=@IsRequireGoods,
+                    supplier_type=@SupplierType
+                WHERE Id=@Id AND IFNULL(IsDeleted,0)=0
+                """,
+                new SugarParameter("@Id", input.Id.Value),
+                new SugarParameter("@PrPurchaseId", input.PrPurchaseId!.Value),
+                new SugarParameter("@PrPurchaseNumber", input.PrPurchaseNumber?.Trim()),
+                new SugarParameter("@PrPurchaseName", input.PrPurchaseName?.Trim()),
+                new SugarParameter("@PrPurchaser", input.PrPurchaser?.Trim()),
+                new SugarParameter("@PrPurchaserNum", input.PrPurchaserNum?.Trim()),
+                new SugarParameter("@PrRqty", aqty),
+                new SugarParameter("@PrAqty", aqty),
+                new SugarParameter("@PrSqty", aqty),
+                new SugarParameter("@IcitemId", input.IcitemId!.Value),
+                new SugarParameter("@IcitemName", input.IcitemName?.Trim()),
+                new SugarParameter("@PrSsendDate", prSsendDate),
+                new SugarParameter("@PrSarriveDate", prSarriveDate),
+                new SugarParameter("@PrUnit", input.PrUnit?.Trim()),
+                new SugarParameter("@State", state),
+                new SugarParameter("@PrType", prType),
+                new SugarParameter("@CurrencyType", currency),
+                new SugarParameter("@UpdateBy", _userManager.UserId),
+                new SugarParameter("@UpdateByName", _userManager.Account),
+                new SugarParameter("@UpdateTime", now),
+                new SugarParameter("@IsRequireGoods", input.IsRequireGoods ?? 0),
+                new SugarParameter("@SupplierType", input.SupplierType?.Trim())
+            );
+
+            return new { id = input.Id, message = "编辑成功" };
+        }
+    }
+
+    [DisplayName("删除物料采购申请")]
+    [HttpPost("purchase-request/delete/{id:long}")]
+    public async Task<object> Delete(long id)
+    {
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE srm_pr_main
+            SET IsDeleted=1,state=0,update_by=@UpdateBy,update_by_name=@UpdateByName,update_time=@UpdateTime
+            WHERE Id=@Id AND IFNULL(IsDeleted,0)=0
+            """,
+            new SugarParameter("@Id", id),
+            new SugarParameter("@UpdateBy", _userManager.UserId),
+            new SugarParameter("@UpdateByName", _userManager.Account),
+            new SugarParameter("@UpdateTime", DateTime.Now));
+
+        if (affected <= 0) throw Oops.Oh("记录不存在");
+        return new { message = "删除成功" };
+    }
+
+    private static string BuildOrderBy(string? sortField, string? sortOrder)
+    {
+        var dir = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return sortField?.ToLowerInvariant() switch
+        {
+            "prbillno" => $"pm.pr_billno {dir}",
+            "purord" => $"pod.PurOrd {dir}",
+            "prtype" => $"pm.pr_type {dir}",
+            "state" => $"pm.state {dir}",
+            "number" => $"ic.number {dir}",
+            "name" => $"ic.name {dir}",
+            "prpurchasename" => $"pm.pr_purchasename {dir}",
+            "prsqty" => $"pm.pr_sqty {dir}",
+            "praqty" => $"pm.pr_aqty {dir}",
+            "stockqty" => $"its.sqty {dir}",
+            "prssenddate" => $"pm.pr_ssend_date {dir}",
+            "prsarrivedate" => $"pm.pr_sarrive_date {dir}",
+            "createtime" => $"pm.create_time {dir}",
+            _ => "pm.id DESC"
+        };
+    }
+
+    private static DateTime? ParseDate(string? value) => DateTime.TryParse(value, out var dt) ? dt : null;
+
+    private static string GenerateBillNo()
+    {
+        return $"PR{DateTime.Now:yyyyMMddHHmmssfff}";
+    }
+
+    private sealed class PurchaseRequestListRow
+    {
+        public long Id { get; set; }
+        public string? PrBillNo { get; set; }
+        public string? PurOrd { get; set; }
+        public int? PrType { get; set; }
+        public string? SupplierType { get; set; }
+        public int? IsRequireGoods { get; set; }
+        public int? State { get; set; }
+        public string? Number { get; set; }
+        public string? Name { get; set; }
+        public string? Model { get; set; }
+        public string? PrUnit { get; set; }
+        public string? PrPurchaseNumber { get; set; }
+        public string? PrPurchaseName { get; set; }
+        public decimal? PrSqty { get; set; }
+        public decimal? PrAqty { get; set; }
+        public decimal? StockQty { get; set; }
+        public DateTime? PrSsendDate { get; set; }
+        public DateTime? PrSarriveDate { get; set; }
+        public string? PrPurchaser { get; set; }
+        public DateTime? CreateTime { get; set; }
+    }
+
+    private sealed class PurchaseRequestDetailRow
+    {
+        public long Id { get; set; }
+        public string? PrBillNo { get; set; }
+        public long? IcitemId { get; set; }
+        public string? IcitemName { get; set; }
+        public string? PrUnit { get; set; }
+        public string? PrPurchaseNumber { get; set; }
+        public string? PrPurchaseName { get; set; }
+        public string? SupplierType { get; set; }
+        public decimal? PrAqty { get; set; }
+        public DateTime? PrSsendDate { get; set; }
+        public DateTime? PrSarriveDate { get; set; }
+        public int? PrType { get; set; }
+        public int? State { get; set; }
+        public long? PrPurchaseId { get; set; }
+        public int? IsRequireGoods { get; set; }
+        public string? PrPurchaserNum { get; set; }
+        public string? PrPurchaser { get; set; }
+        public long? CurrencyType { get; set; }
+    }
+}

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/Dto/UniversalSourceListDto.cs

@@ -0,0 +1,51 @@
+namespace Admin.NET.Plugin.AiDOP.Universal;
+
+public class UniversalSourceListPageInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+    public string? Number { get; set; }
+    public string? IcitemName { get; set; }
+    public string? SupplierNumber { get; set; }
+    public string? SortField { get; set; }
+    public string? SortOrder { get; set; }
+}
+
+public class UniversalSourceListOutput
+{
+    public long Id { get; set; }
+    public long? TenantId { get; set; }
+    public long? FactoryId { get; set; }
+    public string? IcitemId { get; set; }
+    public string? Number { get; set; }
+    public string? IcitemName { get; set; }
+    public string? ItemType { get; set; }
+    public string? Model { get; set; }
+    public string? Unit { get; set; }
+    public string? SupplierType { get; set; }
+    public int? IsActive { get; set; }
+    public long? SupplierId { get; set; }
+    public string? SupplierName { get; set; }
+    public string? SupplierNumber { get; set; }
+    public decimal? OrderPrice { get; set; }
+    public string? CurrencyType { get; set; }
+    public decimal? TaxRate { get; set; }
+    public decimal? Tariff { get; set; }
+    public decimal? Freight { get; set; }
+    public string? PriceTerms { get; set; }
+    public DateTime? EffectiveDate { get; set; }
+    public DateTime? ExpiringDate { get; set; }
+    public decimal? QuotaRate { get; set; }
+    public int? LeadTime { get; set; }
+    public decimal? QtyMin { get; set; }
+    public decimal? PackagingQty { get; set; }
+    public string? OrderRectorName { get; set; }
+    public string? OrderRectorNum { get; set; }
+    public string? Icitem { get; set; }
+    public string? Supplier { get; set; }
+    public int? IsRequireGoods { get; set; }
+    public string? Location { get; set; }
+    public string? Um { get; set; }
+    public string? Rev { get; set; }
+    public string? Drawing { get; set; }
+}

+ 121 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/UniversalSourceListService.cs

@@ -0,0 +1,121 @@
+namespace Admin.NET.Plugin.AiDOP.Universal;
+
+/// <summary>
+/// 通用货源清单选择服务
+/// </summary>
+[ApiDescriptionSettings(Order = 280, Description = "通用-货源清单选择")]
+[Route("api/Universal")]
+[AllowAnonymous]
+[NonUnify]
+public class UniversalSourceListService : IDynamicApiController, ITransient
+{
+    private const string C = "utf8mb4_general_ci";
+    private readonly ISqlSugarClient _db;
+
+    public UniversalSourceListService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    [DisplayName("获取货源清单选择列表")]
+    [HttpGet("source-list/page")]
+    public async Task<object> GetPage([FromQuery] UniversalSourceListPageInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var pars = new List<SugarParameter>();
+        var where = new List<string>
+        {
+            "(it.erp_cls=3 OR it.erp_cls=2)",
+            "IFNULL(sp.quota_rate,0)>0",
+            "IFNULL(sp.IsDeleted,0)=0"
+        };
+        if (!string.IsNullOrWhiteSpace(input.Number))
+        {
+            where.Add("it.number LIKE @Number");
+            pars.Add(new SugarParameter("@Number", $"%{input.Number.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.IcitemName))
+        {
+            where.Add("sp.icitem_name LIKE @IcitemName");
+            pars.Add(new SugarParameter("@IcitemName", $"%{input.IcitemName.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.SupplierNumber))
+        {
+            where.Add("sp.supplier_number LIKE @SupplierNumber");
+            pars.Add(new SugarParameter("@SupplierNumber", $"%{input.SupplierNumber.Trim()}%"));
+        }
+
+        var orderBy = BuildOrderBy(input.SortField, input.SortOrder);
+        var fromSql = $"""
+            FROM srm_purchase sp
+            LEFT JOIN ic_item it ON sp.icitem_id = it.Id
+            LEFT JOIN ItemMaster im ON (it.number COLLATE {C}) = (im.ItemNum COLLATE {C})
+            WHERE {string.Join(" AND ", where)}
+            """;
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<UniversalSourceListOutput>(
+            $"""
+            SELECT
+                sp.id AS Id,
+                sp.tenant_id AS TenantId,
+                sp.factory_id AS FactoryId,
+                CAST(sp.icitem_id AS CHAR(30)) AS IcitemId,
+                it.number AS Number,
+                sp.icitem_name AS IcitemName,
+                '原材料' AS ItemType,
+                it.model AS Model,
+                it.unit AS Unit,
+                sp.supplier_type AS SupplierType,
+                sp.is_active AS IsActive,
+                sp.supplier_id AS SupplierId,
+                sp.supplier_name AS SupplierName,
+                sp.supplier_number AS SupplierNumber,
+                sp.order_price AS OrderPrice,
+                sp.currency_type AS CurrencyType,
+                IFNULL(sp.taxrate,0) AS TaxRate,
+                IFNULL(sp.tariff,0) AS Tariff,
+                sp.freight AS Freight,
+                sp.price_terms AS PriceTerms,
+                sp.effective_date AS EffectiveDate,
+                sp.expiring_date AS ExpiringDate,
+                IFNULL(sp.quota_rate,0) AS QuotaRate,
+                sp.lead_time AS LeadTime,
+                sp.qty_min AS QtyMin,
+                sp.packaging_qty AS PackagingQty,
+                sp.order_rector_name AS OrderRectorName,
+                sp.order_rector_num AS OrderRectorNum,
+                CONCAT(IFNULL(it.number,''),IFNULL(sp.icitem_name,'')) AS Icitem,
+                CONCAT(IFNULL(sp.supplier_name,''),IFNULL(sp.supplier_number,'')) AS Supplier,
+                sp.IsRequireGoods AS IsRequireGoods,
+                im.Location AS Location,
+                im.UM AS Um,
+                im.Rev AS Rev,
+                im.Drawing AS Drawing
+            {fromSql}
+            ORDER BY {orderBy}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    private static string BuildOrderBy(string? sortField, string? sortOrder)
+    {
+        var dir = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return sortField?.ToLowerInvariant() switch
+        {
+            "number" => $"it.number {dir}",
+            "icitemname" => $"sp.icitem_name {dir}",
+            "suppliertype" => $"sp.supplier_type {dir}",
+            "suppliernumber" => $"sp.supplier_number {dir}",
+            "suppliername" => $"sp.supplier_name {dir}",
+            "leadtime" => $"sp.lead_time {dir}",
+            _ => "sp.id DESC"
+        };
+    }
+}