Procházet zdrojové kódy

feat(s5): add purchase receipt mdp readonly pipeline

- 新增 mdp_std_purchase_receipt 标准层表(1.0.210.sql,方案1:直读源表自建标准层)
- 新增 PurchaseReceiptMdpSyncService(只读源表 → mdp_std,幂等 upsert,run-log)
- 新增 s5-purchase-receipt-mdp refresh 刷新入口
- 新增 PurchaseReceipt list 只读列表 API(租户过滤 + 过滤/排序白名单)
- 新增采购收货单只读页面 + API 封装
- 菜单 FUNC-S5-004 重指真实页面 /aidop/s5/warehouse/purchaseReceiptList
- 不做写入、导出、S3/S4 改造

版本:Web 2.4.205 / server 1.0.210
YY968XX před 2 dny
rodič
revize
7602f5ed38

+ 1 - 1
Web/package.json

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

+ 58 - 0
Web/src/views/aidop/s5/api/purchaseReceipt.ts

@@ -0,0 +1,58 @@
+import service from '/@/utils/request';
+import { withAidopTenantParams } from '../../api/aidopTenant';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+// ── S5 采购收货单(只读,数据中台标准层 mdp_std_purchase_receipt)──
+export interface PurchaseReceiptRow {
+	id?: number;
+	/** 收货单号 receiver */
+	receiver?: string;
+	/** 收货明细行号 line */
+	line?: number;
+	/** 收货日期 rct_date */
+	rctDate?: string;
+	/** 供应商编码 supp */
+	supp?: string;
+	/** 供应商名称 sort_name */
+	sortName?: string;
+	/** 物料编码 item_num */
+	itemNum?: string;
+	/** 物料名称 item_name */
+	itemName?: string;
+	/** 物料规格 item_spec */
+	itemSpec?: string;
+	/** 单位 um */
+	um?: string;
+	/** 订单数量 qty_ordered */
+	qtyOrdered?: number;
+	/** 收货数量 qty_received */
+	qtyReceived?: number;
+	/** 批次号 lot_serial */
+	lotSerial?: string;
+	/** 库位 location */
+	location?: string;
+	/** 采购单号 pur_ord */
+	purOrd?: string;
+	/** 销售工单 sales_job */
+	salesJob?: string;
+	/** 收货地址 address1(默认隐藏列) */
+	address1?: string;
+	/** 请购单号 req(默认隐藏列) */
+	req?: string;
+	/** DOP请购号 dop_req(默认隐藏列) */
+	dopReq?: string;
+	/** 来源单号 ord_nbr(默认隐藏列) */
+	ordNbr?: string;
+}
+
+export function fetchPurchaseReceiptList(params: any) {
+	return service
+		.get<Paged<PurchaseReceiptRow>>('/api/PurchaseReceipt/list', { params: withAidopTenantParams(params) })
+		.then((r) => r.data);
+}

+ 188 - 0
Web/src/views/aidop/s5/warehouse/purchaseReceiptList.vue

