ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ASP.NET低值易耗品管理系统:从库存模型到领用流程的设计与实践

ASP.NET低值易耗品管理系统:从库存模型到领用流程的设计与实践

简介:ASP.NET低值易耗品管理系统源码包,面向需要建立低值易耗品与低值耐用品管理流程的企业信息化人员及ASP.NET开发学习者。系统涵盖系统管理、基础设置、物品采购入库领用退库盘点、报表分析等模块,可有效控制成本、提升资产透明度。资源包共1177个文件,解压后约3.83MB,以aspx页面、cs后台逻辑、css样式和js脚本为核心,另含大量gif、png图片素材及mdf/ldf数据库文件,结构完整,便于部署或二次开发。已有220人学习下载。压缩包内既包含用户管理、部门管理、模块管理等功能页面,也提供数据库备份与前端素材,可快速搭建运行环境,适合作为课程设计、毕业设计或企业轻量级资产管理的参考实现。

1. ASP.NET低值易耗品管理系统,到底是做什么的一套系统

固定资产有财务台账,管得严管得住,可办公室里天天消耗的A4纸、硒鼓、劳保手套、维修配件,才是真正让行政头疼的东西。ASP.NET低值易耗品管理系统,就是给这些单价低、数量大、领用频繁的物品单独建一套台账,解决“账上有、库里找不到”“谁领了、还剩多少”这类纠缠不清的问题。系统本身不大,但一个百人规模的部门一年能产生几千条领用记录,Excel和纸质单据根本扛不住。它适合行政后勤、生产车间、IT耗材管理这类场景,也适合想把Excel台账迁到正经系统的团队。真正值得做的不是功能有多花哨,而是每一笔出库都能与实物对得上。

2. 技术选型与数据模型:为什么用ASP.NET做不折腾,六张核心表怎么拆

低值易耗品管理系统说到底是个企业内部工具,选型的第一原则不是新潮,是“现有环境下谁能最快跑起来并长期有人维护”。ASP.NET在这类场景里不是最亮眼的,但往往是最省事的,下面把理由和数据模型一起讲透。

2.1 技术栈取舍:ASP.NET + EF Core + SQL Server在企业内网的优势

企业内网的服务器环境,十有七八还是Windows Server加IIS,数据库是SQL Server或者某个历史遗留的Oracle实例。这种环境下,ASP.NET应用部署成本极低:服务器上装个对应版本的.NET运行时,IIS里建个站点指向发布目录,连接字符串改一改就能跑。换Java那套,要么客户环境里没有Tomcat和对应JDK,要么运维不熟悉Linux,折腾一轮部署就劝退一半人。换Node或Python写管理后台,部署是轻了,但接Windows域账户、接SQL Server的Windows身份验证、和财务系统导数据时,反而要自己处理一堆底层细节。

我一般会这样选:如果服务器是Windows Server 2016以上,能用.NET 8就跑.NET 8,EF Core配SQL Server很顺手,性能和维护性都好;如果服务器老旧、只能装.NET Framework 4.8,那就老老实实用ASP.NET MVC 5加EF6,别追求新版本,稳定压倒一切。低值易耗品系统的访问量撑死了每秒几十个请求,性能和架构先进性根本不是瓶颈,瓶颈永远在业务逻辑是否严谨和能不能被新人接手。

框架定了,前端不必搞前后端分离。服务端Razor视图或者简单的Web API加jQuery页面都能胜任。我倾向用Web API返回JSON,页面用原生JS或少量Vue引入,原因是这类系统大概率后续要接扫码枪、对接钉钉或企微审批,API比MVC页面更容易被外部调用。下面这张表是核心选型对比:

对比项ASP.NET + SQL ServerJava + MySQLNode + MongoDB
部署难度低,IIS一站搞定中,要装JDK和容器中,进程管理要自己弄
Windows域账号集成原生支持要写额外适配要写额外适配
与财务/OA系统对接同生态,方便能接,多个网关能接,要自己封装
团队招聘难度招人容易招人容易相对较难
长期维护成本低中中

结论很直接:在企业内网做这种内部管理系统,ASP.NET不是最有想象力的选择,却是返工率最低的选择。低值易耗品管理这个题目本身也不复杂,与其把精力耗在基础设施上,不如把时间花在库存模型和流程设计上。

2.2 六张核心表:物品字典、库存、领用单、库存流水、报废单、审批记录

