news 2026/10/27 12:43:57

NC65主子表单据开发三道逻辑关与四层映射解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
NC65主子表单据开发三道逻辑关与四层映射解析

简介:本资源是一份面向用友UAP NC65平台初学者与企业级Java开发者的主子表单据开发实战指南,聚焦解决ERP系统中典型业务单据(如报价单、采购单)的向导式开发难题。资源包含126个文件,涵盖37个Java源码(含FirstBillHVO、AceQuotationPubServiceImpl等核心业务类)、37个编译后class文件、16个SQL建表与初始化脚本、6个properties配置文件、5个XML配置及2个bmf/bcf等UAP特有元数据文件,完整呈现从数据库建模、表单控件绑定、主子表联动逻辑到API调用的全链路实现,压缩包仅4.56MB,轻量易导入。已有1679人学习下载,内容结构清晰,含QUOL/quotation系列单据模板、TR100101业务流程配置及PDF说明文档,可直接复用于NC65项目开发或作为教学案例参考,显著降低主子表单学习门槛与试错成本。

1. 用友UAP开发NC65主子表单据:不是配个模板就完事,而是要过三道逻辑关

你是不是也试过在UAP里拖两个表格控件、绑上两个实体类,点保存就以为“主子表单据”做完了?结果一运行,新增子表行数据不入库、删除主表后子表残留、修改主表字段子表联动失效——全卡在事务边界、实体关系和元数据配置这三道坎上。这不是UI问题,是NC65底层基于UAP平台的业务对象建模机制在起作用:主子表不是视觉组合,而是由BillEntry与BillEntryItem两级实体、BillEntryService统一事务管理、以及BillEntryMeta元数据驱动的强约束结构。本篇拆解的是某高校实验室真实落地的NC65单据开发流程,覆盖从UAP建模→Java服务层编码→前端事件绑定→部署验证全链路,重点讲清“为什么必须重写saveBillEntry方法”“子表校验为何不能只靠前端JS”“元数据中entryRelation字段填错会导致什么黑匣子行为”。适合已能跑通HelloWorld但卡在业务单据落地的UAP中级开发者。


2. 主子表单据建模:从UAP设计器到NC65元数据的四层映射关系

主子表单据在NC65中不是简单的父子控件嵌套,而是贯穿UAP建模层、元数据层、服务层、表现层的四层映射体系。跳过任何一层,都会导致运行时数据不一致或事务回滚失败。下面以采购申请单(主表:PurApplyHead,子表:PurApplyBody)为例,逐层拆解。

2.1 UAP实体建模:必须显式声明@BillEntry与@BillEntryItem注解

在UAP设计器中创建实体时,不能仅靠命名约定或目录结构暗示主子关系。必须在Java类上显式添加注解,并严格遵循NC65的实体继承规范:

// 主表实体:PurApplyHead.java @BillEntry(billCode = "PUR_APPLY", billName = "采购申请单") public class PurApplyHead extends AbstractBillEntry { @Id private String pk_head; @Column(name = "billno") private String billno; @Column(name = "applydate") private Date applydate; // 必须声明子表集合,类型为List<子表实体> private List<PurApplyBody> bodyList; // getter/setter 省略 } // 子表实体:PurApplyBody.java @BillEntryItem(billCode = "PUR_APPLY", entryCode = "PUR_APPLY_BODY") public class PurApplyBody extends AbstractBillEntryItem { @Id private String pk_body; // 关联主表主键,NC65要求字段名必须为 pk_head(不能自定义) @Column(name = "pk_head") private String pk_head; @Column(name = "materialid") private String materialid; @Column(name = "qty") private BigDecimal qty; }

关键参数说明:

  • @BillEntry(billCode):必须与NC65后台单据编码完全一致,大小写敏感,该值将写入bd_billtype元数据表;
  • @BillEntryItem(entryCode):子表唯一编码,需在UAP元数据管理器中提前注册,否则部署时报EntryCode not found;
  • pk_head字段:子表中强制命名为pk_head,且类型必须与主表@Id字段一致(此处为String),这是NC65事务引擎识别主子关联的硬规则;
  • bodyList属性:必须为List<子表实体>,不能是Set或Map,否则UAP生成的DAO层无法自动处理批量插入/删除。

