Parcourir la source

feat: 采购退货单弹窗化改造、要货令CRUD、发货单附件功能,版本升至前端2.4.140/后端1.0.107

Pengxy il y a 6 jours
Parent
commit
8bbe0bf31e
27 fichiers modifiés avec 2580 ajouts et 305 suppressions
  1. 123 0
      Web/src/views/aidop/s3/api/demandOrder.ts
  2. 331 0
      Web/src/views/aidop/s3/supply/demandOrderForm.vue
  3. 293 0
      Web/src/views/aidop/s3/supply/demandOrderList.vue
  4. 15 17
      Web/src/views/aidop/s3/supply/outsourceOrderDetailForm.vue
  5. 2 2
      Web/src/views/aidop/s3/supply/outsourceOrderList.vue
  6. 2 2
      Web/src/views/aidop/s3/supply/processOutsourceOrderList.vue
  7. 108 0
      Web/src/views/aidop/s3/supply/selectDemandItem.vue
  8. 19 4
      Web/src/views/aidop/s4/api/procurementExecution.ts
  9. 126 0
      Web/src/views/aidop/s4/delivery/ShipmentAttachment.vue
  10. 34 0
      Web/src/views/aidop/s4/delivery/supplierHiprint.ts
  11. 99 0
      Web/src/views/aidop/s4/delivery/supplierShipmentFile.ts
  12. 35 38
      Web/src/views/aidop/s4/delivery/supplierShipmentForm.vue
  13. 43 39
      Web/src/views/aidop/s4/delivery/supplierShipmentList.vue
  14. 38 5
      Web/src/views/aidop/s4/return/purchaseReturnOrderForm.vue
  15. 119 17
      Web/src/views/aidop/s4/return/purchaseReturnOrderList.vue
  16. 15 3
      Web/src/views/system/print/component/hiprint/preview.vue
  17. 5 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMenuLinkSync.cs
  18. 2 1
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/PurchaseReturnDto.cs
  19. 58 60
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/PurchaseReturnService.cs
  20. 216 77
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShipmentService.cs
  21. 15 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs
  22. 666 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/DemandOrderService.cs
  23. 59 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Dto/DemandOrderDto.cs
  24. 8 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Entity/PurOrdMaster.cs
  25. 56 25
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/OutsourceOrderService.cs
  26. 6 7
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/ProcessOutsourceOrderService.cs
  27. 87 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurOrdNumberGenerator.cs

+ 123 - 0
Web/src/views/aidop/s3/api/demandOrder.ts

@@ -0,0 +1,123 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface OptionRow {
+	value: string;
+	label: string;
+}
+
+export interface DemandOrderRow {
+	id: number;
+	purOrd?: string | null;
+	suppName?: string | null;
+	buyer?: string | null;
+	departmentDescr?: string | null;
+	ordDate?: string | null;
+	contract?: string | null;
+	dueDate?: string | null;
+	contact?: string | null;
+	shipTo?: string | null;
+	curr?: string | null;
+	taxClass?: string | null;
+	taxIn?: boolean | number | null;
+	status?: string | null;
+	remark?: string | null;
+	supp?: string | null;
+	buyerCode?: string | null;
+	department?: string | null;
+	usage?: string | null;
+	fstid?: string | null;
+}
+
+export interface DemandOrderDetailRow {
+	id?: number | null;
+	line?: number | null;
+	itemNum?: string | null;
+	um?: string | null;
+	location?: string | null;
+	qtyOrded?: number | null;
+	rctQty?: number | null;
+	receiptQty?: number | null;
+	dueDate?: string | null;
+	rev?: string | null;
+	drawing?: string | null;
+	lotSerial?: string | null;
+	potype?: string | null;
+	purOrd?: string | null;
+	purOrdRecID?: number | null;
+}
+
+export interface DemandOrderSaveInput {
+	id?: number | null;
+	purOrd?: string | null;
+	ordDate?: string | null;
+	supp?: string | null;
+	reqBy?: string | null;
+	buyer?: string | null;
+	department?: string | null;
+	curr?: string | null;
+	usage?: string | null;
+	remark?: string | null;
+	potype?: string | null;
+	fstid?: string | null;
+	details: DemandOrderDetailRow[];
+}
+
+export interface ItemRow {
+	recID: number;
+	itemNum?: string | null;
+	descr?: string | null;
+	descr1?: string | null;
+	um?: string | null;
+	location?: string | null;
+	rev?: string | null;
+	drawing?: string | null;
+}
+
+export function fetchDemandOrderList(params: Record<string, unknown>) {
+	return service.get<Paged<DemandOrderRow>>('/api/Supply/demand-order/list', { params }).then((r) => r.data);
+}
+
+export function fetchDemandOrderDetail(id: number) {
+	return service
+		.get<{ master: DemandOrderRow; details: DemandOrderDetailRow[] }>(`/api/Supply/demand-order/${id}`)
+		.then((r) => r.data);
+}
+
+export function saveDemandOrder(body: DemandOrderSaveInput) {
+	return service.post('/api/Supply/demand-order/save', body).then((r) => r.data);
+}
+
+export function deleteDemandOrder(id: number, purOrd: string) {
+	return service.post(`/api/Supply/demand-order/delete/${id}`, null, { params: { purOrd } }).then((r) => r.data);
+}
+
+export function fetchDemandOrderItems(params: Record<string, unknown>) {
+	return service.get<Paged<ItemRow>>('/api/Supply/demand-order/items', { params }).then((r) => r.data);
+}
+
+export function fetchDemandOrderBuyers() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/demand-order/options/buyers').then((r) => r.data);
+}
+
+export function fetchDemandOrderSuppliers() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/demand-order/options/suppliers').then((r) => r.data);
+}
+
+export function fetchDemandOrderDepartments() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/demand-order/options/departments').then((r) => r.data);
+}
+
+export function fetchDemandOrderCurr() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/demand-order/options/curr').then((r) => r.data);
+}
+
+export function fetchDemandOrderLocations() {
+	return service.get<{ list: OptionRow[] }>('/api/Supply/demand-order/options/locations').then((r) => r.data);
+}

+ 331 - 0
Web/src/views/aidop/s3/supply/demandOrderForm.vue