很多第一次做这个系统的人上来就建一张“物品台账”,里面既放物品信息又放数量,结果一个物品在三个部门各存一箱,就不知道怎么表达了。正确做法是把“是什么物品”和“哪个仓库有多少”拆成两张表,再加上领用单据、流水、报废、审批,一共六张核心表。下面逐个给字段设计。

物品字典表 Goods,这张表只描述物品本身,不关注库存:

字段类型说明
Idint主键,自增
GoodsCodenvarchar(30)物品编码,唯一,非空
GoodsNamenvarchar(50)物品名称
Specnvarchar(50)规格型号
Unitnvarchar(10)计量单位
CategoryIdint分类,如文具、劳保、维修件
IsActivebit是否启用,停用后不可领用

库存表 Inventory,它描述当前各仓库各物品的结存:

字段类型说明
Idint主键
GoodsIdint物品ID
WarehouseIdint仓库/存放点ID
Quantitydecimal(18,2)当前库存数量
SafeStockdecimal(18,2)安全库存,低于它时提醒补货

领用单主表和明细表,主表管一次领用的整体状态,明细表管领了哪些物品:

字段类型说明
Idint主键
RequisitionNonvarchar(30)单号
ApplicantIdint领用人
DepartmentIdint领用部门
WarehouseIdint出库仓库
Statusint草稿/待审批/已出库/已退库/已驳回
ApplyTimedatetime申请时间
Remarknvarchar(200)填写用途等备注

库存流水表 StockRecord,这是整张库存账的根基,任何库存变动都记一行:

字段类型说明
Idbigint主键
GoodsIdint物品ID
WarehouseIdint仓库ID
ChangeTypeint采购入库/领用出库/退库/盘盈/盘亏/报废
ChangeQtydecimal(18,2)正数入库,负数出库
RefNonvarchar(30)关联单号
OperatorIdint操作人
CreateTimedatetime操作时间

报废单 WriteOff 和审批记录 ApprovalRecord 相对简单,报废单记录“哪件物品报废了多少、谁申请、谁批准”,审批记录记录每个单据的审批节点与意见。这两张表的存在,保证了任何库存变化都能追溯到责任人,这是低值易耗品管理里比“数量准确”更重要的目标。

2.3 单号生成与状态机:让每一件易耗品都可追溯

低值易耗品不像固定资产有唯一的资产标签,它的“可追溯”是靠单据编号和状态流转实现的。单号生成有个常见做法:年月日加当日流水,比如LY20250115-001,同一天内自增,跨天重置。我见过不少项目直接拿数据库自增ID当单号,表面上省事,实际对账时非常痛苦,Excel里贴出来看不出任何时间信息,财务也不认。所以单号要单独生成,用独立计数器或查当日最大号加一都行。

领用单的状态机建议控制在五个以内,别贪多。我的习惯是:

  • 草稿:创建后未提交,库存不受影响
  • 待审批:已提交,等审批人确认
  • 已出库:审批通过并扣减库存
  • 已退库:使用人退回部分或全部物品
  • 已驳回:审批未通过

这里关键点是“扣库存发生在出库节点,而不是提交节点”。草稿和待审批阶段都不动库存,审批通过执行出库动作时才扣减,这样即使审批驳回也不会产生库存调整的麻烦。每次状态变更,同时在审批记录表里插入一行,记录谁在什么时间把单据从哪个状态推到了哪个状态。等系统跑了两三个月,若要查“上个月领了多少硒鼓、谁批的、给谁了”,一条SQL就能把整条链拉出来,这才叫台账。

3. 从建项目到跑通领用流程:最小可用的ASP.NET实现

这一章把代码落到具体操作上,目标是跑通“创建领用单 → 提交 → 审批 → 出库”的核心链路。环境假设为.NET 8 + EF Core + SQL Server,这是当前新项目最顺手的组合。

3.1 项目骨架:依赖注入、数据库上下文、连接字符串

先创建ASP.NET Core Web API项目,然后安装EF Core的SQL Server包。项目结构上我习惯分四层:Controllers放API入口,Services放业务逻辑,Entities放数据实体,Dtos放接口出入参。这种结构对低值易耗品系统足够清晰,不必引入过分复杂的领域驱动设计。

Program.cs里要做两件关键事:注册数据库上下文和注册业务服务。

var builder = WebApplication.CreateBuilder(args); // 数据库上下文,连接字符串从 appsettings.json 读取 builder.Services.AddDbContext<AssetDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultDb"))); // 业务服务按请求生命周期注册,保证一次请求内复用同一个 DbContext 实例 builder.Services.AddScoped<IRequisitionService, RequisitionService>(); builder.Services.AddScoped<IInventoryService, InventoryService>(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddControllers(); var app = builder.Build(); app.MapControllers(); app.Run();

