소스 검색

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

Pengxy 3 주 전
부모
커밋
8bbe0bf31e
27개의 변경된 파일2580개의 추가작업 그리고 305개의 파일을 삭제
  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>
 		</div>
 		</div>
 		<el-table :data="form.batches" border>
 		<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>
 			<el-table-column prop="itemNum" label="物料编号" width="140">
 			<el-table-column prop="itemNum" label="物料编号" width="140">
 				<template #default="{ row }"><el-input v-model="row.itemNum" disabled /></template>
 				<template #default="{ row }"><el-input v-model="row.itemNum" disabled /></template>
@@ -198,7 +193,7 @@ const rules: FormRules = {
 };
 };
 
 
 const pickVisible = ref(false);
 const pickVisible = ref(false);
-const pickMode = ref<'main' | 'batch' | 'suppItem'>('main');
+const pickMode = ref<'main' | 'suppItem'>('main');
 const pickRowIndex = ref(-1);
 const pickRowIndex = ref(-1);
 const qtySyncReady = ref(false);
 const qtySyncReady = ref(false);
 
 
@@ -256,11 +251,18 @@ function editBatch(index: number) {
 
 
 function removeBatch(index: number) {
 function removeBatch(index: number) {
 	form.batches.splice(index, 1);
 	form.batches.splice(index, 1);
+	renumberBatches();
 	if (editingBatchIndex.value === index) editingBatchIndex.value = -1;
 	if (editingBatchIndex.value === index) editingBatchIndex.value = -1;
 	else 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;
 	pickMode.value = mode;
 	pickRowIndex.value = index;
 	pickRowIndex.value = index;
 	pickVisible.value = true;
 	pickVisible.value = true;
@@ -282,14 +284,10 @@ function onPicked(row: ItemRow) {
 		form.drawing = row.drawing || '';
 		form.drawing = row.drawing || '';
 	} else if (pickRowIndex.value >= 0) {
 	} else if (pickRowIndex.value >= 0) {
 		const tar = form.batches[pickRowIndex.value];
 		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;
 	pickVisible.value = false;
 }
 }

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

@@ -2,7 +2,7 @@
 	<div class="aidop-page">
 	<div class="aidop-page">
 		<aidop-demo-shell title="委外加工订单">
 		<aidop-demo-shell title="委外加工订单">
 			<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<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-form-item label="采购组">
 					<el-select v-model="query.buyer" clearable filterable style="width: 180px">
 					<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-option v-for="o in buyerOptions" :key="o.value" :label="o.label" :value="o.value" />
@@ -24,7 +24,7 @@
 			</div>
 			</div>
 
 
 			<el-table :data="rows" v-loading="loading" border stripe @sort-change="onSortChange">
 			<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="suppName" label="供应商" min-width="200" sortable="custom" />
 					<el-table-column prop="buyer" label="采购组" width="160" 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>
 					<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">
 	<div class="aidop-page">
 		<aidop-demo-shell title="工序外协订单">
 		<aidop-demo-shell title="工序外协订单">
 			<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<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-input v-model="query.workOrd" clearable style="width: 170px" /></el-form-item>
 				<el-form-item label="采购组">
 				<el-form-item label="采购组">
 					<el-select v-model="query.buyer" clearable filterable style="width: 170px">
 					<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">
 					<el-table-column prop="status" label="状态" width="100" fixed="left" sortable="custom">
 						<template #default="{ row }">{{ statusText(row.status) }}</template>
 						<template #default="{ row }">{{ statusText(row.status) }}</template>
 					</el-table-column>
 					</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="suppName" label="供应商" min-width="190" sortable="custom" />
 					<el-table-column prop="buyer" label="采购组" width="160" sortable="custom" />
 					<el-table-column prop="buyer" label="采购组" width="160" sortable="custom" />
 					<el-table-column prop="itemNum" label="物料编号" width="140" sortable="custom" />
 					<el-table-column prop="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;
 	id: number;
 	shddh?: string;
 	shddh?: string;
 	poBill?: string;
 	poBill?: string;
-	usage?: string;
+	/** 采购订单物料类型(PurOrdMaster.Usage) */
+	poUsage?: string;
 	jhshrq?: string;
 	jhshrq?: string;
 	shMaterialCode?: string;
 	shMaterialCode?: string;
 	shMaterialName?: 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);
 	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);
 	return service.post(`/api/ProcurementExecution/supplier-shipment/${action}`, { id }).then((r) => r.data);
 }
 }
 
 
 export interface SupplierShipmentLabelRow {
 export interface SupplierShipmentLabelRow {
-	glid?: number | null;
+	glid?: string | null;
 	wlbm?: string | null;
 	wlbm?: string | null;
 	wlmc?: string | null;
 	wlmc?: string | null;
 	ggxh?: string | null;
 	ggxh?: string | null;
@@ -148,7 +149,7 @@ export interface SupplierShipmentLabelRow {
 	ddlx?: string | null;
 	ddlx?: string | null;
 	ddh?: string | null;
 	ddh?: string | null;
 	shdh?: string | null;
 	shdh?: string | null;
-	shdhh?: string | null;
+	shdhh?: number | null;
 	scrq?: string | null;
 	scrq?: string | null;
 	scph?: string | null;
 	scph?: string | null;
 	xh?: string | null;
 	xh?: string | null;
@@ -166,8 +167,22 @@ export function fetchSupplierShipmentLabelData(shddh: string) {
 		.then((r) => r.data);
 		.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 采购退货单 ──
 // ── S4 采购退货单 ──
 export interface PurchaseReturnListRow {
 export interface PurchaseReturnListRow {
+	/** 主表 PurOrdRctMaster.RecID */
+	recID?: number;
 	id?: number;
 	id?: number;
 	detailRecId?: number;
 	detailRecId?: number;
 	receiver?: string;
 	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>
 					<el-col :span="12">
 					<el-col :span="12">
 						<el-form-item label="偏差申请附件">
 						<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-form-item>
 					</el-col>
 					</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>
 					<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="生产批号" 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="备注" 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="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 }">
 					<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>
 					</template>
 				</el-table-column>
 				</el-table-column>
 				<el-table-column label="交货单号" width="120"><template #default="{ row }"><el-input v-model="row.jhdbh" :disabled="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>
 				<el-button v-if="!isView" type="primary" :loading="saving" @click="onSave">保存</el-button>
 			</div>
 			</div>
 		</div>
 		</div>
+		<printDialog ref="printShippingDialogRef" title="打印送货单" />
 	</AidopDemoShell>
 	</AidopDemoShell>
 </template>
 </template>
 
 
@@ -140,10 +135,18 @@ import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import {
 import {
 	fetchSupplierShipmentDetail,
 	fetchSupplierShipmentDetail,
 	fetchSupplierShipmentDraft,
 	fetchSupplierShipmentDraft,
+	fetchSupplierShipmentShippingNoteData,
 	saveSupplierShipment,
 	saveSupplierShipment,
 	type SupplierShipmentDetailRow,
 	type SupplierShipmentDetailRow,
 	type SupplierShipmentFormData,
 	type SupplierShipmentFormData,
 } from '../api/procurementExecution';
 } 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(
 const props = withDefaults(
 	defineProps<{
 	defineProps<{
@@ -263,7 +266,27 @@ function removeDetail(index: number) {
 	form.details.splice(index, 1);
 	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() {
 async function loadData() {
+	let shouldAutoPrint = false;
 	loading.value = true;
 	loading.value = true;
 	try {
 	try {
 		if (mode.value === 'create' && ids.value) {
 		if (mode.value === 'create' && ids.value) {
@@ -276,20 +299,17 @@ async function loadData() {
 			const detail = await fetchSupplierShipmentDetail(id.value);
 			const detail = await fetchSupplierShipmentDetail(id.value);
 			setForm(detail);
 			setForm(detail);
 			await afterFormLoaded();
 			await afterFormLoaded();
-			if (mode.value === 'view' && autoPrint.value) {
-				// 等表单渲染完成后再触发浏览器打印
-				await nextTick();
-				window.print();
-			}
+			if (mode.value === 'view' && autoPrint.value) shouldAutoPrint = true;
 			return;
 			return;
 		}
 		}
 	} finally {
 	} finally {
 		loading.value = false;
 		loading.value = false;
+		if (shouldAutoPrint) await triggerPrintShippingNote();
 	}
 	}
 }
 }
 
 
 function onPrintShippingNote() {
 function onPrintShippingNote() {
-	window.print();
+	triggerPrintShippingNote();
 }
 }
 
 
 async function onSave() {
 async function onSave() {
@@ -328,28 +348,5 @@ onMounted(loadData);
 .top-toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
 .top-toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
 .sub-toolbar { margin-bottom: 8px; }
 .sub-toolbar { margin-bottom: 8px; }
 .footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
 .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>
 </style>
 
 

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

@@ -1,6 +1,5 @@
 <template>
 <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 :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="发货日期">
 			<el-form-item label="发货日期">
 				<el-date-picker v-model="query.jhshrqFrom" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
 				<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.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.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.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.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.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" />
 			<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">
 			<el-table-column label="操作" width="360" fixed="right">
 				<template #default="{ row }">
 				<template #default="{ row }">
 					<el-button link type="primary" @click="onPlaceholder('generate-label', row)">生成标签</el-button>
 					<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="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>
 					<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"
 				@size-change="loadList"
 			/>
 			/>
 		</div>
 		</div>
-		</AidopDemoShell>
-
-		<printDialog ref="printDialogRef" :title="'打印送货标签'" />
-	</div>
+		<printDialog ref="printLabelDialogRef" title="打印送货标签" />
+		<printDialog ref="printShippingDialogRef" title="打印送货单" />
+	</AidopDemoShell>
 </template>
 </template>
 
 
 <script setup lang="ts" name="aidopS4SupplierShipmentList">
 <script setup lang="ts" name="aidopS4SupplierShipmentList">
@@ -89,19 +87,25 @@ import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useRoute, useRouter } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 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 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 { formatDate } from '/@/utils/formatTime';
+import { showSysPrintDialog } from './supplierHiprint';
 
 
 const route = useRoute();
 const route = useRoute();
 const router = useRouter();
 const router = useRouter();
 const pageTitle = computed(() => (route.meta?.title as string) || '供应商发货单');
 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_LABEL_TEMPLATE_NAME = '送货标签';
+const PRINT_SHIPPING_TEMPLATE_NAME = '送货单';
 
 
 const query = reactive({
 const query = reactive({
 	jhshrqFrom: '',
 	jhshrqFrom: '',
@@ -123,12 +127,12 @@ const rows = ref<SupplierShipmentRow[]>([]);
 const total = ref(0);
 const total = ref(0);
 
 
 const col = reactive({
 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,
 	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;
 type ColumnKey = keyof typeof col;
 const toggleItems: Array<{ key: ColumnKey; label: string }> = [
 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: 'shMaterialCode', label: '物料编码' }, { key: 'shMaterialName', label: '物料名称' }, { key: 'shDeliveryQuantity', label: '发货数量' }, { key: 'sfpc', label: '是否偏差' },
 	{ key: 'pcrksl', label: '合格入库' }, { key: 'shPurchaseName', label: '供应商名称' }, { key: 'shpc', label: '客户批次号' }, { key: 'scph', 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: '图号' },
 	{ key: 'wldh', label: '物流单号' }, { key: 'dycs', label: '已打次数' }, { key: 'shzt', label: '送货状态' }, { key: 'th', label: '图号' },
@@ -186,14 +190,7 @@ async function onDelete(row: SupplierShipmentRow) {
 	await loadList();
 	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 ret = await shipmentPlaceholderAction(action, row.mid);
 	const msg = ret?.message || ret?.msg || '功能预留';
 	const msg = ret?.message || ret?.msg || '功能预留';
 	if (ret?.success === true) ElMessage.success(msg);
 	if (ret?.success === true) ElMessage.success(msg);
@@ -213,32 +210,38 @@ async function onPrintLabel(row: SupplierShipmentRow) {
 		return;
 		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 printDate = formatDate(new Date(), 'YYYY-mm-dd HH:MM:SS');
-	// 兼容:模板既可能用“明细列表”,也可能用“单行字段”
-	const printData = {
+	const printData = items.map((item) => ({
+		...item,
 		shddh: row.shddh,
 		shddh: row.shddh,
 		printDate,
 		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;
 		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() {
 function onExport() {
 	const headers = ['发货单编号', '订单编号', '物料类型', '发货日期', '物料编码', '物料名称', '发货数量', '是否偏差', '合格入库', '供应商名称', '客户批次号', '供应商批号', '物流单号', '已打次数', '送货状态', '图号'];
 	const headers = ['发货单编号', '订单编号', '物料类型', '发货日期', '物料编码', '物料名称', '发货数量', '是否偏差', '合格入库', '供应商名称', '客户批次号', '供应商批号', '物流单号', '已打次数', '送货状态', '图号'];
 	const lines = rows.value.map((r) => [
 	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 ?? '',
 		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');
 	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; }
 .pager { margin-top: 12px; display: flex; justify-content: flex-end; }
 :deep(.row-deleted .el-table__cell) { background: #ffe7e7 !important; }
 :deep(.row-deleted .el-table__cell) { background: #ffe7e7 !important; }
 </style>
 </style>
+

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

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

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

@@ -60,13 +60,51 @@
 			/>
 			/>
 		</div>
 		</div>
 	</AidopDemoShell>
 	</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>
 </template>
 
 
 <script setup lang="ts" name="aidopS4PurchaseReturnOrderList">
 <script setup lang="ts" name="aidopS4PurchaseReturnOrderList">
 import { computed, onMounted, reactive, ref } from 'vue';
 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 { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import PurchaseReturnOrderForm from './purchaseReturnOrderForm.vue';
 import {
 import {
 	deletePurchaseReturn,
 	deletePurchaseReturn,
 	fetchPurchaseReturnList,
 	fetchPurchaseReturnList,
@@ -76,7 +114,6 @@ import {
 } from '../api/procurementExecution';
 } from '../api/procurementExecution';
 
 
 const route = useRoute();
 const route = useRoute();
-const router = useRouter();
 const pageTitle = computed(() => (route.meta?.title as string) || '采购退货单');
 const pageTitle = computed(() => (route.meta?.title as string) || '采购退货单');
 
 
 const query = reactive({
 const query = reactive({
@@ -95,6 +132,14 @@ const rows = ref<PurchaseReturnListRow[]>([]);
 const total = ref(0);
 const total = ref(0);
 const suppOpts = ref<PurchaseReturnKv[]>([]);
 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({
 const col = reactive({
 	receiver: true,
 	receiver: true,
 	suppName: true,
 	suppName: true,
@@ -130,6 +175,27 @@ function truthy(v: unknown) {
 	return false;
 	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) {
 function setColumnVisible(key: ColumnKey, visible: boolean) {
 	col[key] = visible;
 	col[key] = visible;
 }
 }
@@ -153,7 +219,7 @@ async function loadList() {
 	loading.value = true;
 	loading.value = true;
 	try {
 	try {
 		const data = await fetchPurchaseReturnList({ ...query });
 		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;
 		total.value = data.total || 0;
 	} finally {
 	} finally {
 		loading.value = false;
 		loading.value = false;
@@ -177,22 +243,50 @@ function resetQuery() {
 }
 }
 
 
 function openForm(mode: 'create' | 'edit' | 'view', row?: PurchaseReturnListRow) {
 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(() => {
 onMounted(() => {
@@ -218,3 +312,11 @@ onMounted(() => {
 	margin-top: 12px;
 	margin-top: 12px;
 }
 }
 </style>
 </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.printParam = printParam;
 	state.printType = printType;
 	state.printType = printType;
 	nextTick(() => {
 	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);
 		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/procurement/process-outsource-order", "aidopS3ProcessOutsourceOrder",
             "/aidop/s3/supply/processOutsourceOrderList", "ele-List", 40, "S3 工序外协订单(PurOrdMaster/PW)");
             "/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(
         EnsureS3DirMenu(
             db, ct, 1329003100010L, s3RootId, "供应协同看板",
             db, ct, 1329003100010L, s3RootId, "供应协同看板",
             "/aidop/s3/supply-kanban", "aidopS3SupplyKanbanDir",
             "/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 class PurchaseReturnListRow
 {
 {
-    public int Id { get; set; }
+    /// <summary>主表 PurOrdRctMaster.RecID</summary>
+    public int RecID { get; set; }
     public int DetailRecId { get; set; }
     public int DetailRecId { get; set; }
     public string RctType { get; set; }
     public string RctType { get; set; }
     public string Receiver { 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.Dto;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
+using Admin.NET.Plugin.AiDOP.Supply;
 
 
 namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 
 
@@ -13,6 +14,9 @@ namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 [NonUnify]
 [NonUnify]
 public class PurchaseReturnService : IDynamicApiController, ITransient
 public class PurchaseReturnService : IDynamicApiController, ITransient
 {
 {
+    /// <summary>采购退货单号:PT + yyyyMMdd + 4 位流水,如 PT202605190001。</summary>
+    private const int PtReceiverSerialWidth = 4;
+
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
     private readonly SqlSugarRepository<PurOrdRctMaster> _masterRep;
     private readonly SqlSugarRepository<PurOrdRctMaster> _masterRep;
     private readonly SqlSugarRepository<PurOrdRctDetail> _detailRep;
     private readonly SqlSugarRepository<PurOrdRctDetail> _detailRep;
@@ -45,7 +49,7 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
             ["suppname"] = "SuppName",
             ["suppname"] = "SuppName",
         };
         };
         if (!map.TryGetValue(col, out var sqlCol))
         if (!map.TryGetValue(col, out var sqlCol))
-            sqlCol = "Id";
+            sqlCol = "RecID";
         return $"ORDER BY {sqlCol} {ord}, DetailRecId ASC";
         return $"ORDER BY {sqlCol} {ord}, DetailRecId ASC";
     }
     }
 
 
@@ -86,7 +90,7 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
         var where = string.Join(" AND ", cond);
         var where = string.Join(" AND ", cond);
         var baseSql = $"""
         var baseSql = $"""
             SELECT
             SELECT
-              p.RecID AS Id,
+              p.RecID AS RecID,
               IFNULL(d1.RecID, 0) AS DetailRecId,
               IFNULL(d1.RecID, 0) AS DetailRecId,
               p.RctType AS RctType,
               p.RctType AS RctType,
               p.Receiver AS Receiver,
               p.Receiver AS Receiver,
@@ -105,9 +109,9 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
               IFNULL(d1.RctQty, 0) AS RctQty
               IFNULL(d1.RctQty, 0) AS RctQty
             FROM PurOrdRctMaster p
             FROM PurOrdRctMaster p
             LEFT JOIN PurOrdRctDetail d1 ON p.RecID = d1.PurOrdRctRecID AND d1.RctType='pt' AND d1.IsActive=1
             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}
             WHERE {where}
             """;
             """;
 
 
@@ -149,8 +153,8 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
                               TRIM(IFNULL(i.Descr,'')))) AS `Label`
                               TRIM(IFNULL(i.Descr,'')))) AS `Label`
             FROM PurOrdRctMaster p
             FROM PurOrdRctMaster p
             LEFT JOIN PurOrdRctDetail d
             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,'') <> ''
             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
             GROUP BY p.Receiver, d.LotSerial, d.ItemNum, i.Descr, p.Domain
             ORDER BY p.Receiver DESC
             ORDER BY p.Receiver DESC
@@ -198,7 +202,7 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
               CAST(d.Line AS SIGNED) AS Line
               CAST(d.Line AS SIGNED) AS Line
             FROM PurOrdRctMaster p
             FROM PurOrdRctMaster p
             LEFT JOIN PurOrdRctDetail d
             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
             WHERE p.RctType = 'rc' AND p.Receiver = @Receiver AND d.RecID > 0
             ORDER BY d.Line
             ORDER BY d.Line
             """,
             """,
@@ -380,40 +384,51 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
 
 
         if (input.Id is null or 0)
         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")
         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;
         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)
     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,
                         b.sh_material_code AS wlbm,
                         IFNULL(b.scph, '') AS scph
                         IFNULL(b.scph, '') AS scph
                     FROM scm_shd a
                     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
                     WHERE a.shddh = @shddh
                       AND IFNULL(b.sh_material_code, '') <> ''
                       AND IFNULL(b.sh_material_code, '') <> ''
                       AND IFNULL(b.shpc, '') = '';
                       AND IFNULL(b.shpc, '') = '';
@@ -355,7 +355,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         await _db.Ado.ExecuteCommandAsync(
                         await _db.Ado.ExecuteCommandAsync(
                             """
                             """
                             UPDATE scm_shdzb b
                             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
                             SET b.shpc = @shpc
                             WHERE a.shddh = @shddh
                             WHERE a.shddh = @shddh
                               AND b.sh_material_code = @wlbm
                               AND b.sh_material_code = @wlbm
@@ -379,7 +379,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         {
                         {
                             await _db.Ado.ExecuteCommandAsync(
                             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()
                                 SELECT @shpc, @wlbm, @scph, @shdh, @xh, @gysdm, NOW()
                                 FROM DUAL
                                 FROM DUAL
                                 WHERE NOT EXISTS (
                                 WHERE NOT EXISTS (
@@ -456,14 +456,14 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         IFNULL(im.Rev, pd.Rev) AS bbh,
                         IFNULL(im.Rev, pd.Rev) AS bbh,
                         a.sh_purchase_name AS gysmc,
                         a.sh_purchase_name AS gysmc,
                         a.sh_purchase_num AS gysdm,
                         a.sh_purchase_num AS gysdm,
-                        pm.Usage AS usage,
+                        pm.`Usage` AS po_usage,
                         b.ccrq AS ccrq,
                         b.ccrq AS ccrq,
                         a.jhshrq AS jhshrq,
                         a.jhshrq AS jhshrq,
                         b.jhdbh AS jhdbh,
                         b.jhdbh AS jhdbh,
                         b.jhdhh AS jhdhh,
                         b.jhdhh AS jhdhh,
                         b.shpc AS shpc
                         b.shpc AS shpc
                     FROM scm_shd a
                     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 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 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
                     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("@pohh", row.PoBillLine ?? 0),
                 new("@bbh", row.Bbh ?? string.Empty),
                 new("@bbh", row.Bbh ?? string.Empty),
                 new("@th", row.Th ?? 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("@ccrq", row.Ccrq ?? string.Empty),
                 new("@shpc", row.Shpc ?? string.Empty),
                 new("@shpc", row.Shpc ?? string.Empty),
                 new("@jhdbh", row.Jhdbh ?? string.Empty),
                 new("@jhdbh", row.Jhdbh ?? string.Empty),
@@ -843,17 +843,135 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public string? Scph { get; set; }
         public string? Scph { get; set; }
         public string? Th { get; set; }
         public string? Th { get; set; }
         public string? Bbh { get; set; }
         public string? Bbh { get; set; }
-        public string? Usage { get; set; }
+        public string? PoUsage { get; set; }
         public string? Ccrq { get; set; }
         public string? Ccrq { get; set; }
         public string? Jhdbh { get; set; }
         public string? Jhdbh { get; set; }
         public string? Jhdhh { get; set; }
         public string? Jhdhh { get; set; }
         public string? Shpc { 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("打印送货单(预留)")]
     [DisplayName("打印送货单(预留)")]
     [HttpPost("supplier-shipment/print-shipping-note")]
     [HttpPost("supplier-shipment/print-shipping-note")]
     public Task<object> PrintShippingNote([FromBody] SupplierShipmentOperationInput input)
     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("打印标签(预留)")]
     [DisplayName("打印标签(预留)")]
     [HttpPost("supplier-shipment/print-label")]
     [HttpPost("supplier-shipment/print-label")]
@@ -882,7 +1000,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                                po_billno AS ddh,
                                po_billno AS ddh,
                                shdh AS shdh,
                                shdh AS shdh,
                                shdhh AS shdhh,
                                shdhh AS shdhh,
-                               DATE_FORMAT(scrq, '%Y.%m.%d') AS scrq,
+                               scrq,
                                scph,
                                scph,
                                xh,
                                xh,
                                gysmc,
                                gysmc,
@@ -967,20 +1085,22 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     {
     {
         var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
         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 field = map.TryGetValue(sortField ?? string.Empty, out var sqlField) ? sqlField : "t.mid";
         var order = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
         var order = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
@@ -989,54 +1109,26 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
 
 
     private static string BuildListSql() => """
     private static string BuildListSql() => """
         SELECT
         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
         FROM
         (
         (
             SELECT
             SELECT
@@ -1080,8 +1172,8 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                 b.sh_delivery_quantity,
                 b.sh_delivery_quantity,
                 b.th
                 b.th
             FROM scm_shd a
             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
         ) m
         LEFT JOIN
         LEFT JOIN
         (
         (
@@ -1102,7 +1194,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
             WHERE QtyChange > 0 AND Reason LIKE '%收货'
             WHERE QtyChange > 0 AND Reason LIKE '%收货'
             GROUP BY LotSerial
             GROUP BY LotSerial
         ) l ON m.shpc = l.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
     private sealed class SupplierShipmentListRow
@@ -1122,7 +1214,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public decimal? ShDeliveryQuantity { get; set; }
         public decimal? ShDeliveryQuantity { get; set; }
         public decimal? Pcrksl { get; set; }
         public decimal? Pcrksl { get; set; }
         public string? PoBill { get; set; }
         public string? PoBill { get; set; }
-        public string? Usage { get; set; }
+        public string? PoUsage { get; set; }
         public string? Shpc { get; set; }
         public string? Shpc { get; set; }
         public string? Scph { get; set; }
         public string? Scph { get; set; }
         public string? Th { get; set; }
         public string? Th { get; set; }
@@ -1149,9 +1241,56 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public string? ShMaterialGgxh { get; set; }
         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
     private sealed class SupplierShipmentLabelRow
     {
     {
-        public long? Glid { get; set; }
+        public string? Glid { get; set; }
         public string? Wlbm { get; set; }
         public string? Wlbm { get; set; }
         public string? Wlmc { get; set; }
         public string? Wlmc { get; set; }
         public string? Ggxh { get; set; }
         public string? Ggxh { get; set; }
@@ -1162,7 +1301,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public string? Ddlx { get; set; }
         public string? Ddlx { get; set; }
         public string? Ddh { get; set; }
         public string? Ddh { get; set; }
         public string? Shdh { get; set; }
         public string? Shdh { get; set; }
-        public string? Shdhh { get; set; }
+        public int? Shdhh { get; set; }
         public string? Scrq { get; set; }
         public string? Scrq { get; set; }
         public string? Scph { get; set; }
         public string? Scph { get; set; }
         public string? Xh { 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)"
             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
         yield return new SysMenu
         {
         {
             Id = 1329003100010L,
             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; }
     public string? SchedulesVia { get; set; }
 
 
     [SugarColumn(ColumnName = "ServiceCharge", ColumnDataType = "decimal(6,5)")]
     [SugarColumn(ColumnName = "ServiceCharge", ColumnDataType = "decimal(6,5)")]
-    public decimal ServiceCharge { get; set; }
+    public decimal? ServiceCharge { get; set; }
 
 
     [SugarColumn(ColumnName = "ShipTo", Length = 20, IsNullable = true)]
     [SugarColumn(ColumnName = "ShipTo", Length = 20, IsNullable = true)]
     public string? ShipTo { get; set; }
     public string? ShipTo { get; set; }
@@ -211,13 +211,13 @@ public class PurOrdMaster
     public string? Site { get; set; }
     public string? Site { get; set; }
 
 
     [SugarColumn(ColumnName = "SecondarySOCrHold")]
     [SugarColumn(ColumnName = "SecondarySOCrHold")]
-    public short SecondarySOCrHold { get; set; }
+    public short? SecondarySOCrHold { get; set; }
 
 
     [SugarColumn(ColumnName = "PrimarySO", Length = 8, IsNullable = true)]
     [SugarColumn(ColumnName = "PrimarySO", Length = 8, IsNullable = true)]
     public string? PrimarySO { get; set; }
     public string? PrimarySO { get; set; }
 
 
     [SugarColumn(ColumnName = "SpecialCharge", ColumnDataType = "decimal(6,5)")]
     [SugarColumn(ColumnName = "SpecialCharge", ColumnDataType = "decimal(6,5)")]
-    public decimal SpecialCharge { get; set; }
+    public decimal? SpecialCharge { get; set; }
 
 
     [SugarColumn(ColumnName = "Status", Length = 2, IsNullable = true)]
     [SugarColumn(ColumnName = "Status", Length = 2, IsNullable = true)]
     public string? Status { get; set; }
     public string? Status { get; set; }
@@ -235,13 +235,13 @@ public class PurOrdMaster
     public string? TaxEnvironment { get; set; }
     public string? TaxEnvironment { get; set; }
 
 
     [SugarColumn(ColumnName = "Tax1", ColumnDataType = "decimal(7,5)")]
     [SugarColumn(ColumnName = "Tax1", ColumnDataType = "decimal(7,5)")]
-    public decimal Tax1 { get; set; }
+    public decimal? Tax1 { get; set; }
 
 
     [SugarColumn(ColumnName = "Tax2", ColumnDataType = "decimal(7,5)")]
     [SugarColumn(ColumnName = "Tax2", ColumnDataType = "decimal(7,5)")]
-    public decimal Tax2 { get; set; }
+    public decimal? Tax2 { get; set; }
 
 
     [SugarColumn(ColumnName = "Tax3", ColumnDataType = "decimal(7,5)")]
     [SugarColumn(ColumnName = "Tax3", ColumnDataType = "decimal(7,5)")]
-    public decimal Tax3 { get; set; }
+    public decimal? Tax3 { get; set; }
 
 
     [SugarColumn(ColumnName = "TaxUsage", Length = 8, IsNullable = true)]
     [SugarColumn(ColumnName = "TaxUsage", Length = 8, IsNullable = true)]
     public string? TaxUsage { get; set; }
     public string? TaxUsage { get; set; }
@@ -250,7 +250,7 @@ public class PurOrdMaster
     public string? TermsofTrade { get; set; }
     public string? TermsofTrade { get; set; }
 
 
     [SugarColumn(ColumnName = "TransportDays", ColumnDataType = "decimal(8,5)")]
     [SugarColumn(ColumnName = "TransportDays", ColumnDataType = "decimal(8,5)")]
-    public decimal TransportDays { get; set; }
+    public decimal? TransportDays { get; set; }
 
 
     [SugarColumn(ColumnName = "Typed", Length = 18, IsNullable = true)]
     [SugarColumn(ColumnName = "Typed", Length = 18, IsNullable = true)]
     public string? Typed { get; set; }
     public string? Typed { get; set; }
@@ -307,7 +307,7 @@ public class PurOrdMaster
     public bool TaxIn { get; set; }
     public bool TaxIn { get; set; }
 
 
     [SugarColumn(ColumnName = "Amt", ColumnDataType = "decimal(15,5)")]
     [SugarColumn(ColumnName = "Amt", ColumnDataType = "decimal(15,5)")]
-    public decimal Amt { get; set; }
+    public decimal? Amt { get; set; }
 
 
     [SugarColumn(ColumnName = "Usage", Length = 30, IsNullable = true)]
     [SugarColumn(ColumnName = "Usage", Length = 30, IsNullable = true)]
     public string? Usage { get; set; }
     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]
 [NonUnify]
 public class OutsourceOrderService : IDynamicApiController, ITransient
 public class OutsourceOrderService : IDynamicApiController, ITransient
 {
 {
+    private const int PwPurOrdSerialWidth = 4;
+
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
     private readonly UserManager _userManager;
 
 
@@ -138,31 +140,60 @@ public class OutsourceOrderService : IDynamicApiController, ITransient
 
 
         if (input.Id is null or <= 0)
         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));
         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]
 [NonUnify]
 public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
 public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
 {
 {
+    private const int PwPurOrdSerialWidth = 4;
+
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
     private readonly UserManager _userManager;
-    private readonly NumberRuleService _numberRuleService;
 
 
-    public ProcessOutsourceOrderService(ISqlSugarClient db, UserManager userManager, NumberRuleService numberRuleService)
+    public ProcessOutsourceOrderService(ISqlSugarClient db, UserManager userManager)
     {
     {
         _db = db;
         _db = db;
         _userManager = userManager;
         _userManager = userManager;
-        _numberRuleService = numberRuleService;
     }
     }
 
 
     [DisplayName("工序外协订单列表")]
     [DisplayName("工序外协订单列表")]
@@ -28,7 +28,7 @@ public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * 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>();
         var pars = new List<SugarParameter>();
         if (!string.IsNullOrWhiteSpace(input.PurOrd))
         if (!string.IsNullOrWhiteSpace(input.PurOrd))
         {
         {
@@ -188,9 +188,8 @@ public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
         try
         try
         {
         {
             _db.Ado.BeginTran();
             _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++)
             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);
+    }
+}