☰
自研资产管理系统:从耗材库存到员工管理的全流程实践
2026/10/16 8:21:51 网站建设 项目流程

上个月末做季度资产盘点,行政同事对着那几份明显改过十几版的Excel表格,在仓库里蹲了一整天,最后仍然有二十来件耗材对不上账。这其实是很多公司一直在反复上演的场景:耗材进出库靠登记表,员工信息靠通讯录,两者之间没有任何关系。当时我正好在公司内部做一个管理类系统,索性把耗材进出库管理和员工管理合并到一起,完成了一套资产管理系统。

这套系统不算大,前后大概忙了六周,但麻雀虽小五脏俱全:有入库、出库、退库、盘点、预警,有部门树、员工档案、领用记录,还带了审批流和操作日志。这篇文章写给两类人:一类是想给公司做内部资产或耗材系统但不知道从哪下手的开发同学,另一类是负责行政、采购或仓库管理、想推动信息化建设的业务同学。我会尽量把需求设计、数据库建模、业务流程、技术实现和踩坑过程都讲清楚,内容比较细,但都是实际做完之后沉淀下来的经验。

1. 我为什么要折腾一套自研资产管理系统

1.1 手工台账时代的真实痛点

动手之前,我先花了两天时间跟着公司的耗材流转过程走了两圈,发现痛点比预想的多。公司规模不大,耗材主要是打印纸、硒鼓、墨盒、文具、清洁用品,以及标签纸、扎带这类零星物资。之前的管理方式非常原始:行政统一采购后,把到货信息登记在一份共享Excel里,仓库货架旁边放着一本纸质台账,谁领了什么就在上面签个名,月底再由行政把纸质台账手工录入Excel。这套流程平时看着能用,一到盘点就出问题。

我梳理出的痛点主要有这么几条:

  • 单据完全分散:采购单、送货单、领用登记、Excel库存表之间没有任何关联,出了问题很难追溯。
  • 领用没有审批:员工到仓库直接拿走,口头说一句“用完了再说”,行政也不好意思拦,月底对不上账是常态。
  • 归属不清晰:台账上只记名字,不记部门和用途。人一旦离职,名下领的耗材根本无人追问。
  • 盘点基本靠人肉:Excel筛选、仓库翻箱、挨个问同事,一次盘点要耗时一整天,结果还是账户对不上实物。

这些痛点的本质,并不是“统计工具不够好”,而是库存账和人员账没有打通。耗材被谁领走了、这个人是否还在职、哪个部门消耗最大,全靠人工联想,数据一旦超过一个月就完全失真。所以做系统时我给自己定了一个硬性原则:耗材的每一次变动都必须落在单据上,每一张单据都要关联到员工和组织架构。

1.2 系统目标:不只管库存,还要管人

跟行政、采购、财务几轮沟通之后,我把需求收敛成四类核心场景,整理成了下面这个表:

需求点业务场景对应系统功能
库存实时准确入库、出库、退库后,剩余库存立刻可查单据管理 + 库存流水
领用可追溯每件耗材是谁领的、哪个部门、什么时候领的员工关联 + 领用记录
审批与权限大宗领用需主管审批,库管员才能发货出库审批流 + 角色权限
预警与盘点低于安全库存自动提醒,定期盘点并调整盈亏库存预警 + 盘点单

除了功能需求,我还给系统定了两条非功能要求。第一,操作必须足够简单,不能让库管员觉得系统比Excel还麻烦,否则他们根本不会坚持录入。第二,内网部署,页面操作要尽量在1秒内反馈,这是内部工具能不能被日常使用的底线。

这里我想多说一句:定需求的时候不能只问“要不要这个功能”,一定要追问“具体操作流程是什么样的”。如果行政说“先登记再领用”,系统就按先登记后扣库存来设计;如果她说“先拿走,月底补登记”,那系统再漂亮也拦不住月底对不上账。需求调研阶段多问几个“然后呢”,能省掉后面大量的返工。

2. 数据模型设计:把耗材、员工和领用关系一次性理清