@@ -0,0 +1,331 @@
+<template>
+	<div v-loading="loading">
+		<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
+			<el-row :gutter="12">
+				<el-col :span="12">
+					<el-form-item label="要货令单号">
+						<el-input v-model="form.purOrd" disabled />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="订单日期" prop="ordDate">
+						<el-date-picker v-model="form.ordDate" :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="supp">
+						<el-select v-model="form.supp" :disabled="isView" filterable style="width: 100%">
+							<el-option v-for="o in supplierOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="订单类型">
+						<el-select v-model="form.reqBy" :disabled="isView" style="width: 100%">
+							<el-option label="要货令" value="DO" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="采购组">
+						<el-input v-model="form.buyer" disabled />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="部门" prop="department">
+						<el-select v-model="form.department" :disabled="isView" filterable style="width: 100%">
+							<el-option v-for="o in departmentOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="币别">
+						<el-select v-model="form.curr" :disabled="isView" filterable clearable style="width: 100%">
+							<el-option v-for="o in currOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="供应类别">
+						<el-select v-model="form.usage" :disabled="isView" style="width: 100%" @change="onUsageChange">
+							<el-option label="VMI" value="VMI" />
+							<el-option label="标准" value="标准" />
+							<el-option label="研发" value="研发" />
+							<el-option label="ECR" value="ECR" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="24">
+					<el-form-item label="备注">
+						<el-input v-model="form.remark" :disabled="isView" type="textarea" :rows="3" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+
+			<el-divider content-position="left">明细</el-divider>
+
+			<div v-if="!isView" class="sub-toolbar">
+				<el-button type="primary" plain @click="addRow">添加</el-button>
+			</div>
+
+			<el-table :data="form.details" border stripe style="width: 100%">
+				<el-table-column label="行号" width="70" align="center">
+					<template #default="{ $index }">{{ $index + 1 }}</template>
+				</el-table-column>
+				<el-table-column label="物料编号" width="180">
+					<template #default="{ row }">
+						<el-input v-model="row.itemNum" disabled placeholder="选择物料" />
+						<el-button v-if="!isView" class="ml8" @click="openItemDlg(row)">选择</el-button>
+					</template>
+				</el-table-column>
+				<el-table-column label="单位" width="90">
+					<template #default="{ row }">
+						<el-input v-model="row.um" :disabled="isView" />
+					</template>
+				</el-table-column>
+				<el-table-column label="收货库位" width="140">
+					<template #default="{ row }">
+						<el-select v-model="row.location" :disabled="isView" filterable clearable style="width: 100%">
+							<el-option v-for="o in locationOptions" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+					</template>
+				</el-table-column>
+				<el-table-column label="订单数量" width="120">
+					<template #default="{ row }">
+						<el-input-number v-model="row.qtyOrded" :disabled="isView" :min="0" :precision="2" style="width: 100%" />
+					</template>
+				</el-table-column>
+				<el-table-column label="收货数量" width="110">
+					<template #default="{ row }">
+						<el-input-number v-model="row.rctQty" disabled :precision="2" style="width: 100%" />
+					</template>
+				</el-table-column>
+				<el-table-column label="暂收数量" width="110">
+					<template #default="{ row }">
+						<el-input-number v-model="row.receiptQty" disabled :precision="2" style="width: 100%" />
+					</template>
+				</el-table-column>
+				<el-table-column label="交货日期" width="140">
+					<template #default="{ row }">
+						<el-date-picker v-model="row.dueDate" :disabled="isView" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
+					</template>
+				</el-table-column>
+				<el-table-column label="物料版本" width="100">
+					<template #default="{ row }">
+						<el-input v-model="row.rev" :disabled="isView" />
+					</template>
+				</el-table-column>
+				<el-table-column label="图号" width="110">
+					<template #default="{ row }">
+						<el-input v-model="row.drawing" :disabled="isView" />
+					</template>
+				</el-table-column>
+				<el-table-column label="生产批号" width="120">
+					<template #default="{ row }">
+						<el-input v-model="row.lotSerial" :disabled="isView" />
+					</template>
+				</el-table-column>
+				<el-table-column v-if="!isView" label="操作" width="80" fixed="right" align="center">
+					<template #default="{ $index }">
+						<el-button link type="danger" @click="removeRow($index)">删除</el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+		</el-form>
+
+		<div class="footer">
+			<el-button @click="$emit('cancel')">取消</el-button>
+			<el-button v-if="!isView" type="primary" :loading="saving" @click="onSave">保存</el-button>
+		</div>
+
+		<el-dialog v-model="itemDlgVisible" title="选择物料" width="920px" append-to-body destroy-on-close>
+			<SelectDemandItem @picked="onPickItem" />
+		</el-dialog>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopS3SupplyDemandOrderForm">
+import { computed, onMounted, reactive, ref } from 'vue';
+import type { FormInstance, FormRules } from 'element-plus';
+import SelectDemandItem from './selectDemandItem.vue';
+import {
+	fetchDemandOrderCurr,
+	fetchDemandOrderDepartments,
+	fetchDemandOrderDetail,
+	fetchDemandOrderLocations,
+	fetchDemandOrderSuppliers,
+	saveDemandOrder,
+	type DemandOrderDetailRow,
+	type DemandOrderSaveInput,
+	type ItemRow,
+	type OptionRow,
+} from '../api/demandOrder';
+
+const props = defineProps<{
+	mode: 'create' | 'edit' | 'view';
+	orderId: 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 formRef = ref<FormInstance>();
+
+const supplierOptions = ref<OptionRow[]>([]);
+const departmentOptions = ref<OptionRow[]>([]);
+const currOptions = ref<OptionRow[]>([]);
+const locationOptions = ref<OptionRow[]>([]);
+
+const form = reactive<DemandOrderSaveInput>({
+	id: null,
+	purOrd: '',
+	ordDate: new Date().toISOString().slice(0, 10),
+	supp: '',
+	reqBy: 'DO',
+	buyer: '110',
+	department: '',
+	curr: '',
+	usage: '标准',
+	remark: '',
+	potype: 'PO',
+	fstid: '1',
+	details: [],
+});
+
+const rules: FormRules = {
+	ordDate: [{ required: true, message: '请选择订单日期', trigger: 'change' }],
+	supp: [{ required: true, message: '请选择供应商', trigger: 'change' }],
+	department: [{ required: true, message: '请选择部门', trigger: 'change' }],
+};
+
+const itemDlgVisible = ref(false);
+let pickingRow: DemandOrderDetailRow | null = null;
+
+function onUsageChange() {
+	form.fstid = form.usage === 'VMI' ? '3' : '1';
+}
+
+function addRow() {
+	form.details.push({
+		line: form.details.length + 1,
+		itemNum: '',
+		um: '',
+		location: '',
+		qtyOrded: 0,
+		rctQty: 0,
+		receiptQty: 0,
+		dueDate: '',
+		rev: '',
+		drawing: '',
+		lotSerial: '',
+		potype: 'PO',
+	});
+}
+
+function removeRow(index: number) {
+	form.details.splice(index, 1);
+}
+
+function openItemDlg(row: DemandOrderDetailRow) {
+	pickingRow = row;
+	itemDlgVisible.value = true;
+}
+
+function onPickItem(item: ItemRow) {
+	if (!pickingRow) return;
+	pickingRow.itemNum = item.itemNum || '';
+	pickingRow.um = item.um || '';
+	pickingRow.location = item.location || pickingRow.location;
+	pickingRow.rev = item.rev || '';
+	pickingRow.drawing = item.drawing || '';
+	itemDlgVisible.value = false;
+	pickingRow = null;
+}
+
+async function loadOptions() {
+	const [sup, dep, curr, loc] = await Promise.all([
+		fetchDemandOrderSuppliers(),
+		fetchDemandOrderDepartments(),
+		fetchDemandOrderCurr(),
+		fetchDemandOrderLocations(),
+	]);
+	supplierOptions.value = sup.list || [];
+	departmentOptions.value = dep.list || [];
+	currOptions.value = curr.list || [];
+	locationOptions.value = loc.list || [];
+}
+
+async function loadDetail() {
+	if (!props.orderId) return;
+	loading.value = true;
+	try {
+		const { master, details } = await fetchDemandOrderDetail(props.orderId);
+		form.id = master.id;
+		form.purOrd = master.purOrd || '';
+		form.ordDate = master.ordDate ? String(master.ordDate).slice(0, 10) : '';
+		form.supp = master.supp || '';
+		form.reqBy = master.reqBy || 'DO';
+		form.buyer = master.buyerCode || '110';
+		form.department = master.department || '';
+		form.curr = master.curr || '';
+		form.usage = master.usage || '标准';
+		form.remark = master.remark || '';
+		form.fstid = master.fstid || (form.usage === 'VMI' ? '3' : '1');
+		form.details = (details || []).map((d) => ({
+			...d,
+			dueDate: d.dueDate ? String(d.dueDate).slice(0, 10) : '',
+		}));
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function onSave() {
+	await formRef.value?.validate();
+	saving.value = true;
+	try {
+		await saveDemandOrder({
+			...form,
+			potype: 'PO',
+			fstid: form.usage === 'VMI' ? '3' : '1',
+			details: form.details.map((d, i) => ({
+				...d,
+				line: i + 1,
+				potype: 'PO',
+				purOrd: form.purOrd,
+				purOrdRecID: form.id ?? undefined,
+			})),
+		});
+		emit('saved');
+	} finally {
+		saving.value = false;
+	}
+}
+
+onMounted(async () => {
+	await loadOptions();
+	if (props.mode === 'create') {
+		form.ordDate = new Date().toISOString().slice(0, 10);
+		onUsageChange();
+	} else {
+		await loadDetail();
+	}
+});
+</script>
+
+<style scoped>
+.sub-toolbar {
+	margin-bottom: 10px;
+}
+.footer {
+	margin-top: 16px;
+	display: flex;
+	justify-content: flex-end;
+	gap: 10px;
+}
+.ml8 {
+	margin-left: 8px;
+}
+</style>

+ 293 - 0
Web/src/views/aidop/s3/supply/demandOrderList.vue

@@ -0,0 +1,293 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="要货令">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="要货令单号">
+				<el-input v-model="query.purOrd" clearable placeholder="模糊查询" style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="采购组">
+				<el-select v-model="query.buyer" clearable filterable style="width: 180px">
+					<el-option v-for="o in buyerOptions" :key="o.value" :label="o.label" :value="o.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="供应商">
+				<el-select v-model="query.supp" clearable filterable style="width: 200px">
+					<el-option v-for="o in supplierOptions" :key="o.value" :label="o.label" :value="o.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="状态">
+				<el-select v-model="query.status" clearable style="width: 140px">
+					<el-option v-for="s in statusOptions" :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-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.purOrd" prop="purOrd" label="要货令单号" min-width="160" fixed="left" sortable="custom" />
+			<el-table-column v-if="col.status" label="状态" width="100" fixed="left" sortable="custom">
+				<template #default="{ row }">
+					<el-tag size="small" :type="statusTag(row.status)">{{ statusText(row.status) }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column v-if="col.suppName" prop="suppName" label="供应商" min-width="180" sortable="custom" />
+			<el-table-column v-if="col.buyer" prop="buyer" label="采购组" width="150" sortable="custom" />
+			<el-table-column v-if="col.departmentDescr" prop="departmentDescr" label="部门" min-width="150" sortable="custom" />
+			<el-table-column v-if="col.ordDate" label="订单日期" width="120" sortable="custom">
+				<template #default="{ row }">{{ fmtDate(row.ordDate) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.contract" prop="contract" label="合同" width="120" sortable="custom" />
+			<el-table-column v-if="col.dueDate" label="交付日期" width="120" sortable="custom">
+				<template #default="{ row }">{{ fmtDate(row.dueDate) }}</template>
+			</el-table-column>
+			<el-table-column v-if="col.contact" prop="contact" label="联系方式" width="120" sortable="custom" />
+			<el-table-column v-if="col.shipTo" prop="shipTo" label="发至" width="120" sortable="custom" />
+			<el-table-column v-if="col.curr" prop="curr" label="币别" width="90" sortable="custom" />
+			<el-table-column v-if="col.taxClass" prop="taxClass" label="税类型" width="100" sortable="custom" />
+			<el-table-column v-if="col.taxIn" label="含税" width="90" sortable="custom">
+				<template #default="{ row }">{{ taxInText(row.taxIn) }}</template>
+			</el-table-column>
+			<el-table-column label="行操作" width="200" fixed="right" align="center">
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+					<el-button link type="danger" @click="onDelete(row)">删除</el-button>
+					<el-button link @click="openView(row)">查看</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<div class="pager">
+			<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="1100px" destroy-on-close>
+			<DemandOrderForm
+				:mode="formMode"
+				:order-id="editingId"
+				@cancel="dialogVisible = false"
+				@saved="onSaved"
+			/>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS3SupplyDemandOrderList">
+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 DemandOrderForm from './demandOrderForm.vue';
+import {
+	deleteDemandOrder,
+	fetchDemandOrderBuyers,
+	fetchDemandOrderList,
+	fetchDemandOrderSuppliers,
+	type DemandOrderRow,
+	type OptionRow,
+} from '../api/demandOrder';
+
+const route = useRoute();
+const pageTitle = computed(() => (route.meta?.title as string) || '要货令');
+
+const statusOptions = [
+	{ value: 'R', label: '新增' },
+	{ value: 'A', label: '审核中' },
+	{ value: 'B', label: '同意' },
+	{ value: 'C', label: '关闭' },
+];
+
+const query = reactive({
+	purOrd: '',
+	buyer: '',
+	supp: '',
+	status: '',
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+
+const loading = ref(false);
+const rows = ref<DemandOrderRow[]>([]);
+const total = ref(0);
+
+const buyerOptions = ref<OptionRow[]>([]);
+const supplierOptions = ref<OptionRow[]>([]);
+
+const col = reactive({
+	purOrd: true,
+	status: true,
+	suppName: true,
+	buyer: true,
+	departmentDescr: true,
+	ordDate: true,
+	contract: true,
+	dueDate: true,
+	contact: true,
+	shipTo: true,
+	curr: true,
+	taxClass: true,
+	taxIn: true,
+});
+
+type ColumnKey = keyof typeof col;
+const toggleItems: Array<{ key: ColumnKey; label: string }> = [
+	{ key: 'purOrd', label: '要货令单号' },
+	{ key: 'status', label: '状态' },
+	{ key: 'suppName', label: '供应商' },
+	{ key: 'buyer', label: '采购组' },
+	{ key: 'departmentDescr', label: '部门' },
+	{ key: 'ordDate', label: '订单日期' },
+	{ key: 'contract', label: '合同' },
+	{ key: 'dueDate', label: '交付日期' },
+	{ key: 'contact', label: '联系方式' },
+	{ key: 'shipTo', label: '发至' },
+	{ key: 'curr', label: '币别' },
+	{ key: 'taxClass', label: '税类型' },
+	{ key: 'taxIn', 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 fmtDate(v?: string | null) {
+	return v ? String(v).slice(0, 10) : '';
+}
+function statusText(v?: string | null) {
+	return statusOptions.find((s) => s.value === v)?.label ?? v ?? '';
+}
+function statusTag(v?: string | null) {
+	if (v === 'B') return 'success';
+	if (v === 'A') return 'warning';
+	if (v === 'C') return 'info';
+	return '';
+}
+function taxInText(v?: boolean | number | null) {
+	if (v === true || v === 1 || v === '1') return '是';
+	return '否';
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchDemandOrderList({
+			page: query.page,
+			pageSize: query.pageSize,
+			purOrd: query.purOrd || undefined,
+			buyer: query.buyer || undefined,
+			supp: query.supp || undefined,
+			status: query.status || 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 resetQuery() {
+	query.purOrd = '';
+	query.buyer = '';
+	query.supp = '';
+	query.status = '';
+	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: DemandOrderRow) {
+	editingId.value = row.id;
+	formMode.value = 'edit';
+	dialogVisible.value = true;
+}
+function openView(row: DemandOrderRow) {
+	editingId.value = row.id;
+	formMode.value = 'view';
+	dialogVisible.value = true;
+}
+async function onDelete(row: DemandOrderRow) {
+	await ElMessageBox.confirm('确认删除该要货令?', '提示', { type: 'warning' });
+	await deleteDemandOrder(row.id, row.purOrd || '');
+	ElMessage.success('删除成功');
+	await loadList();
+}
+async function onSaved() {
+	dialogVisible.value = false;
+	ElMessage.success('保存成功');
+	await loadList();
+}
+
+onMounted(async () => {
+	const [buyers, suppliers] = await Promise.all([fetchDemandOrderBuyers(), fetchDemandOrderSuppliers()]);
+	buyerOptions.value = buyers.list || [];
+	supplierOptions.value = suppliers.list || [];
+	await 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>

+ 15 - 17
Web/src/views/aidop/s3/supply/outsourceOrderDetailForm.vue

@@ -65,13 +65,8 @@
 			</div>
 		</div>
 		<el-table :data="form.batches" border>
-			<el-table-column prop="batch" label="组件行号" width="150">
-				<template #default="{ row, $index }">
-					<div class="pick-wrap">
-						<el-input v-model="row.batch" disabled />
-						<el-button v-if="!isView && editingBatchIndex === $index" @click="openPick('batch', $index)">选择</el-button>
-					</div>
-				</template>
+			<el-table-column prop="batch" label="组件行号" width="100" align="center">
+				<template #default="{ row, $index }">{{ row.batch ?? $index + 1 }}</template>
 			</el-table-column>
 			<el-table-column prop="itemNum" label="物料编号" width="140">
 				<template #default="{ row }"><el-input v-model="row.itemNum" disabled /></template>
@@ -198,7 +193,7 @@ const rules: FormRules = {
 };
 
 const pickVisible = ref(false);
-const pickMode = ref<'main' | 'batch' | 'suppItem'>('main');
+const pickMode = ref<'main' | 'suppItem'>('main');
 const pickRowIndex = ref(-1);
 const qtySyncReady = ref(false);
 
@@ -256,11 +251,18 @@ function editBatch(index: number) {
 
 function removeBatch(index: number) {
 	form.batches.splice(index, 1);
+	renumberBatches();
 	if (editingBatchIndex.value === index) editingBatchIndex.value = -1;
 	else if (editingBatchIndex.value > index) editingBatchIndex.value -= 1;
 }
 
-function openPick(mode: 'main' | 'batch' | 'suppItem', index = -1) {
+function renumberBatches() {
+	form.batches.forEach((row, i) => {
+		row.batch = i + 1;
+	});
+}
+
+function openPick(mode: 'main' | 'suppItem', index = -1) {
 	pickMode.value = mode;
 	pickRowIndex.value = index;
 	pickVisible.value = true;
@@ -282,14 +284,10 @@ function onPicked(row: ItemRow) {
 		form.drawing = row.drawing || '';
 	} else if (pickRowIndex.value >= 0) {
 		const tar = form.batches[pickRowIndex.value];
-		if (pickMode.value === 'batch') {
-			applyItemToBatchRow(tar, row, true);
-		} else {
-			tar.suppItem = row.itemNum || '';
-			if (!tar.itemNum) tar.itemNum = row.itemNum || '';
-			if (!tar.um) tar.um = row.um || '';
-			if (!tar.location) tar.location = row.location || '';
-		}
+		tar.suppItem = row.itemNum || '';
+		if (!tar.itemNum) tar.itemNum = row.itemNum || '';
+		if (!tar.um) tar.um = row.um || '';
+		if (!tar.location) tar.location = row.location || '';
 	}
 	pickVisible.value = false;
 }

+ 2 - 2
Web/src/views/aidop/s3/supply/outsourceOrderList.vue

@@ -2,7 +2,7 @@
 	<div class="aidop-page">
 		<aidop-demo-shell title="委外加工订单">
 			<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
-				<el-form-item label="采购单号"><el-input v-model="query.purOrd" clearable style="width: 180px" /></el-form-item>
+				<el-form-item label="委外加工单号"><el-input v-model="query.purOrd" clearable style="width: 180px" /></el-form-item>
 				<el-form-item label="采购组">
 					<el-select v-model="query.buyer" clearable filterable style="width: 180px">
 						<el-option v-for="o in buyerOptions" :key="o.value" :label="o.label" :value="o.value" />
@@ -24,7 +24,7 @@
 			</div>
 
 			<el-table :data="rows" v-loading="loading" border stripe @sort-change="onSortChange">
-				<el-table-column prop="purOrd" label="工单号" min-width="160" fixed="left" sortable="custom" />
+				<el-table-column prop="purOrd" label="委外加工单号" min-width="160" fixed="left" sortable="custom" />
 					<el-table-column prop="suppName" label="供应商" min-width="200" sortable="custom" />
 					<el-table-column prop="buyer" label="采购组" width="160" sortable="custom" />
 					<el-table-column prop="ordDate" label="订单日期" width="120" sortable="custom"><template #default="{ row }">{{ fmtDate(row.ordDate) }}</template></el-table-column>

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

@@ -2,7 +2,7 @@
 	<div class="aidop-page">
 		<aidop-demo-shell title="工序外协订单">
 			<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
-				<el-form-item label="采购单号"><el-input v-model="query.purOrd" clearable style="width: 170px" /></el-form-item>
+				<el-form-item label="工序外协单号"><el-input v-model="query.purOrd" clearable style="width: 170px" /></el-form-item>
 				<el-form-item label="工单"><el-input v-model="query.workOrd" clearable style="width: 170px" /></el-form-item>
 				<el-form-item label="采购组">
 					<el-select v-model="query.buyer" clearable filterable style="width: 170px">
@@ -37,7 +37,7 @@
 					<el-table-column prop="status" label="状态" width="100" fixed="left" sortable="custom">
 						<template #default="{ row }">{{ statusText(row.status) }}</template>
 					</el-table-column>
-					<el-table-column prop="purOrd" label="采购单号" width="160" sortable="custom" />
+					<el-table-column prop="purOrd" label="工序外协单号" width="160" sortable="custom" />
 					<el-table-column prop="suppName" label="供应商" min-width="190" sortable="custom" />
 					<el-table-column prop="buyer" label="采购组" width="160" sortable="custom" />
 					<el-table-column prop="itemNum" label="物料编号" width="140" sortable="custom" />

+ 108 - 0
Web/src/views/aidop/s3/supply/selectDemandItem.vue

@@ -0,0 +1,108 @@
+<template>
+	<div>
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="物料编号">
+				<el-input v-model="query.itemNum" clearable style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="物料名称">
+				<el-input v-model="query.descr" clearable style="width: 160px" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="reset">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table :data="rows" v-loading="loading" border stripe height="420" @sort-change="onSortChange" @row-dblclick="onDblClick">
+			<el-table-column prop="itemNum" label="物料编号" width="140" sortable="custom" />
+			<el-table-column prop="descr" label="物料名称" min-width="160" show-overflow-tooltip sortable="custom" />
+			<el-table-column prop="descr1" label="规格型号" width="140" sortable="custom" />
+			<el-table-column prop="um" label="单位" width="90" sortable="custom" />
+			<el-table-column prop="location" label="库位" width="110" sortable="custom" />
+			<el-table-column prop="rev" label="版本" width="100" sortable="custom" />
+			<el-table-column prop="drawing" label="图纸号" width="140" sortable="custom" />
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[10, 20, 50]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue';
+import { fetchDemandOrderItems, type ItemRow } from '../api/demandOrder';
+
+const emit = defineEmits<{ (e: 'picked', row: ItemRow): void }>();
+
+const query = reactive({
+	itemNum: '',
+	descr: '',
+	page: 1,
+	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
+});
+const loading = ref(false);
+const rows = ref<ItemRow[]>([]);
+const total = ref(0);
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchDemandOrderItems({
+			page: query.page,
+			pageSize: query.pageSize,
+			itemNum: query.itemNum || undefined,
+			descr: query.descr || 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.itemNum = '';
+	query.descr = '';
+	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: ItemRow) {
+	emit('picked', row);
+}
+
+onMounted(loadList);
+</script>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 10px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 19 - 4
Web/src/views/aidop/s4/api/procurementExecution.ts

@@ -36,7 +36,8 @@ export interface SupplierShipmentRow {
 	id: number;
 	shddh?: string;
 	poBill?: string;
-	usage?: string;
+	/** 采购订单物料类型(PurOrdMaster.Usage) */
+	poUsage?: string;
 	jhshrq?: string;
 	shMaterialCode?: string;
 	shMaterialName?: string;
@@ -132,12 +133,12 @@ export function deleteSupplierShipment(id: number) {
 	return service.post('/api/ProcurementExecution/supplier-shipment/delete', { id }).then((r) => r.data);
 }
 
-export function shipmentPlaceholderAction(action: 'generate-label' | 'print-shipping-note' | 'print-label', id: number) {
+export function shipmentPlaceholderAction(action: 'generate-label' | 'print-label', id: number) {
 	return service.post(`/api/ProcurementExecution/supplier-shipment/${action}`, { id }).then((r) => r.data);
 }
 
 export interface SupplierShipmentLabelRow {
-	glid?: number | null;
+	glid?: string | null;
 	wlbm?: string | null;
 	wlmc?: string | null;
 	ggxh?: string | null;
@@ -148,7 +149,7 @@ export interface SupplierShipmentLabelRow {
 	ddlx?: string | null;
 	ddh?: string | null;
 	shdh?: string | null;
-	shdhh?: string | null;
+	shdhh?: number | null;
 	scrq?: string | null;
 	scph?: string | null;
 	xh?: string | null;
@@ -166,8 +167,22 @@ export function fetchSupplierShipmentLabelData(shddh: string) {
 		.then((r) => r.data);
 }
 
+/** 送货单 hiprint 模板数据(主表字段 + list/items/details/table 明细) */
+export type SupplierShipmentShippingNotePrintData = Record<string, unknown> & {
+	shddh?: string;
+	list?: Record<string, unknown>[];
+};
+
+export function fetchSupplierShipmentShippingNoteData(shddh: string) {
+	return service
+		.get<SupplierShipmentShippingNotePrintData>(`/api/ProcurementExecution/supplier-shipment/shipping-note-data`, { params: { shddh } })
+		.then((r) => r.data);
+}
+
 // ── S4 采购退货单 ──
 export interface PurchaseReturnListRow {
+	/** 主表 PurOrdRctMaster.RecID */
+	recID?: number;
 	id?: number;
 	detailRecId?: number;
 	receiver?: string;

+ 126 - 0
Web/src/views/aidop/s4/delivery/ShipmentAttachment.vue

@@ -0,0 +1,126 @@
+<template>
+	<div class="shipment-attachment">
+		<template v-if="readonly">
+			<span v-if="!displayRef" class="empty">—</span>
+			<template v-else>
+				<el-image
+					v-if="canOpen && isImage"
+					class="thumb"
+					:src="fullUrl"
+					:preview-src-list="[fullUrl]"
+					fit="contain"
+					preview-teleported
+				/>
+				<span v-else class="file-name" :title="displayRef.name">{{ displayRef.name }}</span>
+				<template v-if="canOpen">
+					<el-button link type="primary" @click="previewFileRef(modelValue)">预览</el-button>
+					<el-button link type="primary" @click="downloadFileRef(modelValue)">下载</el-button>
+				</template>
+				<span v-else class="hint">(仅文件名,无法打开)</span>
+			</template>
+		</template>
+		<template v-else>
+			<el-upload :auto-upload="false" :show-file-list="false" :disabled="uploading" :on-change="onPick">
+				<el-button :loading="uploading">{{ displayRef ? '重新上传' : '上传' }}</el-button>
+			</el-upload>
+			<template v-if="displayRef">
+				<span class="file-name" :title="displayRef.name">{{ displayRef.name }}</span>
+				<el-button v-if="canOpen" link type="primary" @click="previewFileRef(modelValue)">预览</el-button>
+				<el-button v-if="canOpen" link type="primary" @click="downloadFileRef(modelValue)">下载</el-button>
+				<el-button link type="danger" :disabled="uploading" @click="clear">清除</el-button>
+			</template>
+		</template>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import type { UploadFile } from 'element-plus';
+import { ElMessage } from 'element-plus';
+import { getAPI } from '/@/utils/axios-utils';
+import { SysFileApi } from '/@/api-services/api';
+import {
+	canOpenFileRef,
+	downloadFileRef,
+	encodeFileRef,
+	isImageFile,
+	parseFileRef,
+	previewFileRef,
+	resolveFileUrl,
+	sysFileToAccessUrl,
+} from './supplierShipmentFile';
+
+const props = withDefaults(
+	defineProps<{
+		modelValue?: string;
+		readonly?: boolean;
+	}>(),
+	{ modelValue: '', readonly: false }
+);
+
+const emit = defineEmits<{ 'update:modelValue': [string] }>();
+
+const uploading = ref(false);
+const displayRef = computed(() => parseFileRef(props.modelValue));
+const canOpen = computed(() => canOpenFileRef(props.modelValue));
+const fullUrl = computed(() => (displayRef.value?.url ? resolveFileUrl(displayRef.value.url) : ''));
+const isImage = computed(() => isImageFile(displayRef.value?.name || displayRef.value?.url || ''));
+
+async function onPick(file: UploadFile) {
+	if (!file.raw) return;
+	uploading.value = true;
+	try {
+		const { data } = await getAPI(SysFileApi).apiSysFileUploadFilePostForm(file.raw);
+		if (data.type !== 'success' || !data.result) {
+			ElMessage.error(data.message || '上传失败');
+			return;
+		}
+		const url = sysFileToAccessUrl(data.result);
+		if (!url) {
+			ElMessage.error('上传成功但未返回文件地址');
+			return;
+		}
+		const name = data.result.fileName ?? file.name;
+		emit('update:modelValue', encodeFileRef(url, name));
+		ElMessage.success('上传成功');
+	} catch {
+		ElMessage.error('上传失败');
+	} finally {
+		uploading.value = false;
+	}
+}
+
+function clear() {
+	emit('update:modelValue', '');
+}
+</script>
+
+<style scoped lang="scss">
+.shipment-attachment {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 6px;
+}
+.file-name {
+	max-width: 200px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	font-size: 12px;
+	color: #606266;
+}
+.hint {
+	font-size: 12px;
+	color: #909399;
+}
+.empty {
+	color: #909399;
+}
+.thumb {
+	width: 48px;
+	height: 48px;
+	border: 1px solid var(--el-border-color);
+	border-radius: 4px;
+}
+</style>

+ 34 - 0
Web/src/views/aidop/s4/delivery/supplierHiprint.ts

@@ -0,0 +1,34 @@
+import { ElMessage } from 'element-plus';
+import type { Ref } from 'vue';
+import { hiprint } from 'vue-plugin-hiprint';
+import { getAPI } from '/@/utils/axios-utils';
+import { SysPrintApi } from '/@/api-services/api';
+import type { SysPrint } from '/@/api-services/models';
+
+type PrintDialogExpose = {
+	showDialog: (template: unknown, printData: unknown, width?: number) => void;
+};
+
+/** 按系统打印模板名称打开 hiprint 预览/打印 */
+export async function showSysPrintDialog(
+	printDialogRef: Ref<PrintDialogExpose | undefined>,
+	templateName: string,
+	printData: Record<string, unknown> | Record<string, unknown>[]
+) {
+	if (!printDialogRef.value?.showDialog) {
+		ElMessage.error('打印组件未就绪,请刷新页面后重试');
+		return;
+	}
+	const res = await getAPI(SysPrintApi).apiSysPrintPrintNameGet(templateName);
+	const printTemplate = res.data.result as SysPrint;
+	if (!printTemplate?.template) {
+		ElMessage.error(`未找到打印模板:${templateName}`);
+		return;
+	}
+	const template = JSON.parse(printTemplate.template);
+	printDialogRef.value.showDialog(
+		new hiprint.PrintTemplate({ template }),
+		printData,
+		template.panels?.[0]?.width ?? 210
+	);
+}

+ 99 - 0
Web/src/views/aidop/s4/delivery/supplierShipmentFile.ts

@@ -0,0 +1,99 @@
+import { ElMessage } from 'element-plus';
+import type { SysFile } from '/@/api-services/models';
+import { getApiPublicBase } from '/@/utils/api-public-base';
+import { downloadByUrl } from '/@/utils/download';
+
+export interface ShipmentFileRef {
+	url: string;
+	name: string;
+}
+
+const imageExt = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
+const pdfExt = /\.pdf$/i;
+
+export function sysFileToAccessUrl(file: SysFile): string {
+	if (file.url) return file.url;
+	if (file.bucketName === 'Local' && file.filePath && file.id != null) {
+		const base = getApiPublicBase().replace(/\/$/, '');
+		const path = `${file.filePath}/${file.id}${file.suffix ?? ''}`.replace(/^\//, '');
+		return `${base}/${path}`;
+	}
+	return '';
+}
+
+export function encodeFileRef(url: string, name: string): string {
+	return JSON.stringify({ url, name });
+}
+
+function fileNameFromUrl(url: string): string {
+	try {
+		const part = url.split('?')[0]?.split('/').pop() || url;
+		return decodeURIComponent(part);
+	} catch {
+		return url;
+	}
+}
+
+/** 解析 chbg/jybb:支持 JSON、URL、纯文件名(历史数据) */
+export function parseFileRef(raw?: string | null): ShipmentFileRef | null {
+	const s = (raw ?? '').trim();
+	if (!s) return null;
+	if (s.startsWith('{')) {
+		try {
+			const o = JSON.parse(s) as { url?: string; name?: string };
+			const url = (o.url ?? '').trim();
+			const name = (o.name ?? '').trim() || (url ? fileNameFromUrl(url) : s);
+			if (url) return { url, name };
+			if (name) return { url: '', name };
+		} catch {
+			// 非 JSON,继续按 URL/文件名处理
+		}
+	}
+	if (/^https?:\/\//i.test(s) || s.startsWith('/')) {
+		return { url: s, name: fileNameFromUrl(s) };
+	}
+	return { url: '', name: s };
+}
+
+export function resolveFileUrl(url: string): string {
+	const u = (url ?? '').trim();
+	if (!u) return '';
+	if (/^https?:\/\//i.test(u)) return u;
+	const base = getApiPublicBase().replace(/\/$/, '');
+	return u.startsWith('/') ? `${base}${u}` : `${base}/${u}`;
+}
+
+export function canOpenFileRef(raw?: string | null): boolean {
+	const ref = parseFileRef(raw);
+	return !!ref?.url;
+}
+
+export function isImageFile(nameOrUrl: string): boolean {
+	return imageExt.test(nameOrUrl);
+}
+
+export function isPdfFile(nameOrUrl: string): boolean {
+	return pdfExt.test(nameOrUrl);
+}
+
+/** 新窗口预览;图片/PDF 直接打开,其它类型触发下载 */
+export function previewFileRef(raw?: string | null): void {
+	const ref = parseFileRef(raw);
+	if (!ref) return;
+	if (!ref.url) {
+		ElMessage.warning(`「${ref.name}」无可用地址,请重新上传附件`);
+		return;
+	}
+	const full = resolveFileUrl(ref.url);
+	window.open(full, '_blank', 'noopener,noreferrer');
+}
+
+export function downloadFileRef(raw?: string | null): void {
+	const ref = parseFileRef(raw);
+	if (!ref) return;
+	if (!ref.url) {
+		ElMessage.warning(`「${ref.name}」无可用地址,请重新上传附件`);
+		return;
+	}
+	downloadByUrl({ url: resolveFileUrl(ref.url), target: '_blank', fileName: ref.name });
+}

+ 35 - 38
Web/src/views/aidop/s4/delivery/supplierShipmentForm.vue

@@ -48,10 +48,7 @@
 					</el-col>
 					<el-col :span="12">
 						<el-form-item label="偏差申请附件">
-							<el-upload :auto-upload="false" :show-file-list="false" :disabled="isView" :on-change="(file) => (form.chbg = file.name)">
-								<el-button :disabled="isView">上传</el-button>
-							</el-upload>
-							<span class="upload-name">{{ form.chbg }}</span>
+							<ShipmentAttachment v-model="form.chbg" :readonly="isView" />
 						</el-form-item>
 					</el-col>
 					<el-col :span="24"><el-form-item label="偏差说明"><el-input v-model="form.pcsm" type="textarea" :rows="2" :disabled="isView" /></el-form-item></el-col>
@@ -108,12 +105,9 @@
 				<el-table-column label="生产批号" width="120"><template #default="{ row }"><el-input v-model="row.scph" :disabled="isView" /></template></el-table-column>
 				<el-table-column label="备注" min-width="140"><template #default="{ row }"><el-input v-model="row.remarks" :disabled="isView" /></template></el-table-column>
 				<el-table-column label="待交数量" width="100"><template #default="{ row }"><el-input-number v-model="row.djsl" :controls="false" :disabled="isView" style="width: 100%" /></template></el-table-column>
-				<el-table-column label="检验报告" width="130">
+				<el-table-column label="检验报告" min-width="220">
 					<template #default="{ row }">
-						<el-upload :auto-upload="false" :show-file-list="false" :disabled="isView" :on-change="(file) => (row.jybb = file.name)">
-							<el-button :disabled="isView">上传</el-button>
-						</el-upload>
-						<div class="upload-name">{{ row.jybb }}</div>
+						<ShipmentAttachment v-model="row.jybb" :readonly="isView" />
 					</template>
 				</el-table-column>
 				<el-table-column label="交货单号" width="120"><template #default="{ row }"><el-input v-model="row.jhdbh" :disabled="isView" /></template></el-table-column>
@@ -129,6 +123,7 @@
 				<el-button v-if="!isView" type="primary" :loading="saving" @click="onSave">保存</el-button>
 			</div>
 		</div>
+		<printDialog ref="printShippingDialogRef" title="打印送货单" />
 	</AidopDemoShell>
 </template>
 
@@ -140,10 +135,18 @@ import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import {
 	fetchSupplierShipmentDetail,
 	fetchSupplierShipmentDraft,
+	fetchSupplierShipmentShippingNoteData,
 	saveSupplierShipment,
 	type SupplierShipmentDetailRow,
 	type SupplierShipmentFormData,
 } from '../api/procurementExecution';
+import ShipmentAttachment from './ShipmentAttachment.vue';
+import printDialog from '/@/views/system/print/component/hiprint/preview.vue';
+import { formatDate } from '/@/utils/formatTime';
+import { showSysPrintDialog } from './supplierHiprint';
+
+const PRINT_SHIPPING_TEMPLATE_NAME = '送货单';
+const printShippingDialogRef = ref();
 
 const props = withDefaults(
 	defineProps<{
@@ -263,7 +266,27 @@ function removeDetail(index: number) {
 	form.details.splice(index, 1);
 }
 
+async function triggerPrintShippingNote() {
+	await nextTick();
+	if (!form.shddh?.trim()) {
+		ElMessage.warning('暂无送货单号,无法打印');
+		return;
+	}
+	try {
+		const data = await fetchSupplierShipmentShippingNoteData(form.shddh);
+		const printDate = formatDate(new Date(), 'YYYY-mm-dd HH:MM:SS');
+		await showSysPrintDialog(printShippingDialogRef, PRINT_SHIPPING_TEMPLATE_NAME, {
+			...data,
+			printDate,
+		});
+	} catch (e: unknown) {
+		const msg = e instanceof Error ? e.message : '获取送货单打印数据失败';
+		ElMessage.error(msg);
+	}
+}
+
 async function loadData() {
+	let shouldAutoPrint = false;
 	loading.value = true;
 	try {
 		if (mode.value === 'create' && ids.value) {
@@ -276,20 +299,17 @@ async function loadData() {
 			const detail = await fetchSupplierShipmentDetail(id.value);
 			setForm(detail);
 			await afterFormLoaded();
-			if (mode.value === 'view' && autoPrint.value) {
-				// 等表单渲染完成后再触发浏览器打印
-				await nextTick();
-				window.print();
-			}
+			if (mode.value === 'view' && autoPrint.value) shouldAutoPrint = true;
 			return;
 		}
 	} finally {
 		loading.value = false;
+		if (shouldAutoPrint) await triggerPrintShippingNote();
 	}
 }
 
 function onPrintShippingNote() {
-	window.print();
+	triggerPrintShippingNote();
 }
 
 async function onSave() {
@@ -328,28 +348,5 @@ onMounted(loadData);
 .top-toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
 .sub-toolbar { margin-bottom: 8px; }
 .footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
-.upload-name { margin-left: 8px; color: #606266; font-size: 12px; }
-
-@media print {
-	.top-toolbar,
-	.footer,
-	.sub-toolbar,
-	:deep(.el-upload),
-	:deep(.el-button) {
-		display: none !important;
-	}
-
-	/* 尽量让表单内容铺满打印页 */
-	:deep(.el-input__wrapper),
-	:deep(.el-textarea__inner) {
-		box-shadow: none !important;
-	}
-
-	:deep(.el-divider__text) {
-		background: transparent !important;
-	}
-
-	/* 打印时去掉页面外边距由浏览器控制,这里只做最小干预 */
-}
 </style>
 

+ 43 - 39
Web/src/views/aidop/s4/delivery/supplierShipmentList.vue

@@ -1,6 +1,5 @@
 <template>
-	<div class="s4-shipment-page">
-		<AidopDemoShell :title="pageTitle" subtitle="供应商发货单">
+	<AidopDemoShell :title="pageTitle" subtitle="供应商发货单">
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="发货日期">
 				<el-date-picker v-model="query.jhshrqFrom" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
@@ -39,7 +38,7 @@
 			<el-table-column v-if="col.shddh" prop="shddh" label="发货单编号" width="140" fixed="left" sortable="custom" />
 			<el-table-column v-if="col.shzt" prop="shzt" label="送货状态" width="100" fixed="left" sortable="custom" />
 			<el-table-column v-if="col.poBill" prop="poBill" label="订单编号" width="130" sortable="custom" />
-			<el-table-column v-if="col.usage" prop="usage" label="物料类型" width="100" sortable="custom" />
+			<el-table-column v-if="col.poUsage" prop="poUsage" label="物料类型" width="100" sortable="custom" />
 			<el-table-column v-if="col.jhshrq" prop="jhshrq" label="发货日期" width="120" sortable="custom" />
 			<el-table-column v-if="col.shMaterialCode" prop="shMaterialCode" label="物料编码" width="120" sortable="custom" />
 			<el-table-column v-if="col.shMaterialName" prop="shMaterialName" label="物料名称" min-width="150" show-overflow-tooltip sortable="custom" />
@@ -57,7 +56,7 @@
 			<el-table-column label="操作" width="360" fixed="right">
 				<template #default="{ row }">
 					<el-button link type="primary" @click="onPlaceholder('generate-label', row)">生成标签</el-button>
-					<el-button link type="primary" @click="onPlaceholder('print-shipping-note', row)">打印送货单</el-button>
+					<el-button link type="primary" @click="onPrintShippingNote(row)">打印送货单</el-button>
 					<el-button link type="primary" @click="onPrintLabel(row)">打印标签</el-button>
 					<el-button link type="primary" @click="openForm('edit', row.mid)">质检报告</el-button>
 					<el-button link type="primary" @click="openForm('edit', row.mid)">编辑</el-button>
@@ -78,10 +77,9 @@
 				@size-change="loadList"
 			/>
 		</div>
-		</AidopDemoShell>
-
-		<printDialog ref="printDialogRef" :title="'打印送货标签'" />
-	</div>
+		<printDialog ref="printLabelDialogRef" title="打印送货标签" />
+		<printDialog ref="printShippingDialogRef" title="打印送货单" />
+	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS4SupplierShipmentList">
@@ -89,19 +87,25 @@ 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 { deleteSupplierShipment, fetchSupplierShipmentLabelData, fetchSupplierShipmentList, shipmentPlaceholderAction, type SupplierShipmentRow } from '../api/procurementExecution';
+import {
+	deleteSupplierShipment,
+	fetchSupplierShipmentLabelData,
+	fetchSupplierShipmentList,
+	fetchSupplierShipmentShippingNoteData,
+	shipmentPlaceholderAction,
+	type SupplierShipmentRow,
+} from '../api/procurementExecution';
 import printDialog from '/@/views/system/print/component/hiprint/preview.vue';
-import { hiprint } from 'vue-plugin-hiprint';
-import { getAPI } from '/@/utils/axios-utils';
-import { SysPrintApi } from '/@/api-services/api';
-import type { SysPrint } from '/@/api-services/models';
 import { formatDate } from '/@/utils/formatTime';
+import { showSysPrintDialog } from './supplierHiprint';
 
 const route = useRoute();
 const router = useRouter();
 const pageTitle = computed(() => (route.meta?.title as string) || '供应商发货单');
-const printDialogRef = ref<{ showDialog: (...args: any[]) => void } | null>(null);
+const printLabelDialogRef = ref();
+const printShippingDialogRef = ref();
 const PRINT_LABEL_TEMPLATE_NAME = '送货标签';
+const PRINT_SHIPPING_TEMPLATE_NAME = '送货单';
 
 const query = reactive({
 	jhshrqFrom: '',
@@ -123,12 +127,12 @@ const rows = ref<SupplierShipmentRow[]>([]);
 const total = ref(0);
 
 const col = reactive({
-	shddh: true, poBill: true, usage: true, jhshrq: true, shMaterialCode: true, shMaterialName: true,
+	shddh: true, poBill: true, poUsage: true, jhshrq: true, shMaterialCode: true, shMaterialName: true,
 	shDeliveryQuantity: true, sfpc: true, pcrksl: true, shPurchaseName: true, shpc: true, scph: true, wldh: true, dycs: true, shzt: true, th: true,
 });
 type ColumnKey = keyof typeof col;
 const toggleItems: Array<{ key: ColumnKey; label: string }> = [
-	{ key: 'shddh', label: '发货单编号' }, { key: 'poBill', label: '订单编号' }, { key: 'usage', label: '物料类型' }, { key: 'jhshrq', label: '发货日期' },
+	{ key: 'shddh', label: '发货单编号' }, { key: 'poBill', label: '订单编号' }, { key: 'poUsage', label: '物料类型' }, { key: 'jhshrq', label: '发货日期' },
 	{ key: 'shMaterialCode', label: '物料编码' }, { key: 'shMaterialName', label: '物料名称' }, { key: 'shDeliveryQuantity', label: '发货数量' }, { key: 'sfpc', label: '是否偏差' },
 	{ key: 'pcrksl', label: '合格入库' }, { key: 'shPurchaseName', label: '供应商名称' }, { key: 'shpc', label: '客户批次号' }, { key: 'scph', label: '供应商批号' },
 	{ key: 'wldh', label: '物流单号' }, { key: 'dycs', label: '已打次数' }, { key: 'shzt', label: '送货状态' }, { key: 'th', label: '图号' },
@@ -186,14 +190,7 @@ async function onDelete(row: SupplierShipmentRow) {
 	await loadList();
 }
 
-async function onPlaceholder(action: 'generate-label' | 'print-shipping-note' | 'print-label', row: SupplierShipmentRow) {
-	if (action === 'print-shipping-note') {
-		router.push({
-			path: '/aidop/s4/delivery/supplier-shipment-form',
-			query: { mode: 'view', id: String(row.mid), autoPrint: '1' },
-		});
-		return;
-	}
+async function onPlaceholder(action: 'generate-label' | 'print-label', row: SupplierShipmentRow) {
 	const ret = await shipmentPlaceholderAction(action, row.mid);
 	const msg = ret?.message || ret?.msg || '功能预留';
 	if (ret?.success === true) ElMessage.success(msg);
@@ -213,32 +210,38 @@ async function onPrintLabel(row: SupplierShipmentRow) {
 		return;
 	}
 
-	const res = await getAPI(SysPrintApi).apiSysPrintPrintNameGet(PRINT_LABEL_TEMPLATE_NAME);
-	const printTemplate = res.data.result as SysPrint;
-	const template = JSON.parse(printTemplate.template);
-
 	const printDate = formatDate(new Date(), 'YYYY-mm-dd HH:MM:SS');
-	// 兼容:模板既可能用“明细列表”,也可能用“单行字段”
-	const printData = {
+	const printData = items.map((item) => ({
+		...item,
 		shddh: row.shddh,
 		printDate,
-		list: items,
-		items,
-		...(items[0] ?? {}),
-	};
+	}));
+
+	await showSysPrintDialog(printLabelDialogRef, PRINT_LABEL_TEMPLATE_NAME, printData);
+}
 
-	const dialog = printDialogRef.value;
-	if (!dialog?.showDialog) {
-		ElMessage.error('打印预览组件加载失败');
+async function onPrintShippingNote(row: SupplierShipmentRow) {
+	if (!row?.shddh) {
+		ElMessage.warning('当前行缺少发货单编号(shddh),无法打印');
 		return;
 	}
-	dialog.showDialog(new hiprint.PrintTemplate({ template }), printData, template.panels[0].width);
+	try {
+		const data = await fetchSupplierShipmentShippingNoteData(row.shddh);
+		const printDate = formatDate(new Date(), 'YYYY-mm-dd HH:MM:SS');
+		await showSysPrintDialog(printShippingDialogRef, PRINT_SHIPPING_TEMPLATE_NAME, {
+			...data,
+			printDate,
+		});
+	} catch (e: unknown) {
+		const msg = e instanceof Error ? e.message : '获取送货单打印数据失败';
+		ElMessage.error(msg);
+	}
 }
 
 function onExport() {
 	const headers = ['发货单编号', '订单编号', '物料类型', '发货日期', '物料编码', '物料名称', '发货数量', '是否偏差', '合格入库', '供应商名称', '客户批次号', '供应商批号', '物流单号', '已打次数', '送货状态', '图号'];
 	const lines = rows.value.map((r) => [
-		r.shddh ?? '', r.poBill ?? '', r.usage ?? '', r.jhshrq ?? '', r.shMaterialCode ?? '', r.shMaterialName ?? '', r.shDeliveryQuantity ?? '',
+		r.shddh ?? '', r.poBill ?? '', r.poUsage ?? '', r.jhshrq ?? '', r.shMaterialCode ?? '', r.shMaterialName ?? '', r.shDeliveryQuantity ?? '',
 		r.sfpc === 1 ? '是' : '否', r.pcrksl ?? '', r.shPurchaseName ?? '', r.shpc ?? '', r.scph ?? '', r.wldh ?? '', r.dycs ?? '', r.shzt ?? '', r.th ?? '',
 	]);
 	const csv = [headers, ...lines].map((row) => row.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',')).join('\n');
@@ -260,3 +263,4 @@ onMounted(loadList);
 .pager { margin-top: 12px; display: flex; justify-content: flex-end; }
 :deep(.row-deleted .el-table__cell) { background: #ffe7e7 !important; }
 </style>
+

+ 38 - 5
Web/src/views/aidop/s4/return/purchaseReturnOrderForm.vue

@@ -1,5 +1,5 @@
 <template>
-	<AidopDemoShell :title="titleText">
+	<AidopDemoShell :title="embedded ? '' : titleText" :show-bar="!embedded" :card-body-padding="embedded ? '8px' : '12px'">
 		<div v-loading="loading">
 			<el-divider content-position="left">表头</el-divider>
 			<el-form :model="head" label-width="120px" class="form-grid">
@@ -198,10 +198,31 @@ import {
 	type PurchaseReturnKv,
 } from '../api/procurementExecution';
 
+const props = withDefaults(
+	defineProps<{
+		/** 在列表页弹窗内使用 */
+		embedded?: boolean;
+		formMode?: 'create' | 'edit' | 'view';
+		recordId?: number;
+	}>(),
+	{ embedded: false, formMode: 'create', recordId: 0 },
+);
+
+const emit = defineEmits<{
+	close: [];
+	saved: [];
+}>();
+
 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 mode = computed(() => {
+	if (props.embedded) return props.formMode;
+	return String(route.query.mode || 'create') as 'create' | 'edit' | 'view';
+});
+const id = computed(() => {
+	if (props.embedded) return Number(props.recordId || 0);
+	return Number(route.query.id || 0);
+});
 const isView = computed(() => mode.value === 'view');
 const isEdit = computed(() => mode.value === 'edit');
 const titleText = computed(() =>
@@ -257,6 +278,10 @@ function toggleEditRow(idx: number) {
 }
 
 function goBack() {
+	if (props.embedded) {
+		emit('close');
+		return;
+	}
 	router.push('/aidop/s4/return-mgmt/purchase-return-order');
 }
 
@@ -280,7 +305,7 @@ function normalizeKv(arr: PurchaseReturnKv[] | null | undefined) {
 
 async function onSuppChanged() {
 	rcOpts.value = [];
-	head.ordNbr = '';
+	if (!isView.value && !isEdit.value) head.ordNbr = '';
 	if (!head.supp) return;
 	const rc = await fetchPurchaseReturnRcReceivers(head.supp);
 	rcOpts.value = normalizeKv(rc as any);
@@ -477,6 +502,9 @@ async function loadExisting() {
 			poCost: Number(x.poCost ?? 0),
 			supp: x.supp ?? head.supp,
 		}));
+	} catch (e: any) {
+		console.error('采购退货单详情加载失败', e);
+		ElMessage.error('加载详情失败:' + (e?.message || '未知错误'));
 	} finally {
 		loading.value = false;
 	}
@@ -555,8 +583,13 @@ async function submit() {
 		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;
+		if (props.embedded) {
+			emit('saved');
+			emit('close');
+			return;
+		}
+		router.replace({ query: { mode: 'edit', id: String(res.id ?? head.id) } });
 	} finally {
 		saving.value = false;
 	}

+ 119 - 17
Web/src/views/aidop/s4/return/purchaseReturnOrderList.vue

@@ -60,13 +60,51 @@
 			/>
 		</div>
 	</AidopDemoShell>
+
+	<el-dialog
+		v-model="createDialog.visible"
+		title="采购退货单-新增"
+		width="92%"
+		top="3vh"
+		append-to-body
+		class="purchase-return-embed-dialog"
+	>
+		<PurchaseReturnOrderForm
+			v-if="createDialog.visible"
+			:key="createDialog.key"
+			embedded
+			form-mode="create"
+			@close="createDialog.visible = false"
+			@saved="onCreateSaved"
+		/>
+	</el-dialog>
+
+	<el-dialog
+		v-model="editViewDialog.visible"
+		:title="editViewDialog.mode === 'edit' ? '采购退货单-编辑' : '采购退货单-查看'"
+		width="92%"
+		top="3vh"
+		append-to-body
+		class="purchase-return-embed-dialog"
+	>
+		<PurchaseReturnOrderForm
+			v-if="editViewDialog.visible"
+			:key="editViewDialog.key"
+			embedded
+			:form-mode="editViewDialog.mode"
+			:record-id="editViewDialog.recordId"
+			@close="editViewDialog.visible = false"
+			@saved="onEditSaved"
+		/>
+	</el-dialog>
 </template>
 
 <script setup lang="ts" name="aidopS4PurchaseReturnOrderList">
 import { computed, onMounted, reactive, ref } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
+import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import PurchaseReturnOrderForm from './purchaseReturnOrderForm.vue';
 import {
 	deletePurchaseReturn,
 	fetchPurchaseReturnList,
@@ -76,7 +114,6 @@ import {
 } from '../api/procurementExecution';
 
 const route = useRoute();
-const router = useRouter();
 const pageTitle = computed(() => (route.meta?.title as string) || '采购退货单');
 
 const query = reactive({
@@ -95,6 +132,14 @@ const rows = ref<PurchaseReturnListRow[]>([]);
 const total = ref(0);
 const suppOpts = ref<PurchaseReturnKv[]>([]);
 
+const createDialog = reactive({ visible: false, key: 0 });
+const editViewDialog = reactive<{ visible: boolean; key: number; mode: 'edit' | 'view'; recordId: number }>({
+	visible: false,
+	key: 0,
+	mode: 'view',
+	recordId: 0,
+});
+
 const col = reactive({
 	receiver: true,
 	suppName: true,
@@ -130,6 +175,27 @@ function truthy(v: unknown) {
 	return false;
 }
 
+/** 列表行主键 = PurOrdRctMaster.RecID(非 PurOrdRctRecID) */
+function resolveMasterId(row?: PurchaseReturnListRow | Record<string, unknown>): number {
+	if (!row) return 0;
+	const r = row as Record<string, unknown>;
+	const raw = r.recID ?? r.RecID ?? row.id ?? r.Id;
+	const n = Number(raw);
+	return Number.isFinite(n) && n > 0 ? n : 0;
+}
+
+function normalizeListRow(raw: Record<string, unknown>): PurchaseReturnListRow {
+	const isPlan = raw.isPlan ?? raw.IsPlan;
+	const masterId = resolveMasterId(raw);
+	return {
+		...(raw as PurchaseReturnListRow),
+		recID: masterId || undefined,
+		id: masterId || undefined,
+		detailRecId: Number(raw.detailRecId ?? raw.DetailRecId ?? 0) || undefined,
+		isPlan: isPlan === true || isPlan === 1 || isPlan === '1' || isPlan === 'true',
+	};
+}
+
 function setColumnVisible(key: ColumnKey, visible: boolean) {
 	col[key] = visible;
 }
@@ -153,7 +219,7 @@ async function loadList() {
 	loading.value = true;
 	try {
 		const data = await fetchPurchaseReturnList({ ...query });
-		rows.value = data.list || [];
+		rows.value = (data.list || []).map((x) => normalizeListRow(x as Record<string, unknown>));
 		total.value = data.total || 0;
 	} finally {
 		loading.value = false;
@@ -177,22 +243,50 @@ function resetQuery() {
 }
 
 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) : '' },
-	});
+	if (mode === 'create') {
+		createDialog.key += 1;
+		createDialog.visible = true;
+		return;
+	}
+	const id = resolveMasterId(row);
+	if (!id) {
+		ElMessage.warning('缺少单据主键,无法打开');
+		return;
+	}
+	editViewDialog.key += 1;
+	editViewDialog.mode = mode;
+	editViewDialog.recordId = id;
+	editViewDialog.visible = true;
+}
+
+function onCreateSaved() {
+	createDialog.visible = false;
+	loadList();
 }
 
-function onDelete(row: PurchaseReturnListRow) {
-	if (!row?.id) return;
-	ElMessageBox.confirm('确认删除该退货单?将把主表设为无效。', '提示', { type: 'warning' })
-		.then(async () => {
-			await deletePurchaseReturn(row.id!);
-			ElMessage.success('已删除');
-			loadList();
-		})
-		.catch(() => undefined);
+function onEditSaved() {
+	editViewDialog.visible = false;
+	loadList();
+}
+
+async function onDelete(row: PurchaseReturnListRow) {
+	const id = resolveMasterId(row);
+	if (!id) {
+		ElMessage.warning('缺少单据主键,无法删除');
+		return;
+	}
+	try {
+		await ElMessageBox.confirm('确认删除该退货单?将把主表设为无效。', '提示', {
+			confirmButtonText: '删除',
+			cancelButtonText: '取消',
+			type: 'warning',
+		});
+		await deletePurchaseReturn(id);
+		ElMessage.success('已删除');
+		await loadList();
+	} catch (e: unknown) {
+		if (e === 'cancel' || e === 'close') return;
+	}
 }
 
 onMounted(() => {
@@ -218,3 +312,11 @@ onMounted(() => {
 	margin-top: 12px;
 }
 </style>
+
+<style>
+.purchase-return-embed-dialog .el-dialog__body {
+	max-height: calc(96vh - 120px);
+	overflow-y: auto;
+	padding-top: 8px;
+}
+</style>

+ 15 - 3
Web/src/views/system/print/component/hiprint/preview.vue

@@ -53,11 +53,23 @@ const showDialog = (hiprintTemplate: any, printData: {}, width = 210, printType
 	state.printParam = printParam;
 	state.printType = printType;
 	nextTick(() => {
-		while (previewContentRef.value?.firstChild) {
-			previewContentRef.value.removeChild(previewContentRef.value.firstChild);
+		const container = previewContentRef.value as HTMLElement | undefined;
+		if (!container) return;
+		while (container.firstChild) {
+			container.removeChild(container.firstChild);
 		}
 		const newHtml = hiprintTemplate.getHtml(printData);
-		previewContentRef.value.appendChild(newHtml[0]);
+		if (!newHtml) return;
+		// 多条数据时 getHtml 返回多页(jQuery/类数组),需全部挂载,不能只取 [0]
+		if (typeof newHtml.length === 'number' && newHtml.length > 0) {
+			for (let i = 0; i < newHtml.length; i++) {
+				const el = newHtml[i];
+				if (el) container.appendChild(el);
+			}
+		} else {
+			const el = newHtml[0] ?? newHtml;
+			if (el) container.appendChild(el);
+		}
 	});
 };
 

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

@@ -740,6 +740,11 @@ public static class AidopMenuLinkSync
             "/aidop/s3/procurement/process-outsource-order", "aidopS3ProcessOutsourceOrder",
             "/aidop/s3/supply/processOutsourceOrderList", "ele-List", 40, "S3 工序外协订单(PurOrdMaster/PW)");
 
+        EnsureS3LeafMenu(
+            db, ct, 1329003100018L, procurementDirId, "要货令",
+            "/aidop/s3/procurement/demand-order", "aidopS3DemandOrder",
+            "/aidop/s3/supply/demandOrderList", "ele-ShoppingCart", 15, "S3 要货令(PurOrdMaster/PO,ReqBy=DO)");
+
         EnsureS3DirMenu(
             db, ct, 1329003100010L, s3RootId, "供应协同看板",
             "/aidop/s3/supply-kanban", "aidopS3SupplyKanbanDir",

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/Dto/PurchaseReturnDto.cs

@@ -14,7 +14,8 @@ public class PurchaseReturnListInput
 
 public class PurchaseReturnListRow
 {
-    public int Id { get; set; }
+    /// <summary>主表 PurOrdRctMaster.RecID</summary>
+    public int RecID { get; set; }
     public int DetailRecId { get; set; }
     public string RctType { get; set; }
     public string Receiver { get; set; }

+ 58 - 60
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/PurchaseReturnService.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
+using Admin.NET.Plugin.AiDOP.Supply;
 
 namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 
@@ -13,6 +14,9 @@ namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 [NonUnify]
 public class PurchaseReturnService : IDynamicApiController, ITransient
 {
+    /// <summary>采购退货单号:PT + yyyyMMdd + 4 位流水,如 PT202605190001。</summary>
+    private const int PtReceiverSerialWidth = 4;
+
     private readonly ISqlSugarClient _db;
     private readonly SqlSugarRepository<PurOrdRctMaster> _masterRep;
     private readonly SqlSugarRepository<PurOrdRctDetail> _detailRep;
@@ -45,7 +49,7 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
             ["suppname"] = "SuppName",
         };
         if (!map.TryGetValue(col, out var sqlCol))
-            sqlCol = "Id";
+            sqlCol = "RecID";
         return $"ORDER BY {sqlCol} {ord}, DetailRecId ASC";
     }
 
@@ -86,7 +90,7 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
         var where = string.Join(" AND ", cond);
         var baseSql = $"""
             SELECT
-              p.RecID AS Id,
+              p.RecID AS RecID,
               IFNULL(d1.RecID, 0) AS DetailRecId,
               p.RctType AS RctType,
               p.Receiver AS Receiver,
@@ -105,9 +109,9 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
               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
+            LEFT JOIN SuppMaster s ON p.Supp = s.Supp
+            LEFT JOIN DepartmentMaster d ON p.Department = d.Department
+            LEFT JOIN ItemMaster i ON d1.ItemNum = i.ItemNum
             WHERE {where}
             """;
 
@@ -149,8 +153,8 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
                               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
+              ON p.RctType = d.RctType AND p.Receiver = d.Receiver
+            LEFT JOIN ItemMaster i ON 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
@@ -198,7 +202,7 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
               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
+              ON 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
             """,
@@ -380,40 +384,51 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
 
         if (input.Id is null or 0)
         {
-            var receiver = await AllocReceiverAsync();
-            var rctDate = ParseDate(input.RctDate) ?? now.Date;
-
-            var master = new PurOrdRctMaster
+            try
             {
-                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 = "新增成功" };
+                _db.Ado.BeginTran();
+
+                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);
+
+                _db.Ado.CommitTran();
+                return new { id = master.RecID, receiver, message = "新增成功" };
+            }
+            catch
+            {
+                _db.Ado.RollbackTran();
+                throw;
+            }
         }
 
         var existing = await _masterRep.GetFirstAsync(m => m.RecID == input.Id!.Value && m.RctType == "pt")
@@ -567,25 +582,8 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
         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 Task<string> AllocReceiverAsync() =>
+        PurOrdNumberGenerator.NextRctReceiverAsync(_db, "PT", "pt", DateTime.Today, PtReceiverSerialWidth);
 
     private static DateTime? ParseDate(string raw)
     {

+ 216 - 77
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShipmentService.cs

@@ -323,7 +323,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         b.sh_material_code AS wlbm,
                         IFNULL(b.scph, '') AS scph
                     FROM scm_shd a
-                    INNER JOIN scm_shdzb b ON a.id = b.glid
+                    INNER JOIN scm_shdzb b ON CAST(a.id AS CHAR) = b.glid
                     WHERE a.shddh = @shddh
                       AND IFNULL(b.sh_material_code, '') <> ''
                       AND IFNULL(b.shpc, '') = '';
@@ -355,7 +355,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         await _db.Ado.ExecuteCommandAsync(
                             """
                             UPDATE scm_shdzb b
-                            INNER JOIN scm_shd a ON a.id = b.glid
+                            INNER JOIN scm_shd a ON CAST(a.id AS CHAR) = b.glid
                             SET b.shpc = @shpc
                             WHERE a.shddh = @shddh
                               AND b.sh_material_code = @wlbm
@@ -379,7 +379,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         {
                             await _db.Ado.ExecuteCommandAsync(
                                 """
-                                INSERT INTO scm_shdshph (shpc, wlbm, scph, shdh, xh, gysdm, create_time)
+                                INSERT INTO scm_shdshph (shpc, wlbm, scph, shdh, xh, gysbm, csrq)
                                 SELECT @shpc, @wlbm, @scph, @shdh, @xh, @gysdm, NOW()
                                 FROM DUAL
                                 WHERE NOT EXISTS (
@@ -456,14 +456,14 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         IFNULL(im.Rev, pd.Rev) AS bbh,
                         a.sh_purchase_name AS gysmc,
                         a.sh_purchase_num AS gysdm,
-                        pm.Usage AS usage,
+                        pm.`Usage` AS po_usage,
                         b.ccrq AS ccrq,
                         a.jhshrq AS jhshrq,
                         b.jhdbh AS jhdbh,
                         b.jhdhh AS jhdhh,
                         b.shpc AS shpc
                     FROM scm_shd a
-                    INNER JOIN scm_shdzb b ON a.id = b.glid
+                    INNER JOIN scm_shdzb b ON CAST(a.id AS CHAR) = b.glid
                     LEFT JOIN PurOrdMaster pm ON pm.PurOrd = b.po_bill
                     LEFT JOIN PurOrdDetail pd ON pd.PurOrd = b.po_bill AND pd.Line = b.po_billline
                     LEFT JOIN ItemMaster im ON im.ItemNum = b.sh_material_code
@@ -663,7 +663,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                 new("@pohh", row.PoBillLine ?? 0),
                 new("@bbh", row.Bbh ?? string.Empty),
                 new("@th", row.Th ?? string.Empty),
-                new("@yt", row.Usage ?? string.Empty),
+                new("@yt", row.PoUsage ?? string.Empty),
                 new("@ccrq", row.Ccrq ?? string.Empty),
                 new("@shpc", row.Shpc ?? string.Empty),
                 new("@jhdbh", row.Jhdbh ?? string.Empty),
@@ -843,17 +843,135 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public string? Scph { get; set; }
         public string? Th { get; set; }
         public string? Bbh { get; set; }
-        public string? Usage { get; set; }
+        public string? PoUsage { get; set; }
         public string? Ccrq { get; set; }
         public string? Jhdbh { get; set; }
         public string? Jhdhh { get; set; }
         public string? Shpc { get; set; }
     }
 
+    [DisplayName("送货单打印数据(模板:送货单)")]
+    [HttpGet("supplier-shipment/shipping-note-data")]
+    public async Task<object> GetShippingNotePrintData([FromQuery] string shddh)
+    {
+        if (string.IsNullOrWhiteSpace(shddh))
+            throw Oops.Oh("缺少发货单编号(shddh)");
+
+        var pars = new List<SugarParameter> { new("@shddh", shddh.Trim()) };
+
+        const string masterSql = """
+            SELECT
+                id,
+                sh_purchase_id,
+                sh_purchase_name AS gysmc,
+                sh_purchase_num AS gysdm,
+                sh_purchase_address,
+                sh_purchase_lxr,
+                sh_purchase_phone,
+                client,
+                delivery_Address,
+                expected_consignee,
+                consignee_phone,
+                estimated_delivery_date,
+                po_billno,
+                shddh,
+                jhshrq,
+                tjrid,
+                tjrxm,
+                tjrq,
+                scbq,
+                wlsc,
+                yjdhrq,
+                wldh,
+                sfpc,
+                pcsm,
+                chbg
+            FROM scm_shd
+            WHERE shddh = @shddh
+            LIMIT 1
+            """;
+
+        const string detailSql = """
+            SELECT
+                a.shddh,
+                b.hh AS xh,
+                b.po_bill AS ddbh,
+                c.`Usage` AS ddlx,
+                b.order_type AS lx1,
+                b.sh_material_code AS wlbm,
+                b.sh_material_name AS wlmc,
+                b.sh_material_ggxh AS ggxh,
+                b.sh_delivery_quantity AS shsl,
+                b.sh_material_dw AS dw,
+                b.remarks AS bz,
+                b.scrq,
+                b.scph,
+                b.th
+            FROM scm_shd a
+            INNER JOIN scm_shdzb b ON CAST(a.id AS CHAR) = b.glid
+            LEFT JOIN (
+                SELECT
+                    i.Drawing AS th,
+                    d.Rev AS bbh,
+                    d.Line,
+                    p.PurOrd,
+                    p.`Usage`
+                FROM PurOrdMaster p
+                LEFT JOIN PurOrdDetail d ON p.Potype = d.Potype AND p.PurOrd = d.PurOrd
+                LEFT JOIN ItemMaster i ON d.ItemNum = i.ItemNum
+                WHERE p.IsActive = 1
+            ) c ON c.PurOrd = b.po_bill AND CAST(c.Line AS CHAR) = TRIM(CAST(IFNULL(b.po_billline, '') AS CHAR))
+            WHERE a.shddh = @shddh
+            ORDER BY b.hh, b.id
+            """;
+
+        var masterRows = await _db.Ado.SqlQueryAsync<ShippingNoteMasterRow>(masterSql, pars);
+        var master = masterRows.FirstOrDefault();
+        if (master == null)
+            throw Oops.Oh($"发货单不存在:{shddh}");
+
+        var list = await _db.Ado.SqlQueryAsync<ShippingNoteDetailRow>(detailSql, pars);
+        if (list.Count == 0)
+            throw Oops.Oh($"发货单无明细,无法打印:{shddh}");
+
+        return new
+        {
+            id = master.id,
+            sh_purchase_id = master.sh_purchase_id,
+            gysmc = master.gysmc,
+            gysdm = master.gysdm,
+            sh_purchase_address = master.sh_purchase_address,
+            sh_purchase_lxr = master.sh_purchase_lxr,
+            sh_purchase_phone = master.sh_purchase_phone,
+            client = master.client,
+            delivery_Address = master.delivery_Address,
+            expected_consignee = master.expected_consignee,
+            consignee_phone = master.consignee_phone,
+            estimated_delivery_date = master.estimated_delivery_date,
+            po_billno = master.po_billno,
+            shddh = master.shddh,
+            jhshrq = master.jhshrq,
+            tjrid = master.tjrid,
+            tjrxm = master.tjrxm,
+            tjrq = master.tjrq,
+            scbq = master.scbq,
+            wlsc = master.wlsc,
+            yjdhrq = master.yjdhrq,
+            wldh = master.wldh,
+            sfpc = master.sfpc,
+            pcsm = master.pcsm,
+            chbg = master.chbg,
+            list,
+            items = list,
+            details = list,
+            table = list
+        };
+    }
+
     [DisplayName("打印送货单(预留)")]
     [HttpPost("supplier-shipment/print-shipping-note")]
     public Task<object> PrintShippingNote([FromBody] SupplierShipmentOperationInput input)
-        => Task.FromResult<object>(new { message = $"发货单{input.Id}:功能预留,暂未启用" });
+        => Task.FromResult<object>(new { message = $"请使用 GET supplier-shipment/shipping-note-data 与打印模板「送货单」" });
 
     [DisplayName("打印标签(预留)")]
     [HttpPost("supplier-shipment/print-label")]
@@ -882,7 +1000,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                                po_billno AS ddh,
                                shdh AS shdh,
                                shdhh AS shdhh,
-                               DATE_FORMAT(scrq, '%Y.%m.%d') AS scrq,
+                               scrq,
                                scph,
                                xh,
                                gysmc,
@@ -967,20 +1085,22 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     {
         var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
         {
-            ["shddh"] = "t.shddh",
-            ["poBill"] = "t.po_bill",
-            ["jhshrq"] = "t.jhshrq",
-            ["shMaterialCode"] = "t.sh_material_code",
-            ["shMaterialName"] = "t.sh_material_name",
-            ["shDeliveryQuantity"] = "t.sh_delivery_quantity",
-            ["sfpc"] = "t.sfpc",
-            ["pcrksl"] = "t.pcrksl",
-            ["shPurchaseName"] = "t.sh_purchase_name",
-            ["shpc"] = "t.shpc",
-            ["scph"] = "t.scph",
-            ["wldh"] = "t.wldh",
-            ["dycs"] = "t.dycs",
-            ["shzt"] = "t.shzt"
+            ["shddh"] = "t.Shddh",
+            ["poBill"] = "t.PoBill",
+            ["poUsage"] = "t.PoUsage",
+            ["jhshrq"] = "t.Jhshrq",
+            ["shMaterialCode"] = "t.ShMaterialCode",
+            ["shMaterialName"] = "t.ShMaterialName",
+            ["shDeliveryQuantity"] = "t.ShDeliveryQuantity",
+            ["sfpc"] = "t.Sfpc",
+            ["pcrksl"] = "t.Pcrksl",
+            ["shPurchaseName"] = "t.ShPurchaseName",
+            ["shpc"] = "t.Shpc",
+            ["scph"] = "t.Scph",
+            ["wldh"] = "t.Wldh",
+            ["dycs"] = "t.Dycs",
+            ["shzt"] = "t.Shzt",
+            ["th"] = "t.Th"
         };
         var field = map.TryGetValue(sortField ?? string.Empty, out var sqlField) ? sqlField : "t.mid";
         var order = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
@@ -989,54 +1109,26 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
 
     private static string BuildListSql() => """
         SELECT
-            m.mid,
-            m.id,
-            m.sh_purchase_id,
-            m.sh_purchase_name,
-            m.sh_purchase_num,
-            m.sh_purchase_address,
-            m.sh_purchase_lxr,
-            m.sh_purchase_phone,
-            m.client,
-            m.delivery_Address,
-            m.expected_consignee,
-            m.consignee_phone,
-            m.estimated_delivery_date,
-            m.po_billno,
-            m.shddh,
-            m.jhshrq,
-            m.tjrid,
-            m.tjrxm,
-            m.tjrq,
-            m.scbq,
-            m.chbg,
-            m.sfpc,
-            m.pcsm,
-            m.wlsc,
-            m.yjdhrq,
-            m.state,
-            m.shzt,
-            m.wldh,
-            m.dycs,
-            m.gys,
-            m.sh_material_code,
-            m.sh_material_name,
-            m.sh_material_ggxh,
-            m.hh,
-            m.po_bill,
-            m.shpc,
-            m.scph,
-            m.sh_delivery_quantity,
-            m.th,
-            n.rksl,
-            n.bhgsl,
-            n.thsl,
-            n.zshl,
-            n.Delivery,
-            n.pc,
-            l.pcrksl,
-            po.potype,
-            po.Usage
+            m.mid AS Mid,
+            m.id AS Id,
+            m.sh_purchase_name AS ShPurchaseName,
+            m.sh_purchase_num AS ShPurchaseNum,
+            m.shddh AS Shddh,
+            m.jhshrq AS Jhshrq,
+            m.sfpc AS Sfpc,
+            m.wldh AS Wldh,
+            m.dycs AS Dycs,
+            m.shzt AS Shzt,
+            m.sh_material_code AS ShMaterialCode,
+            m.sh_material_name AS ShMaterialName,
+            m.sh_delivery_quantity AS ShDeliveryQuantity,
+            l.pcrksl AS Pcrksl,
+            m.po_bill AS PoBill,
+            po.`Usage` AS PoUsage,
+            m.shpc AS Shpc,
+            m.scph AS Scph,
+            m.th AS Th,
+            m.state AS State
         FROM
         (
             SELECT
@@ -1080,8 +1172,8 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                 b.sh_delivery_quantity,
                 b.th
             FROM scm_shd a
-            LEFT JOIN scm_shdzb b ON a.id = b.glid
-            LEFT JOIN (SELECT DISTINCT glid, shpc FROM scm_shbq) c ON b.id = c.glid
+            LEFT JOIN scm_shdzb b ON CAST(a.id AS CHAR) = b.glid
+            LEFT JOIN (SELECT DISTINCT glid, shpc FROM scm_shbq) c ON CAST(b.id AS CHAR) = c.glid
         ) m
         LEFT JOIN
         (
@@ -1102,7 +1194,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
             WHERE QtyChange > 0 AND Reason LIKE '%收货'
             GROUP BY LotSerial
         ) l ON m.shpc = l.LotSerial
-        LEFT JOIN PurOrdMaster po ON m.po_bill = po.purord
+        LEFT JOIN PurOrdMaster po ON m.po_bill = po.PurOrd
         """;
 
     private sealed class SupplierShipmentListRow
@@ -1122,7 +1214,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public decimal? ShDeliveryQuantity { get; set; }
         public decimal? Pcrksl { get; set; }
         public string? PoBill { get; set; }
-        public string? Usage { get; set; }
+        public string? PoUsage { get; set; }
         public string? Shpc { get; set; }
         public string? Scph { get; set; }
         public string? Th { get; set; }
@@ -1149,9 +1241,56 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public string? ShMaterialGgxh { get; set; }
     }
 
+    private sealed class ShippingNoteMasterRow
+    {
+        public long id { get; set; }
+        public long? sh_purchase_id { get; set; }
+        public string? gysmc { get; set; }
+        public string? gysdm { get; set; }
+        public string? sh_purchase_address { get; set; }
+        public string? sh_purchase_lxr { get; set; }
+        public string? sh_purchase_phone { get; set; }
+        public long? client { get; set; }
+        public string? delivery_Address { get; set; }
+        public string? expected_consignee { get; set; }
+        public string? consignee_phone { get; set; }
+        public DateTime? estimated_delivery_date { get; set; }
+        public string? po_billno { get; set; }
+        public string? shddh { get; set; }
+        public string? jhshrq { get; set; }
+        public string? tjrid { get; set; }
+        public string? tjrxm { get; set; }
+        public string? tjrq { get; set; }
+        public int? scbq { get; set; }
+        public string? wlsc { get; set; }
+        public string? yjdhrq { get; set; }
+        public string? wldh { get; set; }
+        public int? sfpc { get; set; }
+        public string? pcsm { get; set; }
+        public string? chbg { get; set; }
+    }
+
+    private sealed class ShippingNoteDetailRow
+    {
+        public string? shddh { get; set; }
+        public int? xh { get; set; }
+        public string? ddbh { get; set; }
+        public string? ddlx { get; set; }
+        public string? lx1 { get; set; }
+        public string? wlbm { get; set; }
+        public string? wlmc { get; set; }
+        public string? ggxh { get; set; }
+        public decimal? shsl { get; set; }
+        public string? dw { get; set; }
+        public string? bz { get; set; }
+        public string? scrq { get; set; }
+        public string? scph { get; set; }
+        public string? th { get; set; }
+    }
+
     private sealed class SupplierShipmentLabelRow
     {
-        public long? Glid { get; set; }
+        public string? Glid { get; set; }
         public string? Wlbm { get; set; }
         public string? Wlmc { get; set; }
         public string? Ggxh { get; set; }
@@ -1162,7 +1301,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public string? Ddlx { get; set; }
         public string? Ddh { get; set; }
         public string? Shdh { get; set; }
-        public string? Shdhh { get; set; }
+        public int? Shdhh { get; set; }
         public string? Scrq { get; set; }
         public string? Scph { get; set; }
         public string? Xh { get; set; }

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

@@ -1583,6 +1583,21 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             Remark = "S3 工序外协订单(PurOrdMaster/PW)"
         };
 
+        yield return new SysMenu
+        {
+            Id = 1329003100018L,
+            Pid = s3ProcurementDirId,
+            Title = "要货令",
+            Path = "/aidop/s3/procurement/demand-order",
+            Name = "aidopS3DemandOrder",
+            Component = "/aidop/s3/supply/demandOrderList",
+            Icon = "ele-ShoppingCart",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 15,
+            Remark = "S3 要货令(PurOrdMaster/PO,ReqBy=DO)"
+        };
+
         yield return new SysMenu
         {
             Id = 1329003100010L,

+ 666 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/DemandOrderService.cs

@@ -0,0 +1,666 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 要货令服务(PurOrdMaster/PurOrdDetail,Potype=PO,ReqBy=DO)
+/// </summary>
+[ApiDescriptionSettings(Order = 311, Description = "要货令")]
+[Route("api/Supply")]
+[AllowAnonymous]
+[NonUnify]
+public class DemandOrderService : IDynamicApiController, ITransient
+{
+    private const int PurOrdSerialWidth = 3;
+
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public DemandOrderService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    [DisplayName("要货令列表")]
+    [HttpGet("demand-order/list")]
+    public async Task<object> GetList([FromQuery] DemandOrderListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var where = new List<string>
+        {
+            "p.Potype='PO'",
+            "IFNULL(p.ReqBy,'')='DO'",
+            "p.IsActive<=1"
+        };
+        var pars = new List<SugarParameter>();
+
+        if (!string.IsNullOrWhiteSpace(input.PurOrd))
+        {
+            where.Add("p.PurOrd LIKE @PurOrd");
+            pars.Add(new SugarParameter("@PurOrd", $"%{input.PurOrd.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Buyer))
+        {
+            where.Add("p.Buyer=@Buyer");
+            pars.Add(new SugarParameter("@Buyer", input.Buyer.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Supp))
+        {
+            where.Add("p.Supp=@Supp");
+            pars.Add(new SugarParameter("@Supp", input.Supp.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Status))
+        {
+            var st = input.Status.Trim();
+            if (string.Equals(st, "R", StringComparison.OrdinalIgnoreCase))
+                where.Add("(IFNULL(LENGTH(p.Status),0)=0 OR p.Buyer IS NULL)");
+            else
+            {
+                where.Add("p.Status=@Status");
+                pars.Add(new SugarParameter("@Status", st));
+            }
+        }
+
+        var fromSql = $"""
+            FROM PurOrdMaster p
+            LEFT JOIN SuppMaster s ON p.Supp=s.Supp
+            LEFT JOIN DepartmentMaster d ON p.Department=d.Department
+            LEFT JOIN EmployeeMaster e ON p.Buyer=e.Employee
+            WHERE {string.Join(" AND ", where)}
+            """;
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<DemandOrderListRow>(
+            $"""
+            SELECT
+                p.RecID AS Id,
+                p.PurOrd AS PurOrd,
+                CONCAT(TRIM(IFNULL(p.Supp,'')),' ',TRIM(IFNULL(s.SortName,''))) AS SuppName,
+                p.Potype AS Potype,
+                p.ReqBy AS ReqBy,
+                CONCAT(TRIM(IFNULL(p.Buyer,'')),' ',TRIM(IFNULL(e.Name,''))) AS Buyer,
+                CONCAT(TRIM(IFNULL(p.Department,'')),' ',TRIM(IFNULL(d.Descr,''))) AS DepartmentDescr,
+                p.OrdDate AS OrdDate,
+                p.Contract AS Contract,
+                p.DeliverTo AS DeliverTo,
+                p.DueDate AS DueDate,
+                p.Contact AS Contact,
+                p.ShipTo AS ShipTo,
+                p.Curr AS Curr,
+                p.TaxClass AS TaxClass,
+                p.TaxIn AS TaxIn,
+                CASE
+                    WHEN IFNULL(LENGTH(p.Status),0)=0 OR p.Buyer IS NULL THEN 'R'
+                    ELSE p.Status
+                END AS Status,
+                p.Remark AS Remark,
+                p.Supp AS Supp,
+                p.Buyer AS BuyerCode,
+                p.Department AS Department,
+                p.`Usage` AS `Usage`,
+                p.FSTID AS Fstid
+            {fromSql}
+            ORDER BY {BuildOrderBy(input.SortField, input.SortOrder)}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("要货令详情")]
+    [HttpGet("demand-order/{id:int}")]
+    public async Task<object> GetDetail(int id)
+    {
+        var master = await GetMasterRowAsync(id);
+        var details = await _db.Ado.SqlQueryAsync<DemandOrderDetailRow>(
+            """
+            SELECT
+                d.RecID AS Id,
+                d.Line AS Line,
+                d.ItemNum AS ItemNum,
+                d.UM AS UM,
+                d.Location AS Location,
+                d.QtyOrded AS QtyOrded,
+                d.RctQty AS RctQty,
+                d.ReceiptQty AS ReceiptQty,
+                d.DueDate AS DueDate,
+                d.Rev AS Rev,
+                d.Drawing AS Drawing,
+                d.LotSerial AS LotSerial,
+                d.Potype AS Potype,
+                d.PurOrd AS PurOrd,
+                d.PurOrdRecID AS PurOrdRecID
+            FROM PurOrdDetail d
+            WHERE d.PurOrd=@PurOrd AND d.Potype='PO'
+            ORDER BY d.Line
+            """,
+            new SugarParameter("@PurOrd", master.PurOrd));
+
+        return new { master, details };
+    }
+
+    [DisplayName("保存要货令")]
+    [HttpPost("demand-order/save")]
+    public async Task<object> Save([FromBody] DemandOrderSaveInput input)
+    {
+        if (string.IsNullOrWhiteSpace(input.Supp)) throw Oops.Oh("请选择供应商");
+        if (string.IsNullOrWhiteSpace(input.Department)) throw Oops.Oh("请选择部门");
+
+        var now = DateTime.Now;
+        var ordDate = ParseDate(input.OrdDate) ?? now.Date;
+        var buyer = string.IsNullOrWhiteSpace(input.Buyer) ? "110" : input.Buyer.Trim();
+        var reqBy = string.IsNullOrWhiteSpace(input.ReqBy) ? "DO" : input.ReqBy.Trim();
+        var usage = string.IsNullOrWhiteSpace(input.Usage) ? "标准" : input.Usage.Trim();
+        var fstid = string.Equals(usage, "VMI", StringComparison.OrdinalIgnoreCase) ? "3" : "1";
+        if (!string.IsNullOrWhiteSpace(input.Fstid)) fstid = input.Fstid.Trim();
+
+        try
+        {
+            _db.Ado.BeginTran();
+
+            int masterId;
+            string purOrd;
+
+            if (input.Id is null or <= 0)
+            {
+                purOrd = await PurOrdNumberGenerator.NextAsync(_db, "DO", DateTime.Today, PurOrdSerialWidth);
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO PurOrdMaster
+                    (PurOrd,OrdDate,Supp,ReqBy,Buyer,Department,Curr,`Usage`,Remark,Potype,FSTID,Status,IsActive,
+                     CreateUser,CreateTime,UpdateUser,UpdateTime,Domain,tenant_id)
+                    VALUES
+                    (@PurOrd,@OrdDate,@Supp,@ReqBy,@Buyer,@Department,@Curr,@Usage,@Remark,'PO',@FSTID,'R',1,
+                     @CreateUser,@CreateTime,@UpdateUser,@UpdateTime,@Domain,@TenantId)
+                    """,
+                    new SugarParameter("@PurOrd", purOrd),
+                    new SugarParameter("@OrdDate", ordDate),
+                    new SugarParameter("@Supp", input.Supp.Trim()),
+                    new SugarParameter("@ReqBy", reqBy),
+                    new SugarParameter("@Buyer", buyer),
+                    new SugarParameter("@Department", input.Department.Trim()),
+                    new SugarParameter("@Curr", input.Curr?.Trim()),
+                    new SugarParameter("@Usage", usage),
+                    new SugarParameter("@Remark", input.Remark?.Trim()),
+                    new SugarParameter("@FSTID", fstid),
+                    new SugarParameter("@CreateUser", _userManager.Account),
+                    new SugarParameter("@CreateTime", now),
+                    new SugarParameter("@UpdateUser", _userManager.Account),
+                    new SugarParameter("@UpdateTime", now),
+                    new SugarParameter("@Domain", "100"),
+                    new SugarParameter("@TenantId", _userManager.TenantId <= 0 ? null : _userManager.TenantId)
+                );
+                masterId = await _db.Ado.GetIntAsync(
+                    "SELECT IFNULL(MAX(RecID),0) FROM PurOrdMaster WHERE PurOrd=@PurOrd",
+                    new SugarParameter("@PurOrd", purOrd));
+            }
+            else
+            {
+                masterId = input.Id.Value;
+                var exists = await _db.Ado.GetIntAsync(
+                    "SELECT COUNT(1) FROM PurOrdMaster WHERE RecID=@Id AND Potype='PO' AND IFNULL(ReqBy,'')='DO'",
+                    new SugarParameter("@Id", masterId));
+                if (exists <= 0) throw Oops.Oh("记录不存在");
+
+                purOrd = await _db.Ado.GetStringAsync(
+                    "SELECT PurOrd FROM PurOrdMaster WHERE RecID=@Id LIMIT 1",
+                    new SugarParameter("@Id", masterId)) ?? throw Oops.Oh("记录不存在");
+
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE PurOrdMaster
+                    SET OrdDate=@OrdDate,Supp=@Supp,ReqBy=@ReqBy,Buyer=@Buyer,Department=@Department,
+                        Curr=@Curr,`Usage`=@Usage,Remark=@Remark,FSTID=@FSTID,
+                        UpdateUser=@UpdateUser,UpdateTime=@UpdateTime
+                    WHERE RecID=@Id
+                    """,
+                    new SugarParameter("@Id", masterId),
+                    new SugarParameter("@OrdDate", ordDate),
+                    new SugarParameter("@Supp", input.Supp.Trim()),
+                    new SugarParameter("@ReqBy", reqBy),
+                    new SugarParameter("@Buyer", buyer),
+                    new SugarParameter("@Department", input.Department.Trim()),
+                    new SugarParameter("@Curr", input.Curr?.Trim()),
+                    new SugarParameter("@Usage", usage),
+                    new SugarParameter("@Remark", input.Remark?.Trim()),
+                    new SugarParameter("@FSTID", fstid),
+                    new SugarParameter("@UpdateUser", _userManager.Account),
+                    new SugarParameter("@UpdateTime", now)
+                );
+            }
+
+            await SaveDetailsAsync(masterId, purOrd, input.Details ?? new List<DemandOrderDetailInput>());
+
+            _db.Ado.CommitTran();
+            return new { id = masterId, purOrd, message = input.Id is null or <= 0 ? "新增成功" : "编辑成功" };
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+    }
+
+    [DisplayName("删除要货令")]
+    [HttpPost("demand-order/delete/{id:int}")]
+    public async Task<object> Delete(int id, [FromQuery] string purOrd)
+    {
+        if (string.IsNullOrWhiteSpace(purOrd)) throw Oops.Oh("缺少要货令单号");
+
+        var hasReceipt = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1)
+            FROM PurOrdRctDetail
+            WHERE OrdNbr=@PurOrd AND IFNULL(QtyReceived,0)>0
+            """,
+            new SugarParameter("@PurOrd", purOrd.Trim()));
+        if (hasReceipt > 0) throw Oops.Oh("存在收货数量,不允许删除");
+
+        var hasDetailRct = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1)
+            FROM PurOrdDetail
+            WHERE PurOrd=@PurOrd AND IFNULL(RctQty,0)>0
+            """,
+            new SugarParameter("@PurOrd", purOrd.Trim()));
+        if (hasDetailRct > 0) throw Oops.Oh("存在收货数量,不允许删除");
+
+        var hasShip = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM scm_shdzb WHERE po_bill=@PurOrd",
+            new SugarParameter("@PurOrd", purOrd.Trim()));
+        if (hasShip > 0) throw Oops.Oh("存在发货单,不允许删除");
+
+        try
+        {
+            _db.Ado.BeginTran();
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM PurOrdDetail WHERE PurOrd=@PurOrd AND Potype='PO'",
+                new SugarParameter("@PurOrd", purOrd.Trim()));
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM PurOrdMaster WHERE RecID=@Id AND PurOrd=@PurOrd",
+                new SugarParameter("@Id", id),
+                new SugarParameter("@PurOrd", purOrd.Trim()));
+            _db.Ado.CommitTran();
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+
+        return new { message = "删除成功" };
+    }
+
+    [DisplayName("选择物料列表")]
+    [HttpGet("demand-order/items")]
+    public async Task<object> GetItemList([FromQuery] DemandOrderItemListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+        var where = new List<string> { "1=1" };
+        var pars = new List<SugarParameter>();
+
+        if (!string.IsNullOrWhiteSpace(input.ItemNum))
+        {
+            where.Add("ItemNum LIKE @ItemNum");
+            pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Descr))
+        {
+            where.Add("Descr LIKE @Descr");
+            pars.Add(new SugarParameter("@Descr", $"%{input.Descr.Trim()}%"));
+        }
+
+        var fromSql = $"FROM ItemMaster WHERE {string.Join(" AND ", where)}";
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<ItemLookupRow>(
+            $"""
+            SELECT RecID, ItemNum, Descr, Descr1, UM, Location, Rev, Drawing
+            {fromSql}
+            ORDER BY {BuildItemOrderBy(input.SortField, input.SortOrder)}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+        return new { total, page, pageSize, list };
+    }
+
+    [DisplayName("采购组下拉")]
+    [HttpGet("demand-order/options/buyers")]
+    public async Task<object> GetBuyerOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Employee AS Value, CONCAT(TRIM(Employee),' ',TRIM(IFNULL(Name,''))) AS Label
+            FROM EmployeeMaster
+            ORDER BY Employee
+            """);
+        return new { list };
+    }
+
+    [DisplayName("供应商下拉")]
+    [HttpGet("demand-order/options/suppliers")]
+    public async Task<object> GetSupplierOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Supp AS Value, CONCAT(TRIM(Supp),' ',TRIM(IFNULL(SortName,''))) AS Label
+            FROM SuppMaster
+            ORDER BY Supp
+            """);
+        return new { list };
+    }
+
+    [DisplayName("部门下拉")]
+    [HttpGet("demand-order/options/departments")]
+    public async Task<object> GetDepartmentOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Department AS Value, CONCAT(TRIM(Department),' ',TRIM(IFNULL(Descr,''))) AS Label
+            FROM DepartmentMaster
+            ORDER BY Department
+            """);
+        return new { list };
+    }
+
+    [DisplayName("币别下拉")]
+    [HttpGet("demand-order/options/curr")]
+    public async Task<object> GetCurrOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT Val AS Value, CONCAT(TRIM(Val),' ',TRIM(IFNULL(Comments,''))) AS Label
+            FROM GeneralizedCodeMaster
+            WHERE FldName='Curr'
+            ORDER BY Val
+            """);
+        return new { list };
+    }
+
+    [DisplayName("库位下拉")]
+    [HttpGet("demand-order/options/locations")]
+    public async Task<object> GetLocationOptions()
+    {
+        var list = await _db.Ado.SqlQueryAsync<OptionRow>(
+            """
+            SELECT location AS Value, CONCAT(TRIM(location),' ',TRIM(IFNULL(descr,''))) AS Label
+            FROM LocationMaster
+            WHERE IFNULL(typed,'')<>'Supp'
+            ORDER BY location
+            """);
+        return new { list };
+    }
+
+    private async Task SaveDetailsAsync(int masterId, string purOrd, List<DemandOrderDetailInput> details)
+    {
+        var dbDetails = await _db.Ado.SqlQueryAsync<DemandOrderDetailRow>(
+            """
+            SELECT RecID AS Id, Line AS Line
+            FROM PurOrdDetail
+            WHERE PurOrd=@PurOrd AND Potype='PO'
+            """,
+            new SugarParameter("@PurOrd", purOrd));
+        var dbById = dbDetails.ToDictionary(d => d.Id);
+        var inputIds = new HashSet<int>(details.Where(d => d.Id is > 0).Select(d => d.Id!.Value));
+
+        for (var i = 0; i < details.Count; i++)
+        {
+            var d = details[i];
+            if (string.IsNullOrWhiteSpace(d.ItemNum)) continue;
+            if (!d.QtyOrded.HasValue || d.QtyOrded.Value <= 0) throw Oops.Oh("订单数量必须大于0");
+
+            var item = (await _db.Ado.SqlQueryAsync<ItemLookupRow>(
+                """
+                SELECT ItemNum, Descr, UM, Location, Rev, Drawing
+                FROM ItemMaster
+                WHERE ItemNum=@ItemNum
+                LIMIT 1
+                """,
+                new SugarParameter("@ItemNum", d.ItemNum.Trim()))).FirstOrDefault();
+            if (item == null) throw Oops.Oh($"物料不存在:{d.ItemNum}");
+
+            var um = d.UM ?? item.UM;
+            var location = d.Location ?? item.Location;
+            var rev = d.Rev ?? item.Rev;
+            var drawing = d.Drawing ?? item.Drawing;
+            var dueDate = ParseDate(d.DueDate);
+            var now = DateTime.Now;
+
+            if (d.Id is > 0 && dbById.ContainsKey(d.Id.Value))
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE PurOrdDetail
+                    SET ItemNum=@ItemNum,Descr=@Descr,UM=@UM,Location=@Location,QtyOrded=@QtyOrded,
+                        DueDate=@DueDate,Rev=@Rev,Drawing=@Drawing,LotSerial=@LotSerial,
+                        UpdateUser=@UpdateUser,UpdateTime=@UpdateTime
+                    WHERE RecID=@Id
+                    """,
+                    new SugarParameter("@Id", d.Id.Value),
+                    new SugarParameter("@ItemNum", item.ItemNum),
+                    new SugarParameter("@Descr", item.Descr),
+                    new SugarParameter("@UM", um),
+                    new SugarParameter("@Location", location),
+                    new SugarParameter("@QtyOrded", d.QtyOrded.Value),
+                    new SugarParameter("@DueDate", dueDate),
+                    new SugarParameter("@Rev", rev),
+                    new SugarParameter("@Drawing", drawing),
+                    new SugarParameter("@LotSerial", d.LotSerial?.Trim()),
+                    new SugarParameter("@UpdateUser", _userManager.Account),
+                    new SugarParameter("@UpdateTime", now)
+                );
+            }
+            else
+            {
+                var line = d.Line ?? await _db.Ado.GetIntAsync(
+                    "SELECT IFNULL(MAX(Line),0)+1 FROM PurOrdDetail WHERE PurOrd=@PurOrd",
+                    new SugarParameter("@PurOrd", purOrd));
+
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO PurOrdDetail
+                    (
+                        QtyBO, RctCost, CreditTermsInt, UpdateCurrentCost, CumReceived1, CumReceived2,
+                        CumReceived3, CumReceived4, Disc, FixedPrice, InspectReq, SingleLot, SupplyPer,
+                        PurOrd, PST, PackingSlipQty, PayUMConv, PurCost, RctQty, QtyOrded, QtyReceived,
+                        QtyReturned, Active, QtyReleased, RctUMConversion, Scheduled, ScheduledChanged,
+                        SchedMRPReq, SafetyDays, SafetyHours, StdCost, Taxable, TaxIn, MaxTaxableAmt,
+                        TransportHours, UMConversion, VAT, IsActive, IsConfirm, Potype, IsChanged,
+                        TaxRate, IsRounding, ReceiptQty, BarCodeQty, IsClosed, QtyReturnedRefund, CumQtyBO,
+                        Line, ItemNum, Descr, UM, Rev, Drawing, Location, DueDate, LotSerial, PurOrdRecID, Status,
+                        CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
+                    )
+                    VALUES
+                    (
+                        0, 0, 0, 0, 0, 0,
+                        0, 0, 0, 0, 0, 0, 0,
+                        @PurOrd, 0, 0, 1, 0, 0, @QtyOrded, 0,
+                        0, 1, 0, 1, 0, 0,
+                        0, 0, 0, 0, 1, 1, 0,
+                        0, 1, 0, 1, 1, 'PO', 0,
+                        0, 0, 0, 0, 0, 0, 0,
+                        @Line, @ItemNum, @Descr, @UM, @Rev, @Drawing, @Location, @DueDate, @LotSerial, @PurOrdRecID, 'R',
+                        @CreateUser, @CreateTime, @UpdateUser, @UpdateTime, @TenantId
+                    )
+                    """,
+                    new SugarParameter("@PurOrd", purOrd),
+                    new SugarParameter("@Line", line),
+                    new SugarParameter("@ItemNum", item.ItemNum),
+                    new SugarParameter("@Descr", item.Descr),
+                    new SugarParameter("@UM", um),
+                    new SugarParameter("@Rev", rev),
+                    new SugarParameter("@Drawing", drawing),
+                    new SugarParameter("@Location", location),
+                    new SugarParameter("@QtyOrded", d.QtyOrded.Value),
+                    new SugarParameter("@DueDate", dueDate),
+                    new SugarParameter("@LotSerial", d.LotSerial?.Trim()),
+                    new SugarParameter("@PurOrdRecID", masterId),
+                    new SugarParameter("@CreateUser", _userManager.Account),
+                    new SugarParameter("@CreateTime", now),
+                    new SugarParameter("@UpdateUser", _userManager.Account),
+                    new SugarParameter("@UpdateTime", now),
+                    new SugarParameter("@TenantId", _userManager.TenantId <= 0 ? null : _userManager.TenantId)
+                );
+            }
+        }
+
+        foreach (var toDelete in dbDetails.Where(u => !inputIds.Contains(u.Id)))
+        {
+            var rctQty = await _db.Ado.GetDecimalAsync(
+                "SELECT IFNULL(RctQty,0) FROM PurOrdDetail WHERE RecID=@Id",
+                new SugarParameter("@Id", toDelete.Id));
+            if (rctQty > 0) throw Oops.Oh("明细存在收货数量,不允许删除");
+
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM PurOrdDetail WHERE RecID=@Id",
+                new SugarParameter("@Id", toDelete.Id));
+        }
+    }
+
+    private async Task<DemandOrderListRow> GetMasterRowAsync(int id)
+    {
+        var row = (await _db.Ado.SqlQueryAsync<DemandOrderListRow>(
+            """
+            SELECT
+                p.RecID AS Id,
+                p.PurOrd AS PurOrd,
+                CONCAT(TRIM(IFNULL(p.Supp,'')),' ',TRIM(IFNULL(s.SortName,''))) AS SuppName,
+                p.Potype AS Potype,
+                p.ReqBy AS ReqBy,
+                CONCAT(TRIM(IFNULL(p.Buyer,'')),' ',TRIM(IFNULL(e.Name,''))) AS Buyer,
+                CONCAT(TRIM(IFNULL(p.Department,'')),' ',TRIM(IFNULL(d.Descr,''))) AS DepartmentDescr,
+                p.OrdDate AS OrdDate,
+                p.Contract AS Contract,
+                p.DeliverTo AS DeliverTo,
+                p.DueDate AS DueDate,
+                p.Contact AS Contact,
+                p.ShipTo AS ShipTo,
+                p.Curr AS Curr,
+                p.TaxClass AS TaxClass,
+                p.TaxIn AS TaxIn,
+                CASE WHEN IFNULL(LENGTH(p.Status),0)=0 OR p.Buyer IS NULL THEN 'R' ELSE p.Status END AS Status,
+                p.Remark AS Remark,
+                p.Supp AS Supp,
+                p.Buyer AS BuyerCode,
+                p.Department AS Department,
+                p.`Usage` AS `Usage`,
+                p.FSTID AS Fstid
+            FROM PurOrdMaster p
+            LEFT JOIN SuppMaster s ON p.Supp=s.Supp
+            LEFT JOIN DepartmentMaster d ON p.Department=d.Department
+            LEFT JOIN EmployeeMaster e ON p.Buyer=e.Employee
+            WHERE p.RecID=@Id AND p.Potype='PO' AND IFNULL(p.ReqBy,'')='DO'
+            LIMIT 1
+            """,
+            new SugarParameter("@Id", id))).FirstOrDefault();
+        return row ?? throw Oops.Oh("记录不存在");
+    }
+
+    private static DateTime? ParseDate(string? v) => DateTime.TryParse(v, out var d) ? d : null;
+
+    private static string BuildOrderBy(string? sortField, string? sortOrder)
+    {
+        var dir = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return sortField?.ToLowerInvariant() switch
+        {
+            "purord" => $"p.PurOrd {dir}",
+            "suppname" => $"p.Supp {dir}",
+            "buyer" => $"p.Buyer {dir}",
+            "departmentdescr" => $"p.Department {dir}",
+            "orddate" => $"p.OrdDate {dir}",
+            "contract" => $"p.Contract {dir}",
+            "duedate" => $"p.DueDate {dir}",
+            "contact" => $"p.Contact {dir}",
+            "shipto" => $"p.ShipTo {dir}",
+            "curr" => $"p.Curr {dir}",
+            "taxclass" => $"p.TaxClass {dir}",
+            "taxin" => $"p.TaxIn {dir}",
+            "status" => $"p.Status {dir}",
+            _ => "p.RecID DESC"
+        };
+    }
+
+    private static string BuildItemOrderBy(string? sortField, string? sortOrder)
+    {
+        var dir = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return sortField?.ToLowerInvariant() switch
+        {
+            "itemnum" => $"ItemNum {dir}",
+            "descr" => $"Descr {dir}",
+            "descr1" => $"Descr1 {dir}",
+            "um" => $"UM {dir}",
+            "location" => $"Location {dir}",
+            "rev" => $"Rev {dir}",
+            "drawing" => $"Drawing {dir}",
+            _ => "RecID DESC"
+        };
+    }
+
+    private sealed class DemandOrderListRow
+    {
+        public int Id { get; set; }
+        public string? PurOrd { get; set; }
+        public string? SuppName { get; set; }
+        public string? Potype { get; set; }
+        public string? ReqBy { get; set; }
+        public string? Buyer { get; set; }
+        public string? DepartmentDescr { get; set; }
+        public DateTime? OrdDate { get; set; }
+        public string? Contract { get; set; }
+        public string? DeliverTo { get; set; }
+        public DateTime? DueDate { get; set; }
+        public string? Contact { get; set; }
+        public string? ShipTo { get; set; }
+        public string? Curr { get; set; }
+        public string? TaxClass { get; set; }
+        public bool TaxIn { get; set; }
+        public string? Status { get; set; }
+        public string? Remark { get; set; }
+        public string? Supp { get; set; }
+        public string? BuyerCode { get; set; }
+        public string? Department { get; set; }
+        public string? Usage { get; set; }
+        public string? Fstid { get; set; }
+    }
+
+    private sealed class DemandOrderDetailRow
+    {
+        public int Id { get; set; }
+        public int? Line { get; set; }
+        public string? ItemNum { get; set; }
+        public string? UM { get; set; }
+        public string? Location { get; set; }
+        public decimal? QtyOrded { get; set; }
+        public decimal? RctQty { get; set; }
+        public decimal? ReceiptQty { get; set; }
+        public DateTime? DueDate { get; set; }
+        public string? Rev { get; set; }
+        public string? Drawing { get; set; }
+        public string? LotSerial { get; set; }
+        public string? Potype { get; set; }
+        public string? PurOrd { get; set; }
+        public int? PurOrdRecID { get; set; }
+    }
+
+    private sealed class ItemLookupRow
+    {
+        public long RecID { get; set; }
+        public string? ItemNum { get; set; }
+        public string? Descr { get; set; }
+        public string? Descr1 { get; set; }
+        public string? UM { get; set; }
+        public string? Location { get; set; }
+        public string? Rev { get; set; }
+        public string? Drawing { get; set; }
+    }
+
+    private sealed class OptionRow
+    {
+        public string? Value { get; set; }
+        public string? Label { get; set; }
+    }
+}

+ 59 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Dto/DemandOrderDto.cs

@@ -0,0 +1,59 @@
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+public class DemandOrderListInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+    public string? PurOrd { get; set; }
+    public string? Buyer { get; set; }
+    public string? Supp { get; set; }
+    public string? Status { get; set; }
+    public string? SortField { get; set; }
+    public string? SortOrder { get; set; }
+}
+
+public class DemandOrderSaveInput
+{
+    public int? Id { get; set; }
+    public string? PurOrd { get; set; }
+    public string? OrdDate { get; set; }
+    public string? Supp { get; set; }
+    public string? ReqBy { get; set; }
+    public string? Buyer { get; set; }
+    public string? Department { get; set; }
+    public string? Curr { get; set; }
+    public string? Usage { get; set; }
+    public string? Remark { get; set; }
+    public string? Potype { get; set; }
+    public string? Fstid { get; set; }
+    public List<DemandOrderDetailInput> Details { get; set; } = new();
+}
+
+public class DemandOrderDetailInput
+{
+    public int? Id { get; set; }
+    public int? Line { get; set; }
+    public string? ItemNum { get; set; }
+    public string? UM { get; set; }
+    public string? Location { get; set; }
+    public decimal? QtyOrded { get; set; }
+    public decimal? RctQty { get; set; }
+    public decimal? ReceiptQty { get; set; }
+    public string? DueDate { get; set; }
+    public string? Rev { get; set; }
+    public string? Drawing { get; set; }
+    public string? LotSerial { get; set; }
+    public string? Potype { get; set; }
+    public string? PurOrd { get; set; }
+    public int? PurOrdRecID { get; set; }
+}
+
+public class DemandOrderItemListInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+    public string? ItemNum { get; set; }
+    public string? Descr { get; set; }
+    public string? SortField { get; set; }
+    public string? SortOrder { get; set; }
+}

+ 8 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Entity/PurOrdMaster.cs

@@ -199,7 +199,7 @@ public class PurOrdMaster
     public string? SchedulesVia { get; set; }
 
     [SugarColumn(ColumnName = "ServiceCharge", ColumnDataType = "decimal(6,5)")]
-    public decimal ServiceCharge { get; set; }
+    public decimal? ServiceCharge { get; set; }
 
     [SugarColumn(ColumnName = "ShipTo", Length = 20, IsNullable = true)]
     public string? ShipTo { get; set; }
@@ -211,13 +211,13 @@ public class PurOrdMaster
     public string? Site { get; set; }
 
     [SugarColumn(ColumnName = "SecondarySOCrHold")]
-    public short SecondarySOCrHold { get; set; }
+    public short? SecondarySOCrHold { get; set; }
 
     [SugarColumn(ColumnName = "PrimarySO", Length = 8, IsNullable = true)]
     public string? PrimarySO { get; set; }
 
     [SugarColumn(ColumnName = "SpecialCharge", ColumnDataType = "decimal(6,5)")]
-    public decimal SpecialCharge { get; set; }
+    public decimal? SpecialCharge { get; set; }
 
     [SugarColumn(ColumnName = "Status", Length = 2, IsNullable = true)]
     public string? Status { get; set; }
@@ -235,13 +235,13 @@ public class PurOrdMaster
     public string? TaxEnvironment { get; set; }
 
     [SugarColumn(ColumnName = "Tax1", ColumnDataType = "decimal(7,5)")]
-    public decimal Tax1 { get; set; }
+    public decimal? Tax1 { get; set; }
 
     [SugarColumn(ColumnName = "Tax2", ColumnDataType = "decimal(7,5)")]
-    public decimal Tax2 { get; set; }
+    public decimal? Tax2 { get; set; }
 
     [SugarColumn(ColumnName = "Tax3", ColumnDataType = "decimal(7,5)")]
-    public decimal Tax3 { get; set; }
+    public decimal? Tax3 { get; set; }
 
     [SugarColumn(ColumnName = "TaxUsage", Length = 8, IsNullable = true)]
     public string? TaxUsage { get; set; }
@@ -250,7 +250,7 @@ public class PurOrdMaster
     public string? TermsofTrade { get; set; }
 
     [SugarColumn(ColumnName = "TransportDays", ColumnDataType = "decimal(8,5)")]
-    public decimal TransportDays { get; set; }
+    public decimal? TransportDays { get; set; }
 
     [SugarColumn(ColumnName = "Typed", Length = 18, IsNullable = true)]
     public string? Typed { get; set; }
@@ -307,7 +307,7 @@ public class PurOrdMaster
     public bool TaxIn { get; set; }
 
     [SugarColumn(ColumnName = "Amt", ColumnDataType = "decimal(15,5)")]
-    public decimal Amt { get; set; }
+    public decimal? Amt { get; set; }
 
     [SugarColumn(ColumnName = "Usage", Length = 30, IsNullable = true)]
     public string? Usage { get; set; }

+ 56 - 25
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/OutsourceOrderService.cs

@@ -9,6 +9,8 @@ namespace Admin.NET.Plugin.AiDOP.Supply;
 [NonUnify]
 public class OutsourceOrderService : IDynamicApiController, ITransient
 {
+    private const int PwPurOrdSerialWidth = 4;
+
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
 
@@ -138,31 +140,60 @@ public class OutsourceOrderService : IDynamicApiController, ITransient
 
         if (input.Id is null or <= 0)
         {
-            var purOrd = string.IsNullOrWhiteSpace(input.PurOrd) ? $"PW{DateTime.Now:yyyyMMddHHmmssfff}" : input.PurOrd.Trim();
-            await _db.Ado.ExecuteCommandAsync(
-                """
-                INSERT INTO PurOrdMaster
-                (PurOrd,OrdDate,Supp,ReqBy,Buyer,Department,`Usage`,Remark,Potype,Status,IsActive,CreateUser,CreateTime,UpdateUser,UpdateTime,Domain,tenant_id)
-                VALUES
-                (@PurOrd,@OrdDate,@Supp,@ReqBy,@Buyer,@Department,@Usage,@Remark,'PW','R',1,@CreateUser,@CreateTime,@UpdateUser,@UpdateTime,@Domain,@TenantId)
-                """,
-                new SugarParameter("@PurOrd", purOrd),
-                new SugarParameter("@OrdDate", ordDate),
-                new SugarParameter("@Supp", input.Supp.Trim()),
-                new SugarParameter("@ReqBy", reqBy),
-                new SugarParameter("@Buyer", buyer),
-                new SugarParameter("@Department", input.Department.Trim()),
-                new SugarParameter("@Usage", usage),
-                new SugarParameter("@Remark", input.Remark?.Trim()),
-                new SugarParameter("@CreateUser", _userManager.Account),
-                new SugarParameter("@CreateTime", now),
-                new SugarParameter("@UpdateUser", _userManager.Account),
-                new SugarParameter("@UpdateTime", now),
-                new SugarParameter("@Domain", "100"),
-                new SugarParameter("@TenantId", _userManager.TenantId <= 0 ? null : _userManager.TenantId)
-            );
-            var id = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID),0) FROM PurOrdMaster WHERE PurOrd=@PurOrd", new SugarParameter("@PurOrd", purOrd));
-            return new { id, purOrd, message = "新增成功" };
+            try
+            {
+                _db.Ado.BeginTran();
+                var purOrd = await PurOrdNumberGenerator.NextAsync(_db, "PW", DateTime.Today, PwPurOrdSerialWidth);
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO PurOrdMaster
+                    (
+                        Confirming, CreditTermsInt, Disc, ExchRate, EstVal, ExchRate1, ExchRate2,
+                        FixedPrice, FixedRate, Frt, PartialOK, AmtPrepaid, PrintPO, PST, Recurr,
+                        `Release`, Revision, Scheduled, ServiceCharge, SpecialCharge, Taxable,
+                        Tax1, Tax2, Tax3, TransportDays, IsActive, IsConfirm, Potype, IsChanged,
+                        TaxIn, Amt, IsPriceChanged,
+                        PurOrd, OrdDate, Supp, ReqBy, Buyer, Department, `Usage`, Remark, Status,
+                        Domain, FSTID, CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
+                    )
+                    VALUES
+                    (
+                        0, 0, 0, 1, 0, 1, 1,
+                        0, 0, 0, 1, 0, 0, 0, 0,
+                        0, 0, 0, 0, 0, 1,
+                        0, 0, 0, 0, 1, 1, 'PW', 0,
+                        1, 0, 0,
+                        @PurOrd, @OrdDate, @Supp, @ReqBy, @Buyer, @Department, @Usage, @Remark, 'R',
+                        @Domain, @FSTID, @CreateUser, @CreateTime, @UpdateUser, @UpdateTime, @TenantId
+                    )
+                    """,
+                    new SugarParameter("@PurOrd", purOrd),
+                    new SugarParameter("@OrdDate", ordDate),
+                    new SugarParameter("@Supp", input.Supp.Trim()),
+                    new SugarParameter("@ReqBy", reqBy),
+                    new SugarParameter("@Buyer", buyer),
+                    new SugarParameter("@Department", input.Department.Trim()),
+                    new SugarParameter("@Usage", usage),
+                    new SugarParameter("@Remark", input.Remark?.Trim()),
+                    new SugarParameter("@Domain", "100"),
+                    new SugarParameter("@FSTID", "1"),
+                    new SugarParameter("@CreateUser", _userManager.Account),
+                    new SugarParameter("@CreateTime", now),
+                    new SugarParameter("@UpdateUser", _userManager.Account),
+                    new SugarParameter("@UpdateTime", now),
+                    new SugarParameter("@TenantId", _userManager.TenantId <= 0 ? null : _userManager.TenantId)
+                );
+                var id = await _db.Ado.GetIntAsync(
+                    "SELECT IFNULL(MAX(RecID),0) FROM PurOrdMaster WHERE PurOrd=@PurOrd",
+                    new SugarParameter("@PurOrd", purOrd));
+                _db.Ado.CommitTran();
+                return new { id, purOrd, message = "新增成功" };
+            }
+            catch
+            {
+                _db.Ado.RollbackTran();
+                throw;
+            }
         }
 
         var exists = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM PurOrdMaster WHERE RecID=@Id", new SugarParameter("@Id", input.Id.Value));

+ 6 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/ProcessOutsourceOrderService.cs

@@ -9,15 +9,15 @@ namespace Admin.NET.Plugin.AiDOP.Supply;
 [NonUnify]
 public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
 {
+    private const int PwPurOrdSerialWidth = 4;
+
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
-    private readonly NumberRuleService _numberRuleService;
 
-    public ProcessOutsourceOrderService(ISqlSugarClient db, UserManager userManager, NumberRuleService numberRuleService)
+    public ProcessOutsourceOrderService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
         _userManager = userManager;
-        _numberRuleService = numberRuleService;
     }
 
     [DisplayName("工序外协订单列表")]
@@ -28,7 +28,7 @@ public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        var where = new List<string> { "p.Potype='PW'", "p.IsActive<=1" };
+        var where = new List<string> { "p.Potype='PW'", "p.IsActive<=1", "IFNULL(p.`Usage`,'')='外协'" };
         var pars = new List<SugarParameter>();
         if (!string.IsNullOrWhiteSpace(input.PurOrd))
         {
@@ -188,9 +188,8 @@ public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
         try
         {
             _db.Ado.BeginTran();
-            var purOrdNumbers = await _numberRuleService.NextBatchInCurrentTransactionAsync("pw", domain, suppliers.Count, _userManager.Account);
-            if (purOrdNumbers.Count < suppliers.Count || purOrdNumbers.Any(string.IsNullOrWhiteSpace))
-                throw Oops.Oh("当前采购单号生成失败,请检查单号规则维护。");
+            var purOrdNumbers = await PurOrdNumberGenerator.NextBatchAsync(
+                _db, "PW", DateTime.Today, PwPurOrdSerialWidth, suppliers.Count);
 
             for (var i = 0; i < suppliers.Count; i++)
             {

+ 87 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurOrdNumberGenerator.cs

@@ -0,0 +1,87 @@
+using System.Globalization;
+
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// 采购单号生成:类型前缀 + yyyyMMdd + 固定宽度流水(事务内 FOR UPDATE 取号)。
+/// </summary>
+public static class PurOrdNumberGenerator
+{
+    public static async Task<string> NextAsync(ISqlSugarClient db, string typePrefix, DateTime sequenceDate, int serialWidth)
+    {
+        var batch = await NextBatchAsync(db, typePrefix, sequenceDate, serialWidth, 1);
+        return batch[0];
+    }
+
+    public static async Task<IReadOnlyList<string>> NextBatchAsync(
+        ISqlSugarClient db,
+        string typePrefix,
+        DateTime sequenceDate,
+        int serialWidth,
+        int count)
+    {
+        if (count <= 0) throw Oops.Oh("编号数量必须大于 0。");
+        if (serialWidth <= 0 || serialWidth > 9) throw Oops.Oh("流水号宽度无效。");
+
+        var prefix = typePrefix.Trim() + sequenceDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+        var maxSeq = await db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(MAX(CAST(RIGHT(TRIM(PurOrd), @SerialWidth) AS UNSIGNED)), 0)
+            FROM PurOrdMaster
+            WHERE TRIM(PurOrd) LIKE CONCAT(@Prefix, '%')
+              AND CHAR_LENGTH(TRIM(PurOrd)) = CHAR_LENGTH(@Prefix) + @SerialWidth
+            FOR UPDATE
+            """,
+            new SugarParameter("@Prefix", prefix),
+            new SugarParameter("@SerialWidth", serialWidth));
+
+        var maxSerial = (int)Math.Pow(10, serialWidth) - 1;
+        var result = new List<string>(count);
+        for (var i = 1; i <= count; i++)
+        {
+            var next = maxSeq + i;
+            if (next > maxSerial)
+                throw Oops.Oh($"当日{typePrefix.Trim()}单号流水已超过 {maxSerial}(前缀 {prefix})");
+
+            result.Add(prefix + next.ToString($"D{serialWidth}", CultureInfo.InvariantCulture));
+        }
+
+        return result;
+    }
+
+    /// <summary>
+    /// PurOrdRctMaster.Receiver:类型前缀 + yyyyMMdd + 固定宽度流水(如 PT202605190001)。
+    /// </summary>
+    public static async Task<string> NextRctReceiverAsync(
+        ISqlSugarClient db,
+        string typePrefix,
+        string rctType,
+        DateTime sequenceDate,
+        int serialWidth)
+    {
+        if (string.IsNullOrWhiteSpace(typePrefix)) throw Oops.Oh("单号前缀无效。");
+        if (string.IsNullOrWhiteSpace(rctType)) throw Oops.Oh("收货类型无效。");
+        if (serialWidth <= 0 || serialWidth > 9) throw Oops.Oh("流水号宽度无效。");
+
+        var prefix = typePrefix.Trim() + sequenceDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+        var maxSeq = await db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(MAX(CAST(RIGHT(TRIM(Receiver), @SerialWidth) AS UNSIGNED)), 0)
+            FROM PurOrdRctMaster
+            WHERE RctType = @RctType
+              AND TRIM(Receiver) LIKE CONCAT(@Prefix, '%')
+              AND CHAR_LENGTH(TRIM(Receiver)) = CHAR_LENGTH(@Prefix) + @SerialWidth
+            FOR UPDATE
+            """,
+            new SugarParameter("@Prefix", prefix),
+            new SugarParameter("@RctType", rctType.Trim()),
+            new SugarParameter("@SerialWidth", serialWidth));
+
+        var maxSerial = (int)Math.Pow(10, serialWidth) - 1;
+        var next = maxSeq + 1;
+        if (next > maxSerial)
+            throw Oops.Oh($"当日{typePrefix.Trim()}单号流水已超过 {maxSerial}(前缀 {prefix})");
+
+        return prefix + next.ToString($"D{serialWidth}", CultureInfo.InvariantCulture);
+    }
+}