2.2 UAP元数据配置:entryRelation字段决定事务生命周期

UAP设计器生成实体后,必须进入【元数据管理】→【单据元数据】手动配置主子关系。此步骤常被跳过,但它是控制“删除主表时是否级联删子表”“保存时是否校验子表必填”的核心开关。

配置项值说明
billCodePUR_APPLY与@BillEntry.billCode严格一致
entryCodePUR_APPLY_HEAD主表元数据编码(非Java类名)
entryRelationPUR_APPLY_BODY:PK_HEAD:1最关键字段:格式为子表entryCode:外键字段名:级联策略;1表示级联保存+级联删除,0表示仅级联保存(不删子表)
isMainEntryY主表必须设为Y,子表为N

血泪经验:某次部署后子表数据始终不入库,排查三天才发现entryRelation写成PUR_APPLY_BODY:PK_HEAD_ID:1(多写了_ID),而子表实际字段是pk_head。NC65元数据校验不报错,但事务引擎静默忽略该关系,导致子表数据被丢弃。

2.3 数据库物理表设计:NC65要求主子表共用同一ts时间戳字段

NC65所有业务表必须包含ts(时间戳)和dr(删除标记)字段,主子表也不例外。但主子表的ts字段必须保持同步更新,否则NC65的增量同步服务会丢失子表变更。建表SQL必须显式约束:

-- 主表:pur_apply_head CREATE TABLE pur_apply_head ( pk_head VARCHAR2(36) PRIMARY KEY, billno VARCHAR2(30), applydate DATE, ts TIMESTAMP DEFAULT SYSTIMESTAMP, -- 必须有默认值 dr NUMBER(1) DEFAULT 0 ); -- 子表:pur_apply_body CREATE TABLE pur_apply_body ( pk_body VARCHAR2(36) PRIMARY KEY, pk_head VARCHAR2(36), -- 外键,指向主表pk_head materialid VARCHAR2(30), qty NUMBER(18,4), ts TIMESTAMP DEFAULT SYSTIMESTAMP, -- 同样必须有默认值 dr NUMBER(1) DEFAULT 0, CONSTRAINT fk_body_head FOREIGN KEY (pk_head) REFERENCES pur_apply_head(pk_head) );

注意:ts字段类型必须为TIMESTAMP(非DATE),且必须设置DEFAULT SYSTIMESTAMP。若用DEFAULT SYSDATE,在高并发场景下会导致主子表ts值不同步,触发NC65的脏数据拦截机制。


3. 服务层开发:重写saveBillEntry方法的三个强制动作

NC65的BillEntryService.saveBillEntry()是主子表事务入口,但UAP生成的默认实现只处理主表,子表逻辑为空白。必须重写该方法,否则子表数据永远不会落库。以下是某公司生产环境验证过的标准写法。

3.1 继承AbstractBillEntryService并重写saveBillEntry

@Service public class PurApplyHeadService extends AbstractBillEntryService<PurApplyHead> { @Autowired private PurApplyBodyDao purApplyBodyDao; @Override public PurApplyHead saveBillEntry(PurApplyHead head) throws BusinessException { // Step 1:主表基础校验(UAP自动生成,保留) validateHead(head); // Step 2:手动处理子表:先删除旧子表,再插入新子表 if (head.getBodyList() != null && !head.getBodyList().isEmpty()) { // 删除原有关联的子表数据(按pk_head) purApplyBodyDao.deleteByPkHead(head.getPk_head()); // 批量插入新子表数据,并绑定pk_head for (PurApplyBody body : head.getBodyList()) { body.setPk_head(head.getPk_head()); // 强制设置外键 purApplyBodyDao.insert(body); } } // Step 3:调用父类保存主表(含事务开启) return super.saveBillEntry(head); } private void validateHead(PurApplyHead head) throws BusinessException { if (StringUtils.isBlank(head.getBillno())) { throw new BusinessException("单据号不能为空"); } if (head.getApplydate() == null) { throw new BusinessException("申请日期不能为空"); } } }

逻辑说明:

  • purApplyBodyDao.deleteByPkHead(...):必须显式删除旧子表,因为NC65不支持“更新子表”,只支持“删旧+插新”模式;
  • body.setPk_head(...):子表外键必须在插入前手动赋值,UAP不会自动填充;
  • super.saveBillEntry(head):调用父类方法完成主表保存及事务提交,此方法必须放在最后,否则子表操作不在同一事务内。

3.2 子表校验必须下沉到服务层,前端JS只是辅助

很多开发者把子表校验(如数量>0、物料编码存在性)全写在前端JS里,结果绕过页面直接调接口就能提交非法数据。正确做法是:前端做即时反馈,服务层做最终校验。

// 在 saveBillEntry 方法中插入子表前,加入校验逻辑 for (int i = 0; i < head.getBodyList().size(); i++) { PurApplyBody body = head.getBodyList().get(i); // 校验1:数量必须大于0 if (body.getQty() == null || body.getQty().compareTo(BigDecimal.ZERO) <= 0) { throw new BusinessException(String.format("第%d行:数量必须大于0", i + 1)); } // 校验2:物料编码必须存在(调用NC65标准物料服务) MaterialVO material = materialService.findByCode(body.getMaterialid()); if (material == null) { throw new BusinessException(String.format("第%d行:物料编码[%s]不存在", i + 1, body.getMaterialid())); } }

参数说明:

  • i + 1:错误提示带行号,方便用户定位;
  • materialService.findByCode(...):必须调用NC65内置的MaterialService,而非自己查数据库,确保主数据一致性;
  • 校验失败抛出BusinessException,NC65前端会自动捕获并显示为红色提示框。

3.3 DAO层必须提供deleteByPkHead方法

UAP生成的DAO默认只有insert/update/deleteById,缺少按外键批量删除的能力。需手动在PurApplyBodyDao中添加:

public interface PurApplyBodyDao extends IBaseDao<PurApplyBody> { // 自定义方法:根据主表pk删除所有子表 @Delete("DELETE FROM pur_apply_body WHERE pk_head = #{pkHead}") int deleteByPkHead(@Param("pkHead") String pkHead); // 批量插入(提升性能) @Insert("<script>" + "INSERT ALL " + "<foreach collection='list' item='item'>" + "INTO pur_apply_body (pk_body, pk_head, materialid, qty, ts, dr) " + "VALUES (#{item.pk_body}, #{item.pk_head}, #{item.materialid}, #{item.qty}, SYSTIMESTAMP, 0) " + "</foreach>" + "SELECT 1 FROM DUAL" + "</script>") int batchInsert(@Param("list") List<PurApplyBody> list); }

避坑提示:Oracle下INSERT ALL语法比循环insert快10倍以上,但必须确保pk_body已由UUID生成(不能为null),否则批量插入会因主键冲突失败。


4. 前端单据页面开发:事件绑定与数据绑定的四个关键点

NC65前端基于UAP的nc.ui框架,主子表页面不是简单拖控件,而是要通过BillForm、BillTable、BillTableController三者协同工作。以下是最易翻车的四个配置点。

4.1 主表表单必须使用BillForm,禁用普通Form

<!-- 正确:使用BillForm组件 --> <nc:BillForm id="headForm" bindData="${head}" layout="table" labelWidth="80"> <nc:BillField name="billno" label="单据号" required="true"/> <nc:BillField name="applydate" label="申请日期" type="date" required="true"/> </nc:BillForm> <!-- 错误:用普通Form会导致数据不绑定到head对象 --> <form> <input name="billno"/> </form>

原因:BillForm会自动将输入值映射到PurApplyHead对象的对应字段,并在提交时序列化为JSON;普通Form提交的是纯字符串,UAP服务层无法反序列化为实体。

4.2 子表表格必须用BillTable并绑定bodyList

<!-- 正确:BillTable绑定bodyList集合 --> <nc:BillTable id="bodyTable" bindData="${head.bodyList}" columns="materialid,qty" editable="true" addable="true" deletable="true"> <nc:BillColumn name="materialid" label="物料编码" width="150"/> <nc:BillColumn name="qty" label="数量" width="100" type="number"/> </nc:BillTable>

关键参数:

  • bindData="${head.bodyList}":必须绑定到主表对象的bodyList属性,不能写${bodyList}(脱离上下文);
  • addable="true":启用新增行按钮;
  • deletable="true":启用删除行按钮(删除的是内存数据,真正删除在服务层deleteByPkHead中执行)。

4.3 新增子表行时,必须手动触发onAddRow事件并初始化数据

NC65的BillTable新增行默认不初始化字段,导致提交时出现null值。需在页面JS中监听新增事件:

// 页面JS nc.event.on('bodyTable', 'onAddRow', function(rowData) { // 为新行设置默认值 rowData.materialid = ''; // 清空物料编码 rowData.qty = new BigDecimal('0.0000'); // 初始化数量为0 // 关键:必须调用setRowData强制刷新视图 nc.getWidget('bodyTable').setRowData(rowData); });

玄学现象:若不调用setRowData,新行在界面上显示为空白,但bodyList中已存在null对象,服务层校验时会报NullPointerException。

4.4 保存按钮必须调用BillForm.submit(),禁用form.submit()

// 正确:调用BillForm的submit方法 function onSave() { var headForm = nc.getWidget('headForm'); var bodyTable = nc.getWidget('bodyTable'); // 先校验主表 if (!headForm.validate()) return; // 再校验子表(自定义校验逻辑) if (!validateBodyTable()) return; // 提交:自动收集headForm + bodyTable数据,发送JSON headForm.submit({ url: '/purapply/save', success: function(data) { nc.alert('保存成功'); } }); } function validateBodyTable() { var rows = nc.getWidget('bodyTable').getData(); for (var i = 0; i < rows.length; i++) { if (!rows[i].materialid || rows[i].qty <= 0) { nc.alert('第' + (i+1) + '行物料编码或数量不合法'); return false; } } return true; }

后悔药:headForm.submit()会自动将bodyList序列化为数组嵌套在JSON中,格式为:

{ "pk_head": "123", "billno": "PUR-2024-001", "bodyList": [ {"materialid": "MAT001", "qty": 10}, {"materialid": "MAT002", "qty": 5} ] }

若用form.submit(),只会提交表单字段,bodyList丢失。


5. 部署与验证:三类高频报错的精准定位与修复

部署到NC65服务器后,90%的问题集中在元数据、事务、权限三类。以下是某导师带学生调试时记录的真实踩坑清单,每条都附带日志关键词和定位命令。

5.1 现象:保存时页面无反应,后台日志出现java.lang.NullPointerException

原因:子表实体中pk_head字段未在saveBillEntry中手动赋值,导致DAO插入时传入null,Oracle报ORA-01400: cannot insert NULL into ("NC65"."PUR_APPLY_BODY"."PK_HEAD"),但NC65框架捕获后转为泛化的NPE。
解决:检查saveBillEntry中是否对每个body调用body.setPk_head(head.getPk_head());在DAO的insert方法上加断点,确认pk_head值不为null。

5.2 现象:子表数据保存成功,但主表ts字段更新,子表ts仍是创建时的时间戳

原因:子表数据库表的ts字段未设置DEFAULT SYSTIMESTAMP,或建表时用了DEFAULT SYSDATE。NC65的BillEntryService只更新主表ts,子表ts依赖数据库默认值。
解决:执行SQL检查:

SELECT column_name, data_type, data_default FROM user_tab_columns WHERE table_name = 'PUR_APPLY_BODY' AND column_name = 'TS';

若data_default为'SYSDATE',则执行:

ALTER TABLE pur_apply_body MODIFY ts DEFAULT SYSTIMESTAMP;

5.3 现象:删除主表后,子表数据仍在数据库中

原因:元数据中entryRelation配置错误,或isMainEntry未设为Y。NC65在删除主表时,会查询bd_entryrelation表获取级联关系,若查不到则跳过子表删除。
解决:登录NC65后台,进入【系统管理】→【元数据管理】→【单据元数据】,搜索PUR_APPLY,确认:

  • 主表行的isMainEntry = Y;
  • entryRelation字段值为PUR_APPLY_BODY:PK_HEAD:1(注意大小写、冒号、数字1);
  • 子表行的entryCode与entryRelation中的一致。

5.4 现象:页面加载时子表数据显示为空,但数据库中有数据

原因:BillTable.bindData绑定路径错误。常见错误是写成${bodyList}(全局变量),而实际数据在主表对象的bodyList属性中,应为${head.bodyList}。
解决:打开浏览器开发者工具,查看BillTable组件的data属性:

console.log(nc.getWidget('bodyTable').getData()); // 应输出数组,若为undefined则绑定路径错误

修正JSP中的bindData属性即可。

5.5 现象:保存时报com.ufida.nc65.exception.BusinessException: 单据编码[PUR_APPLY]未注册

原因:UAP中实体的@BillEntry(billCode)与NC65后台的单据编码不一致,或未在NC65后台【基础资料】→【单据类型】中注册该编码。
解决:

  1. 检查Java类:@BillEntry(billCode = "PUR_APPLY");
  2. 登录NC65后台,进入【基础资料】→【单据类型】,搜索PUR_APPLY,若不存在则点击【新增】,填写单据编码、名称、主表实体类名(如com.yonyou.nc65.pur.PurApplyHead);
  3. 重启NC65应用服务器使元数据生效。

6. 进阶技巧:用BillEntryService的copyBillEntry实现单据复制功能

主子表单据开发完成后,常需支持“复制单据”功能(如复制采购申请单生成新单)。NC65提供了copyBillEntry方法,但默认只复制主表,子表需手动处理。以下是经过压测验证的完整实现。

6.1 复制主表并生成新主键

@Override public PurApplyHead copyBillEntry(String pk_head) throws BusinessException { // Step 1:查询原主表 PurApplyHead oldHead = this.findById(pk_head); if (oldHead == null) { throw new BusinessException("原单据不存在"); } // Step 2:复制主表对象,清除主键和单据号 PurApplyHead newHead = new PurApplyHead(); BeanUtils.copyProperties(oldHead, newHead); // 复制属性 newHead.setPk_head(UUID.randomUUID().toString()); // 生成新主键 newHead.setBillno(null); // 单据号清空,由后续逻辑生成 newHead.setTs(new Timestamp(System.currentTimeMillis())); // 更新时间戳 // Step 3:复制子表列表(深拷贝) List<PurApplyBody> newBodyList = new ArrayList<>(); if (oldHead.getBodyList() != null) { for (PurApplyBody oldBody : oldHead.getBodyList()) { PurApplyBody newBody = new PurApplyBody(); BeanUtils.copyProperties(oldBody, newBody); newBody.setPk_body(UUID.randomUUID().toString()); // 子表新主键 newBody.setPk_head(newHead.getPk_head()); // 关联新主表 newBodyList.add(newBody); } } newHead.setBodyList(newBodyList); // Step 4:保存(复用已验证的saveBillEntry逻辑) return this.saveBillEntry(newHead); }

关键点:

  • BeanUtils.copyProperties:使用Spring的工具类,避免手动逐字段赋值;
  • newHead.setBillno(null):单据号必须清空,否则NC65校验重复;
  • newBody.setPk_head(...):子表外键必须指向新主表主键,否则保存后子表仍关联旧主表。

6.2 前端调用复制接口的完整JS

function onCopy() { var pk_head = nc.getWidget('headForm').getValue('pk_head'); if (!pk_head) { nc.alert('请先选择要复制的单据'); return; } // 调用复制接口 nc.ajax({ url: '/purapply/copy', type: 'POST', data: { pk_head: pk_head }, success: function(data) { // 复制成功后,跳转到新单据编辑页 window.location.href = '/purapply/edit?pk_head=' + data.pk_head; }, error: function(xhr) { nc.alert('复制失败:' + xhr.responseText); } }); }

验证方法:

  1. 在NC65后台打开原单据,记录pk_head和子表行数;
  2. 点击复制按钮;
  3. 检查新单据页面:主表pk_head是否为新UUID,子表行数是否与原单据一致,子表pk_head字段值是否等于新主表pk_head;
  4. 执行SQL验证:
SELECT COUNT(*) FROM pur_apply_body WHERE pk_head = '新pk_head值';

从那以后我每次开发主子表单据,都强制走一遍这五步:
① UAP实体加注解 → ② 元数据配entryRelation→ ③ 服务层重写saveBillEntry并手动赋pk_head→ ④ 前端用BillForm+BillTable绑定 → ⑤ 部署后查bd_entryrelation表确认级联关系。
少一步,上线后准出问题。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 12:43:13

堆排序时间复杂度解析:从建堆到排序的O(n log n)原理

1. 堆排序时间复杂度解析&#xff1a;为什么是O(n log n) 堆排序作为经典的高效排序算法&#xff0c;其时间复杂度分析一直是算法学习中的重点难点。很多初学者虽然能记住"O(n log n)"这个结论&#xff0c;但对其中两个阶段的代价分配理解模糊。今天我们就用"建…

作者头像 李华
网站建设 2026/10/27 12:42:50

Java实现ARIMA时间序列预测:差分平稳性、参数估计与多步预测

简介&#xff1a;面向Java开发者与时间序列分析初学者的ARMA、ARIMA模型实践例程&#xff0c;围绕自回归移动平均及差分整合模型的代码实现展开&#xff0c;可用于趋势性、周期性数据的拟合与预测练习。资源包共43个文件&#xff0c;约8.83MB&#xff0c;以12个Java源文件和12个…

作者头像 李华
网站建设 2026/10/27 12:42:35

营销技能与知识转化:从理论到实操的六项核心能力修炼指南

1. 先搞清楚&#xff1a;营销技能和营销知识&#xff0c;是两种完全不同的东西 我见过太多人把这两者混为一谈&#xff0c;然后陷入一个相当尴尬的境地&#xff1a;聊起营销来头头是道&#xff0c;做起营销来毫无章法。 有位朋友在一家创业公司做市场&#xff0c;某次面试一个…

作者头像 李华
网站建设 2026/10/27 12:42:11

MinGW-w64:Windows原生C++静态编译终极方案

简介&#xff1a;这是一份开箱即用的 MinGW-w64 C 编译器完整环境包&#xff0c;面向 Windows 平台 C/C 初学者、嵌入式开发学习者及 VS Code 用户&#xff0c;解决官方源下载慢、易中断、配置繁琐等痛点。压缩包为 RAR 格式&#xff0c;共 2000 个文件&#xff0c;主体包含 21…

作者头像 李华
网站建设 2026/10/27 12:40:34

AI服务稳定性诊断的三层漏斗法:模型层、Harness层、运行额度层

1. 这不是报错排查指南&#xff0c;而是一套AI服务稳定性诊断的“三层漏斗法” 你刚点下“生成”按钮&#xff0c;页面却弹出一行冷冰冰的提示&#xff1a;“任务失败”。没有堆栈、没有错误码、甚至没说清是哪一环出了问题——这种场景&#xff0c;我过去三年在给二十多家企业…

作者头像 李华
网站建设 2026/10/27 12:40:11

Impeccable怎么用?从词源到实操的完整用法指南

先说个真实场景&#xff1a;我上个月帮一个朋友改英文简历&#xff0c;他在自我评价栏写了"I am an impeccable worker"&#xff0c;我问他你想表达什么&#xff0c;他说"我是个工作很靠谱的人"。这句话语法没错&#xff0c;但味道完全不对&#xff0c;因为…

作者头像 李华