ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于Unieap的物资申领系统设计:主子表、审批流与分页导出

基于Unieap的物资申领系统设计:主子表、审批流与分页导出

简介:Unieap 简单小项目面向刚接触业务流程管理框架的 Java 开发者与初学者,提供一份可运行的示例工程,用于理解 Unieap 3.4 在工作流引擎、表单设计、数据模型和权限控制上的基本用法。项目通过实际代码演示应用的启动、流程定义与部署,以及与框架交互的业务逻辑,可作为入门练手或团队培训的参考样例。

资源以 7z 压缩包形式提供,整体约 82.8MB,上游暂未给出文件数量与类型明细,按示例工程惯例主要包含工程配置、流程定义文件与示例源码等部分,可按目录逐层查阅。

已有 387 人学习,说明其在企业级应用入门场景具备一定参考价值。读者可从配置和流程定义入手,梳理工程组织方式,理解 BPMN 2.0 流程由图形化设计转为可执行代码的过程,并借鉴表单与数据模型的集成思路,为搭建审批、跨部门协作等业务流程打基础。

1. 一张纸质申领单牵出的 Unieap 简单项目

实验室的物资申领还在跑纸单子:填一张表、找三位老师签字、再去库房核一遍库存,赶上导师临时问一句“上个月那批耗材谁申的”,只能翻柜子。这类需求说大不大,说小也不小——它有完整的主子表、一条审批线、一套权限和导出,正好是 Unieap 这类 Java 企业级开发平台最擅长的场景。用 Unieap 做一个简单项目,难点从来不在业务本身,而在于平台把很多东西做成了“配置”,你不清楚哪些该配、哪些该写代码,就会在“页面打得开、数据出不来”之间反复横跳。下面这套方案按需求、设计、实现、验收四段推进,5 张表、3 个页面、1 条状态线,一周左右能落地,也足够支撑一份课程设计或毕设的完整章节。

2. 需求拆解与选型:这个 Java 小项目为什么落在 Unieap 上

先把角色摆出来,不然后面所有权限配置都是拍脑袋。这套申领系统只有四类人:申请人负责填单和撤回,部门负责人负责审批,库房管理员负责核库存和归档,系统管理员只管菜单、字典和账号。角色一确定,页面数量基本就定死了——申请人两个页面(列表 + 编辑),审批人一个页面(待办),管理员不单独做页面,直接用平台自带的后台管理。

2.1 业务流程拆成四段,每段对应一个交付物

整条线拆成“填报、提交、审批、归档”四段,每段都能落到一个可验收的产物上,这是小项目不跑偏的关键。

填报段要解决的是“少填错”。申领单是典型的主子表:一张单子下面挂若干条物资明细,每条明细有物资、数量、单价、金额。这里有个容易被忽略的点——物资只能从字典里选,不能让用户手打,否则一年之后库房会对着一堆“打印纸”“A4 打印纸”“A4纸”发呆。填报段还要允许存草稿,草稿可以反复改,不占库存、不进审批流。

提交段要解决的是“敢提交”。提交前必须做三件事:明细不能为空、数量必须为正整数、库存不足要拦下来但允许申请人改数量。提交成功后单据状态从草稿变为待审,同时写一条审批日志。日志这张表别省,答辩的时候被问“怎么证明这单是谁批的”,翻日志比翻数据库强得多。

审批段要解决的是“能追溯”。负责人只看得到本部门的待审单,通过或驳回都必须填意见,驳回后单据回到申请人手里可以改完再提。这里不引入完整的流程引擎,一条两节点的线性流转用状态字段加日志表就够了,硬上工作流引擎反而会让一个简单项目变成部署事故。

归档段要解决的是“能出口”。库房管理员对已通过的单据点归档,扣减库存、写归档时间。列表页要支持按单号、部门、状态、日期区间查询,并且能把当前查询结果导出成 Excel——国内的项目验收现场,导出功能几乎是必被点名的。

