C#资产管理系统源码1.0:从Excel台账到状态机闭环的落地实践

发布时间:2026/10/14 15:45:58
C#资产管理系统源码1.0:从Excel台账到状态机闭环的落地实践
简介c#资产管理系统源码1.0是一套基于C# WinForms与SQLite开发的固定资产管理程序适合中小企业行政、财务或开发人员用于资产出入库、盘点、结存和折旧统计的信息化改造。源码覆盖资产处理、查询统计、参数维护、系统管理四大模块包含入库、出库、库存初始化、资产台账、月统计、折旧表、卡片管理等完整操作并附带计算器等辅助功能。压缩包内共635个文件其中以333个C#源文件、46个resx资源文件、42个DLL、32个BMP界面图像、8个RDLC报表定义及2个SQLite数据库文件为主整体大小约8.34MB可在Visual Studio 2010环境中直接打开解决方案。资源已有557人浏览学习适合希望快速掌握经典C#桌面数据库开发流程的读者拿到后既可部署试用也可基于清晰模块结构做二次开发或学习报表设计、权限管理等实现方式。1. 这套 C# 资产管理系统源码 1.0到底解决什么问题很多公司的第一套资产台账就是一张共享 Excel 表传到第三年就开始失控同一台电脑被登记两次离职员工的笔记本不知道在哪个柜子里年底盘点账上有 230 台设备、实物只找到 180 台。问题的根源不是大家不认真而是台账和流程没有绑在一起——每次领用、归还、维修都靠手工改表状态自然对不上。C# 资产管理系统源码 1.0 要解决的就是这个基础问题用 C# 把资产档案、领用归还、维修记录、折旧计提、盘点差异串成一个闭环让每一台设备的当前状态都来自操作流程而不是人工修改。它适合刚接手内部系统的开发者参考也适合想在一周内给团队搭出可用工具的入门项目。2. 先想清楚再写码编码规则与六张核心表2.1 资产编号不是自增 ID而是业务身份有些 1.0 源码里直接把数据库自增 ID 当资产编号界面上显示「资产 1024」盘点时扫码枪根本不知道该打哪个标签。我一般会先把编号规则定下来再动代码。推荐规则是ZC-{分类码}-{入库年月}-{三位流水}比如ZC-IT-202406-001。固定前缀ZC表示资产IT来自资产分类表的分类代码202406是入库年月001是当月该分类下的流水。分类代码不能是中文因为标签打印、Excel 导入、扫码枪识别对中文都不友好。需要强调一点编号一旦生成就不允许修改录错了只能走报废然后重新新增。自增 ID 的问题在于它没有业务含义导出给财务、送去外部维修时对方看到的是一串无法理解的数字而且如果日后要合并两个系统的数据自增 ID 几乎必然冲突。所以无论系统多小资产编号都应该是独立的业务字段而不是数据库主键。生成编号的代码放在 Service 层不要放在界面按钮里。逻辑是查询同分类同年月已有的最大流水号取最大值加一再补零到三位。单机桌面应用并发低但两台电脑同时录入时也可能撞号所以数据库里要加唯一索引兜底插入失败就重新生成编号重试一次。2.2 六张核心表与关键字段1.0 版本先建六张核心表就够跑通台账和领用归还主链路Assets资产主表、AssetCategories分类字典、Departments部门字典、Employees员工表、AssetTransactions操作流水表、RepairRecords维修记录表。盘点和折旧用的表可以后补不影响主流程。表名角色关键字段设计要点Assets资产档案主表AssetCode、Status、DepartmentId、OwnerId、OriginalValue软删除、唯一编号、状态枚举AssetCategories分类字典Code、Name分类代码用两位大写字母Departments部门字典Code、Name编码固定部门改名不影响引用Employees员工表Name、DepartmentId记录责任人归属部门AssetTransactions动作流水AssetCode、Type、OwnerId、DepartmentId、OccurTime冗余快照历史可追溯RepairRecords维修记录AssetId、StartTime、EndTime、Cost维修单与状态流转联动Assets 表是所有功能的核心SQLite 建表语句如下CREATE TABLE Assets ( Id INTEGER PRIMARY KEY AUTOINCREMENT, AssetCode TEXT NOT NULL, Name TEXT NOT NULL, CategoryId INTEGER NOT NULL, DepartmentId INTEGER NOT NULL, OwnerId INTEGER NULL, Location TEXT NOT NULL DEFAULT , Status INTEGER NOT NULL DEFAULT 0, PurchaseDate TEXT NOT NULL, OriginalValue NUMERIC(12,2) NOT NULL, SalvageValue NUMERIC(12,2) NOT NULL DEFAULT 0, DepreciationYears INTEGER NOT NULL DEFAULT 5, LastDepreciationMonth TEXT NULL, IsDeleted INTEGER NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX UX_Assets_AssetCode ON Assets(AssetCode); CREATE INDEX IX_Assets_Status ON Assets(Status); CREATE INDEX IX_Assets_DepartmentId ON Assets(DepartmentId);Status 字段用整数存枚举0 在库、1 已领用、2 维修中、3 已报废、4 盘亏。不建议直接存字符串因为枚举值在代码里有强约束字典表维护成本高且容易写错。DepartmentId 和 OwnerId 分开是有原因的部门资产与个人资产并存一台打印机可能挂在技术部但没人认领OwnerId 允许为空。OriginalValue 存原值SalvageValue 存残值折旧计算依赖这两个字段。AssetTransactions 流水表有个容易忽略的设计要冗余存 AssetCode 和当时的 DepartmentId、OwnerId 快照而不是只存 AssetId 外键。原因是资产后来可能调拨到别的部门或者员工离职换人如果只靠外键关联历史流水查出来全是当前值根本无法还原当时的情况。CREATE TABLE AssetTransactions ( Id INTEGER PRIMARY KEY AUTOINCREMENT, AssetId INTEGER NOT NULL, AssetCode TEXT NOT NULL, Type INTEGER NOT NULL, OwnerId INTEGER NULL, DepartmentId INTEGER NULL, OccurTime TEXT NOT NULL, Remark TEXT NOT NULL DEFAULT ); CREATE INDEX IX_AssetTransactions_AssetId ON AssetTransactions(AssetId);RepairRecords 表比较简单关键字段是 AssetId、StartTime、EndTime、Cost、Description。注意维修记录和资产状态是两回事设备送修时状态改为维修中维修完成后再从维修中转为在库不能在维修记录里直接改资产状态。3. 让源码跑起来项目骨架与资产档案增删改查3.1 技术选型与最小项目结构1.0 版本我一般选 WinForms EF Core SQLite。理由很实际内部系统跑在 Windows 内网环境WinForms 控件简单、维护成本低WPF 样式能力强但对以数据展示为主的系统收益不明显。数据访问用 EF Core因为多数查询是单表组合条件表达式树翻译省去大量手写 SQL 和 DataReader 映射数据库用 SQLite 则是因为内网机器大多没有 SQL Server 实例单文件免安装拷贝即用。项目结构分三层就行UI 层放窗体Service 层放业务逻辑Data 层放 DbContext 和实体。1.0 阶段不要引入仓储模式和工作单元那会让简单系统变得很难读。Program.cs 是整个应用的入口using AssetManagement.Data; using Microsoft.EntityFrameworkCore; using System.Windows.Forms; namespace AssetManagement.UI; internal static class Program { [STAThread] private static void Main() { ApplicationConfiguration.Initialize(); DbContextOptionsAssetDbContext options new DbContextOptionsBuilderAssetDbContext() .UseSqlite(Data Sourceassets.db) .Options; using var db new AssetDbContext(options); db.Database.EnsureCreated(); DbInitializer.Seed(db); Application.Run(new MainForm(db)); } }[STAThread]是 WinForms 的线程模型要求必须保留。EnsureCreated在数据库不存在时直接建库建表1.0 阶段用它能少踩迁移的坑等表结构稳定后再换 EF Migrations。连接串直接写在代码里是偷懒做法但 1.0 阶段可接受正式上线前挪到 appsettings.json 或配置文件里。DbContext 里除了六张核心表还要注册后面折旧和盘点会用到的两张扩展表。HasQueryFilter是软删除的关键加上之后所有_db.Assets查询自动排除已删除记录只有显式调用IgnoreQueryFilters()才能查出来public class AssetDbContext : DbContext { public AssetDbContext(DbContextOptionsAssetDbContext options) : base(options) { } public DbSetAsset Assets SetAsset(); public DbSetAssetCategory AssetCategories SetAssetCategory(); public DbSetDepartment Departments SetDepartment(); public DbSetEmployee Employees SetEmployee(); public DbSetAssetTransaction AssetTransactions SetAssetTransaction(); public DbSetRepairRecord RepairRecords SetRepairRecord(); public DbSetDepreciationRecord DepreciationRecords SetDepreciationRecord(); public DbSetStocktakeRecord StocktakeRecords SetStocktakeRecord(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityAsset() .HasIndex(a a.AssetCode) .IsUnique(); modelBuilder.EntityAsset() .HasIndex(a a.Status); modelBuilder.EntityAsset() .HasIndex(a a.DepartmentId); modelBuilder.EntityAsset() .HasQueryFilter(a !a.IsDeleted); } }外键在代码里用 int 字段表示不建导航属性。这样 EF 追踪简单也不容易误删关联数据。索引方面AssetCode 唯一索引保证编号不重复Status 和 DepartmentId 索引覆盖最常用的列表过滤条件。3.2 组合查询与界面绑定资产列表页最常见的需求是组合查询关键字、分类、状态、价格区间、分页。我习惯定义一个查询参数类把条件全部收进去避免界面代码里拼查询逻辑public class AssetQuery { public string? Keyword { get; set; } public int? CategoryId { get; set; } public AssetStatus? Status { get; set; } public decimal? MinPrice { get; set; } public decimal? MaxPrice { get; set; } public int PageIndex { get; set; } public int PageSize { get; set; } 50; }Service 层查询方法只做一个职责把条件翻译成 IQueryable 并执行分页public ListAsset SearchAssets(AssetQuery query) { IQueryableAsset q _db.Assets.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Keyword)) { q q.Where(a a.AssetCode.Contains(query.Keyword) || a.Name.Contains(query.Keyword)); } if (query.CategoryId.HasValue) q q.Where(a a.CategoryId query.CategoryId.Value); if (query.Status.HasValue) q q.Where(a a.Status query.Status.Value); if (query.MinPrice.HasValue) q q.Where(a a.OriginalValue query.MinPrice.Value); if (query.MaxPrice.HasValue) q q.Where(a a.OriginalValue query.MaxPrice.Value); return q.OrderByDescending(a a.Id) .Skip(query.PageIndex * query.PageSize) .Take(query.PageSize) .ToList(); }Contains在 EF Core 里翻译成LIKE %关键字%前置通配符会导致索引失效。如果查询场景主要是按编号前缀查找改用StartsWith能走索引。AsNoTracking用于只读列表避免 EF 跟踪实体带来的内存占用编辑操作需要重新查跟踪实体否则SaveChanges不会更新。界面绑定用 DataGridView 直接绑定 List 即可展示但建议加一层分页不要全表加载。搜索按钮触发重新查询页码变化时更新 PageIndex 再查一次var query new AssetQuery { Keyword textBoxKeyword.Text.Trim(), PageIndex currentPage, PageSize 50 }; var list _assetService.SearchAssets(query); dataGridView1.DataSource list; dataGridView1.ClearSelection();这里有个细节不要在 DataGridView 里直接允许编辑单元格否则用户改的只是界面上的值根本没保存到数据库。1.0 的做法是双击行打开资产编辑窗口保存后重新加载当前页。4. 领用归还、维修报废与折旧计提把流程做成状态机4.1 状态机先画一张状态转换表再写代码资产状态是这套系统里最容易失控的地方。很多 1.0 源码把 Status 当普通字段谁想改就改结果就是维修中的设备被直接改成已领用维修记录还挂在那儿。我的习惯是先定义枚举再画一张转换表最后用代码把这张表固化下来public enum AssetStatus { InStock 0, // 在库 InUse 1, // 已领用 Maintaining 2, // 维修中 Scrapped 3, // 已报废 StockLoss 4 // 盘亏 }状态转换规则放在一个静态类里用字典表驱动。注意已报废是终态不允许再流转盘亏资产找回后可以重新入库当前状态允许流转到在库已领用、维修中、已报废、盘亏已领用在库、维修中、已报废、盘亏维修中在库、已报废、盘亏已报废无盘亏在库public static class AssetStateMachine { private static readonly DictionaryAssetStatus, AssetStatus[] Transitions new() { [AssetStatus.InStock] new[] { AssetStatus.InUse, AssetStatus.Maintaining, AssetStatus.Scrapped, AssetStatus.StockLoss }, [AssetStatus.InUse] new[] { AssetStatus.InStock, AssetStatus.Maintaining, AssetStatus.Scrapped, AssetStatus.StockLoss }, [AssetStatus.Maintaining] new[] { AssetStatus.InStock, AssetStatus.Scrapped, AssetStatus.StockLoss }, [AssetStatus.Scrapped] Array.EmptyAssetStatus(), [AssetStatus.StockLoss] new[] { AssetStatus.InStock } }; public static bool CanTransition(AssetStatus from, AssetStatus to) { return Transitions.TryGetValue(from, out var targets) targets.Contains(to); } }这个类把所有状态流转规则集中到一处所有 Service 方法在改 Status 之前先调CanTransition不符合规则直接抛异常。1.0 版本完全不需要引入工作流引擎一个静态类加一张表就是最可靠的实现。4.2 领用归还流水要记账别只改字段领用操作至少做三件事校验状态是否能领用、更新资产的责任人和状态、写一条操作流水。这三件事必须在同一个事务里完成否则会出现状态改了但流水丢了的情况。EF Core 的SaveChanges默认包事务如果中途抛异常状态和流水会一起回滚public void CheckOut(int assetId, int employeeId, string remark) { var asset _db.Assets.FirstOrDefault(a a.Id assetId) ?? throw new InvalidOperationException(资产不存在); var employee _db.Employees.FirstOrDefault(e e.Id employeeId) ?? throw new InvalidOperationException(员工不存在); if (!AssetStateMachine.CanTransition(asset.Status, AssetStatus.InUse)) throw new InvalidOperationException($资产 [{asset.AssetCode}] 当前状态 {asset.Status} 不允许领用); asset.DepartmentId employee.DepartmentId; asset.OwnerId employee.Id; asset.Status AssetStatus.InUse; _db.AssetTransactions.Add(new AssetTransaction { AssetId asset.Id, AssetCode asset.AssetCode, Type TransactionType.CheckOut, DepartmentId employee.DepartmentId, OwnerId employee.Id, OccurTime DateTime.Now, Remark remark }); _db.SaveChanges(); }归还操作逻辑对称但有个细节容易写错归还时要记录的是归还前的责任人和部门而不是归还后的空值。所以先把旧责任人存到局部变量再清空 OwnerId这样流水里才能看出这台设备之前是谁在用的public void ReturnAsset(int assetId, string remark) { var asset _db.Assets.FirstOrDefault(a a.Id assetId) ?? throw new InvalidOperationException(资产不存在); if (!AssetStateMachine.CanTransition(asset.Status, AssetStatus.InStock)) throw new InvalidOperationException(当前状态不允许直接归还); int? oldOwnerId asset.OwnerId; int? oldDepartmentId asset.DepartmentId; asset.Status AssetStatus.InStock; asset.OwnerId null; _db.AssetTransactions.Add(new AssetTransaction { AssetId asset.Id, AssetCode asset.AssetCode, Type TransactionType.Return, DepartmentId oldDepartmentId, OwnerId oldOwnerId, OccurTime DateTime.Now, Remark remark }); _db.SaveChanges(); }归还时不建议清空 DepartmentId因为资产仍然存放在这个部门只是没有个人责任人。如果清空了部门的资产统计就会丢数据。4.3 折旧计提LastDepreciationMonth 防止重复折旧跑批是最容易重复计算的逻辑。如果直接用「当前日期 - 购买日期」算已提月份任务重复执行一次就会多算一个月。我给资产的 LastDepreciationMonth 字段存上次计提的年月跑批时跳过已经提过当月的资产public void RunMonthlyDepreciation(DateTime month) { string ym month.ToString(yyyy-MM); var assets _db.Assets .Where(a a.Status ! AssetStatus.Scrapped) .Where(a a.LastDepreciationMonth ! ym) .Where(a a.PurchaseDate.Year month.Year || (a.PurchaseDate.Year month.Year a.PurchaseDate.Month month.Month)) .ToList(); foreach (var asset in assets) { int totalMonths asset.DepreciationYears * 12; int monthsDone _db.DepreciationRecords.Count(r r.AssetId asset.Id); if (monthsDone totalMonths) { asset.LastDepreciationMonth ym; continue; } decimal monthly (asset.OriginalValue - asset.SalvageValue) / totalMonths; _db.DepreciationRecords.Add(new DepreciationRecord { AssetId asset.Id, Month ym, Amount Math.Round(monthly, 2) }); asset.LastDepreciationMonth ym; } _db.SaveChanges(); }折旧口径为当月购入、次月计提所以查询条件里排除购买当月。monthsDone通过统计折旧明细表得到提满折旧年限后自动停止不再生成新记录。资产量上万时循环里逐台 Count 会慢可以先按 AssetId 分组统计已提月份再在循环里查字典效率高很多。注意如果中途改了原值或折旧年限从改动后的次月按新参数继续计提之前的记录不动。这种会计估计变更 1.0 版本不用支持但要写进操作手册否则财务对账对不上找不到原因。5. 避坑指南从 1.0 到能上线的六个常见翻车现场5.1 Excel 导入资产编号变成科学计数法现象从 Excel 导入台账后几十条资产编号变成类似1.23E15的格式后几位数字全变成 0有些编号还被自动识别成日期比如2024-06被读成某年某月。原因Excel 对超过 11 位的数字自动转科学计数法并且原始精度已经丢失。导入组件按数值列读取时拿到的是已经失真的数。解决资产编号列在 Excel 模板里必须预先设置成文本格式导入时逐行验证 AssetCode 是否符合正则。如果源数据已经变成科学计数法Excel 本身也恢复不了唯一的办法是回到原始系统重新导出。不要试图靠代码修复已经损坏的编号那是白费功夫。var errors new Liststring(); int rowIndex 2; foreach (DataRow row in table.Rows) { string? code row[资产编号]?.ToString()?.Trim(); if (string.IsNullOrEmpty(code)) continue; if (!Regex.IsMatch(code, ^[A-Z]{2}-\d{6}-\d{3,}$)) { errors.Add($第 {rowIndex} 行资产编号不合法{code}); } rowIndex; } if (errors.Count 0) { File.WriteAllLines(import_errors.txt, errors); return; }这里rowIndex从 2 开始是因为模板第一行是标题。错误信息写入本地文件比弹窗友好导入结束后一次性展示给用户。5.2 SQLite 多客户端写入报 database is locked现象单机测试一切正常放到内网让三四个人同时操作时不时弹出SQLite Error 5: database is locked。原因SQLite 同一时刻只允许一个写事务。EF Core 的SaveChanges默认开显式事务如果事务里同时更新资产和写入流水事务持续时间变长多客户端冲突概率急剧上升。解决连接串加Default Timeout30让写入排队等待同时把写操作控制在短事务内不要在事务里做耗时查询。SQLite 的正确边界是单机或最多三五个人的小团队超过这个规模就切 SQL Server别硬扛。.UseSqlite(Data Sourceassets.db;Default Timeout30)有一种玄学做法是开 WAL 模式扛并发但 WAL 只解决读写互斥解决不了写写互斥业务系统多客户端场景还是要换数据库。5.3 盘点差异发现后系统不允许改账现象盘点时发现账上有的设备实物找不到系统里没有「盘亏」这个操作入口实物多出来一台设备也不知道怎么入账。原因1.0 源码里只做了「盘点登记」没有设计差异处理流程。直接改状态会让账实不符无处追溯不改又盘不平。解决盘点差异分成两类处理。盘亏走状态机里的 StockLoss 状态生成一条盘点差异记录经审批后转报废盘盈走新增资产单补录分类、地点、来源后再入账。核心原则是盘点差异必须留痕不能直接改库存。5.4 物理删资产把流水和外键一起删断了现象为了清掉一条录错的资产直接 DELETE结果领用归还记录全部显示异常报表里关联不到资产。原因Assets 表被物理删除AssetTransactions 里的 AssetId 悬空历史流水失去了关联对象。解决Assets 表加 IsDeleted 字段删除操作一律改成IsDeleted true。配合 DbContext 里的HasQueryFilter正常查询自动过滤已删除资产流水表仍然能按 AssetCode 查出历史记录。5.5 状态字段被到处 UPDATE流程记录对不上现象维修单还没关闭资产状态已经被改成在库盘点还没结束状态变成已报废。流程慢半拍甚至错位。原因多个窗口的代码直接给asset.Status赋值绕过了状态机校验。解决所有状态变更都收口到 AssetService 的方法里Service 内部先调用AssetStateMachine.CanTransition不允许的流转直接抛异常。上线前把代码里的.Status 全部搜一遍凡是直接赋值的都要改成调用 Service 方法。5.6 五万条资产查询卡顿现象台账数据到五万条之后列表加载要好几秒关键字查询更慢SQLite 在 SQL 窗口里直接卡死转圈。原因没有分页查询条件走了全表扫描EF 默认把所有列都 select 出来。解决3.2 里的SearchAssets已经带分页注意 PageSize 不要超过 100AssetCode、Status、DepartmentId 建索引列表页只 select 需要的列用匿名类型投影而不是整实体。数据量更大时Skip/Take 在 SQLite 里偏移量越大越慢可以考虑改成WHERE Id lastId的游标分页。6. 进阶给 1.0 加条码盘点和 Excel 台账迁移6.1 扫码盘点把扫码枪当成键盘输入市面上一百多块的扫码枪大多是 HID 键盘模式扫一下二维码就等于在焦点控件里输入一串字符然后自动敲回车。所以不接任何 SDK做一个 TextBox 把焦点锁住回车触发查询就行。先给 AssetService 加一个按编号查单条资产的方法public Asset? GetByCode(string code) { return _db.Assets.FirstOrDefault(a a.AssetCode code); }盘点界面扫描时编号存在就显示账面信息不存在就加入盘盈列表private async void textBoxScan_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode ! Keys.Enter) return; string code textBoxScan.Text.Trim(); textBoxScan.Clear(); if (code.Length 0) return; var asset await Task.Run(() _assetService.GetByCode(code)); if (asset null) { listBoxDiff.Items.Add($盘盈{code} 账面无记录); } else { listBoxScan.Items.Add(${asset.AssetCode} {asset.Name} 账面状态{asset.Status}); } }标签打印用 ZXing.Net 生成 Code128 码标签上只放资产编号和名称。盘点结束后把 listBoxDiff 里的差异导成 Excel走盘亏和盘盈流程。6.2 初始台账迁移Excel 模板要包含哪些列从 Excel 老台账迁到新系统是个高发翻车点。模板列建议按这个顺序资产编号、名称、分类代码、部门代码、存放地点、购置日期、原值、残值率、折旧年限、责任人、当前状态。导入顺序有讲究先导分类和部门字典再导资产否则资产表里的外键对不上。列名必填校验规则资产编号是正则校验^[A-Z]{2}-\d{6}-\d{3,}$名称是非空分类代码是必须在分类字典中存在部门代码是必须在部门字典中存在购置日期是不能晚于当前日期原值是大于 0残值率否0~5% 之间状态否只能填在库/已领用/维修中状态列可选是为兼容老台账来不及更新的情况默认按在库导入。导入过程中资产编号重复直接报错拒绝不覆盖。我给某公司的模拟项目X搭这套 1.0 时最后悔的就是先画界面、后定编码规则导致上线后重新导了两遍账。现在的习惯是先写状态机再写 Service 层最后才碰界面。希望帮到你。本文还有配套的精品资源点击获取