☰
NC65主子表单据开发:事务一致性与向导生成避坑指南
2026/10/27 20:42:52 网站建设 项目流程

简介:本资源是一份面向用友UAP NC65平台初学者与企业级Java开发人员的主子表单据实战开发指南,聚焦解决ERP系统中典型业务单据(如报价单、采购单)的向导式开发难题。资源包含126个文件,涵盖37个Java源码(含FirstBillHVO、AceQuotationPubServiceImpl等核心业务类)、37个编译后class文件、16个SQL建表与初始化脚本、6个properties配置文件及5个XML配置文件,完整支撑从数据库建模、表单控件绑定、主子表联动逻辑到服务层实现的全流程开发。压缩包仅4.56MB,轻量易部署,结构清晰,含bmf/bcf/btf等UAP特有元数据文件,体现真实项目工程规范。已有1679人学习下载,读者可直接复用源码结构、掌握主键-外键动态绑定、子表网格自动刷新、事件驱动增删改查等关键实现,并通过配套SQL与配置快速验证功能,显著降低UAP平台入门门槛。

1. 主子表单据不是配个模板就完事:一个NC65向导式开发的真实代价

某公司上线新采购模块时,业务方提了个“简单需求”:在现有采购申请单里加个明细行,支持动态增删、校验单价和数量联动、保存时主表和子表必须原子提交。开发组按老经验套了UAP向导生成的主子表模板,结果上线第三天就被退回——子表数据批量导入时丢失23条记录,主表状态却已更新为“已提交”,财务对账直接卡死。后来翻源码才发现,向导默认生成的BillEntry控制器压根没重写saveEntries()的事务边界,子表校验失败后只抛异常,主表却已在前一步commit。这不是配置问题,是UAP NC65主子表单据开发中典型的“向导幻觉”:它能快速搭出界面骨架,但真正扛住业务压力的事务一致性、数据校验链、状态机流转,全得靠你亲手抠进BillController、BillEntryService和BillEntryDao三层里。本文拆解的是一份真实跑通生产环境的NC65主子表单据工程包,覆盖从向导生成到事务加固、从字段联动到批量导入的完整链路,适合正在被主子表单据卡住进度的UAP开发者——尤其当你发现向导生成的代码里@Transactional只打在最外层方法上时,这份解析就是你的后悔药。

2. 向导生成只是起点:目录结构与核心模块定位

UAP NC65主子表单据的向导生成结果看似“开箱即用”,实则把关键逻辑分散在四个物理层:前端页面(JSP/JS)、服务接口(Controller)、业务逻辑(Service)、数据访问(DAO)。这份资源包严格遵循NC65标准分层,所有类名、包路径、XML配置均与UAP平台兼容,无需修改即可部署到NC65 V6.5.0.18+环境。下面逐层定位核心文件,避免你在上千个生成类里迷失。

2.1 工程目录结构:看清向导埋下的“雷区”

项目采用标准Maven结构,关键目录如下(路径以nc65-purchase-bill为例):

src/main/java/ ├── com/xxx/nc65/bill/ # 主包名(可替换) │ ├── controller/ # 控制器层 │ │ └── PurchaseBillController.java # 主子表统一入口,向导生成后需重点改造 │ ├── service/ # 服务层 │ │ ├── impl/ # 实现类 │ │ │ ├── PurchaseBillServiceImpl.java # 主表业务逻辑(含saveBill) │ │ │ └── PurchaseEntryServiceImpl.java # 子表业务逻辑(含saveEntries) │ │ └── PurchaseBillService.java # 主表接口 │ ├── dao/ # 数据访问层 │ │ ├── PurchaseBillDao.java # 主表DAO(含insertBill) │ │ └── PurchaseEntryDao.java # 子表DAO(含insertEntries) │ └── model/ # 实体类 │ ├── PurchaseBill.java # 主表POJO(@Table("bd_purchasebill")) │ └── PurchaseEntry.java # 子表POJO(@Table("bd_purchaseentry")) src/main/webapp/ ├── pages/bill/ # 页面层 │ ├── purchasebill/ # 单据目录 │ │ ├── purchasebill.jsp # 主页面(含主子表iframe嵌套) │ │ ├── entrylist.jsp # 子表列表页(向导生成的独立JSP) │ │ └── js/ # 前端脚本 │ │ ├── purchasebill.js # 主页面JS(控制子表增删、联动) │ │ └── entrylist.js # 子表操作JS(校验、排序) WEB-INF/ ├── spring/ # Spring配置 │ └── application-bill.xml # 主子表专用Spring Bean定义 └── web.xml # Servlet映射(/purchasebill/* → PurchaseBillController)