2.2 技术选型与工程目录:小项目更要卡死分层

选 Unieap 的理由很实在:它自带组织机构、用户角色、菜单权限、数据字典、分页组件和导出工具,这些都是 Java Web 项目里最枯燥、最容易写崩的部分。如果自己从零搭一套 Spring Boot + 前端框架,光登录和权限就够写三天,最后业务代码只占三成。用平台做,把组织、字典、权限全部复用内置的,业务只写申领这一条线,工期能压到原来的三分之一。

技术栈锁定为:Unieap 平台(页面建模 + 内置基础服务)+ Java 服务层 + 平台通用数据访问 + 关系型数据库。不引额外前端框架,不自己建用户表和部门表,不写自己的一套分页。这三个“不”是纪律,破一个就会连锁反应。

工程目录我一般按下面这样切,跟平台的标准结构对齐,别人接手时不用猜:

apply-demo/ ├── src/main/java/com/demo/apply/ │ ├── action/ # 页面动作层,只做参数接收和结果跳转 │ ├── service/ # 业务接口,事务边界都在这一层 │ ├── service/impl/ # 业务实现,校验、计算、状态判断 │ ├── dao/ # 数据访问接口,继承平台通用 Dao │ ├── model/ # 实体与表单对象(ApplyOrder / ApplyItem) │ └── common/ # 单号生成、状态常量、导出工具 ├── src/main/resources/ │ ├── sqlmap/ # SQL 映射文件,一表一文件 │ └── config/ # 平台配置文件、字典与菜单初始化脚本 ├── src/main/webapp/ │ ├── apply/ # 三个业务页面及其配置 │ └── common/ # 公共 JS 校验、样式 └── db/ # 建表脚本、初始化数据脚本

action层只做三件事:取请求参数、调一次 service、把结果丢回页面。任何if判断超过两行的业务逻辑都不该出现在这里,否则后期加一个“库存不足拦截”,你得在两个地方改。service层是唯一允许开事务的地方,这一点后面 3.3 会展开。sqlmap一表一文件,是为了让改 SQL 的人不用在几百行里找自己那张表。

3. 总体设计:五张表、三个页面、一条状态线的骨架

总体设计这一章要能画在一张纸上:五张表撑数据,三个页面撑交互,一条状态线撑流程。设计阶段多花半天,实现阶段能省两天。

3.1 数据模型设计:主子表加日志表怎么切

五张表分别是申领主表、申领明细表、物资表、审批日志表,加上平台内置的字典表(状态值放这里,不单独建)。核心字段如下:

表名关键字段设计说明
T_APPLY_ORDERORDER_ID、ORDER_NO、APPLICANT_ID、DEPT_ID、STATUS、TOTAL_AMOUNT主表;TOTAL_AMOUNT 冗余明细汇总,列表页免 join
T_APPLY_ITEMITEM_ID、ORDER_ID、MATERIAL_ID、QTY、UNIT_PRICE、AMOUNT明细表;ORDER_ID 建普通索引,删单先删明细
T_MATERIALMATERIAL_ID、MATERIAL_NAME、SPEC、UNIT、STOCK物资字典;STOCK 只在归档时扣减
T_APPROVE_LOGLOG_ID、ORDER_ID、NODE、OPERATOR_ID、ACTION、OPINION、OP_TIME日志表只增不改,驳回原因从此可查

几个约定值得写进设计文档。主键统一用 32 位 UUID 字符串,由服务层生成,不用数据库自增——平台的组织表、用户表都是字符串主键,混用自增在关联查询时会有类型转换的麻烦。业务单号ORDER_NO单独生成,格式为“SQ + yyyyMMdd + 4 位流水”,加唯一索引,它是给人看的,主键是给机器看的,别混。金额字段统一两位小数,后端用BigDecimal,前端传上来的金额一律不采信,重新算。