@@ -0,0 +1,188 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="只读列表">
+		<!--
+			S5 采购收货单 · 只读页(S5-PURCHASE-RECEIPT-MDP-PIPELINE-1)。
+			数据源:DOP 数据中台标准层 mdp_std_purchase_receipt(由 PurOrdRctDetail/PurOrdRctMaster RctType='rc' 同步标准化)。
+			只读:无新增/编辑/保存/收货确认/入库写入/库存事务/状态流转;导出为禁用占位;无 mock。
+		-->
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="供应商">
+				<el-input v-model="query.supp" clearable placeholder="编码/名称" style="width: 180px" />
+			</el-form-item>
+			<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.receiver" clearable style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="采购单号">
+				<el-input v-model="query.purOrd" clearable style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="销售工单">
+				<el-input v-model="query.salesJob" clearable style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="请购单号">
+				<el-input v-model="query.req" clearable style="width: 160px" />
+			</el-form-item>
+			<el-form-item label="收货日期">
+				<el-date-picker
+					v-model="dateRange"
+					type="daterange"
+					value-format="YYYY-MM-DD"
+					range-separator="-"
+					start-placeholder="开始日期"
+					end-placeholder="结束日期"
+					unlink-panels
+					style="width: 260px"
+				/>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+				<el-button disabled>导出Excel</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table :data="rows" row-key="id" v-loading="loading" border stripe @sort-change="onSortChange">
+			<el-table-column type="index" label="#" width="55" align="center" />
+			<el-table-column prop="receiver" label="收货单号" min-width="150" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column prop="line" label="行号" width="70" align="center" resizable />
+			<el-table-column prop="rctDate" label="收货日期" width="120" sortable="custom" resizable>
+				<template #default="{ row }">{{ fmtDate(row.rctDate) }}</template>
+			</el-table-column>
+			<el-table-column prop="sortName" label="供应商" min-width="170" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column prop="itemNum" label="物料编码" min-width="130" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column prop="itemName" label="物料名称" min-width="170" show-overflow-tooltip resizable />
+			<el-table-column prop="itemSpec" label="规格" min-width="140" show-overflow-tooltip resizable />
+			<el-table-column prop="um" label="单位" width="70" align="center" resizable />
+			<el-table-column prop="qtyOrdered" label="订单数量" width="110" align="right" sortable="custom" resizable />
+			<el-table-column prop="qtyReceived" label="收货数量" width="110" align="right" sortable="custom" resizable />
+			<el-table-column prop="lotSerial" label="批次号" min-width="120" show-overflow-tooltip resizable />
+			<el-table-column prop="location" label="库位" width="90" resizable />
+			<el-table-column prop="purOrd" label="采购单号" min-width="130" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column prop="salesJob" label="销售工单" min-width="140" sortable="custom" show-overflow-tooltip resizable />
+			<template #empty>
+				<el-empty description="暂无数据" />
+			</template>
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[10, 20, 50]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS5WarehousePurchaseReceipt">
+import { computed, onActivated, onMounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchPurchaseReceiptList, type PurchaseReceiptRow } from '../api/purchaseReceipt';
+
+const route = useRoute();
+const pageTitle = computed(() => (route.meta?.title as string) || '采购收货单');
+
+const query = reactive({
+	supp: '',
+	itemNum: '',
+	receiver: '',
+	purOrd: '',
+	salesJob: '',
+	req: '',
+	rctDateFrom: '',
+	rctDateTo: '',
+	page: 1,
+	pageSize: 10,
+	orderBy: '',
+	orderDir: '',
+});
+const dateRange = ref<[string, string] | null>(null);
+
+const loading = ref(false);
+const rows = ref<PurchaseReceiptRow[]>([]);
+const total = ref(0);
+
+function fmtDate(v?: string | null) {
+	if (!v) return '';
+	return String(v).slice(0, 10);
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.orderBy = prop || '';
+	query.orderDir = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		query.rctDateFrom = dateRange.value?.[0] || '';
+		query.rctDateTo = dateRange.value?.[1] || '';
+		const data = await fetchPurchaseReceiptList({
+			supp: query.supp,
+			itemNum: query.itemNum,
+			receiver: query.receiver,
+			purOrd: query.purOrd,
+			salesJob: query.salesJob,
+			req: query.req,
+			rctDateFrom: query.rctDateFrom,
+			rctDateTo: query.rctDateTo,
+			page: query.page,
+			pageSize: query.pageSize,
+			orderBy: query.orderBy,
+			orderDir: query.orderDir,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} catch (e: any) {
+		rows.value = [];
+		total.value = 0;
+		ElMessage.error(e?.message || '加载采购收货单列表失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.supp = '';
+	query.itemNum = '';
+	query.receiver = '';
+	query.purOrd = '';
+	query.salesJob = '';
+	query.req = '';
+	dateRange.value = null;
+	query.rctDateFrom = '';
+	query.rctDateTo = '';
+	query.page = 1;
+	query.orderBy = '';
+	query.orderDir = '';
+	loadList();
+}
+
+onMounted(() => loadList());
+onActivated(() => loadList());
+</script>
+
+<style scoped lang="scss">
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.209</AssemblyVersion>
-    <FileVersion>1.0.209</FileVersion>
-    <Version>1.0.209</Version>
+    <AssemblyVersion>1.0.210</AssemblyVersion>
+    <FileVersion>1.0.210</FileVersion>
+    <Version>1.0.210</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -193,6 +193,9 @@
     <None Update="UpdateScripts\1.0.207.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.210.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 67 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.210.sql

@@ -0,0 +1,67 @@
+-- ============================================================
+-- 1.0.210.sql
+-- S5-PURCHASE-RECEIPT-MDP-PIPELINE-1
+--
+-- 业务目标:
+--   1) 新建 S5 采购收货单标准层表 mdp_std_purchase_receipt(只读数据中台,方案1:
+--      S5 直读源表 PurOrdRctDetail/PurOrdRctMaster + 4 张维表,自建标准层,
+--      不读/不改 S3 mdp_stg_receipt、不读/不改 S4 ado_s4_receipt)。
+--   2) 将「采购收货单」菜单(Id=1329015020002,仓储管理目录 1322000000016 下)
+--      的 Component 由 placeholder 重指到真实页面 /aidop/s5/warehouse/purchaseReceiptList。
+--
+-- 安全保证:
+--   - DDL 仅 CREATE TABLE IF NOT EXISTS,幂等;不 DROP / TRUNCATE。
+--   - 菜单仅 UPDATE 该单条 Component / Remark / UpdateTime;不改 Path/Name/Pid/Title/Type/OrderNo;
+--     不 INSERT/DELETE;不动 SysTenantMenu / SysRoleMenu(该菜单已分配);不动其它菜单。
+--   - 不动源单据表(PurOrdRctMaster/PurOrdRctDetail/PurOrdDetail 等),不动 S3/S4 资产。
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mdp_std_purchase_receipt (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL DEFAULT 0,
+    factory_id BIGINT NULL DEFAULT 1,
+    source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+    domain VARCHAR(24) NOT NULL COMMENT '工厂/域 PurOrdRctDetail.Domain',
+    receiver VARCHAR(24) NOT NULL COMMENT '收货单号 PurOrdRctDetail.Receiver',
+    line SMALLINT NOT NULL DEFAULT 0 COMMENT '收货明细行号 PurOrdRctDetail.Line',
+    rct_date DATETIME NULL COMMENT '收货日期 PurOrdRctMaster.RctDate',
+    supp VARCHAR(20) NULL COMMENT '供应商编码 PurOrdRctDetail.Supp',
+    sort_name VARCHAR(255) NULL COMMENT '供应商名称 rtrim(Supp+SuppMaster.SortName)',
+    item_num VARCHAR(60) NULL COMMENT '物料编码 PurOrdRctDetail.ItemNum',
+    item_name VARCHAR(200) NULL COMMENT '物料名称 ItemMaster.Descr',
+    item_spec VARCHAR(200) NULL COMMENT '物料规格 ItemMaster.Descr1',
+    um VARCHAR(8) NULL COMMENT '单位 PurOrdRctDetail.UM',
+    qty_ordered DECIMAL(18,6) NULL DEFAULT 0 COMMENT '订单数量 PurOrdRctDetail.QtyOrded',
+    qty_received DECIMAL(18,6) NULL DEFAULT 0 COMMENT '收货数量 PurOrdRctDetail.QtyReceived',
+    lot_serial VARCHAR(120) NULL COMMENT '批次号 PurOrdRctDetail.LotSerial',
+    location VARCHAR(8) NULL COMMENT '库位 PurOrdRctDetail.Location',
+    ord_nbr VARCHAR(24) NULL COMMENT '来源单号 PurOrdRctDetail.OrdNbr',
+    ord_line SMALLINT NULL COMMENT '来源行号 PurOrdRctDetail.OrdLine',
+    blanket_line INT NULL COMMENT '一揽子行 PurOrdDetail.BlanketLine',
+    pur_ord VARCHAR(24) NULL COMMENT '采购单号 PurOrdDetail.PurOrd',
+    pur_line SMALLINT NULL COMMENT '采购单行 PurOrdDetail.Line',
+    sales_job VARCHAR(200) NULL COMMENT '销售工单 PurOrdDetail.SalesJob',
+    address1 VARCHAR(200) NULL COMMENT '收货地址 ConsigneeAddressMaster.Address1',
+    req VARCHAR(20) NULL COMMENT '请购单号(非DO时=PurOrdDetail.Req)',
+    req_line INT NULL COMMENT '请购行号 PurOrdDetail.ReqLine',
+    dop_req VARCHAR(255) NULL COMMENT 'DOP请购号(DO时=Req,否则=srm_pr_main.pr_billno)',
+    source_biz_key VARCHAR(200) NULL COMMENT 'domain#receiver#line',
+    sync_batch_id VARCHAR(100) NOT NULL,
+    sync_time DATETIME NOT NULL,
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_mdp_std_pur_rct (tenant_id, domain, receiver, line),
+    KEY idx_mdp_std_pur_rct_date (tenant_id, rct_date),
+    KEY idx_mdp_std_pur_rct_item (tenant_id, item_num),
+    KEY idx_mdp_std_pur_rct_supp (tenant_id, supp),
+    KEY idx_mdp_std_pur_rct_purord (tenant_id, pur_ord),
+    KEY idx_mdp_std_pur_rct_salesjob (tenant_id, sales_job),
+    KEY idx_mdp_std_pur_rct_req (tenant_id, req),
+    KEY idx_mdp_std_pur_rct_dopreq (tenant_id, dop_req)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5采购收货单标准层';
+
+UPDATE SysMenu
+SET Component  = '/aidop/s5/warehouse/purchaseReceiptList',
+    Remark     = 'S5 采购收货单(只读列表,数据中台标准层 mdp_std_purchase_receipt)',
+    UpdateTime = NOW()
+WHERE Id = 1329015020002;

+ 20 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -29,6 +29,7 @@ public partial class AidopKanbanController : ControllerBase
     private readonly S7MdpSyncTransformService _s7MdpSyncTransformService;
     private readonly SmartOpsKpiAtomicQueryService _atomicQuery;
     private readonly OutsourceIssueMdpSyncService _outsourceIssueMdpSyncService;
+    private readonly PurchaseReceiptMdpSyncService _purchaseReceiptMdpSyncService;
 
     public AidopKanbanController(
         ISqlSugarClient db,
@@ -40,7 +41,8 @@ public partial class AidopKanbanController : ControllerBase
         S6MdpSyncTransformService s6MdpSyncTransformService,
         S7MdpSyncTransformService s7MdpSyncTransformService,
         SmartOpsKpiAtomicQueryService atomicQuery,
-        OutsourceIssueMdpSyncService outsourceIssueMdpSyncService)
+        OutsourceIssueMdpSyncService outsourceIssueMdpSyncService,
+        PurchaseReceiptMdpSyncService purchaseReceiptMdpSyncService)
     {
         _db = db;
         _s1MdpSyncTransformService = s1MdpSyncTransformService;
@@ -52,6 +54,7 @@ public partial class AidopKanbanController : ControllerBase
         _s7MdpSyncTransformService = s7MdpSyncTransformService;
         _atomicQuery = atomicQuery;
         _outsourceIssueMdpSyncService = outsourceIssueMdpSyncService;
+        _purchaseReceiptMdpSyncService = purchaseReceiptMdpSyncService;
     }
 
     [HttpGet("home-l1")]
@@ -452,6 +455,22 @@ LIMIT 60
         });
     }
 