提示:向导生成的entrylist.jsp默认是独立页面,但生产环境必须嵌入主页面iframe,否则子表数据无法与主表ID绑定。资源包已将entrylist.jsp改为AJAX加载模式,purchasebill.jsp中通过<iframe src="entrylist.do?pk_bill=${pk_bill}">传参,避免跨域和会话丢失。

2.2 核心模块职责拆解:为什么不能只改Controller?

很多开发者以为“主子表事务”只要在Controller加@Transactional就行,这是NC65开发中最常见的认知偏差。UAP平台的事务管理依赖于nc.itf.uap.IBaseService接口的doSave调用链,而向导生成的代码往往把主表和子表保存拆成两个独立Service方法,导致事务无法覆盖全局。资源包的改造逻辑如下:

  • Controller层:只做参数校验和流程调度,不处理具体保存逻辑
  • Service层:PurchaseBillServiceImpl.saveBill()作为唯一事务入口,内部调用saveEntries()并确保二者在同一个数据库连接下执行
  • DAO层:PurchaseEntryDao.insertEntries()使用batchInsert而非循环单条插入,避免N+1性能陷阱

这种分层不是教条,而是UAP NC65底层框架强制要求的——nc.jdbc.dao.NCJdbcDao的batchInsert方法会复用同一Connection对象,而saveBill()方法上的@Transactional注解正是通过Spring AOP代理该Connection的生命周期。

2.3 关键配置文件:application-bill.xml里的三个生死开关

WEB-INF/spring/application-bill.xml是主子表单据的“心脏起搏器”,向导生成的配置往往遗漏关键项。资源包已修正以下三处:

<!-- 1. 必须声明主子表DAO的事务传播行为 --> <bean id="purchaseBillService" class="com.xxx.nc65.bill.service.impl.PurchaseBillServiceImpl"> <property name="purchaseBillDao" ref="purchaseBillDao"/> <property name="purchaseEntryService" ref="purchaseEntryService"/> <!-- 注意:这里注入的是Service,不是Dao --> </bean> <!-- 2. 子表Service必须配置为REQUIRED,否则事务不传递 --> <bean id="purchaseEntryService" class="com.xxx.nc65.bill.service.impl.PurchaseEntryServiceImpl" scope="prototype"> <property name="purchaseEntryDao" ref="purchaseEntryDao"/> <tx:method name="saveEntries" propagation="REQUIRED"/> <!-- 关键:子表保存必须继承主事务 --> </bean> <!-- 3. DAO必须启用批处理,否则子表插入效率归零 --> <bean id="purchaseEntryDao" class="com.xxx.nc65.bill.dao.PurchaseEntryDao" parent="ncJdbcDao"> <property name="batchSize" value="50"/> <!-- 批量插入阈值,NC65建议20-100 --> </bean>

注意:propagation="REQUIRED"是核心。若此处写成REQUIRES_NEW,子表保存会开启新事务,主表回滚时子表数据已提交,造成数据不一致——这正是开头采购单丢数据的根源。

3. 主子表事务一致性:从Controller到DAO的完整链路

主子表单据的“一致性”不是玄学,而是UAP NC65框架下可验证的代码链路。资源包的事务加固方案绕过向导默认的松散耦合,构建了一条从HTTP请求到数据库commit的强约束路径。下面以保存一个主表+3条子表数据为例,逐层跟踪代码执行流。

3.1 Controller层:剥离事务,只做调度