这里有两个参数选择值得说明。AddDbContext默认生命周期就是Scoped,即每个HTTP请求创建一个数据库上下文实例,这正好匹配EF Core的设计,不要改成Singleton,否则会踩并发上下文共享的坑。AddScoped注册的业务服务同理,同一个请求内的Controller和Service拿到的是同一个DbContext,这才能保证事务生效。

appsettings.json里的连接字符串按实际环境配置:

{ "ConnectionStrings": { "DefaultDb": "Server=.;Database=ConsumableDb;Trusted_Connection=True;TrustServerCertificate=True;" } }

如果是内网域环境,用Trusted_Connection=True走Windows身份验证最省事,不用在代码里管理账号密码;如果是云服务器或需要跨机器访问,才改用User Id=sa;Password=...。建议把数据库访问账号做成独立的低权限账号,不要用sa。

3.2 领用单提交流程:状态校验与审批记录

创建领用单和提交是两个动作。创建时只插入主表和明细表,状态设为草稿,不涉及库存。提交时才做状态校验并推入待审批状态。这里最容易被忽略的是状态校验,很多人直接在Service里写一句order.Status = WaitApprove完事,结果已出库的单子还能被再次提交,数据就乱了。

public async Task<Result> SubmitAsync(int requisitionId) { var order = await _context.Requisitions .Include(x => x.Items) .FirstOrDefaultAsync(x => x.Id == requisitionId); if (order == null) return Result.Fail("领用单不存在"); // 只有草稿状态允许提交,其余状态一律拒绝 if (order.Status != RequisitionStatus.Draft) return Result.Fail("当前状态不允许提交,请检查单据状态"); order.Status = RequisitionStatus.WaitApprove; order.SubmitTime = DateTime.Now; // 审批记录,用于追溯谁在什么时间提交了单据 _context.ApprovalRecords.Add(new ApprovalRecord { BusinessType = BusinessType.Requisition, RefId = order.Id, ApproverId = _currentUser.Id, Result = ApproveResult.Submit, Opinion = "提交领用申请", CreateTime = DateTime.Now }); await _context.SaveChangesAsync(); return Result.Ok(); }

这段代码的逻辑要点是:先查单、再验状态、最后改状态并留痕。Include(x => x.Items)提前加载明细,虽然这个动作里没用到明细,但如果后续要在提交时做库存预检查,就能避免延迟加载带来的额外查询。ApprovalRecord里我用ApproveResult.Submit表示“提交”这个动作,审批动作则用Passed或Rejected,这比只记录审批结果更完整,查历史时能看到整条时间线。

3.3 出库原子扣减与退库回补:库存只凭流水增减

出库是低值易耗品系统里最核心、也最容易出错的一步。常见错误是先用SELECT查库存数量,判断足够后再执行UPDATE减库存。这一步在并发场景下一定会翻车:两个人同时领用同一件物品,各自查询都显示库存还有10件,各自扣减5件,最终库存变成5而不是0。十来个用户并发访问时这个Bug就很容易暴露,库存变负数是最直观的现象。

解决思路不是加分布式锁,而是把“校验库存不够”和“扣减库存”合并成一条原子SQL:

public async Task<Result> ShipAsync(int requisitionId) { var order = await _context.Requisitions .Include(x => x.Items) .FirstOrDefaultAsync(x => x.Id == requisitionId); if (order == null || order.Status != RequisitionStatus.WaitApprove) return Result.Fail("单据不存在或不在待出库状态"); await using var tx = await _context.Database.BeginTransactionAsync(); foreach (var item in order.Items) { // 条件更新:只有当库存数量足够时才执行扣减,原子完成校验和扣减 var affected = await _context.Database.ExecuteSqlRawAsync( "UPDATE Inventory SET Quantity = Quantity - {0} " + "WHERE GoodsId = {1} AND WarehouseId = {2} AND Quantity >= {0}", item.Quantity, item.GoodsId, order.WarehouseId); if (affected == 0) { await tx.RollbackAsync(); return Result.Fail($"物品 {item.GoodsName} 库存不足或不存在该仓库库存记录"); } // 每次扣减都写流水,后续盘点差异只查这一张表 _context.StockRecords.Add(new StockRecord { GoodsId = item.GoodsId, WarehouseId = order.WarehouseId, ChangeType = StockChangeType.RequisitionOut, ChangeQty = -item.Quantity, RefNo = order.RequisitionNo, OperatorId = _currentUser.Id, CreateTime = DateTime.Now }); } order.Status = RequisitionStatus.Shipped; order.ShipTime = DateTime.Now; await _context.SaveChangesAsync(); await tx.CommitAsync(); return Result.Ok(); }

参数说明:{0}是领用数量,{1}是物品ID,{2}是仓库ID,ExecuteSqlRawAsync会自动做参数化处理,不会拼接SQL注入。这个UPDATE语句的关键在于Quantity >= {0}这个条件,SQL Server执行时会利用行锁保证同一时刻只有一个人能更新同一行库存,如果数量不够,影响行数是0,走回滚并返回友好提示。

退库逻辑是对称的:把数量加回去,流水表里ChangeType记成退库,单号仍是原领用单号。这样一张领用单的出库和退库都能通过RefNo串联起来。特别注意退库时回补库存依然要用条件更新,只是把减号换成加号,同样禁止先查后改。

4. 低值易耗品系统避坑指南:五个把台账做成死账的翻车点

这段内容是血泪经验换来的。系统上线后真正折磨人的不是功能不够,而是数据被搞坏了没法收拾。下面五个坑我基本都见过,写出来按“现象 → 原因 → 解决”给各位排掉。

4.1 库存超发:并发领用时库存变成负数

现象:月初同事们集中领用办公用品,A和B同时提交领用单领同一个物品,都提示成功,系统里库存却变成了负数;而实际仓库里那件物品早就发完了。

原因:出库代码是“先查库存是否足够,再执行UPDATE扣减”。两个请求同时查询时都看到库存还剩10,于是都通过校验,各自扣5,最终库存只剩5,这是典型的事务隔离级别下先查后改导致的并发覆盖。

解决:按第3.3节的方案,把校验和扣减合并进同一条原子UPDATE语句里,利用数据库的行锁机制保证同一库存行同时只能被一个事务修改;数量不足时影响行数为0,直接返回“库存不足”即可,不需要事先查询。

4.2 物品编码不规范:从Excel导入时就埋下条码和报表的雷

现象:系统上线初期为了省事,从旧Excel台账导入物品时没校验编码,有人留空,有人填“A4纸”,有人填一串数字。等到打条码、做部门领用统计时,同一物品在报表里被拆成好几行,扫码枪扫编码根本扫不出来。

原因:物品编码字段没有唯一约束,导入脚本也没做非空和重复校验;设计阶段觉得“编码而已,随便填”。

解决:物品编码设为nvarchar(30)并建唯一索引,导入时先做三层校验:空值拒绝、重复拒绝、格式校验。格式建议用“分类前缀+短横+序号”,比如BG-A4-001表示办公类A4纸,LB-ST-001表示劳保类手套。编码一旦启用就不要随意改,条码、报表、盘点都对它依赖。

4.3 审批流设计过度:五级审批把系统推进了回收站

现象:某部门按固定资产的审批思路设计了五级审批,领一包A4纸要经过申请人、部门主管、行政经理、财务、分管副总。上线两周后系统几乎无人使用,大家回到微信群里接龙领东西。

原因:低值易耗品的单价低、频次高,管控重心是“账实相符”和“成本归属”,而不是“逐级审批控制”。拿管固定资产的流程套到低值品上,流程成本比物品本身还高,使用者自然用脚投票。

解决:默认只做一级审批,申请人提交后由部门负责人确认即可;如果担心高值易耗品流失,可以加一条金额阈值规则,单笔超过500元才走二级审批。审批节点配置做成数据库表,不要写死在代码里,后面调整流程时改配置就行,不用重新发布。

4.4 盘点差异直接改库存:账平了,依据没了

现象:月度盘点发现某种螺丝账面1000个、实物只有968个,管理员为了账面好看,直接在库存表里把数量改成968。到了下个月再盘,差异变成负的,而且没人说得清那32个螺丝到底去哪了。

原因:库存表被当成普通数据表随手改,没有盘点流程和差异单据的概念。直接改数值会把所有历史流水对不上号,越往后账越烂。

解决:任何库存调整不准直接改Inventory表。盘亏盘盈要走“盘点单”,创建盘点差异记录,写明差异数量、原因、责任归属,审核通过后再走一次库存调整流水。比如盘亏32个,就插入一张盘亏单,然后写一条ChangeType = StockChangeType.InventoryLoss、ChangeQty = -32的流水,库存由流水驱动变化。这样每笔差异都有凭据,下个月复盘时有据可查。

4.5 物理删除业务记录:单子消失了,流水还在

现象:有人领用后觉得填错了,直接把领用单DELETE掉,结果库存流水表里还留着“领用出库”的记录,对账时库存总金额对不上,审批记录也指向一条不存在的单子。更麻烦的是,供应商送货单和出库流水能对上,就是找不到中间那张领用单。

原因:开发时为了省事用物理删除,没有考虑业务单据的关联性。低值易耗品虽然单张价值低,但它是库存台账的证据链,单据一旦消失,链条就断了。

解决:所有业务单据一律不做物理删除,只提供“作废”操作。作废本质上也是状态变更,单据保留,状态从草稿或待审批改成已作废,并记录操作人。菜单里隐藏删除按钮,数据库层面用软删除字段兜底,查询默认过滤。真遇到数据写错的场景,作废原单、重新建单的成本远低于修复坏账的成本。

5. 报表导出与条码标签:把系统能力延伸到浏览器之外

低值易耗品系统跑稳之后,下一步不是加更多功能,而是把数据变成别人能直接使用的东西。行政要给财务报部门成本,仓库要领用盘点,这两件事都需要把系统能力延伸到浏览器之外。

5.1 NPOI导出月度领用报表,让行政用Excel能直接交付

行政每月都要向财务报送各部门领用成本。直接在页面上贴表格不是不行,但财务要往自己的系统里录入,给Excel文件是最省事的。用NPOI生成xlsx是我常用的做法:

var book = new XSSFWorkbook(); var sheet = book.CreateSheet("领用报表"); var header = sheet.CreateRow(0); header.CreateCell(0).SetCellValue("领用单号"); header.CreateCell(1).SetCellValue("物品编码"); header.CreateCell(2).SetCellValue("物品名称"); header.CreateCell(3).SetCellValue("规格"); header.CreateCell(4).SetCellValue("数量"); int rowIdx = 1; foreach (var item in data) { var row = sheet.CreateRow(rowIdx++); row.CreateCell(0).SetCellValue(item.RequisitionNo); row.CreateCell(1).SetCellValue(item.GoodsCode); row.CreateCell(2).SetCellValue(item.GoodsName); row.CreateCell(3).SetCellValue(item.Spec ?? ""); row.CreateCell(4).SetCellValue((double)item.Quantity); }

这里XSSFWorkbook生成的是xlsx格式,兼容旧版Excel用户可以选择HSSFWorkbook生成xls。报表导出后返回FileContentResult,文件名带上日期范围,比如领用报表_2025年1月.xlsx,方便归档。导出逻辑放在Service层,只负责生成文件,不要和Controller的HTTP细节耦合。

5.2 Code 128条码与扫码枪盘点

给每类物品印一张Code 128条码标签,贴到货架和收纳盒上,盘点时用扫码枪直接扫,效率提升非常明显。条码内容就是物品编码,生成用ZXing.Net就行:

var writer = new BarcodeWriterPixelData { Format = BarcodeFormat.CODE_128, Options = new EncodingOptions { Width = 300, Height = 80, Margin = 4 } }; var pixelData = writer.Write(goodsCode);

生成后转成图片插入标签模板打印。需要注意两点:一是Code 128比Code 39密度更高,同样的编码长度印出来更短,贴在小包装盒上更合适;二是编码里不要放汉字,只放字母数字和短横线,否则扫码枪识别不了。盘点页面做一个输入框,扫码枪扫上去自动定位到该物品,直接填盘点数量,系统自动对比账面数并生成差异项。这套流程我实测下来,一个二百人规模的公司,盘点一上午就能完成。

5.3 权限边界:同一个系统给不同角色开不同的门

系统上线一段时间后,部门之间自然会产生数据隔离诉求。我的习惯是分三类角色:管理员可看全库和所有单据;仓管只管出入库和盘点;普通员工只能看自己的领用记录。实现上用ASP.NET自带的Claims授权就行,不必要上太重的工作流引擎。权限的边界想清楚一点:低值易耗品系统的核心是台账准确,权限钉得太死会影响使用积极性,适度放开查询权限,严格把控改数权限,这个系统才能真正活得久。

这类内部系统做得越多,我越确认一个判断:低值易耗品管理系统的难点从来不在技术,而在库存变化被规范地记录。编码、单据状态、原子扣减、流水留痕这几个基本功做到位,后面怎么扩展都顺手。我自己在多个项目里吃过“先跑通再说、数据以后修”的亏,后来每一次都先设计好流水表再写业务代码。希望这套思路能帮到你,少走点弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进