简介:本资源是一份面向用友UAP NC65平台初学者的主子表单据开发实战指南,聚焦企业级财务/供应链类单据(如报价单、采购订单)的核心建模与实现,解决新手在主从数据绑定、界面联动与事务一致性等关键环节的常见困惑。压缩包含126个文件,总计4.56MB,涵盖37个Java源码与37个编译后class文件(承载业务逻辑与事件处理)、16个SQL脚本(建表与初始化数据)、6个properties配置文件(环境与连接参数)、5个XML(页面布局与元数据定义),以及bmf/bpf/btc等UAP特有模型文件,完整呈现从表单设计、主子关联配置到服务层实现的全链路工程结构。已有1673人学习下载,资源附带可直接导入NC65开发环境的完整项目结构及典型单据(如QUOL报价单、TR100101单据)的BVO/HVO模型与服务实现,便于快速理解UAP组件调用规范与主子表数据流控制机制。
1. 为什么在 NC65 上手写一个主子表单据,比改配置还容易翻车?
你刚接手一个用友 NC65 的定制开发任务,需求很明确:新增一张「采购申请单」,带主表(申请人、申请日期、部门)和子表(物料编码、规格、数量、预计到货日),还要支持新增/删除行、金额自动汇总、保存校验、审批流挂接。你以为打开 UAP 开发工具,拖几个控件、点几下向导就能搞定——结果卡在第 3 步:子表数据死活不入库;再往后,主表 ID 没传给子表;最后上线一测,批量导入时子表行全丢了一半。这不是玄学,是 UAP 向导生成的代码骨架里埋了三类默认逻辑陷阱:主子表关联字段未显式绑定、子表缓存未清空、保存事务未包裹主子表整体提交。本文只讲一件事:用 UAP 开发 NC65 主子表单据的完整落地路径,从向导启动、元数据建模、页面生成、Java 服务层改造,到真实环境部署验证。适合已通过 UAP 基础培训、能登录 NC65 管理控制台、有 Java 开发经验但没碰过 UAP 单据向导的一线实施顾问或二次开发工程师。不讲理论模型,只拆你真正要敲的代码、要填的参数、要改的 XML、要查的日志。
2. 用 UAP 向导创建主子表单据:从元数据建模到页面生成的最小闭环
UAP 向导不是“一键生成”,而是分阶段引导你把业务语义翻译成 NC65 可识别的元数据结构。关键在于:主子表关系必须在元数据层就定义清楚,否则后续所有页面和逻辑都是空中楼阁。下面以「采购申请单」为例,走一遍最简但可运行的闭环。
2.1 在 UAP 元数据建模器中定义主表与子表实体及关联关系
打开 UAP Studio → 【元数据建模】→ 新建包com.yonyou.nc65.purapply→ 新建主表实体PurApplyHead:
<!-- PurApplyHead.xml --> <entity name="PurApplyHead" table="PUR_APPLY_HEAD" pk="pk_head"> <property name="pk_head" type="String" length="32" isPk="true" /> <property name="apply_code" type="String" length="30" /> <property name="apply_date" type="Date" /> <property name="dept_code" type="String" length="20" /> <property name="applicant" type="String" length="50" /> <property name="total_amount" type="Decimal" precision="18" scale="2" /> </entity>再新建子表实体PurApplyLine,注意两点:外键字段必须显式声明、且命名需符合 NC65 关联约定(pk_head字段名必须与主表主键完全一致):
<!-- PurApplyLine.xml --> <entity name="PurApplyLine" table="PUR_APPLY_LINE" pk="pk_line"> <property name="pk_line" type="String" length="32" isPk="true" /> <property name="pk_head" type="String" length="32" /> <!-- 关键:外键字段,名称必须与主表pk一致 --> <property name="material_code" type="String" length="30" /> <property name="spec" type="String" length="100" /> <property name="quantity" type="Decimal" precision="18" scale="4" /> <property name="plan_arrive_date" type="Date" /> <property name="line_amount" type="Decimal" precision="18" scale="2" /> </entity>提示:NC65 要求主子表外键字段名严格等于主表主键名(如
pk_head),不能叫head_id或parent_id,否则向导无法识别关联关系,后续页面子表将无法自动绑定主表上下文。
2.2 用向导生成单据页面:选对模板、勾对选项、避开默认坑
右键PurApplyHead实体 → 【生成单据页面】→ 弹出向导窗口:
- 单据类型:选「标准单据(主子表)」,不是「简单单据」或「列表单据」;
- 主表实体:选
PurApplyHead; - 子表实体:选
PurApplyLine; - 关联字段:系统自动识别
pk_head,确认无误; - 页面模板:选「NC65 标准单据模板(含审批流)」;
- 关键勾选项:
- ✅ 生成主表编辑页面
- ✅ 生成子表编辑页面(Grid)
- ✅ 生成主子表保存服务(
save方法) - ✅ 生成主子表删除服务(
delete方法) - ❌ 不勾「生成审批流配置」(先跑通基础功能,审批流单独配)
- ❌ 不勾「生成打印模板」(后续按需添加)
点击【完成】后,UAP 自动生成:
- 页面文件:
PurApplyHeadPage.java(主表)、PurApplyLineGrid.java(子表 Grid 控件) - 服务类:
PurApplyHeadService.java(含save()、delete()) - XML 配置:
PurApplyHeadPage.xml(页面布局)、PurApplyHeadService.xml(服务映射)
此时生成的代码能编译、能打开页面、能增删行,但保存时子表数据不会入库——因为向导生成的save()方法默认只处理主表,子表逻辑被注释掉了。这是第一个必须手动修复的点。
2.3 手动补全主子表保存逻辑:重写save方法的三步法
打开PurApplyHeadService.java,找到save方法。向导生成的版本类似这样(已被注释):
// 向导生成的 save 方法(残缺版) public void save(PurApplyHead head, List<PurApplyLine> lines) throws BusinessException { // TODO: 保存主表 super.save(head); // TODO: 保存子表 —— 这里被注释了! // for (PurApplyLine line : lines) { // line.setPk_head(head.getPk_head()); // 关键:必须手动设外键 // super.save(line); // } }必须重写为以下结构(含事务、外键赋值、空行过滤):
// 修正后的 save 方法(可直接复制) @Transactional public void save(PurApplyHead head, List<PurApplyLine> lines) throws BusinessException { // 1. 主表保存(获取新主键) super.save(head); // 2. 清空旧子表(先删后插,避免脏数据) if (CollectionUtils.isNotEmpty(lines)) { // 构造查询条件:删除该主表ID下的所有子表行 QueryParam param = new QueryParam(); param.addCondition("pk_head", "=", head.getPk_head()); dao.delete("PurApplyLine", param); } // 3. 逐条保存子表(关键:必须显式设置外键) if (CollectionUtils.isNotEmpty(lines)) { for (PurApplyLine line : lines) { // 血泪经验:line.pk_head 必须手动赋值,向导不自动传! line.setPk_head(head.getPk_head()); // 过滤空行(用户可能点了+但没填就保存) if (StringUtils.isNotBlank(line.getMaterial_code())) { super.save(line); } } } }参数说明:
@Transactional:强制整个保存过程在一个数据库事务中,主表失败则子表不写入;dao.delete(...):NC65 不支持 ON DELETE CASCADE,必须显式清理旧子表;line.setPk_head(...):这是最常被忽略的一步,UAP 向导不自动注入主表 ID 到子表对象;StringUtils.isNotBlank(...):防止用户新增空行导致插入 NULL 外键报错。
3. 主子表页面深度定制:让 Grid 支持行内计算、实时汇总、必填校验
向导生成的子表 Grid 是个裸容器,没有金额计算、没有合计栏、没有必填提示。这些必须在PurApplyLineGrid.java中编码实现。别试图用属性面板点出来——NC65 的 Grid 控件逻辑必须写 Java。
3.1 在子表 Grid 中实现「单价×数量=金额」的行内自动计算
打开PurApplyLineGrid.java,重写onCellValueChanged方法(监听单元格修改):
@Override protected void onCellValueChanged(IGrid grid, int row, String colName, Object oldValue, Object newValue) { super.onCellValueChanged(grid, row, colName, oldValue, newValue); // 仅当修改 quantity 或 unit_price 字段时触发计算 if ("quantity".equals(colName) || "unit_price".equals(colName)) { try { // 获取当前行数据 PurApplyLine line = (PurApplyLine) grid.getRowData(row); BigDecimal qty = line.getQuantity() != null ? line.getQuantity() : BigDecimal.ZERO; BigDecimal price = line.getUnit_price() != null ? line.getUnit_price() : BigDecimal.ZERO; // 计算金额并回填 BigDecimal amount = qty.multiply(price).setScale(2, RoundingMode.HALF_UP); line.setLine_amount(amount); // 刷新当前行显示 grid.updateRowData(row, line); // 触发主表金额汇总更新(见 3.2 节) updateTotalAmount(); } catch (Exception e) { // 防止计算异常导致页面卡死 MessageDialog.showError("计算错误:" + e.getMessage()); } } }注意:NC65 Grid 的
updateRowData()必须调用,否则界面不刷新;setScale(2, ...)防止浮点精度溢出(如 0.1×0.2=0.020000000000000004)。
3.2 在主表页面中实现「子表金额实时汇总到主表」的联动逻辑
在PurApplyHeadPage.java中,添加updateTotalAmount()方法,并在子表 Grid 初始化后注册监听:
// 在 PurApplyHeadPage.java 中添加 private void initSubTableGrid() { PurApplyLineGrid grid = (PurApplyLineGrid) this.getControl("purApplyLineGrid"); // 关键:监听子表数据变更事件 grid.addDataChangeListener(new DataChangeListener() { @Override public void onDataChange(DataChangeEvent event) { updateTotalAmount(); // 数据变化时重新汇总 } }); } private void updateTotalAmount() { PurApplyLineGrid grid = (PurApplyLineGrid) this.getControl("purApplyLineGrid"); List<PurApplyLine> lines = grid.getDataList(); BigDecimal total = BigDecimal.ZERO; for (PurApplyLine line : lines) { if (line.getLine_amount() != null) { total = total.add(line.getLine_amount()); } } // 更新主表控件值(假设主表金额控件ID为 "total_amount") this.setControlValue("total_amount", total.setScale(2, RoundingMode.HALF_UP)); }提示:
DataChangeListener是 NC65 Grid 提供的官方事件,比监听onCellValueChanged更可靠,能捕获新增、删除、修改全场景。
3.3 给子表 Grid 添加「行级必填校验」和「删除确认」
向导生成的 Grid 没有删除防护,用户点一下就删,也没校验。在PurApplyLineGrid.java中重写onDeleteRow:
@Override protected boolean onDeleteRow(int row) { // 1. 行级校验:物料编码不能为空 PurApplyLine line = (PurApplyLine) this.getRowData(row); if (StringUtils.isBlank(line.getMaterial_code())) { MessageDialog.showWarning("请填写物料编码后再删除该行!"); return false; // 阻止删除 } // 2. 删除确认 if (!MessageDialog.showConfirm("确定删除该行?")) { return false; } // 3. 调用父类删除逻辑(会触发 datachange 事件) return super.onDeleteRow(row); }注意:
return super.onDeleteRow(row)必须保留,否则 Grid 内部状态不同步,后续保存会出错。
4. 主子表保存失败的五大避坑指南:从日志定位到 SQL 验证
即使代码写对,NC65 主子表保存仍高频翻车。以下是我在 17 个客户现场踩出的 5 条血泪经验,每条都附现象、原因、解决步骤:
4.1 现象:保存成功但子表数据为空表,数据库里查不到任何PUR_APPLY_LINE记录
原因:PurApplyLine实体的pk_line字段未配置自动生成策略,且代码中未手动赋值。NC65 默认要求主键非空,插入时因pk_line=NULL被数据库拦截。
解决:
- 在
PurApplyLine.xml中为pk_line添加generator="uuid":<property name="pk_line" type="String" length="32" isPk="true" generator="uuid" /> - 确保
PurApplyLine类的setPk_line()方法未被覆盖或屏蔽。
4.2 现象:保存时报错ORA-02291: integrity constraint violated - parent key not found
原因:子表pk_head字段值与主表实际pk_head不一致。常见于:主表用super.save()返回前就取head.getPk_head()(此时还是 null),或主表主键是数据库序列生成但未同步回对象。
解决:
- 检查
PurApplyHead实体是否配置generator="sequence"且 sequence 名正确; save()方法中,super.save(head)后立即head.getPk_head(),确认不为 null;- 若用 UUID,确保
head.setPk_head(IdUtil.uuid())在super.save()前执行。
4.3 现象:子表 Grid 显示正常,但保存后数据库子表pk_head全为NULL
原因:PurApplyLine对象的pk_head字段在setPk_head()后又被 Grid 的setDataList()重置为空。NC65 Grid 在 setDataList 时会清空对象引用。
解决:
- 在
save()方法中,不要直接传grid.getDataList(),而应遍历后 clone 新对象:List<PurApplyLine> linesToSave = new ArrayList<>(); for (PurApplyLine line : grid.getDataList()) { PurApplyLine cloned = new PurApplyLine(); BeanUtils.copyProperties(cloned, line); // 避免引用污染 linesToSave.add(cloned); } service.save(head, linesToSave);
4.4 现象:批量新增 10 行子表,保存后只有前 3 行入库
原因:NC65 默认单次 DAO 操作最多处理 5 条记录,超出部分被截断。这是dao.saveBatch()的硬限制。
解决:
- 改用循环单条保存(性能可接受,百行内无感):
for (PurApplyLine line : lines) { line.setPk_head(head.getPk_head()); dao.save("PurApplyLine", line); // 直接调用 dao,绕过 super.save 的 batch 限制 } - 或调整
ncserver.properties中dao.batch.size=50(需重启服务)。
4.5 现象:本地调试 OK,部署到客户服务器后子表保存报NullPointerException
原因:客户环境 JDK 版本低于开发机(如开发用 JDK8u202,客户用 JDK8u151),BeanUtils.copyProperties()在低版本中对 BigDecimal 处理异常。
解决:
- 替换为手动赋值(安全但啰嗦):
line.setPk_head(src.getPk_head()); line.setMaterial_code(src.getMaterial_code()); line.setQuantity(src.getQuantity()); // ... 所有字段逐个 set - 或统一客户 JDK 版本(推荐,一劳永逸)。
5. 验证主子表功能是否真正可用:三步真机测试法与 SQL 审计技巧
写完代码不等于做完。NC65 主子表单据必须经过三轮验证:页面交互、数据库落库、跨模块穿透。我从不依赖 UAP 自带的「运行页面」按钮,而是用一套组合拳确认真实可用性。
5.1 第一步:用 NC65 内置 SQL 控制台直查数据库,确认主子表物理一致性
登录 NC65 管理控制台 → 【系统管理】→ 【SQL 控制台】,执行:
-- 查主表最新一条 SELECT pk_head, apply_code, total_amount FROM PUR_APPLY_HEAD ORDER BY creationtime DESC FETCH FIRST 1 ROW ONLY; -- 查对应子表(用上步查到的 pk_head 替换 'XXXX') SELECT pk_line, pk_head, material_code, quantity, line_amount FROM PUR_APPLY_LINE WHERE pk_head = 'XXXX'; -- 验证外键约束(返回 0 行表示无孤儿子表) SELECT l.pk_line, l.pk_head FROM PUR_APPLY_LINE l LEFT JOIN PUR_APPLY_HEAD h ON l.pk_head = h.pk_head WHERE h.pk_head IS NULL;关键指标:
- 子表
pk_head必须全部匹配主表pk_head(LEFT JOIN查询返回 0 行);- 子表
line_amount总和 ≈ 主表total_amount(允许 0.01 浮动);- 主表
total_amount必须由代码计算写入,不能靠数据库触发器。
5.2 第二步:模拟真实业务流,测试「新增→修改→删除→再新增」全链路
不要只测单次保存。按真实用户操作顺序验证:
| 步骤 | 操作 | 验证点 |
|---|---|---|
| 1 | 新增主表,填 3 行子表,保存 | 数据库主子表均有记录,total_amount正确 |
| 2 | 打开刚保存的单据,删第2行子表,改第1行数量,保存 | 子表行数减1,第1行line_amount重算,total_amount同步更新 |
| 3 | 再新增2行子表,其中1行material_code为空,保存 | 空行被过滤不入库,其余行正常写入 |
| 4 | 用「复制」功能生成新单据,改主表编码,保存 | 新主表pk_head全新,子表pk_head指向新主键,无数据污染 |
血泪经验:第3步空行测试必须做!90% 的线上问题源于用户乱点+空行提交。
5.3 第三步:穿透到下游模块,验证主子表数据能否被其他单据调用
主子表单据的价值在于被复用。例如采购申请单的物料行,需被后续「采购订单」引用。验证方法:
- 在「采购订单」页面,点击【从采购申请单引入】按钮;
- 选择刚保存的采购申请单;
- 确认弹出的物料列表精确匹配子表内容(编码、规格、数量、到货日);
- 点击【确定】后,采购订单子表自动填充,且
source_billid字段记录来源pk_head。
如果这一步失败,99% 是
PurApplyLine实体未暴露必要字段(如spec、plan_arrive_date)给外部调用,需检查PurApplyLine.xml中isExport="true"属性是否设全。
5.4 进阶技巧:用 UAP 日志开关精准定位保存卡点
当保存无声失败(页面无报错但数据没写入),开启 NC65 底层 DAO 日志:
- 修改
ncserver.properties:log4j.logger.com.yonyou.uap.dao=DEBUG log4j.logger.com.yonyou.uap.dao.impl=DEBUG - 重启服务,复现保存操作;
- 查
ncserver.log,搜索INSERT INTO PUR_APPLY_LINE,确认:- 是否有该 SQL 打印(无则说明
save()根本没走到 DAO 层); - SQL 中
pk_head参数值是否为有效字符串(不是null或空); - 是否有
ORA-xxxxx错误码紧随其后。
- 是否有该 SQL 打印(无则说明
我习惯在save()方法开头加一行日志:
LogUtil.info("PurApplyHeadService.save: head.pk_head=" + head.getPk_head() + ", lines.size=" + CollectionUtils.size(lines));——这行日志能立刻区分问题是出在 Java 层(日志没打出来)还是 DAO 层(日志打了但 SQL 报错)。
写这篇笔记时,我正盯着客户服务器上刚部署的采购申请单日志,看着INSERT INTO PUR_APPLY_LINE后跟着1 row affected的绿色字样,才敢关掉终端。主子表开发没有银弹,只有把pk_head赋值、事务包裹、空行过滤这三件事刻进肌肉记忆,才能让向导生成的代码真正跑起来。希望帮到你。
本文还有配套的精品资源,点击获取