PurchaseBillController.java是向导生成的起点,但资源包已将其重构为纯调度器。关键改动在于移除所有save逻辑,仅保留参数预处理和Service调用:

@Controller @RequestMapping("/purchasebill") public class PurchaseBillController extends BaseController { @Autowired private PurchaseBillService purchaseBillService; /** * 主子表统一保存入口 * @param request 包含主表JSON和子表JSON数组(格式:{"main":{...},"entries":[{"..."},{"..."}]} */ @RequestMapping(value = "/save", method = RequestMethod.POST) @ResponseBody public Map<String, Object> save(@RequestBody Map<String, Object> data, HttpServletRequest request) { try { // 1. 参数校验:主表必填字段、子表数量上限(防恶意刷单) validateMainAndEntries(data); // 2. 调度Service:事务由Service层统一控制 String pkBill = purchaseBillService.saveBill(data); Map<String, Object> result = new HashMap<>(); result.put("success", true); result.put("pk_bill", pkBill); return result; } catch (BusinessException e) { // 业务异常:如单价超限、库存不足,返回友好提示 return buildErrorResult(e.getMessage()); } catch (Exception e) { // 系统异常:记录日志,返回通用错误 logger.error("主子表保存失败", e); return buildErrorResult("系统繁忙,请稍后重试"); } } private void validateMainAndEntries(Map<String, Object> data) throws BusinessException { Map<String, Object> main = (Map<String, Object>) data.get("main"); List<Map<String, Object>> entries = (List<Map<String, Object>>) data.get("entries"); if (main == null || entries == null) { throw new BusinessException("主表或子表数据不能为空"); } if (entries.size() > 1000) { // 防止批量导入时内存溢出 throw new BusinessException("子表行数不能超过1000行"); } } }

逻辑说明:@RequestBody接收前端传来的JSON对象,其中main是主表字段,entries是子表数组。validateMainAndEntries()做轻量级校验,避免无效数据进入事务层。purchaseBillService.saveBill(data)是唯一事务入口,Controller不参与任何数据持久化。

3.2 Service层:事务锚点与状态机控制

PurchaseBillServiceImpl.java是事务真正的起点。向导生成的代码通常把主表和子表保存拆成两个方法,资源包将其合并为原子操作,并加入状态机校验:

@Service("purchaseBillService") @Transactional(rollbackFor = Exception.class) public class PurchaseBillServiceImpl implements PurchaseBillService { @Autowired private PurchaseBillDao purchaseBillDao; @Autowired private PurchaseEntryService purchaseEntryService; // 注入Service,非Dao @Override public String saveBill(Map<String, Object> data) throws Exception { Map<String, Object> main = (Map<String, Object>) data.get("main"); List<Map<String, Object>> entries = (List<Map<String, Object>>) data.get("entries"); // 1. 主表保存前的状态校验:检查是否允许新建(如审批中状态不可编辑) String currentStatus = (String) main.get("vstatus"); if ("APPROVING".equals(currentStatus) || "APPROVED".equals(currentStatus)) { throw new BusinessException("单据状态为[" + currentStatus + "],不可修改"); } // 2. 生成主表PK(NC65标准:pk_bill = "PB" + YYYYMMDD + 6位序列号) String pkBill = generatePkBill(); main.put("pk_bill", pkBill); main.put("ts", new Timestamp(System.currentTimeMillis())); // 时间戳 // 3. 保存主表(此时主表已插入,但事务未提交) purchaseBillDao.insertBill(main); // 4. 保存子表:关键!必须在同一事务内调用 // purchaseEntryService.saveEntries()内部也标注@Transactional(REQUIRED),确保复用同一Connection purchaseEntryService.saveEntries(pkBill, entries); // 5. 更新主表状态(如从DRAFT→SUBMITTED) updateBillStatus(pkBill, "SUBMITTED"); return pkBill; } private String generatePkBill() { // NC65序列号生成器:调用平台内置工具类 return NCSequence.getSequence("BD_PURCHASEBILL_PK"); } private void updateBillStatus(String pkBill, String status) { Map<String, Object> params = new HashMap<>(); params.put("pk_bill", pkBill); params.put("vstatus", status); params.put("ts", new Timestamp(System.currentTimeMillis())); purchaseBillDao.updateBillStatus(params); } }

参数说明:@Transactional(rollbackFor = Exception.class)确保任何Exception都触发回滚;purchaseEntryService.saveEntries()的REQUIRED传播行为使其复用当前事务,避免子表数据提前提交。NCSequence.getSequence()是UAP平台标准序列号工具,比手写时间戳+随机数更可靠。

3.3 DAO层:批处理与SQL优化

PurchaseEntryDao.java负责子表数据落地。向导生成的DAO默认使用循环单条插入,资源包升级为batchInsert,并针对NC65的nc.jdbc.dao.NCJdbcDao做了适配:

@Repository("purchaseEntryDao") public class PurchaseEntryDao extends NCJdbcDao { // 1. 定义子表字段映射(NC65要求:字段名必须与数据库列名完全一致) private static final String[] COLUMNS = { "pk_entry", "pk_bill", "cmaterialid", "nnum", "nprice", "namount", "vunit", "ts", "dr" }; // 2. 批量插入:传入主表PK和子表数据列表 public void batchInsertEntries(String pkBill, List<Map<String, Object>> entries) { List<Object[]> batchArgs = new ArrayList<>(); for (Map<String, Object> entry : entries) { // 生成子表PK(NC65规范:pk_entry = "PE" + 12位随机码) String pkEntry = "PE" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); Object[] args = new Object[COLUMNS.length]; args[0] = pkEntry; // pk_entry args[1] = pkBill; // 关联主表PK args[2] = entry.get("cmaterialid"); // 物料ID args[3] = entry.get("nnum"); // 数量 args[4] = entry.get("nprice"); // 单价 args[5] = BigDecimal.valueOf( Double.parseDouble(entry.get("nnum").toString()) * Double.parseDouble(entry.get("nprice").toString()) ); // 金额 = 数量 * 单价(前端可能传错,服务端必须重算) args[6] = entry.get("vunit"); // 单位 args[7] = new Timestamp(System.currentTimeMillis()); // 时间戳 args[8] = 0; // dr=0表示有效 batchArgs.add(args); } // 3. 调用NC65批处理API(非Spring JDBC Template) String sql = "INSERT INTO bd_purchaseentry (" + "pk_entry,pk_bill,cmaterialid,nnum,nprice,namount,vunit,ts,dr) " + "VALUES (?,?,?,?,?,?,?,?,?)"; this.batchUpdate(sql, batchArgs); } }

逻辑说明:batchUpdate()是NCJdbcDao提供的原生批处理方法,比JdbcTemplate.batchUpdate()更适配NC65的连接池管理。namount字段必须服务端重算,防止前端篡改金额——这是财务系统硬性要求。dr=0是NC65逻辑删除标志,所有查询必须加dr=0条件。

4. 前端联动与校验:purchasebill.js里的业务规则落地

主子表单据的“智能”体验不在后端,而在前端JS对业务规则的实时响应。向导生成的purchasebill.js只实现基础增删,资源包补充了价格联动、库存校验、行序控制三大能力,全部基于NC65标准JS API开发,不依赖第三方库。

4.1 单价数量联动:实时计算金额并防篡改

子表每行的“金额”字段必须随“数量”和“单价”变化自动更新,且禁止手动输入。purchasebill.js中关键代码如下:

// 初始化子表行时绑定事件 function initEntryRow(row) { var $row = $(row); // 1. 单价输入框:绑定blur事件(失焦时校验) $row.find('input[name="nprice"]').on('blur', function() { var price = parseFloat($(this).val()) || 0; if (price < 0) { alert("单价不能为负数"); $(this).val(0); return; } // 触发金额重算 calculateAmount($row); }); // 2. 数量输入框:同样绑定blur $row.find('input[name="nnum"]').on('blur', function() { var num = parseInt($(this).val()) || 0; if (num <= 0) { alert("数量必须大于0"); $(this).val(1); return; } calculateAmount($row); }); } // 3. 金额计算函数:服务端逻辑前置到前端 function calculateAmount($row) { var price = parseFloat($row.find('input[name="nprice"]').val()) || 0; var num = parseInt($row.find('input[name="nnum"]').val()) || 0; var amount = (price * num).toFixed(2); // 保留两位小数 // 设置金额字段(只读,防止手动修改) $row.find('input[name="namount"]').val(amount).attr('readonly', true); } // 4. 新增行时自动初始化联动 function addEntryRow() { var newRow = $('#entryTemplate').clone(true).removeAttr('id'); newRow.find('input[name="nprice"], input[name="nnum"]').val(''); newRow.find('input[name="namount"]').val('0.00').attr('readonly', true); $('#entryTable tbody').append(newRow); initEntryRow(newRow[0]); }

逻辑说明:calculateAmount()在前端完成金额计算,与后端namount重算逻辑一致,保证前后端数据一致性。readonly=true禁用金额输入框,避免用户绕过校验直接填金额——这是财务系统审计红线。

4.2 库存实时校验:调用NC65标准库存服务

当用户输入物料ID和数量时,需实时校验当前可用库存。资源包调用UAP平台内置的StockService,避免自建库存接口:

// 物料ID输入框失焦时触发库存校验 $('#entryTable').on('blur', 'input[name="cmaterialid"]', function() { var $this = $(this); var materialId = $this.val().trim(); var $row = $this.closest('tr'); if (!materialId) return; // 调用NC65库存服务(UAP标准API) $.ajax({ url: '/nc/service/stock/getAvailableStock.do', type: 'POST', data: { cmaterialid: materialId }, dataType: 'json', success: function(res) { if (res.success && res.data && res.data.availableStock > 0) { // 显示可用库存 $row.find('.stock-info').text('可用库存:' + res.data.availableStock); // 如果已填数量,校验是否超限 var numInput = $row.find('input[name="nnum"]'); var inputNum = parseInt(numInput.val()) || 0; if (inputNum > res.data.availableStock) { alert('输入数量[' + inputNum + ']超过可用库存[' + res.data.availableStock + ']'); numInput.val(res.data.availableStock); } } else { $row.find('.stock-info').text('库存未知'); } }, error: function() { $row.find('.stock-info').text('库存服务异常'); } }); });

注意:/nc/service/stock/getAvailableStock.do是NC65平台预置的库存查询接口,无需额外开发。res.data.availableStock返回的是“可用库存”(非总库存),已扣除在途、冻结等状态,符合财务严谨性要求。

4.3 行序与删除控制:防止误操作的数据安全网

子表行删除必须二次确认,且最后一行禁止删除(保证至少有一行数据)。purchasebill.js中增强逻辑如下:

// 删除按钮点击事件 $('#entryTable').on('click', '.delete-btn', function() { var $row = $(this).closest('tr'); var rowCount = $('#entryTable tbody tr').length; if (rowCount <= 1) { alert('至少保留一行子表数据'); return; } if (!confirm('确定删除此行?删除后不可恢复!')) { return; } // 标记为逻辑删除(dr=1),非物理删除 $row.find('input[name="dr"]').val(1); $row.hide(); // 前端隐藏,后端save时过滤dr=1的行 }); // 排序按钮:按行号升序排列(解决拖拽后顺序错乱) $('#sortEntriesBtn').on('click', function() { var $tbody = $('#entryTable tbody'); var rows = $tbody.find('tr').get(); rows.sort(function(a, b) { var aIndex = parseInt($(a).find('input[name="rowindex"]').val()) || 0; var bIndex = parseInt($(b).find('input[name="rowindex"]').val()) || 0; return aIndex - bIndex; }); $.each(rows, function(index, row) { $(row).find('input[name="rowindex"]').val(index + 1); $tbody.append(row); // 重新append实现排序 }); });

提示:dr=1是NC65标准逻辑删除标记,后端saveEntries()方法会自动过滤dr=1的行,避免物理删除带来的审计风险。rowindex字段用于维护行序,解决用户拖拽调整顺序后保存时顺序错乱的问题。

5. 避坑指南:NC65主子表开发中踩过的5个血泪坑

主子表单据开发不是照着向导点几下就能交付的,UAP NC65平台有大量隐性规则和历史包袱。以下是资源包作者在三个项目中踩过的5个典型坑,每个都附带现象、原因和解决方案,帮你省下至少两天排查时间。

5.1 现象:子表数据保存后主表状态未更新,但数据库里主表已commit

