文章目录

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 行影响 = 库存不足

五、照片上传:缩略图 + 缩放查看

固定资产带照片(资产照片上传、缩略图预览、点击缩放查看)。实现要点:

🚨 部署坑(外传):打包必须 mvn -Pprod!dev 是 activeByDefault=true,不带 profile 会把 dev 的上传路径打进 jar,生产环境头像/上传文件全部 404。2026-08-04 头像消失事故根因。

六、入库表单重写:从填表人视角

初版入库表单字段过多(预计费用、抄送人……都是填表人不知道的),重写原则:

// 入库表单字段(重写后):资产类目/名称/编号/价格/数量/存放位置/备注
export interface AssetInboundForm {
    categoryId: number;     // 资产类目(必选)
    assetName: string;      // 资产名称
    assetNo?: string;        // 编号(不填自动生成 HZ+7)
    price?: number;         // 采购单价
    quantity: number;       // 数量(默认 1)
    location?: string;      // 存放位置
}

七、权限与导出

八、踩坑清单(可直接复用)

  1. 菜单收敛:操作类功能别单独开菜单,绑定资产详情页 Tab
  2. 表单从填表人视角:不知道的字段不放,审批人/抄送人走工作流
  3. 编号并发:Redis 自增生成,别用 MAX(id)+1
  4. 状态机显式化:TRANSITIONS 表驱动,禁止任意跳转
  5. 耗材 CAS:UPDATE ... WHERE stock >= qty 防超卖,0 行影响即失败
  6. 打包 -Pprod:生产构建必须显式 profile,否则 dev 配置进 jar
✅ 最终成果:ITAM 模块覆盖固定资产/耗材/申领全流程,菜单 3 项、操作记录全生命周期可追溯、照片/导出/权限点齐备,后续保养计划、维修、报废作为设备子 Tab 持续扩展。

评 论