恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从零到上线:资产管理系统核心设计与并发控制实践
首页
资讯中心
/
从零到上线:资产管理系统核心设计与并发控制实践
从零到上线:资产管理系统核心设计与并发控制实践
发布时间:2026/10/15 23:31:58
总算把这套资产管理系统从零到上线完整跑起来了。项目本身不大核心就两块耗材进出库管理员工管理。但越琢磨越觉得这种内部系统的难点根本不在功能多而在流程口径和边界条件。我接到需求的时候公司还在用Excel加纸质台账登记办公耗材、设备配件和领用记录库里到底有多少、谁领走了什么、哪些快过期基本靠问。后来有几次数额对不上、领用审批走线下又漏单才让我下决心做一套能长期用的系统。下面把实际做的过程、表结构设计、并发踩坑和上线后运维经验都写出来给正在做类似系统的人一个参考。1. 项目背景与核心需求拆解1.1 为什么放着Excel不用非要重新开发最开始我也评估过直接买现成的行政资产管理软件或者继续优化Excel模板。但实际接触业务后发现问题不在工具在流程。Excel能做到“记账”但做不到“约束”。比如仓管员可以同时改库存和领用记录审批后没人核对月底盘点和账面永远差一截。而且员工信息是动态的部门调整、离职交接、领用限额这些Excel管不了。另外公司内部日常涉及的耗材类型比较多有办公类、IT配件类、劳保类部分物品还有批次和有效期。如果只用Excel光维护常用物品编码和计量单位就是灾难。更关键的是后续要和员工花名册、办公区仓库、采购对账打通没有一个数据库级别的系统接口根本接不起来。所以我最后确定自建一套轻量资产管理系统先把耗材进出库和员工管理两个核心域做扎实。1.2 核心需求与范围边界我把需求拆成了三类主数据管理物品分类、物品档案、仓库、供应商、员工信息、部门架构。耗材进出库管理采购入库、退货入库、领用出库、退库、借用、调拨、盘点调整、低库存和近效期预警。员工管理员工档案、系统账号、角色权限、部门审批关系、领用限额、离职交接。这里特别要注意“范围边界”。我一开始差点把固定资产折旧、财务报销也做进来后来果断砍掉了。原因是资产管理系统和财务系统关注口径不一样固定资产折旧涉及财务规范强行做只会拖长项目周期。第一版就专注“物的流转”和“人的权限”保证每一笔进出库都有单据、有经办人、可追溯财务和采购聚合对账留到后续再对接。1.3 技术选型背后的考量技术栈选的是 Spring Boot Vue MySQL很多人觉得这类系统用PHP或者Node写更快。但我选择Spring Boot主要是因为后续要和公司内部已有的通知服务、流程引擎对接团队对这个体系更熟维护成本低。数据库用MySQL是因为数据量预估并不夸张。按一年的耗材流水来看出入库记录大概几十万条加索引之后MySQL完全没问题没必要上PostgreSQL带来的运维新负担。前端用Vue是不想把页面做得太重一个后台管理界面表格、表单、弹窗这套组合Vue最顺手。至于Redis我用它做了两部分一是登录会话二是低库存预警的缓存标记。一开始没有用Redis做库存锁因为单机部署、并发量不高MySQL的行锁就够了后面会在并发控制部分专门解释。2. 系统整体架构与数据模型设计2.1 功能模块划分功能上分为五个模块基础资料部门、员工、仓库、物品分类、物品档案、供应商。入库管理采购入库、退货入库、盘盈入库、初始建账入库。出库管理领用出库、借用出库、调拨出库、盘亏出库、报废出库。审批中心入库审批、领用审批、出库确认、盘点审批。报表与预警库存台账、出入库流水、员工领用统计、低库存预警、效期预警。这里我特意把“审批”抽出来做公共模块而不是写死在出库单里。因为实际业务中不同物品、不同金额、不同部门审批层级都不一样。用一张流程配置表去驱动后期调整不需要改代码。2.2 数据库表结构与核心字段说明数据库第一版我建了十几张表核心表如下表名用途关键字段sys_user登录账号id, username, password_hash, employee_id, statusorg_dept部门树id, parent_id, name, pathemployee_info员工档案id, user_id, dept_id, name, job_no, phone, statusasset_category物品分类id, parent_id, name, code, unitmaterial物品档案id, category_id, name, spec, unit, safety_stock, expire_dayswarehouse仓库id, name, location, keeper_idinventory实时库存id, material_id, warehouse_id, quantity, available_quantity, frozen_quantity, versionstock_record库存流水id, material_id, warehouse_id, change_type, change_qty, before_qty, after_qty, biz_type, biz_no, operator_id, created_atinbound_order入库单id, order_no, supplier_id, warehouse_id, status, approver_id, inbound_atinbound_order_item入库单明细id, order_id, material_id, qty, unit_price, batch_no, expire_dateoutbound_order出库/领用单id, order_no, applicant_id, dept_id, status, reasonoutbound_order_item出库单明细id, order_id, material_id, qty, purposeemployee_material_binding员工资产绑定id, employee_id, material_id, bind_type, bound_at, return_at, status这张表设计里最重要的一点是inventory和stock_record分开。inventory只存当前库存快照stock_record存每一笔变动。为什么要这样因为查询实时库存如果每次都SUM明细数据量上来后会非常慢但如果只信inventory一旦程序Bug或者人工改数据没有任何依据来对账。所以两条腿走路读库存用inventory审计和对账用stock_record每天晚上定时任务做一次平衡检查。2.3 库存流水与状态流转设计库存流水的类型我固定成了枚举不允许业务方随便自定义。IN采购入库、退货入库、盘盈入库、期初入库OUT领用出库、借用出库、调拨出库、盘亏出库、报废出库FREEZE审批通过后冻结库存UNFREEZE审批驳回或取消后解冻MOVE_IN / MOVE_OUT仓库调拨的入和出每一条流水都记录变动前数量和变动后数量这样任何一个时间点都能回放出库单执行时的库存状态。单据状态流转上入库单是草稿 → 待审批 → 已审批 → 已入库 / 已驳回 / 已取消。出库单是草稿 → 部门审批 → 仓库确认 → 已完成 / 已驳回 / 已取消。关键点在于“实际库存变化只发生在最后一步”审批通过不等于库存就变了必须等仓库实际确认执行。这个设计能避免审批时间过长导致库存被预占的问题。3. 耗材进出库管理的关键实现3.1 入库流程从采购到上架入库流程看着简单实际上细节很多。操作员创建入库单时需要选择供应商、仓库然后添加明细物品、数量、单价如果启用批次管理还要填批次号和有效期。单子提交后走审批审批通过后仓库确认入库系统才真正增加库存。这里有两个容易忽略的点。第一单价和小数位数。办公耗材里像胶带、网线、扎带可能按“卷”、“米”、“盒”计量有的物品按小数计算。数据库字段如果用INT一卷胶带拆成半卷就会出问题。所以数量字段我建议直接用DECIMAL(12, 3)而不是整型。第二批次号唯一性。如果同一批货重复录入批次号后续按批发出库时会把两个批次当成同一个导致有效期数据错乱。入库接口里必须校验“同一仓库、同一物品、同一批次号只能存在一条有效记录”。入库真正执行的逻辑可以简化成下面这段事务代码Transactional(rollbackFor Exception.class) public void confirmInbound(InboundOrderDTO dto) { InboundOrder order inboundOrderMapper.selectById(dto.getOrderId()); if (order null || !APPROVED.equals(order.getStatus())) { throw new BizException(入库单不存在或未通过审批); } ListInboundOrderItem items inboundOrderItemMapper.selectByOrderId(order.getId()); for (InboundOrderItem item : items) { // 加行锁防止同一物料并发入库时数据混乱 Inventory inv inventoryMapper.selectByMaterialAndWarehouseForUpdate( item.getMaterialId(), order.getWarehouseId()); if (inv null) { inv new Inventory(item.getMaterialId(), order.getWarehouseId()); inv.setQuantity(item.getQty()); inv.setAvailableQuantity(item.getQty()); inventoryMapper.insert(inv); } else { int before inv.getQuantity(); inv.setQuantity(before item.getQty()); inv.setAvailableQuantity(inv.getAvailableQuantity() item.getQty()); inventoryMapper.updateById(inv); } stockRecordMapper.insert(StockRecord.create( IN, item.getMaterialId(), order.getWarehouseId(), item.getQty(), before, before item.getQty(), INBOUND, order.getOrderNo(), dto.getOperatorId())); } order.setStatus(INBOUNDED); inboundOrderMapper.updateStatus(order); }注意这段代码里的for update行锁很多人会漏。入库虽然看起来是加库存但如果两笔采购单同时入库同一个物品不锁行的话before数量可能算错进而导致流水里的前后值对不上。行锁开销对这种量级的系统可以忽略但数据一致性收益很大。3.2 出库流程领用申请与仓库确认出库比入库复杂因为涉及“人”。业务流程是员工提交领用申请填写物品和数量、用途部门负责人审批仓库管理员确认后库存才真正扣减。如果管理高价值物品还可以在审批环节加一道“资产管理岗复核”。出库扣库存的SQL我建议写成条件更新而不是先查再改UPDATE inventory SET available_quantity available_quantity - #{qty}, quantity quantity - #{qty} WHERE material_id #{materialId} AND warehouse_id #{warehouseId} AND available_quantity #{qty}这个SQL执行后如果返回影响行数为0说明可用库存不足直接抛异常。用条件更新的好处是原子性强不需要显式拿锁再判断避免并发请求同时读到满足条件的库存。出库单确认时还要处理一个特殊情况审批通过后到仓库实际出库之间库存可能被其他单子占用。所以确认出库是重新校验库存而不是默认审批时已经锁定。如果公司内部高并发抢库存的场景多可以加“冻结库存”机制审批通过先把available_quantity减掉加到frozen_quantity仓库确认出库时再把frozen_quantity减掉如果单子驳回再把frozen_quantity释放回available_quantity。在项目实际落地时我第一版只做了“确认时校验库存”后来发现春节前行政统一发放福利的时候多个部门同时领同一类物品总有人提交申请时看到库存有审批完再去出库已经没了。于是第二版加了冻结库存才把这个问题彻底解决。3.3 批次、效期与低库存预警耗材里有一类是必须管理有效期的比如洗手液、清洁剂、部分化学试剂。我的做法是启用“批次库存”表批次信息放在入库单明细里出库时优先出最早到期的批次。算法不复杂就是按expire_date升序排列逐批次扣减查询该物料在所有批次中的剩余数量按expire_date ASC排序。遍历批次用本次出库数量去扣。如果一个批次不够自动扣下一个批次直到数量扣满或者库存不足。低库存预警则靠两个字段material.safety_stock最低库存阈值和inventory.available_quantity当前可用库存。我写了一个Scheduled任务每天早上扫描所有仓库凡是available_quantity safety_stock的物品就生成预警信息推送给仓库管理员。预警内容不要只给一个物品名最好带上“近7天平均领用量”方便判断是紧急补货还是本来就该淘汰的慢周转物品。4. 员工管理模块的落地要点4.1 组织架构与账号权限设计员工管理不是简单建一张花名册它和耗材进出库有很深的耦合。第一件事是梳理部门树因为审批和统计都是按部门走的。我在org_dept表里用了path字段存储类似/1/5/23/的路径这样查某个部门下的所有子部门可以用LEFT JOIN和LIKE实现避免递归查询。系统账号和员工档案要分开。公司里不是每个员工都需要登录系统比如一线生产人员可能只通过部门助理统一领用但他依然要有员工档案因为出库单的申请人和领用人必须对应到真实人。所以employee_info是主数据sys_user只是登录账号两者用employee_id关联。离职的员工账号可以禁用但员工档案不能删除历史流水还要靠它追溯。权限模型用经典的RBAC角色、菜单、按钮。角色我建议至少分三类普通员工只能提交领用申请、查看自己的申请记录。部门审批人查看并审批本部门的申请。仓库管理员出入库操作、库存查询、盘点。系统管理员主数据维护、权限配置、系统日志。权限控制必须在后端做不能只靠前端隐藏按钮。后面会把常见的权限绕过问题单独讲。4.2 领用限额与审批流员工管理里最有价值的功能是“领用限额”。没有限额之前同一类打印纸有人一个月领十包有人一年领一包全靠自觉。我加了一张quota_rule表配置维度是“部门 物品分类 周期月/季/年 限额数量”。员工提交申请时系统会自动统计该员工在当前周期内已领用数量加上本次申请数量如果超过限额就走额外审批流程或者直接拦截。这里统计要按出库单的“仓库确认出库”时间算而不是按申请提交时间算。否则员工提交一单但仓库还没发货先占了额度后面又取消会造成额度浪费。审批流我做得比较轻用配置表决定“是否需要二级审批”和“审批人是谁”。规则大致是单价低于某个金额且数量在限额内部门负责人审批即可超过限额或属于高价值物品自动追加一条资产管理岗审批。不要把审批逻辑硬编码在出库单里因为业务规则一定会变。4.3 员工资产绑定与离职交接耗材之外系统里还有一些需要追踪到人的可长期使用物资比如办公笔记本、外接显示器、工具仪表。这时用employee_material_binding表记录绑定关系字段包括员工、物品实例、绑定时间、归还时间、状态。和耗材出库不同的是这类物资每次发放都对应一个唯一实例所以material档案里要有“是否唯一编码管理”的标记。启用唯一编码后入库就是逐条录编号出库要选择具体编号不能直接填数量。员工离职时系统要能自动查出三样东西名下未归还的借用物资、未完成的领用申请、超过限额未核销的记录。我做了离职检查接口HR在系统里标记离职后会生成一张待办清单。这一步看起来不起眼实际能减少很多后续扯皮。5. 实操过程与核心环节实现5.1 从需求调研到原型确认正式开始写代码前我花了一周时间做需求调研。不是坐在会议室听需求方讲而是直接跟着仓库管理员走了两天看他平时怎么收货、怎么发料、怎么月底盘点。很多需求是“看”出来的不是“问”出来的。比如我发现仓库里存在“一物多名”的问题同一个品牌的打印纸有人叫“A4纸”有人叫“复印纸”Excel里就成了两行。所以我在物品档案里设置了“别名表”并且要求出库时选择物品必须通过统一编码不允许手工输入名称。这个规则非常关键否则统计数据永远是散的。原型我用了简单的页面草图工具画只画关键页面入库单新建页、出库单新建页、库存台账页、员工领用记录页。和业务方确认时我盯着他们问三个问题这个操作谁会做做完之后谁能看到如果填错了怎么改这三个问题能逼出90%的隐藏需求。5.2 关键接口与代码片段我挑两个最有代表性的接口讲讲一个是出库申请一个是库存扣减。出库申请接口的核心逻辑如下Transactional(rollbackFor Exception.class) public Long createOutboundOrder(OutboundOrderDTO dto) { // 校验员工是否存在且在职 EmployeeInfo employee employeeInfoMapper.selectById(dto.getApplicantId()); if (employee null || !ACTIVE.equals(employee.getStatus())) { throw new BizException(申请人不存在或已离职); } // 校验部门是否有审批人 String approverId getApproverByDept(employee.getDeptId()); if (approverId null) { throw new BizException(该部门未配置审批人); } OutboundOrder order new OutboundOrder(); order.setOrderNo(generateOrderNo(OUT)); order.setApplicantId(dto.getApplicantId()); order.setDeptId(employee.getDeptId()); order.setStatus(PENDING); outboundOrderMapper.insert(order); // 逐条校验物品并预占额度 for (OutboundOrderItemDTO item : dto.getItems()) { Material material materialMapper.selectById(item.getMaterialId()); if (material null || !ENABLED.equals(material.getStatus())) { throw new BizException(物品不存在或已停用 item.getMaterialId()); } if (item.getQty() null || item.getQty().compareTo(BigDecimal.ZERO) 0) { throw new BizException(数量必须大于0); } checkQuota(employee, material, item.getQty()); outboundOrderItemMapper.insert(...); } return order.getId(); }出库确认时扣库存用前面那段条件更新SQL。这里要强调出库单新增、审批、确认最好拆成三个接口每个接口做自己的状态校验。不要写一个万能方法用参数控制所有流程否则半年后没人敢改。5.3 并发控制与数据一致性保障小项目也有并发问题尤其是每年固定时间点集中领取物资的时候。我遇到最典型的问题是“同一库存行并发扣减”。假设库存还剩10两个请求同时读到10分别扣8和7最后结果可能是-5。传统解决方式是先SELECT ... FOR UPDATE锁行再更新我入库逻辑里已经用了。出库逻辑我建议直接用条件更新一步到位。如果未来系统拆成多节点部署数据库行锁会遇到跨库问题。那时可以用Redis分布式锁锁的key设计为stock:lock:{warehouseId}:{materialId}获取锁后再执行事务。但要注意加锁和解锁必须配对还要设置过期时间防止死锁。我目前单机部署用MySQL行锁就够了没必要引入额外组件增加复杂度。另一个一致性保障点是“唯一业务单号”。入库单号和出库单号生成后不管接口被重复调用多少次同一个单号只允许执行一次。做法是给order_no字段建唯一索引事务里先执行INSERT如果主键或唯一索引冲突说明是重复请求直接返回成功而不是再执行一次。6. 常见问题与排查技巧实录6.1 并发超卖和负库存负库存是最容易出现的恶性问题。现象是inventory.quantity变成负数月底盘点完全对不上。根因基本都是先查库存Java里判断quantity qty然后再执行更新两个操作之间没有事务保护也没有行锁。我的解决方案分两层。第一层出库扣减用条件更新WHERE available_quantity #{qty}影响行数为0就不允许扣。第二层库存流水表加“扣减前数量”字段但禁止人工改流水如果发现流水前后值对不上说明有程序Bug必须查日志。这里有个排查技巧如果发现负库存已经产生不要直接去改inventory表。新建一张“库存调整单”走盘盈或盘亏流程把差额补平同时留下调整记录。直接改表是饮鸩止渴后面一旦对账原来的错误操作痕迹就没了。6.2 Excel导入乱码与模板校验系统里批量建物品档案、批量导入员工名册一定会用到Excel导入。第一个坑是编码。如果让用户下载CSV模板用户在Windows上用Excel编辑后保存可能是GBK编码。后端读取默认UTF-8中文全部乱码。我的做法是直接用.xlsx模板后端用POI或EasyExcel解析不提供CSV上传。模板里预置表头列顺序固定。导入时先做全量校验把每行错误列出来比如“第3行第5列数量不是数字”全部反馈给用户不要遇到第一行错误就中断。用户一次性把错误改完比来回传十次体验好得多。另外Excel里的日期格式也要专门处理比如仓库提供的入库单Excel里“20240601”和“2024-06-01”都有可能出现。导入接口里我会规定日期格式并在模板附一行说明。解析时用DateTimeFormatter.ofPattern(yyyy-MM-dd)解析失败就报行列错误而不是静默转成null。6.3 权限绕过与越权访问权限这块踩过最深的坑是前端把按钮隐藏了但接口还在。用户直接从浏览器控制台调用接口就能访问未授权的数据。比如普通员工理论上只能查自己的申请单但如果没有在查询接口里强制带上applicant_id 当前登录人他完全可以遍历所有出库单。后端的权限校验必须做两层。第一层是角色权限接口上用注解控制“谁能调”。第二层是数据权限查询时自动拼接部门或员工过滤条件。领用列表查自己的数据用当前登录人 employeeId部门审批人查本部门数据用dept_id IN (当前人管理的部门集合)仓库管理员查所有数据直接放行。数据权限放在SQL拼接里不要指望业务代码里每个方法都记住过滤。6.4 其他典型坑时间时区问题服务器实际环境是UTC业务方看的是GMT8上报领用统计时出现了8小时偏移。建议所有时间字段统一存datetime(3)并按服务器本地时区写入接口返回时序列化为东八区时间。不要在数据库里混用timestamp和datetime。小数精度耗材数量不是全整数线缆按米、胶带按卷但有半卷。所有库存和数量字段用DECIMAL不要用FLOAT。用FLOAT做等值判断会有精度误差对账永远对不上。仓库调拨调拨不能只把A仓库存减掉、B仓库存加回来。中间跨了一个状态一旦失败就不知道货在哪。我做的调拨单包含调出确认和调入确认两个动作调出时A仓库存减少并生成一条“在途库存”调入确认后B仓库存增加、在途库存减少。高价值资产唯一编码如果物资要精确到每一台设备用数量管理会失控。建议启用资产实例表每台设备一个唯一编号用二维码或条形码管理出库、归还都扫编码。7. 上线后的运维小习惯7.1 每天跑一次库存平衡对账上线稳定后我最推荐做的事情就是写一个每日对账任务。凌晨执行把每个物料、每个仓库的库存快照和库存流水明细求和做比对同时对定时任务的执行状态加“幂等处理”防止任务重复执行导致重复预警。对账SQL大致是SELECT i.material_id, i.warehouse_id, i.quantity, COALESCE(SUM( CASE WHEN s.change_type IN (IN, MOVE_IN, UNFREEZE) THEN s.change_qty WHEN s.change_type IN (OUT, MOVE_OUT, FREEZE) THEN -s.change_qty ELSE 0 END ), 0) AS ledger_quantity FROM inventory i LEFT JOIN stock_record s ON s.material_id i.material_id AND s.warehouse_id i.warehouse_id GROUP BY i.material_id, i.warehouse_id, i.quantity HAVING i.quantity ledger_quantity;这个任务不用跑得很频繁每天凌晨一次就行。有异常时发站内信和邮件没异常就只写日志。它能帮你发现不少“看起来没人动过但库存就变了”的问题。7.2 盘点流程要固化不要随手调数盘点不是直接改库存而是走“盘点单”来驱动。我的流程是创建盘点单选择仓库和盘点范围系统冻结该范围内的库存不允许出库盘点人员录入实盘数量系统自动对比账面数和实盘数生成盘盈或盘亏明细审批完成后才生成调整记录。这个流程看起来很重但能把“改数”这件事变得有据可查。哪怕只是差了一个单位的小数位也要走完整的调整审批。时间长了你会发现系统数据的可信度就是靠这种“不近人情”的流程保住。7.3 员工离职检查要自动化我把员工离职检查和库存预警放在了同一个定时任务里。每天扫描状态为“离职中”的员工生成三张待办清单名下未归还物资、待审批申请、借用超期记录。这样做的好处是HR不用盯着系统一个个查离职交接的遗漏率明显下降。我建议在这套系统里把“借出超期提醒”也做掉。借用出库时如果没有设置预期归还日期到期不还就没提示。我在employee_material_binding表里加了expect_return_at字段定时任务扫出超期未还的记录通知到部门负责人和仓库管理员。7.4 给使用者的培训比代码更重要最后想说一个不是技术但比技术更重要的点系统做得再好使用的人不按规则操作数据一样会乱。我上线前给仓库管理员、部门审批人和文员做了三次小范围培训专门讲“为什么不能直接在数据库里改数”和“为什么物品名称不能随意起”。培训里我给了一条硬性规范物品没有编码就不允许入库。这个规范后来帮了大忙仓库录入员养成了先查编码、再录单据的习惯系统里重复物品少了很多。资产管理系统这种内部工具真正决定成败的往往不是框架多先进而是流程设计是否贴合实际数据口径是否足够严格。我个人做下来的最大体会是不要急着写代码先花时间把“一个物品从进公司到被谁领走、最后怎么处理”的完整链路画出来。链路清楚了表结构自然就清楚了表结构稳定了后面的页面和接口基本就是顺水推舟。这套项目后续还能扩展设备维修记录、供应商评价、部门费用统计这些方向但前提是基础数据和进出库流水必须干净这才是整个系统的地基。