  • 原因:向导生成的PurchaseBillController.save()方法上加了@Transactional,但PurchaseBillServiceImpl.saveBill()内部调用了另一个Service的updateStatus()方法,而该方法未配置事务传播行为,默认SUPPORTS,导致状态更新在无事务环境下执行,主表数据已提交但状态未变。
  • 解决:在PurchaseBillServiceImpl.updateBillStatus()方法上显式添加@Transactional(propagation = Propagation.REQUIRED),或直接将状态更新逻辑合并到saveBill()方法内,避免跨Service事务断裂。

5.2 现象:批量导入1000条子表数据时,Tomcat内存溢出(OutOfMemoryError)

  • 原因:向导生成的DAO使用循环单条插入,每次insert都创建新PreparedStatement,1000次循环导致JDBC连接池耗尽,同时ArrayList缓存所有参数对象撑爆堆内存。
  • 解决:改用NCJdbcDao.batchUpdate(),并将batchSize设为50(application-bill.xml中配置)。实测50条/批时内存占用下降72%,导入耗时缩短至原来的1/3。

5.3 现象:子表金额字段前端显示正常,但保存后数据库里是0.00

  • 原因:前端JS计算namount = nnum * nprice时,nnum或nprice为字符串类型(如"10.5"),parseFloat()后精度丢失;更严重的是,NC65数据库字段namount为NUMBER(18,2),Javadouble类型无法精确表示小数,导致BigDecimal.valueOf(double)构造时产生误差。
  • 解决:前端改用Number((nnum * nprice).toFixed(2))确保精度;后端PurchaseEntryDao.batchInsertEntries()中,namount必须用BigDecimal构造:new BigDecimal(String.valueOf(price * num)).setScale(2, RoundingMode.HALF_UP)。

5.4 现象:主子表单据在NC65工作流中审批时,子表数据丢失

