文章目录
SmartOA 的 ITAM(IT Asset Management)模块管理固定资产全生命周期:台账 → 入库 → 领用 → 借用 → 维修 → 报废,附带耗材管理、采购申领、钉钉卡片审批。模块从零搭建(后端 + 前端 + 数据库),中间做了两次关键精简。这篇记录模块设计、菜单收敛和数据建模的决策过程。
一、模块范围:什么算「固定资产」
ITAM 覆盖两类资产:
| 类型 | 例子 | 生命周期 |
|---|---|---|
| 固定资产 | 电脑、显示器、服务器 | 入库 → 领用 → 归还 → 维修 → 报废 |
| 耗材 | 硒鼓、键盘、鼠标 | 入库 → 领用(消耗型,不归还) |
| 申领单 | 采购申请、领用申请 | 提交 → 钉钉卡片审批 → 入库/发放 |
关键设计决策:子资源绑定父主体——保养计划、维修记录、报废记录都挂在具体设备下,进设备详情页的 Tab,不单独开菜单。
二、数据库设计:44 张表里的资产域
资产域核心表:
-- 固定资产台账
CREATE TABLE itam_fixed_asset (
asset_id BIGINT PRIMARY KEY COMMENT '资产ID',
asset_no VARCHAR(32) UNIQUE COMMENT '资产编号 HZ+7位',
category_id BIGINT COMMENT '资产类目',
status TINYINT COMMENT '1在库 2领用 3借用 4维修 5报废',
purchase_price DECIMAL(10,2),
photo_url VARCHAR(255) COMMENT '资产照片',
create_time DATETIME
);
-- 操作记录(全生命周期流水)
CREATE TABLE itam_asset_operation (
op_id BIGINT PRIMARY KEY,
asset_id BIGINT COMMENT '资产ID',
op_type VARCHAR(16) COMMENT 'IN/OUT/BORROW/RETURN/REPAIR/SCRAP',
operator BIGINT COMMENT '操作人',
remark VARCHAR(255),
create_time DATETIME
);
资产编号规则:HZ + 7 位数字(HZ 是公司代号,数字顺序生成)。生成实现要注意并发:
// 编号生成:Redis 自增 + 日期前缀,防并发重号
public String nextAssetNo() {
long seq = redisService.incr("itam:asset:seq", 1L);
return String.format("HZ%07d", seq); // HZ0000001
}
三、菜单收敛:11 项 → 3 项
初版把每个操作都做成了独立菜单页(入库页、领用页、借用页、归还页、维修页、报废页……共 11 项),用起来发现:菜单爆炸,用户找不到入口。二次精简:
| 初版 11 项 | 收敛后 3 项 | 理由 |
|---|---|---|
| 固定资产 / 耗材 / 采购申领 | 资产台账(含子 Tab) | 台账为主入口 |
| 入库 / 领用 / 借用 / 归还 | → 资产详情页操作按钮 | 操作绑定具体资产 |
| 维修 / 报废 / 保养计划 / 保养标准 | → 详情页 Tab | 子资源不单独开菜单 |
| 操作记录 | → 详情页 Tab | 流水跟资产走 |
💡 设计原则:子资源必然属于父主体的(保养/维修/报废都绑定具体设备),合并到父详情页 Tab,不留独立菜单;但按类型预设的模板(保养标准模板)仍独立。
菜单收敛的同时删除了 8 个旧独立操作页面,数据库菜单表同步清理(P2-22 禁用无效菜单 SQL,幂等执行)。
四、状态机:资产流转的核心
资产状态流转是典型的有限状态机,每次操作校验合法性:
// 状态机:不允许跳状态(如 维修中 不能直接 报废,须先 归还/入库)
public enum AssetStatus {
IN_STOCK(1), ISSUED(2), BORROWED(3), REPAIRING(4), SCRAPPED(5);
private static final Map<AssetStatus, Set<AssetStatus>> TRANSITIONS = Map.of(
IN_STOCK, Set.of(ISSUED, BORROWED, SCRAPPED),
ISSUED, Set.of(IN_STOCK, REPAIRING), // 归还 or 送修
BORROWED, Set.of(IN_STOCK, REPAIRING),
REPAIRING, Set.of(IN_STOCK, SCRAPPED) // 修好入库 or 报废
);
public boolean canTransitionTo(AssetStatus target) {
return TRANSITIONS.getOrDefault(this, Set.of()).contains(target);
}
}
耗材库存用 CAS 更新(乐观锁)防超卖:
// 耗材出库:CAS 保证并发下库存不为负
UPDATE itam_consumable
SET stock = stock - #{qty}
WHERE consumable_id = #{id}
AND stock >= #{qty} // 条件不满足 = 0 行影响 = 库存不足
五、照片上传:缩略图 + 缩放查看
固定资产带照片(资产照片上传、缩略图预览、点击缩放查看)。实现要点:
- 上传走项目统一的文件服务(本地存储,路径配置在 yaml)
- 列表用缩略图(
?thumb=1参数或独立小图),详情页用原图 - 前端预览组件支持缩放(滚轮 + 拖拽)
🚨 部署坑(外传):打包必须
mvn -Pprod!dev 是 activeByDefault=true,不带 profile 会把 dev 的上传路径打进 jar,生产环境头像/上传文件全部 404。2026-08-04 头像消失事故根因。
六、入库表单重写:从填表人视角
初版入库表单字段过多(预计费用、抄送人……都是填表人不知道的),重写原则:
- 填表人不知道的不放表单:预计费用是财务算的,不在入库表单里
- 审批人走工作流:抄送人由 Warm-Flow 流程配置决定,不占业务字段
- 入库数量默认 1(实物入库多数单件),隐藏不常用字段
// 入库表单字段(重写后):资产类目/名称/编号/价格/数量/存放位置/备注
export interface AssetInboundForm {
categoryId: number; // 资产类目(必选)
assetName: string; // 资产名称
assetNo?: string; // 编号(不填自动生成 HZ+7)
price?: number; // 采购单价
quantity: number; // 数量(默认 1)
location?: string; // 存放位置
}
七、权限与导出
- 导出端点补全 3 个(台账导出、操作记录导出、耗材导出)+ 16 个缺失权限点注册
- 前端权限点 hyphenated → camelCase 统一(itam-asset → itamAsset),与后端 @SaCheckPermission 一一对应
- 权限点 3-way 一致性测试(前端菜单 / 前端按钮 / 后端注解)防止漂移
八、踩坑清单(可直接复用)
- 菜单收敛:操作类功能别单独开菜单,绑定资产详情页 Tab
- 表单从填表人视角:不知道的字段不放,审批人/抄送人走工作流
- 编号并发:Redis 自增生成,别用 MAX(id)+1
- 状态机显式化:TRANSITIONS 表驱动,禁止任意跳转
- 耗材 CAS:UPDATE ... WHERE stock >= qty 防超卖,0 行影响即失败
- 打包 -Pprod:生产构建必须显式 profile,否则 dev 配置进 jar
✅ 最终成果:ITAM 模块覆盖固定资产/耗材/申领全流程,菜单 3 项、操作记录全生命周期可追溯、照片/导出/权限点齐备,后续保养计划、维修、报废作为设备子 Tab 持续扩展。
评 论