+    /// <summary>
+    /// S5 采购收货单 数据中台只读链路手动刷新(DOP 内部方案 1:PurOrdRctDetail/Master RctType='rc' → mdp_std_purchase_receipt)。
+    /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp_std_purchase_receipt;不读/不改 S3 mdp_stg_receipt、S4 ado_s4_receipt;不碰 T8;rc 为 0 行时成功、处理数 0。
+    /// </summary>
+    [HttpPost("s5-purchase-receipt-mdp/refresh")]
+    public async Task<IActionResult> RefreshS5PurchaseReceiptMdp(CancellationToken cancellationToken)
+    {
+        var result = await _purchaseReceiptMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            batchId = result.BatchId,
+            stdRows = result.StdRows
+        });
+    }
+
     /// <summary>
     /// S6 生产执行 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
     /// 路径 A:方老师 v5.4 KPI #22/#23 原 SQL 直发 T8。

+ 115 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/PurchaseReceiptDto.cs

@@ -0,0 +1,115 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+/// <summary>
+/// 采购收货单列表 查询入参(只读)。读 mdp_std_purchase_receipt。
+/// </summary>
+public class PurchaseReceiptListInput
+{
+    /// <summary>页码(从 1 开始)</summary>
+    public int Page { get; set; } = 1;
+
+    /// <summary>每页条数</summary>
+    public int PageSize { get; set; } = 10;
+
+    /// <summary>供应商(supp / sort_name,模糊匹配)</summary>
+    public string? Supp { get; set; }
+
+    /// <summary>物料编码(item_num,模糊匹配)</summary>
+    public string? ItemNum { get; set; }
+
+    /// <summary>收货日期起(rct_date,含当日,yyyy-MM-dd)</summary>
+    public string? RctDateFrom { get; set; }
+
+    /// <summary>收货日期止(rct_date,含当日,yyyy-MM-dd)</summary>
+    public string? RctDateTo { get; set; }
+
+    /// <summary>采购单号(pur_ord,模糊匹配)</summary>
+    public string? PurOrd { get; set; }
+
+    /// <summary>收货单号(receiver,模糊匹配)</summary>
+    public string? Receiver { get; set; }
+
+    /// <summary>销售工单(sales_job,模糊匹配)</summary>
+    public string? SalesJob { get; set; }
+
+    /// <summary>请购单号(req,模糊匹配)</summary>
+    public string? Req { get; set; }
+
+    /// <summary>DOP请购号(dop_req,模糊匹配)</summary>
+    public string? DopReq { get; set; }
+
+    /// <summary>排序字段(前端列 prop:receiver/rctDate/itemNum/sortName/qtyOrdered/qtyReceived/purOrd/salesJob)</summary>
+    public string? OrderBy { get; set; }
+
+    /// <summary>排序方向(asc / desc)</summary>
+    public string? OrderDir { get; set; }
+
+    /// <summary>租户 ID(前端 withAidopTenantParams 注入;为空则不按租户过滤)</summary>
+    public long? TenantId { get; set; }
+}
+
+/// <summary>
+/// 采购收货单列表 行(只读)。字段对齐前端列 prop(camelCase)。
+/// </summary>
+public class PurchaseReceiptListRow
+{
+    /// <summary>主键 id(mdp_std_purchase_receipt.id)</summary>
+    public long Id { get; set; }
+
+    /// <summary>收货单号(receiver)</summary>
+    public string? Receiver { get; set; }
+
+    /// <summary>收货明细行号(line)</summary>
+    public short Line { get; set; }
+
+    /// <summary>收货日期(rct_date)</summary>
+    public DateTime? RctDate { get; set; }
+
+    /// <summary>供应商编码(supp)</summary>
+    public string? Supp { get; set; }
+
+    /// <summary>供应商名称(sort_name)</summary>
+    public string? SortName { get; set; }
+
+    /// <summary>物料编码(item_num)</summary>
+    public string? ItemNum { get; set; }
+
+    /// <summary>物料名称(item_name)</summary>
+    public string? ItemName { get; set; }
+
+    /// <summary>物料规格(item_spec)</summary>
+    public string? ItemSpec { get; set; }
+
+    /// <summary>单位(um)</summary>
+    public string? Um { get; set; }
+
+    /// <summary>订单数量(qty_ordered)</summary>
+    public decimal? QtyOrdered { get; set; }
+
+    /// <summary>收货数量(qty_received)</summary>
+    public decimal? QtyReceived { get; set; }
+
+    /// <summary>批次号(lot_serial)</summary>
+    public string? LotSerial { get; set; }
+
+    /// <summary>库位(location)</summary>
+    public string? Location { get; set; }
+
+    /// <summary>采购单号(pur_ord)</summary>
+    public string? PurOrd { get; set; }
+
+    /// <summary>销售工单(sales_job)</summary>
+    public string? SalesJob { get; set; }
+
+    /// <summary>收货地址(address1,默认隐藏列)</summary>
+    public string? Address1 { get; set; }
+
+    /// <summary>请购单号(req,默认隐藏列)</summary>
+    public string? Req { get; set; }
+
+    /// <summary>DOP请购号(dop_req,默认隐藏列)</summary>
+    public string? DopReq { get; set; }
+
+    /// <summary>来源单号(ord_nbr,默认隐藏列)</summary>
+    public string? OrdNbr { get; set; }
+}