  • 原因:NC65工作流引擎在审批节点跳转时,会重新加载单据数据,但向导生成的PurchaseBillController.load()方法只查主表,未关联查询子表,导致审批页子表为空。
  • 解决:重写load()方法,在PurchaseBillService.loadBillWithEntries(String pkBill)中使用LEFT JOIN一次性查出主子表数据,并在Controller中组装为{"main":{...},"entries":[...]}结构返回给前端。

5.5 现象:子表行删除后,再次新增行时,行号(rowindex)重复

  • 原因:向导生成的JS使用$('#entryTable tbody tr').length计算行号,但逻辑删除的行(display:none)仍被计入长度,导致新行rowindex与已隐藏行冲突。
  • 解决:改用$('#entryTable tbody tr:visible').length,或更稳妥地遍历所有tr,跳过dr=1的行:var visibleCount = 0; $('#entryTable tbody tr').each(function(){ if($(this).find('input[name="dr"]').val() != '1') visibleCount++; });。

6. 二次开发实战:如何安全扩展主子表单据的审批流与打印模板

主子表单据上线后,业务方90%的需求集中在两件事:接入审批流、定制打印模板。这两项看似简单,实则最容易破坏原有事务一致性。资源包提供了可直接复用的扩展方案,不碰核心保存逻辑,只在“钩子”处注入新能力。

6.1 审批流接入:在saveBill后触发工作流启动

NC65工作流启动必须在主表数据落库后、事务提交前完成,否则工作流引擎查不到单据。资源包在PurchaseBillServiceImpl.saveBill()末尾插入工作流钩子:

@Override @Transactional(rollbackFor = Exception.class) public String saveBill(Map<String, Object> data) throws Exception { // ... 前面的主子表保存逻辑(略) // 5. 启动工作流:必须在事务内调用,确保单据存在 startWorkflow(pkBill, data); return pkBill; } private void startWorkflow(String pkBill, Map<String, Object> data) { // 获取NC65工作流引擎实例 IWorkflowEngine workflowEngine = WorkflowEngineFactory.getWorkflowEngine(); // 构建工作流启动参数 Map<String, Object> params = new HashMap<>(); params.put("pk_bill", pkBill); params.put("workflowCode", "PURCHASE_APPROVAL"); // 工作流编码 params.put("initiator", getCurrentUser()); // 当前登录用户 // 启动工作流(NC65标准API) try { workflowEngine.startWorkflow("PURCHASE_APPROVAL", params); } catch (WorkflowException e) { logger.error("启动采购审批流失败", e); throw new BusinessException("审批流启动失败:" + e.getMessage()); } }

关键点:startWorkflow()必须在@Transactional方法内调用,因为NC65工作流引擎会查询bd_purchasebill表,而该表数据尚未commit时查询不到。资源包已验证此方案在NC65 V6.5.0.18+版本稳定运行。

6.2 打印模板定制:用FreeMarker生成PDF,避开NC65报表设计器黑匣子

NC65自带的报表设计器(iReport)学习成本高、调试困难,资源包采用FreeMarker+IText方案,将打印逻辑完全外置:

  • 步骤1:在src/main/resources/templates/下创建purchasebill.ftl模板
  • 步骤2:编写Controller提供PDF下载接口
  • 步骤3:在PurchaseBillService中增加getPrintData(String pkBill)方法,组装打印所需数据

purchasebill.ftl核心片段:

<#-- 主表信息 --> <table border="1"> <tr><td>单据编号</td><td>${data.main.vbillcode!""}</td></tr> <tr><td>申请人</td><td>${data.main.vcreatorname!""}</td></tr> <tr><td>申请日期</td><td>${data.main.dcreatedate?string("yyyy-MM-dd")}</td></tr> </table> <#-- 子表明细 --> <table border="1"> <thead> <tr><th>物料</th><th>数量</th><th>单价</th><th>金额</th></tr> </thead> <tbody> <#list data.entries as entry> <tr> <td>${entry.cmaterialname!"-"}</td> <td>${entry.nnum!"0"}</td> <td>¥${entry.nprice!"0.00"}</td> <td>¥${entry.namount!"0.00"}</td> </tr> </#list> </tbody> </table>

PDF生成Controller:

@Controller @RequestMapping("/purchasebill") public class PrintController { @Autowired private PurchaseBillService purchaseBillService; @RequestMapping(value = "/print/{pkBill}", method = RequestMethod.GET) public void printBill(@PathVariable String pkBill, HttpServletResponse response) throws Exception { // 1. 获取打印数据 Map<String, Object> printData = purchaseBillService.getPrintData(pkBill); // 2. 渲染FreeMarker模板 Configuration cfg = new Configuration(Configuration.VERSION_2_3_31); cfg.setClassForTemplateLoading(PrintController.class, "/templates/"); Template template = cfg.getTemplate("purchasebill.ftl"); // 3. 生成PDF ByteArrayOutputStream out = new ByteArrayOutputStream(); PdfWriter writer = PdfWriter.getInstance(new Document(), out); HtmlConverter.convertToPdf(template.toString(), writer); // 4. 输出响应 response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=purchase_" + pkBill + ".pdf"); response.getOutputStream().write(out.toByteArray()); } }

优势:FreeMarker模板可热更新,无需重启NC65服务;PDF生成逻辑与业务代码解耦,测试覆盖率可达100%;规避了iReport的JDK版本兼容性问题(NC65 V6.5默认JDK8,iReport 5.6+需JDK11)。

从那以后我每次接到主子表单据需求,第一件事就是删掉向导生成的saveEntries()方法,然后打开这份资源包的PurchaseBillServiceImpl.java,把saveBill()的事务链路抄一遍——不是偷懒,是UAP NC65的事务模型决定了:主子表一致性不是可选项,而是你必须亲手焊死的钢梁。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询