2.1 核心表结构与字段设计

这套系统真正开始写代码之前,我花在表结构上的时间比写接口还多。进销存类业务最怕中途改表,所以我一开始就把核心表分成了八张:部门表、员工表、耗材分类表、耗材信息表、库存表、员工耗材持有关系表、出入库单据表、单据明细表。

部门表我用的是自关联加路径字段,避免树形查询时反复递归:

CREATE TABLE `department` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `parent_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '父部门ID', `name` varchar(64) NOT NULL COMMENT '部门名称', `path` varchar(255) NOT NULL DEFAULT '' COMMENT '层级路径,如 /1/5/12', `status` tinyint NOT NULL DEFAULT 1, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

耗材主数据表的核心字段包括编码、分类、名称、品牌、规格、单位和预警下限。编码字段我单独强调一下,一定不要用自增ID作为业务编码,最好有一列可读的耗材编号,比如HC-10023,用于搜索、扫码和单据上打印:

CREATE TABLE `material_info` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `category_id` bigint unsigned NOT NULL COMMENT '分类ID', `code` varchar(64) NOT NULL COMMENT '耗材编码', `name` varchar(128) NOT NULL COMMENT '耗材名称', `brand` varchar(64) NOT NULL DEFAULT '', `spec` varchar(128) NOT NULL DEFAULT '' COMMENT '规格型号', `unit` varchar(16) NOT NULL COMMENT '计量单位:箱/盒/个/卷', `low_stock_threshold` int NOT NULL DEFAULT 0 COMMENT '库存预警下限', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

数量不直接写在耗材主表里,而是单独放到库存表,这一点非常关键。单独建库存表的好处是后续做并发控制更方便,也便于扩展多仓库。我在库存表里加了version字段,给乐观锁用,后面讲并发扣库存时会详细展开:

CREATE TABLE `material_stock` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `material_id` bigint unsigned NOT NULL, `total_quantity` decimal(14,3) NOT NULL DEFAULT 0 COMMENT '账面总库存', `available_quantity` decimal(14,3) NOT NULL DEFAULT 0 COMMENT '可用库存', `version` bigint NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_material` (`material_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='耗材库存表';

为什么同时保留total_quantity和available_quantity两个字段?因为审批流会产生“已申请未出库”的中间状态。举个例子:有人提交了一张打印纸领用申请,等待主管审批,这时候库存账面总数没变,但货实际上已经被“预占”了,如果其他人也在申请,系统必须知道还剩多少能真正出库。我的做法是提交领用申请时锁定可用库存,审批通过后扣除总数,这样从流程上杜绝了“审批还没完货就被领光”的问题。

2.2 单据流设计:让每一次变动都有迹可循

做进销存类系统,我最开始吃过一个大亏:直接在库存表上改数字,改着改着就忘了哪笔数是谁在什么时候改的。所以这套系统里我立了一条铁规矩:没有单据的库存变动,代码层面直接禁止。所有入库、出库、退库、盘点调整,必须先创建单据,审核通过后,系统同时写库存表和库存流水表(stock_flow),流水表只增不改。

单据类型触发场景库存影响审核人
入库单采购到货、行政收货总数增加,可用增加库管员
领用出库单员工领用耗材总数减少,可用减少部门主管 + 库管员
退库单员工退回、多余退回总数增加,可用增加库管员
盘点调整单盘点盘盈盘亏按盈亏调整库管员 + 管理员

库存流水表我设计成只记录变动前后值,而不是记录当前值。流水表字段包括:单号、单据类型、耗材ID、变动前数量、变动后数量、操作人、操作时间、关联单号。这样设计的价值在排查问题时才体现得出来:当月底对账发现某件耗材数量不对,可以直接从流水倒推,每一步的来龙去脉都摆在那里,不需要把每张业务表翻一遍。

3. 耗材进出库的完整闭环实现

3.1 入库:批次、供应商与唯一编号

入库是所有数据流的源头,上游错了下游全错。我把入库流程设计成三步:新建草稿、审核、确认入库。库管员先创建一张入库单,选择供应商、填写到货日期,再在明细里添加耗材。添加耗材时可以直接输入耗材编码,如果编码不存在,先跳转到基础数据页新建耗材,再回到入库单继续录入。

入库单上除了数量,我还加了两个容易被忽略的字段:生产批次和有效期。虽然多数办公耗材没有严格的保质期,但硒鼓、墨水这类东西放久了也可能失效,有了批次字段,后续做先进先出和供应商退货追溯都会方便很多。批次号不需要单独复杂的表,直接用入库单号加行号生成,例如RK20250623002-01,既简短又可读。

入库单号的生成规则我建议用“类型前缀 + 日期 + 流水号”,比如RK20250623001、CK20250623001。不要用UUID当单号,因为库管员和行政需要念出来、做记录、打电话确认,一长串UUID根本没法用,短一点的日期流水号才是合适的。

审核入库之后,系统会自动生成库存流水,总库存和可用库存同时增加。这里要注意一个坑:入库单的单价字段只做财务参考,不参与库存金额计算,所以允许填0,否则行政收到无价格赠送品或样品时,入库流程会被卡住,给系统添麻烦。

3.2 出库与领用:审批、扣减与退库

出库是整个系统里使用频率最高的流程。实际业务里有两种入口:员工自己想领文具,提交领用申请;行政批量领办公用品,也走领用申请。我把所有出口统一为“领用申请单”,流程是:员工填写领用申请,选择耗材、数量、用途,提交给部门主管审批,审批通过后锁定库存,最后由库管员确认出库,扣减总库存和可用库存,同时生成员工耗材持有记录。

出库环节最容易出错的是领用人的归属。很多同事是帮整个小组领东西,申请人不是实际拿货的人。所以出库确认时,系统允许修改“实际领用人”,默认取申请人,也可以下拉切换到其他员工。申请人和领用人分开记录,盘点时遇到解释不清的耗材才能查明白。

退库是出库的逆向操作,触发场景一般是多领了、用不上了或者不良品退回。退库审核通过后,库存加回去;如果是不良品还要关联原出库单,方便后续跟供应商对账。最开始我只做了数量返还,后来行政反馈要按供应商退货,于是我在退库单上也加了供应商字段,退库时能同时生成退供清单。

3.3 预警、盘点与报表:从记账到决策

库存系统不能只停留在“改数字”上,预警和盘点才是它真正产生价值的地方。预警我做了两级:第一级是在入库和出库之后实时判断,当可用库存小于等于预警下限,立刻生成待办并给库管员和采购发站内消息;第二级是每天凌晨跑一个定时任务扫描,防止某些耗材长期低于安全线但一直没人动账而漏掉。

预警下限的配置要根据耗材属性差异化设定,不要一刀切。比如打印纸这类消耗量大的耗材,安全库存可以设成一个采购周期乘以月均消耗;而某些订书钉、回形针,消耗很慢,安全库存设低一点就行。这个配置我建议放到系统参数表里,让行政自己维护,不要写死在代码里。

盘点流程也是闭环设计的:创建盘点单,勾选需要盘点的耗材(可以全选,也可以按分类勾选),录入实盘数量,系统自动计算盘盈盘亏,生成盘点调整单,审核后调整库存。盘盈盘亏必须生成独立单据,不能直接改库存,这是财务对账的硬性要求。

报表我做了四张:收发存汇总表、部门领用汇总表、个人领用明细表、供应商入库统计表。报表的核心能力是导出Excel,内部系统不需要花哨的可视化大屏,数据能导出来、看得清楚才是关键。

4. 员工管理模块:账号体系和组织架构是关键

4.1 员工信息与部门树

员工管理这个模块,业务上不只是增删改查,组织架构和员工状态的准确维护才是重点。部门表我用了parent_id加path字段,查询某个部门及其所有子部门时,直接LIKE 'path%'就能拿到全部节点,不需要递归查询。具体实现是新增子部门时,path等于父部门的path拼上自己的id,比如/1/5/12。

员工表的核心字段是工号、姓名、部门、职位、入职日期、离职日期和在职状态。需要注意两点:一是员工表和登录账号表要分开,员工表是业务档案,账号表是权限身份,否则业务上“维护员工”和“配置权限”两件事会搅在一起;二是员工离职不要物理删除,把状态置为0、填上离职日期就够了,历史领用记录全部要保留。

员工新增入口有两个:管理员手动新增,以及Excel批量导入。批量导入模板我放在服务端固定路径,导入前先校验工号唯一性,部门名称自动匹配,匹配不上就标红提示。导入前一定先做“预览”,让用户看到解析结果再确认落库,直接导入的后果是数据错误很难定位。

离职处理是最容易被忽略的业务场景。系统上线后我发现,如果不做约束,员工离职时名下会挂着一堆未归还耗材,而且永远没人处理。后来我加了离职检查:离职前必须检测该员工名下是否有未归还耗材、是否有未完成的领用申请,有的话不允许直接置为离职,强制走一遍资产移交或者退库流程。

4.2 领用人和保管人分离

资产管理系统里经常出现一个情况:A同事领了一箱打印纸放在部门小仓库,实际负责保管的是部门助理B。如果系统只记录领用人是A,几个月后盘点时根本找不到人,因为A早就忘了自己领过。所以我在员工耗材持有和领用记录里设计了两个角色:实际使用人和保管联系人。

大多数场景下,实际使用人和保管联系人是同一个人,所以表单默认两个字段相同,只有在特殊场景才允许拆开。这个设计成本不高,但后期价值很大。如果将来把设备类资产也纳入系统,笔记本、显示器、测试仪器的借还和维修都依赖保管人这个概念,数据模型这一步的冗余反而是好处。

4.3 权限控制和审计日志

权限模型我用了典型的RBAC:用户、角色、权限。系统里有四个主要角色,分别对应不同的可见范围和操作能力:

角色可见范围可执行操作
普通员工本人领用记录、耗材查询提交领用申请、退库
部门主管本部门领用记录审批本部门申请
库管员库存、单据、预警出入库、盘点、基础数据维护
系统管理员全部数据员工管理、角色权限、全局参数

审计日志这块,我建议在业务层统一处理,不要依赖数据库自带的binlog。业务层记录的内容更清晰,包含操作人、动作、目标类型、目标ID、修改前内容JSON、修改后内容JSON、IP和时间。有一次库管员误删了耗材价格,就是靠审计日志的修改前修改后对比定位到具体操作时间和操作人的。当时如果我直接相信“没动过”,这问题基本无解。

5. 技术选型与开发实现要点

5.1 为什么选了这套技术栈

技术选型要考虑团队维护能力和项目规模,不要追求新奇。我做这套系统用的组合是:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0,前端用 Vue 3 + Element Plus + Vite,部署在公司内网一台4核8G的服务器上,Nginx做反向代理。

选这个组合的理由有三个。第一,这类管理系统是典型的REST接口加密集表格页面,Element Plus的表格、表单校验、弹窗、分页组件几乎不用自己造轮子。第二,MyBatis-Plus单表CRUD和分页插件能省掉大量重复代码,团队维护成本低。第三,MySQL 8.0对JSON类型和窗口函数的支持足够,后续做统计报表很好写。如果团队更擅长Node或Python,换成NestJS或Django完全没问题,核心是团队能长期维护,而不是盲目追新。

前后端分离的接口规范我顺带说一下:统一返回体{code, message, data},分页参数统一用page和pageSize,时间字段统一用毫秒时间戳传给前端格式化。这些看起来是小事,但如果每个接口风格不一样,前端联调时就会很痛苦。

5.2 核心接口设计:以出库单为例

核心接口我按单据状态机来设计,每个状态一个动作,不搞一个万能接口带一堆type参数。出库单的接口拆成了四个:创建领用申请、审批、确认出库、撤销。状态流转是:草稿 -> 待审批 -> 已审批待出库 -> 已出库,另外有已驳回和已撤销两个终止态。

确认出库是库存变动的核心,我把关键逻辑写成了一段代码,重点看检查和扣减的先后顺序:

@Transactional(rollbackFor = Exception.class) public void confirm(Long orderId, Long operatorId) { OutboundApply order = outboundMapper.selectById(orderId); if (order == null || !"AUDITED".equals(order.getStatus())) { throw new BizException("单据不存在或不在待出库状态"); } if (order.getLockExpireAt() != null && order.getLockExpireAt().before(new Date())) { throw new BizException("库存锁定已过期,请重新提交领用"); } List<OutboundApplyItem> items = itemMapper.selectByOrderId(orderId); for (OutboundApplyItem item : items) { MaterialStock stock = stockMapper.selectByMaterialIdForUpdate(item.getMaterialId()); if (stock == null || stock.getAvailableQuantity().compareTo(item.getQuantity()) < 0) { throw new BizException("库存不足:" + item.getMaterialName()); } } for (OutboundApplyItem item : items) { stockMapper.deductAvailable(item.getMaterialId(), item.getQuantity()); stockMapper.deductTotal(item.getMaterialId(), item.getQuantity()); stockFlowMapper.insert(StockFlow.ofOutbound(item, order)); } employeeMaterialMapper.insertRelations(order); order.setStatus("CONFIRMED"); outboundMapper.updateById(order); }

这段代码里有两个关键点。第一,查询库存时用了selectByMaterialIdForUpdate行锁,防止两个确认请求同时扣同一个耗材。第二,整个方法加了@Transactional,状态检查和扣减必须在同一个事务里完成,否则会出现库存扣了但单据状态没更新的问题。对于公司内部系统,这个量级的并发控制完全够用。

5.3 前端交互与批量操作的细节

管理系统前端最容易被低估的是操作效率。行政和库管员每天要处理大量单据,如果每加一行耗材都要点三个按钮,他们很快会抗拒使用系统。我在前端做了几个细节优化:

  • 搜索框支持耗材编码、名称、规格模糊搜索,回车即添加行,不用先搜索再选择。
  • 支持扫码枪录入,本质上扫码枪就是一个键盘输入设备,输入框自动聚焦,扫码后自动回车新增行,焦点回到输入框,连续扫连续加。
  • 批量出库时,默认连续录入多行明细,前端实时校验每行数量是否小于等于可用库存,不合格直接不允许提交。
  • 表格默认分页50条,而不是默认10条,减少翻页次数。

还有一个细节是数字输入框的处理。数量字段要禁止负数和过多小数位,打印纸按箱、扎带按卷,有些耗材可能按小数计量,所以数量允许最多三位小数,但必须是正数。我在前端做了统一拦截,避免脏数据进到服务端。

6. 实测中踩过的坑和排查过程

6.1 并发扣库存:从剩余库存到超卖

测试阶段我踩过最大的坑是并发扣库存导致库存变负数。当时两个库管员同时确认两张出库单,系统都读到了可用库存5件,也都认为够扣,结果两张单各扣3件后,库存变成了-1。原因很直接:初版代码是“先查询剩余库存,再计算新库存,最后更新”,中间没有加锁,两个事务重叠时,后写的覆盖了先写的。

排查过程我完整复盘一下。先在两个浏览器同时打开同一张单据,手动并发调确认出库接口,稳定复现。再看日志,两个事务都打印了查询到可用库存为5,说明读到了同一份旧数据。最后看更新SQL,写的是set available_quantity = 5 - 3,两个事务拿到的基线都是5,结果必然是2,正确结果应该是先扣的先改成2,后扣的因为不足而失败。

解决办法是把“检查-扣减”合并成一条条件更新SQL:

UPDATE material_stock SET available_quantity = available_quantity - 3, version = version + 1 WHERE material_id = 10023 AND available_quantity >= 3;

然后检查更新行数,返回0就抛库存不足。这里有个要点:SQL里必须用available_quantity = available_quantity - 3这种字段自减写法,不能把Java里算好的旧值传进来,否则锁等于没加。我后来还在库存表的version字段上加了乐观锁兜底,防止极端情况下条件更新被绕过去。Redis预扣方案在大并发抢购场景才需要,办公耗材内部系统的量级,数据库条件更新已经足够。

这个坑给我的教训很直接:进销存系统里,任何“检查并更新”的动作,都要把检查条件写进update语句里,而不是分开查再算再改。

6.2 导入导出乱码与Excel格式兼容

第二个坑在Excel导入导出。一开始导出用POI直接写xlsx,中文显示正常。后来有同事把数据存成CSV下载,用Excel打开乱码。排查半天才发现,CSV文件是UTF-8编码,Excel默认按GBK解码,中文全乱。解决办法很简单:CSV导出时在最前面写入UTF-8 BOM头,Excel就会正确识别编码。后来为了省事,内部系统我统一用xlsx格式,不再用CSV承载中文导出。

导入模板的问题更隐蔽。员工批量导入模板里有一列手机号,导入时一直报格式错误。后来发现Excel自动把手机号识别成了数字,科学计数法显示,数值精度也丢了。解决方法是:模板里这一列设置单元格格式为文本,同时服务端解析时统一按字符串读取,不要按数值类型解析。日期列也踩过同样的坑,模板里锁死yyyy-MM-dd文本格式,代码里再解析,别直接用Excel原生日期序列号。

POI内存问题也值得提一句:几万行的导出,直接用XSSFWorkbook很容易内存溢出。后来我把导出改成EasyExcel,这是目前主流的流式读写组件,二十万行数据也能稳定处理。工具选型这一步,能省很多后期心。

6.3 慢查询排查:盘点报表为什么越来越慢

上线一个月之后,数据量其实也就几万条流水,但某个部门领用报表页面打开要十几秒。排查过程是这样的:先打开MySQL慢查询日志,找到那条出问题的SQL,发现outbound_apply表上只建了主键索引,department_id和create_time都没有索引。用explain一看,type 是 ALL,全表扫描,而且order by还触发了 filesort。

解决办法是加了一个组合索引:

ALTER TABLE outbound_apply ADD INDEX idx_dep_time (department_id, create_time);

索引加完之后,报表接口从十几秒降到了几十毫秒。这之后我养成一个习惯:所有列表查询,索引优先满足where条件,其次满足order by排序,两个字段做一个组合索引,而不是各建一个单列索引。大深翻页也要注意,不要写limit 1000, 20,改成keyset方式:where create_time < 上次查询的最小时间 order by create_time desc limit 20,数据量大时性能提升非常明显。

另外,像收发存汇总这种大报表,不要实时统计。最稳妥的做法是每天凌晨用定时任务跑一次汇总,把结果写到一张汇总表,页面直接查汇总表。这不是什么高技术,但稳定性特别好。

7. 上线一个月后的复盘和后续计划

系统上线一个月,几个真实变化让我觉得这套东西没白做。盘点时间从手工的一整天缩短到一个小时以内,耗材领用记录从“没人认账”变成了有序可查,行政做采购计划时直接看预警列表,不再挨个问“还有多少纸”。最有价值的功能反而是最不起眼的“我的领用记录”,员工自己一查就知道名下有什么耗材,少了一堆“帮我查一下我领了啥”的行政负担。

当然也有明显不足。审批流目前只有一级,跨部门、超过金额阈值的多级审批还没有实现。批次先进先出没有真正落地,目前只是记录了批次字段,出库时还是库存整体扣减。移动端只能看报表,提交领用申请还得在电脑上操作。设备类资产,比如笔记本、显示器、测试仪器的借还和维修流程也还没有纳入,这块会用到更细的保管人概念和维修单据。

我自己的体会是,这类内部管理系统的成败不在于技术多先进,而在于数据入口是否顺畅、业务规则是否闭环。预警、报表、权限这些功能,都是在数据准确之后才有意义。如果从头再做一次,我会更早把部门树和员工状态的数据校验做扎实,而不是先堆功能。希望这篇记录能给正在规划类似系统的朋友一些参考,也欢迎在实际落地过程中多交流踩坑经验。

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

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

立即咨询