+ 213 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PurchaseReceiptMdpSyncService.cs

@@ -0,0 +1,213 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 采购收货单 数据中台只读同步转换服务(DOP 内部,方案 1)。
+///
+/// 源:aidopdev PurOrdRctDetail(p) + PurOrdRctMaster(d),业务类型 RctType='rc'(采购收货);
+///     头明细关联 p.Domain=d.Domain AND p.Receiver=d.Receiver;
+///     维表 ItemMaster(i) / SuppMaster(s) / ConsigneeAddressMaster(a) / PurOrdDetail(pd,sd) / srm_pr_main(dr) 全 LEFT JOIN。
+/// 链路(方案 1):源表只读 → mdp_std_purchase_receipt(typed)。不经 mdp_stg(S3 已持有 mdp_stg_receipt),不建 dwd(只读列表)。
+///
+/// 约束:
+///   - 只读源表,仅写 mdp_std_purchase_receipt;绝不 INSERT/UPDATE/DELETE 任何源表;不读/不改 S3 mdp_stg_receipt、S4 ado_s4_receipt。
+///   - 过滤 p.RctType='rc';不套 ERP 端 UserFactoryNum/UserAccount 占位过滤(全量同步,租户隔离在读 API 侧按 tenant_id)。
+///   - 派生列对齐旧系统 SQL:sort_name/req/dop_req 保持 CASE d.OrdType='DO' 分支语义;danjia/jiage 本批不落。
+///   - 明细粒度 = PurOrdRctDetail 一行(domain#receiver#line);rc 为 0 行时成功完成、处理数为 0,不报错。
+/// </summary>
+public class PurchaseReceiptMdpSyncService : ITransient
+{
+    private const string JobCode = "S5_PURCHASE_RECEIPT_MDP_SYNC";
+
+    private readonly ISqlSugarClient _db;
+
+    public PurchaseReceiptMdpSyncService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>全量同步转换:源表(rc) → mdp_std_purchase_receipt。</summary>
+    public async Task<PurchaseReceiptMdpSyncResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+
+        var now = DateTime.Now;
+        var batchId = $"S5_PUR_RCT_FULL_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
+        var result = new PurchaseReceiptMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            result.StdRows = await TransformStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    /// <summary>防御式建表(与 UpdateScripts WIP-S5PR.sql / 正式 1.0.&lt;n&gt;.sql 同构,幂等)。</summary>
+    private async Task EnsureTablesAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_std_purchase_receipt (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                factory_id BIGINT NULL DEFAULT 1,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                domain VARCHAR(24) NOT NULL,
+                receiver VARCHAR(24) NOT NULL,
+                line SMALLINT NOT NULL DEFAULT 0,
+                rct_date DATETIME NULL,
+                supp VARCHAR(20) NULL,
+                sort_name VARCHAR(255) NULL,
+                item_num VARCHAR(60) NULL,
+                item_name VARCHAR(200) NULL,
+                item_spec VARCHAR(200) NULL,
+                um VARCHAR(8) NULL,
+                qty_ordered DECIMAL(18,6) NULL DEFAULT 0,
+                qty_received DECIMAL(18,6) NULL DEFAULT 0,
+                lot_serial VARCHAR(120) NULL,
+                location VARCHAR(8) NULL,
+                ord_nbr VARCHAR(24) NULL,
+                ord_line SMALLINT NULL,
+                blanket_line INT NULL,
+                pur_ord VARCHAR(24) NULL,
+                pur_line SMALLINT NULL,
+                sales_job VARCHAR(200) NULL,
+                address1 VARCHAR(200) NULL,
+                req VARCHAR(20) NULL,
+                req_line INT NULL,
+                dop_req VARCHAR(255) NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_pur_rct (tenant_id, domain, receiver, line),
+                KEY idx_mdp_std_pur_rct_date (tenant_id, rct_date),
+                KEY idx_mdp_std_pur_rct_item (tenant_id, item_num),
+                KEY idx_mdp_std_pur_rct_supp (tenant_id, supp),
+                KEY idx_mdp_std_pur_rct_purord (tenant_id, pur_ord),
+                KEY idx_mdp_std_pur_rct_salesjob (tenant_id, sales_job),
+                KEY idx_mdp_std_pur_rct_req (tenant_id, req),
+                KEY idx_mdp_std_pur_rct_dopreq (tenant_id, dop_req)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5采购收货单标准层'
+            """);
+    }
+
+    /// <summary>
+    /// 标准化:PurOrdRctDetail(rc) + Master + 维表 -> mdp_std_purchase_receipt。返回处理明细行数。
+    /// SQL 由旧系统源 SQL 翻译(SQL Server -&gt; MySQL):with(nolock) 去除、IsNull-&gt;IFNULL、
+    /// rtrim(a+' '+b)-&gt;TRIM(CONCAT(...))、convert(varchar,x)-&gt;CAST(x AS CHAR)、left(...)/占位过滤去除。
+    /// </summary>
+    private async Task<int> TransformStandardAsync(string batchId, DateTime now)
+    {
+        var rows = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM PurOrdRctDetail p INNER JOIN PurOrdRctMaster d ON p.Domain=d.Domain AND p.Receiver=d.Receiver WHERE p.RctType='rc'");
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_std_purchase_receipt
+            (tenant_id, factory_id, source_system, domain, receiver, line, rct_date, supp, sort_name,
+             item_num, item_name, item_spec, um, qty_ordered, qty_received, lot_serial, location,
+             ord_nbr, ord_line, blanket_line, pur_ord, pur_line, sales_job, address1,
+             req, req_line, dop_req, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                IFNULL(p.tenant_id, 0), 1, 'AIDOP', p.Domain, p.Receiver, p.Line, d.RctDate, p.Supp,
+                TRIM(CONCAT(d.Supp, ' ', IFNULL(s.SortName, ''))),
+                p.ItemNum, i.Descr, i.Descr1, p.UM, p.QtyOrded, p.QtyReceived, p.LotSerial, p.Location,
+                p.OrdNbr, p.OrdLine, pd.BlanketLine, pd.PurOrd, pd.Line, pd.SalesJob, a.Address1,
+                (CASE WHEN d.OrdType='DO' THEN '' ELSE sd.Req END),
+                sd.ReqLine,
+                (CASE WHEN d.OrdType='DO' THEN sd.Req ELSE dr.pr_billno END),
+                CONCAT(p.Domain, '#', p.Receiver, '#', p.Line), @BatchId, @Now
+            FROM PurOrdRctDetail p
+            INNER JOIN PurOrdRctMaster d ON p.Domain = d.Domain AND p.Receiver = d.Receiver
+            LEFT JOIN ItemMaster i ON p.Domain = i.Domain AND p.ItemNum = i.ItemNum
+            LEFT JOIN SuppMaster s ON d.Domain = s.Domain AND d.Supp = s.Supp
+            LEFT JOIN ConsigneeAddressMaster a ON p.Domain = a.Domain AND p.Supp = a.Address AND a.Typed = 'Supp'
+            LEFT JOIN PurOrdDetail pd ON p.Domain = pd.Domain
+                AND (CASE WHEN d.OrdType='DO' AND IFNULL(p.RctNbr,'')<>'' THEN p.RctNbr ELSE p.OrdNbr END)
+                    = (CASE WHEN d.OrdType='DO' AND IFNULL(p.RctNbr,'')<>'' THEN pd.Contract ELSE pd.PurOrd END)
+                AND (CASE WHEN d.OrdType='DO' AND IFNULL(p.RctNbr,'')<>'' THEN p.BlanketLine ELSE p.OrdLine END) = pd.Line
+            LEFT JOIN PurOrdDetail sd ON p.Domain = sd.Domain AND p.OrdNbr = sd.PurOrd AND p.OrdLine = sd.Line
+            LEFT JOIN srm_pr_main dr ON CAST(dr.factory_id AS CHAR) = sd.Domain AND dr.SAP_pr_billno = sd.Req AND IFNULL(dr.SAP_pr_billno,'')<>''
+            WHERE p.RctType = 'rc'
+            ON DUPLICATE KEY UPDATE
+                factory_id=VALUES(factory_id), rct_date=VALUES(rct_date), supp=VALUES(supp), sort_name=VALUES(sort_name),
+                item_num=VALUES(item_num), item_name=VALUES(item_name), item_spec=VALUES(item_spec), um=VALUES(um),
+                qty_ordered=VALUES(qty_ordered), qty_received=VALUES(qty_received), lot_serial=VALUES(lot_serial), location=VALUES(location),
+                ord_nbr=VALUES(ord_nbr), ord_line=VALUES(ord_line), blanket_line=VALUES(blanket_line),
+                pur_ord=VALUES(pur_ord), pur_line=VALUES(pur_line), sales_job=VALUES(sales_job), address1=VALUES(address1),
+                req=VALUES(req), req_line=VALUES(req_line), dop_req=VALUES(dop_req),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+            """,
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@Now", now));
+        return rows;
+    }
+
+    private async Task<long> InsertRunLogAsync(string batchId, DateTime startedAt, string triggerType)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_transform_run_log
+            (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time)
+            VALUES (0, @JobCode, 'S5采购收货单MDP同步与标准化转换', @TriggerType, @BatchId, 'RUNNING', @StartTime)
+            """,
+            new SugarParameter("@JobCode", JobCode),
+            new SugarParameter("@TriggerType", NormalizeTriggerType(triggerType)),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StartTime", startedAt));
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    private async Task MarkRunSuccessAsync(long runLogId, DateTime startedAt, PurchaseReceiptMdpSyncResult result)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
+                stage_rows=0, standard_rows=@StandardRows, dwd_rows=0, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@StandardRows", result.StdRows),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
+                error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@ErrorMessage", message.Length > 2000 ? message[..2000] : message),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private static string NormalizeTriggerType(string? triggerType)
+        => string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
+}
+
+/// <summary>采购收货单 MDP 同步转换结果。</summary>
+public sealed class PurchaseReceiptMdpSyncResult
+{
+    public long RunLogId { get; set; }
+    public string BatchId { get; set; } = string.Empty;
+    public int StdRows { get; set; }
+}

+ 149 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PurchaseReceiptService.cs

@@ -0,0 +1,149 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 采购收货单 只读 list 服务。
+///
+/// 数据源:DOP 数据中台标准层 mdp_std_purchase_receipt(明细粒度 domain#receiver#line)。
+/// 由 PurchaseReceiptMdpSyncService 从 aidopdev.PurOrdRctDetail/PurOrdRctMaster RctType='rc' 同步标准化而来。
+///
+/// 本服务仅 SELECT:无新增/编辑/删除/收货确认/入库写入/库存事务/状态流转;无 detail(扁平列表)。
+/// </summary>
+[ApiDescriptionSettings(Order = 305, Description = "采购收货单")]
+[Route("api/PurchaseReceipt")]
+[AllowAnonymous]
+[NonUnify]
+public class PurchaseReceiptService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public PurchaseReceiptService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>
+    /// 采购收货单列表(只读分页查询)。
+    /// </summary>
+    [DisplayName("采购收货单列表")]
+    [HttpGet("list")]
+    public async Task<object> GetList([FromQuery] PurchaseReceiptListInput 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 (input.TenantId is > 0)
+        {
+            where.Add("m.tenant_id = @TenantId");
+            pars.Add(new SugarParameter("@TenantId", input.TenantId));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Supp))
+        {
+            where.Add("(m.supp LIKE @Supp OR m.sort_name LIKE @Supp)");
+            pars.Add(new SugarParameter("@Supp", $"%{input.Supp.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.ItemNum))
+        {
+            where.Add("m.item_num LIKE @ItemNum");
+            pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.RctDateFrom))
+        {
+            where.Add("m.rct_date >= @RctDateFrom");
+            pars.Add(new SugarParameter("@RctDateFrom", $"{input.RctDateFrom.Trim()} 00:00:00"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.RctDateTo))
+        {
+            where.Add("m.rct_date <= @RctDateTo");
+            pars.Add(new SugarParameter("@RctDateTo", $"{input.RctDateTo.Trim()} 23:59:59"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.PurOrd))
+        {
+            where.Add("m.pur_ord LIKE @PurOrd");
+            pars.Add(new SugarParameter("@PurOrd", $"%{input.PurOrd.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Receiver))
+        {
+            where.Add("m.receiver LIKE @Receiver");
+            pars.Add(new SugarParameter("@Receiver", $"%{input.Receiver.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.SalesJob))
+        {
+            where.Add("m.sales_job LIKE @SalesJob");
+            pars.Add(new SugarParameter("@SalesJob", $"%{input.SalesJob.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Req))
+        {
+            where.Add("m.req LIKE @Req");
+            pars.Add(new SugarParameter("@Req", $"%{input.Req.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.DopReq))
+        {
+            where.Add("m.dop_req LIKE @DopReq");
+            pars.Add(new SugarParameter("@DopReq", $"%{input.DopReq.Trim()}%"));
+        }
+
+        var whereSql = string.Join(" AND ", where);
+
+        var total = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM mdp_std_purchase_receipt m WHERE {whereSql}", pars);
+
+        var list = await _db.Ado.SqlQueryAsync<PurchaseReceiptListRow>(
+            $"""
+            SELECT
+                m.id           AS Id,
+                m.receiver     AS Receiver,
+                m.line         AS Line,
+                m.rct_date     AS RctDate,
+                m.supp         AS Supp,
+                m.sort_name    AS SortName,
+                m.item_num     AS ItemNum,
+                m.item_name    AS ItemName,
+                m.item_spec    AS ItemSpec,
+                m.um           AS Um,
+                m.qty_ordered  AS QtyOrdered,
+                m.qty_received AS QtyReceived,
+                m.lot_serial   AS LotSerial,
+                m.location     AS Location,
+                m.pur_ord      AS PurOrd,
+                m.sales_job    AS SalesJob,
+                m.address1     AS Address1,
+                m.req          AS Req,
+                m.dop_req      AS DopReq,
+                m.ord_nbr      AS OrdNbr
+            FROM mdp_std_purchase_receipt m
+            WHERE {whereSql}
+            ORDER BY {BuildOrderBy(input.OrderBy, input.OrderDir)}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    /// <summary>
+    /// 排序白名单:仅允许按已展示列排序,杜绝 SQL 注入。
+    /// </summary>
+    private static string BuildOrderBy(string? orderBy, string? orderDir)
+    {
+        var column = orderBy switch
+        {
+            "receiver" => "m.receiver",
+            "rctDate" => "m.rct_date",
+            "itemNum" => "m.item_num",
+            "sortName" => "m.sort_name",
+            "qtyOrdered" => "m.qty_ordered",
+            "qtyReceived" => "m.qty_received",
+            "purOrd" => "m.pur_ord",
+            "salesJob" => "m.sales_job",
+            _ => "m.rct_date",
+        };
+        var direction = string.Equals(orderDir, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return $"{column} {direction}, m.id DESC";
+    }
+}

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

@@ -541,7 +541,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         const long warehouseDirId = 1322000000016L;
         const long warehouseBase = 1329015020000L;
         yield return new SysMenu { Id = warehouseBase + 1, Pid = warehouseDirId, Title = "委外发料单", Path = "/aidop/s5/warehouse/outsource-issue", Name = "aidopS5WarehouseOutsourceIssue", Component = "/aidop/s5/warehouse/outsourceIssueList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 10, Remark = "S5 委外发料单(只读骨架,真实数据源待补证)" };
-        yield return new SysMenu { Id = warehouseBase + 2, Pid = warehouseDirId, Title = "采购收货单", Path = "/aidop/s5/warehouse/purchase-receipt", Name = "aidopS5WarehousePurchaseReceipt", Component = placeholderComponent, Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 20, Remark = "S5 采购收货单(真实页面待交付)" };
+        yield return new SysMenu { Id = warehouseBase + 2, Pid = warehouseDirId, Title = "采购收货单", Path = "/aidop/s5/warehouse/purchase-receipt", Name = "aidopS5WarehousePurchaseReceipt", Component = "/aidop/s5/warehouse/purchaseReceiptList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 20, Remark = "S5 采购收货单(只读列表,数据中台标准层 mdp_std_purchase_receipt)" };
         yield return new SysMenu { Id = warehouseBase + 3, Pid = warehouseDirId, Title = "生产领料单", Path = "/aidop/s5/warehouse/production-issue", Name = "aidopS5WarehouseProductionIssue", Component = placeholderComponent, Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 30, Remark = "S5 生产领料单(真实页面待交付)" };
         yield return new SysMenu { Id = warehouseBase + 4, Pid = warehouseDirId, Title = "生产退料单", Path = "/aidop/s5/warehouse/production-return", Name = "aidopS5WarehouseProductionReturn", Component = placeholderComponent, Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 40, Remark = "S5 生产退料单(真实页面待交付)" };
         yield return new SysMenu { Id = warehouseBase + 5, Pid = warehouseDirId, Title = "生产入库单", Path = "/aidop/s5/warehouse/production-receipt", Name = "aidopS5WarehouseProductionReceipt", Component = placeholderComponent, Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 50, Remark = "S5 生产入库单(真实页面待交付)" };