状态值用两位字符串而不用数字,是为了列表页按状态排序时不用再写转换函数:10草稿、20待审、30已通过、40已驳回、50已归档、90已作废。这套编码同时写进平台字典表,页面上的状态下拉框直接引字典码,别在 JSP 里硬编码<option>。

3.2 页面与字典配置:能配的交给平台,要算的留给 Java

三个页面的分工要清晰,不能一个页面什么都能干:

  • apply_list:我的申领列表。查询区四个条件(单号、部门、状态、申请日期区间),列表列显示单号、申请日期、部门、金额、状态、操作。
  • apply_edit:申领单编辑。上半部分是主表信息,下半部分是明细表格,支持增行删行,提交时一次性保存。
  • approve_list:审批待办。查询条件锁定本部门,操作列只有“通过”和“驳回”两个按钮。

页面用平台自带的页面建模工具生成配置,手写也认,结构大同小异。查询区配置我一般写成这样,字段名必须和后台接收参数的 key 一字不差:

<!-- 列表页查询区:name 即后台取参的 key,operator 决定拼 like 还是 = --> <query-panel id="qryApply"> <field name="orderNo" label="申领单号" type="text" operator="like"/> <field name="deptId" label="申请部门" type="org" operator="="/> <field name="status" label="单据状态" type="dict" dictCode="APPLY_STATUS" operator="="/> <field name="applyDate" label="申请日期" type="daterange" column="APPLY_DATE" operator="between"/> </query-panel>

operator="like"服务端会拼成ORDER_NO LIKE '%值%',所以单号输入框要在提交前trim,否则前后空格会让查询莫名其妙查不到。type="org"是平台的组织选择控件,返回值直接是部门 ID,不用自己再做树形下拉。dictCode指向字典表里的编码,字典里加一个状态,页面自动多一个选项,这就是“能配的交给平台”的实际收益。日期区间控件返回两个值,后台按between处理,注意闭区间和开区间的差别:用户选 3 月 1 日到 3 月 1 日,期望的是当天全天,所以结束日期要补到23:59:59,这个坑在 5.2 会再提一次。

3.3 服务层与事务边界:一次提交只允许开一个事务

服务层按业务动作划分方法,一个动作一个方法,方法名就是事务切点:

public interface IApplyOrderService { String saveDraft(ApplyOrderForm form, String operatorId); // 存草稿,可反复调用 String submit(ApplyOrderForm form, String operatorId); // 保存并提交,写日志 void approve(String orderId, String action, String opinion, String operatorId); // 通过/驳回 void archive(String orderId, String operatorId); // 归档并扣库存 }

四个方法对应四个事务,事务只加在impl的实现方法上,用平台的声明式事务配置按方法名匹配save* / submit* / approve* / archive*。这里有个纪律:Action 层绝不开事务。原因很直接——Action 里一旦调了两个 Service 方法,第一个成功第二个失败,事务边界就乱了,数据会“存一半”。如果确实需要跨两个 Service,就在上层再包一个 Service 方法,把两个调用放进去,而不是在 Action 里手动begin/commit。

另外,审批和归档这两个动作都要做幂等保护。用户手快连点两下“通过”,如果不加条件更新,日志会写两条、状态会被覆盖。做法在 4.2 里给。

4. 关键模块实现:表单落库、状态流转与分页导出

这一章是能直接抄的部分。三段代码对应项目里最容易出问题的三个动作:保存、审批、查询。

4.1 表单保存:主子表一次落库,金额只认后端

先看服务层实现。这段代码承担了主键生成、单号生成、金额重算、主子表写入四件事,全部放在一个事务里:

@Override public String saveDraft(ApplyOrderForm form, String operatorId) throws AppException { // 1. 主键与单号只在这一处生成,避免页面和后台各生成一次导致重复 String orderId = form.getOrderId(); boolean isNew = StringUtils.isBlank(orderId); if (isNew) { orderId = IdUtil.uuid(); form.setOrderId(orderId); form.setOrderNo(OrderNoGen.next("SQ")); // SQ + 日期 + 4 位流水 form.setStatus(OrderStatus.DRAFT); // 新建一律草稿,不接受前端传状态 } // 2. 金额不信任前端,按明细逐行重算后汇总 BigDecimal total = BigDecimal.ZERO; for (ApplyItem item : form.getItems()) { if (item.getQty() == null || item.getQty().compareTo(BigDecimal.ZERO) <= 0) { throw new AppException("第 " + (form.getItems().indexOf(item) + 1) + " 行数量必须大于 0"); } item.setAmount(item.getQty().multiply(item.getUnitPrice()).setScale(2, RoundingMode.HALF_UP)); total = total.add(item.getAmount()); } form.setTotalAmount(total.setScale(2, RoundingMode.HALF_UP)); // 3. 先删后插:明细不做差异比对,小单据全量覆盖最简单也最不容易错 commonDao.delete("ApplyItem.deleteByOrderId", orderId); commonDao.insertOrUpdate("ApplyOrder.save", form.toDataObject()); for (ApplyItem item : form.getItems()) { item.setOrderId(orderId); // 回填主键,靠这个字段关联 item.setItemId(IdUtil.uuid()); commonDao.insert("ApplyItem.insert", item.toDataObject()); } return orderId; }

几个参数和写法要说明。IdUtil.uuid()是工程里自己封的工具,返回去掉横线的 32 位串,和平台其他表的主键格式保持一致。OrderNoGen.next("SQ")内部用“前缀 + 当天日期 + 数据表当日最大流水 + 1”实现,必须放在事务内并且对流水号加唯一索引兜底,否则并发下两个请求可能拿到同一个号,这是血泪经验。commonDao是平台通用数据访问对象,insertOrUpdate会根据主键是否存在自动判断新增还是更新,省掉一个if。

规范上还有一条容易被忽略:item.setOrderId(orderId)这行必须在循环内、insert之前执行,不能指望 page 端传过来。页面上的明细行是动态加的,前端并不知道主键,只能靠后端回填。如果漏了这行,明细表里会出现一堆ORDER_ID为空的孤儿数据,列表页 join 不出来,看着就像“保存成功但明细丢了”。

4.2 审批流转:用带条件的 UPDATE 卡住并发

审批的难点不在业务,在并发和幂等。两个负责人同时点“通过”,或者一个人连点两下,都会出问题。做法是把状态判断放进 SQL 的WHERE里,用受影响行数判断有没有抢到:

-- 审批通过:只有当单据当前仍是「待审(20)」时才允许更新 UPDATE T_APPLY_ORDER SET STATUS = '30', UPDATE_TIME = SYSDATE WHERE ORDER_ID = #{orderId} AND STATUS = '20';

服务层拿到返回值后判断:

int rows = commonDao.update("ApplyOrder.updateStatus", params); if (rows == 0) { // 说明单据已被人处理过、或状态不对,直接拒绝,不写日志 throw new AppException("该单据已被处理,请刷新后重试"); } commonDao.insert("ApproveLog.insert", log); // 只有抢到才写日志

#{orderId}是命名参数占位符,具体写法按项目所用 SQL 映射方式调整。STATUS = '20'这个条件就是乐观锁,它保证同一张单子只有一个请求能把状态从 20 改成 30,第二个请求的rows一定是 0,直接给用户提示刷新。日志写在rows判断之后,顺序不能反——先写日志再更新状态,一旦更新失败,日志里就会多出一条根本不存在的审批记录,追溯的时候最坑人。

驳回同理,条件里加上AND STATUS = '20',意见字段做非空校验,长度上限跟表字段对齐(本例 500 字符)。归档则要额外扣库存:UPDATE T_MATERIAL SET STOCK = STOCK - #{qty} WHERE MATERIAL_ID = #{id} AND STOCK >= #{qty},同样用受影响行数判断,库存不足时整笔事务回滚,把已经改掉的单据状态一起撤销。

4.3 列表分页与导出:把慢查询挡在翻页之前

分页最容易出的两个问题:总数和列表对不上、导出时把整个库拉出来。核心是让 count 和 list 共用一套查询条件:

public PageResult<ApplyOrderVO> queryPage(ApplyQuery query) { // 1. 页码和每页条数做上下限保护,防止前端把 pageSize 改成 99999 int pageNo = Math.max(query.getPageNo(), 1); int pageSize = Math.min(Math.max(query.getPageSize(), 10), 100); // 2. 条件集中组装一次,count 与 list 共用,杜绝两处 where 写得不一致 Map<String, Object> params = new HashMap<>(); params.put("status", query.getStatus()); params.put("deptId", query.getDeptId()); params.put("orderNo", StringUtils.trimToNull(query.getOrderNo())); params.put("dateFrom", query.getDateFrom()); params.put("dateTo", DateUtil.endOfDay(query.getDateTo())); // 补到 23:59:59 params.put("dataScope", query.getDataScope()); // 部门数据权限 long total = commonDao.count("ApplyOrder.countByCondition", params); if (total == 0) { return new PageResult<>(0, Collections.emptyList(), pageNo, pageSize); } // 3. 分页参数在数据库侧生效,不要查全量再在内存里 subList params.put("startRow", (pageNo - 1) * pageSize); params.put("endRow", pageNo * pageSize); List<ApplyOrderVO> list = commonDao.queryList("ApplyOrder.selectByCondition", params); return new PageResult<>(total, list, pageNo, pageSize); }

参数上,endOfDay把用户选的结束日期补到当天最后一秒,解决“选同一天查不到数据”的问题;dataScope是部门数据权限的开关,申请人是普通用户时传本部门,管理员传空表示不过滤,这个判断放在 Service 里,不要放在 SQL 里写死。startRow / endRow的写法适配常见数据库的分页语法,换库时只改映射文件。

导出单独走一条链路,不能直接复用queryPage然后传pageSize = 999999。正确做法是导出前先count,超过一万行提示用户缩小查询范围,或者走异步导出生成文件。同步导出也要设条数上限,并且导出用的 SQL 去掉ORDER BY之外的所有排序字段——大数据量下ORDER BY能让一次导出从 3 秒变 3 分钟。

5. 避坑与排查:Unieap 小项目最容易翻车的五处

小项目出问题,八成不是业务逻辑写错,而是环境、事务、权限这三类“平台层”的问题。下面五条是这类项目里重复率最高的。

5.1 环境与启动:页面打得开不等于跑得起来

现象:本地访问页面正常,部署到测试环境后 404,或者页面出来了但点任何按钮都提示“系统异常”,日志里只有一行ClassNotFoundException。

原因:平台的 jar 包版本和编译时用的不一致,或者本地引用了工具类里的某个方法,而这个方法是新版才有的,测试环境还是旧包。另一种常见情况是 JDK 版本不匹配,本地 1.8 编译、服务器跑 1.7,字节码版本对不上。还有一种是配置文件没跟着打包,sqlmap目录漏了,启动时不报错,第一次查询才炸。

解决:固定一套“编译 - 打包 - 部署 - 冒烟”的流程,部署包里的 jar 清单用md5比对一次,别靠肉眼。JDK 版本、平台版本、数据库驱动版本写进 README,交接时一目了然。本地跑通之后,先在一个干净的测试库上完整走一遍“建表 → 初始化字典 → 登录 → 建单 → 审批”,这四步能过,基本不会再出现环境问题。启动脚本里的内存参数也别忽略,默认配置在数据量稍大时容易 OOM,小项目给到 1G 起比较稳。

5.2 数据与事务:存了一半的数据最难查

现象:申领单保存成功,列表能看到,点进详情明细是空的;或者审批通过了,日志表里却查不到审批记录。

原因:明细没回填ORDER_ID(见 4.1),或者主子表写入不在同一个事务里,主表成功、明细抛异常后被吞掉。日志缺失通常是“先写日志后更新状态”的顺序问题,也可能是异常被catch住之后只打了日志没抛出去,事务不回滚。

解决:所有写操作只在 Service 层开事务,Action 里不允许出现try-catch之后啥也不干的情况。开发期把 SQL 日志打开,保存一次看实际执行了几条INSERT,比对着代码猜快得多。另外把“日期区间查不到数据”也归到这一类——多半是结束日期没补时分秒,或者数据库字段是DATE而参数是TIMESTAMP,隐式转换把时间截断了。

5.3 权限与发布:本地好好的,一部署就白屏

现象:新加的功能菜单在本地能看到,部署后管理员登录也找不到入口;或者菜单点得开,但列表提示“无权限访问”。

原因:菜单和权限是平台自带的,不走代码发布流程。本地开发时给账号配了管理员,所以什么都能看;部署后菜单数据没初始化,或者菜单里绑定的资源编码和页面实际路径不一致。还有一种:数据权限的部门过滤在 SQL 里写死了当前用户的部门,换个账号就查不到数据。

解决:把菜单、字典、初始账号的初始化脚本放进db目录,和建表脚本一起版本管理,每次部署先执行脚本再发应用。资源编码统一用“模块_页面_操作”的格式,比如apply_order_export,页面和代码里引用同一个常量,别手敲字符串。上线前用三个账号各走一遍:管理员看全部、负责人看本部门、申请人看自己,三遍都过,权限这块基本就稳了。

6. 验收与答辩:一份自检清单走完再交

验收现场最常见的场景是:演示到一半,评委问“这个单据能不能导出”,你点了导出,转圈十秒,然后报错。问题不在导出没做,而在做的时候只测了三条数据。所以我把验收拆成一张清单,功能、数据、异常三条线各走一遍,走完再点“提交”。

验收项检查方法合格标准
主流程用申请人账号建单、提交,换负责人账号审批通过状态从 10 → 20 → 30,日志三条齐全
驳回回退审批时点驳回并填意见,申请人改完重新提交状态回到 20,原意见可查
并发保护两个浏览器同时点同一单的“通过”一个成功,另一个提示刷新
边界数据明细只填一行、数量填 1、金额填 0.01能存能查,金额不出现 0.009999
查询导出日期区间选同一天、选跨月、导出 500 条结果不为空,导出文件行数一致
权限隔离三个角色分别登录看列表各自只看到该看的

演示脚本要提前写好,控制在五分钟:先讲一句业务背景,然后申请、审批、驳回、再提交、通过、归档,一路走完。中途不要打开代码编辑器,也不要现场改数据。评委最常追问的三个问题基本固定——“数据权限怎么做的”“并发提交怎么防的”“单据状态为什么用字符串不用数字”,答案分别在第 3.2、4.2、3.1 里,提前背熟比临场发挥靠谱。

答辩 PPT 别超过十页:业务背景一页、功能清单一页、表结构一页、流程图一页、关键代码一页、验收结果一页,剩下的留给演示。表格截图比文字描述有说服力,尤其是状态流转那张,把每一步的状态值和操作人标出来,比讲十分钟设计思路管用。

带过几个这样的小项目之后,我养成一个习惯:功能写完先不急着美化页面,而是拿一个空库从头跑一遍建表脚本和初始化脚本,跑通了再开始点页面。因为翻车几乎都发生在“我以为环境是干净的”这个假设上——本地库里有半年前的老数据、有手工加的字典项、有一条已经改成 30 的测试单,页面看着一切正常,换个库就原形毕露。这套流程后来成了我每个小项目的固定动作,从建表到验收一条龙走完,再交出去心里才踏实。希望帮到你。

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

返回列表