ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#合同管理系统源码详解:从数据库设计到到期提醒实战

C#合同管理系统源码详解:从数据库设计到到期提醒实战 简介基于C#与SQL Server的合同管理系统是一套包含完整源码及数据库的打包资源适合课程设计、毕业设计或企业合同管理模块开发者参考。系统聚焦客户、项目、合同信息和合同执行控制管理员可管理全流程数据普通用户可查询合同明细模块划分清晰能直接呈现合同管理系统的需求分析与功能落地。压缩包共123个文件体积仅2.82MB以51个.cs源码文件为业务核心23个.resx与23个.resources资源文件管理界面与本地化内容另含config配置、sln解决方案、mdf/ldf数据库文件和可执行程序结构完整方便本地运行与二次修改。目前已有153人学习下载。通过该资源可掌握C#窗体应用开发、SQL Server数据访问、业务分层及基础权限控制的实际写法也可在现有客户、项目、合同模块上继续扩展是一份兼顾学习与二次开发价值的合同信息管理系统资料。1. 为什么还有人愿意打开一个C#合同管理系统(源码数据库).zip在很多公司合同管理还停留在Excel台账加共享文件夹的原始阶段到期续签靠行政翻日历审批只留一封邮件财务对账要跑到档案室翻纸质件。这时候一个带着源码和数据库文件的C#合同管理系统就成了比预算两万外包更现实的选择。这类资源包通常是一个完整的Visual Studio解决方案加数据库文件.mdf/.db/.accdb或建库脚本解压后能直接编译。这篇笔记按数据库设计、增删改查、审批流、到期提醒的顺序讲透让新手拿到就能跑熟手能避开那些让系统半年后崩掉的隐藏坑。2. 合同管理系统的数据库设计表结构决定这个源码能撑多久2.1 核心表与字段合同主表、明细表和审批日志是底线拿到任何一份合同管理源码先别急着按F5打开数据库文件看看表结构。哪怕UI做得再花哨底层也逃不出三张核心表合同主表、合同明细表、审批日志表。合同主表Contract是台账主体字段一般包括合同编号、合同名称、甲方、乙方、签订日期、生效日期、到期日期、合同金额、付款方式、当前状态、附件路径、负责人、备注。这些字段看着平淡但类型选错后面全是泪。金额必须用 decimal(18,2)不要用float否则累加对账时会差出几分钱日期用 date 或 datetime别用varchar不然到期提醒的日期比对会变成字符串比较跨年必翻车。一个能直接跑的标准建表SQL如下CREATE TABLE Contract ( Id INT IDENTITY(1,1) PRIMARY KEY, ContractNo NVARCHAR(50) NOT NULL UNIQUE, ContractName NVARCHAR(200) NOT NULL, PartyA NVARCHAR(200) NOT NULL, PartyB NVARCHAR(200) NOT NULL, SignDate DATE NULL, StartDate DATE NULL, ExpireDate DATE NULL, Amount DECIMAL(18,2) NOT NULL DEFAULT 0, PayMode NVARCHAR(50) NULL, Status TINYINT NOT NULL DEFAULT 0, AttachmentPath NVARCHAR(500) NULL, OwnerName NVARCHAR(50) NULL, Remark NVARCHAR(500) NULL, CreatedBy NVARCHAR(50) NULL, CreatedTime DATETIME NULL DEFAULT GETDATE() );这里的逻辑很直接ContractNo 加唯一索引保证同编号合同不会重复录入ExpireDate 做成可空因为采购框架合同可能没有明确到期日Status 用 TINYINT 0-9 的数字状态而不是字符串状态机扩展时不用改字段长度。明细表ContractItem用来拆收款计划或履约节点主表 Id 作为外键一条主合同对应多条明细。审批日志表ContractAuditLog记录谁在什么时间做了什么动作、批注是什么这张表是上线后用来审计和复盘的关键证据再小的系统也不该省。类型选型上我一般坚持三条金额 decimal(18,2)日期 date/datetime布尔改用 smallint。很多二手源码用 bit 表示是否作废本身没问题但 SQLite 和 Access 对 bit 支持不一致统一 smallint 换库时少一层麻烦。2.2 数据库选型SQL Server、SQLite 还是 Access先看文件后缀再动手源码包里的数据库通常有三种形态.mdf 的 SQL Server多半是 LocalDB 或 Express.db/.sqlite 的 SQLite.accdb/.mdb 的 Access。老一点的包还有只给一个 create.sql 让你自己建库的。判断方法很简单打开 App.config 或 Web.config 看 connectionStrings或者直接看数据库文件后缀。三种方案对比特征SQL Server LocalDBSQLiteAccess文件形态.mdf 附加后运行单文件 .db单文件 .accdb部署复杂度需安装LocalDB或Express免安装自带驱动兼容性好但并发差适合场景公司内正常并发单机、小团队、绿色版历史遗留项目备份方式备份数据库或分离复制直接复制文件复制文件如果包里有 .sql 建库脚本我最推荐直接跑脚本而不是附加 .mdf。因为 .mdf 附加时常见文件占用、日志文件缺失、版本不兼容三种坑脚本重放则干净确定。跑脚本的常见做法是用 sqlcmd 或 SSMS 执行如果你只想先看表结构用 DbBrowser for SQLite 打开本地 .db 文件是最快的比在代码里连半天库实在得多。2.3 拿到源码第一步初始化数据库而不是先编译很多人拿到压缩包习惯先解压、双击 .sln、然后 CtrlF5 等着报错。正确顺序是先把数据库立起来再编译代码。以最常见的 SQL Server LocalDB 为例最小初始化步骤如下# 启动 LocalDB 实例 sqllocaldb create ContractLocalDB sqllocaldb start ContractLocalDB # 执行建库脚本文件路径按实际解压位置调整 sqlcmd -S (localdb)\ContractLocalDB -E -i create_database.sql命令里 -S 指定实例名create_database.sql 是包里的建库脚本如果包里没有现成脚本就把 2.1 节的建表语句整理成一个文件执行。执行完用 sqlcmd 查一下表是否建成功sqlcmd -S (localdb)\ContractLocalDB -E -Q SELECT name FROM sys.tables看到 Contract、ContractItem、ContractAuditLog 三张表就算立住了。这一步做在前面的价值是后面任何数据库连接失败的报错你都能立刻判断是代码问题还是库没起好而不是对着一个编译不过的工程瞎猜。3. 用C#把合同增删改查跑起来从DbHelper到DataGridView批量导入3.1 一个通用数据访问类DbHelper把连接、增删改查收拢在一处合同管理这类中小系统不值得为每个表写一套仓储代码。一个静态 DbHelper 就能覆盖绝大多数增删改查。以下是我常用的最小实现using System; using System.Data; using System.Data.SqlClient; public static class DbHelper { private static readonly string connStr System.Configuration.ConfigurationManager.ConnectionStrings[ContractDB].ConnectionString; public static int ExecuteNonQuery(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (paras ! null) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteNonQuery(); } } public static DataTable ExecuteDataTable(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(connStr)) { using (SqlDataAdapter da new SqlDataAdapter(sql, conn)) { if (paras ! null) da.SelectCommand.Parameters.AddRange(paras); DataTable dt new DataTable(); da.Fill(dt); return dt; } } } public static object ExecuteScalar(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (paras ! null) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteScalar(); } } }逻辑说明三个方法对应三种诉求——ExecuteNonQuery 跑增删改返回影响行数ExecuteDataTable 查列表绑定给 DataGridViewExecuteScalar 查单值比如合同总数。using 保证连接用完及时释放这是老源码最容易漏的点连接不释放跑两天必报连接池已满。参数化 SQL 从第一行代码就要养成后面在避坑章会专门讲拼接SQL带来的注入问题。3.2 合同列表与搜索DataGridView绑定DataTable搜索条件用参数拼接合同管理的主界面百分之九十是同一个模板上方几个搜索框中间一个 DataGridView下方分页。搜索条件通常是合同名称模糊匹配、状态筛选、到期日期范围。一个可以直接迁移的查询方法如下public DataTable SearchContract(string keyword, int status, DateTime? start, DateTime? expire) { string sql SELECT Id, ContractNo, ContractName, PartyA, PartyB, ExpireDate, Amount, Status FROM Contract WHERE 11; var paras new ListSqlParameter(); if (!string.IsNullOrWhiteSpace(keyword)) { sql AND (ContractName LIKE kw OR ContractNo LIKE kw); paras.Add(new SqlParameter(kw, % keyword.Replace(%, [%]) %)); } if (status 0) { sql AND Status st; paras.Add(new SqlParameter(st, status)); } if (start.HasValue) { sql AND SignDate start; paras.Add(new SqlParameter(start, start.Value)); } if (expire.HasValue) { sql AND ExpireDate expire; paras.Add(new SqlParameter(expire, expire.Value)); } return DbHelper.ExecuteDataTable(sql, paras.ToArray()); }这里有个容易被忽略的参数keyword 里用户可能输入 % 或 _这两个在 LIKE 里是通配符不转义的话用户搜50%预付会把所有合同拉出来。Replace 把 % 换成 [%]SQL Server 会把它当普通字符SQLite 同样兼容这种写法。状态值 status 用 -1 表示全部避免在 SQL 里拼 OR 把索引搞废。3.3 新增、修改和删除明细表要用事务别只写主表合同录入除了插主表还可能同时写入几条履约节点到 ContractItem。新手常见错误是分两次 ExecuteNonQuery第一次成功第二次失败台账和明细就不一致了。正确做法是包在一个事务里using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran conn.BeginTransaction()) { try { string sqlMain INSERT INTO Contract(ContractNo,ContractName,PartyA,PartyB,SignDate,StartDate,ExpireDate,Amount,Status,Remark) VALUES(no,name,a,b,sign,start,expire,amount,0,remark); SELECT SCOPE_IDENTITY();; using (SqlCommand cmd new SqlCommand(sqlMain, conn, tran)) { cmd.Parameters.AddWithValue(no, txtNo.Text.Trim()); cmd.Parameters.AddWithValue(name, txtName.Text.Trim()); // 其余参数略 int newId Convert.ToInt32(cmd.ExecuteScalar()); foreach (DataRow row in itemDt.Rows) { string sqlItem INSERT INTO ContractItem(ContractId,ItemName,ItemDate,ItemAmount) VALUES(cid,name,date,amount); using (SqlCommand cmd2 new SqlCommand(sqlItem, conn, tran)) { cmd2.Parameters.AddWithValue(cid, newId); // 明细参数略 cmd2.ExecuteNonQuery(); } } } tran.Commit(); } catch { tran.Rollback(); throw; } } }逻辑说明BeginTransaction 之后所有 SqlCommand 都要带上同一个 tran 对象Commit 或 Rollback 控制整体结果。ExecuteScalar 配合 SCOPE_IDENTITY() 拿回主表刚才生成的 Id作为明细表的外键。这个事务模式同样适用于签约审批通过时写审计日志更新状态这种多步操作。3.4 用SqlBulkCopy批量导入历史合同Excel台账迁移的最快路径公司上线合同系统最痛的往往是历史数据几百条旧合同还躺在Excel里手工录入能录到怀疑人生。C# 里 SqlBulkCopy 就是为这种批量导入设计的。Excel 读出 DataTable 后直接灌进 SQL ServerDataTable dt ReadExcelToDataTable(filePath); // 从Excel读取列名与目标表对应 using (SqlBulkCopy bc new SqlBulkCopy(connStr)) { bc.DestinationTableName Contract; bc.ColumnMappings.Add(合同编号, ContractNo); bc.ColumnMappings.Add(合同名称, ContractName); bc.ColumnMappings.Add(甲方, PartyA); bc.ColumnMappings.Add(乙方, PartyB); bc.ColumnMappings.Add(签订日期, SignDate); bc.ColumnMappings.Add(到期日期, ExpireDate); bc.ColumnMappings.Add(金额, Amount); bc.BatchSize 500; bc.BulkCopyTimeout 60; bc.WriteToServer(dt); }ColumnMappings 把 Excel 列名和表字段挂上钩Excel 里漏填的字段在导入时会被写成默认值所以导入前先跑一遍数据校验脚本把起始日期晚于到期日期的行清掉。BatchSize 按 500 一批提交既快又不会把事务日志顶到爆炸。导入前一定先备份一次库历史数据迁移没有后悔药这句话是血泪经验。4. 审批流程和到期提醒决定系统能不能真正用起来的两个关卡4.1 合同状态机草稿、待审、已生效、到期、作废合同系统真正区别于Excel表的是状态流转。我见过不少源码把状态存成字符串界面下拉框里让用户随便选结果台账里同时出现生效中已生效执行中三种写法统计报表直接没法看。正确的做法是把状态定义进状态机只有合法路径能到达的状态才允许更新。常用状态表如下状态值名称可到达的下一个状态0草稿1提交审批、5作废1待审批2通过、4驳回2已生效3到期、5作废3已到期无唯一动作是续签生成新合同4已驳回0修改后重新提交5作废无代码层面状态更新不应该散落在各个按钮事件里而是收拢成一个 UpdateStatus 方法public bool UpdateStatus(int contractId, byte newStatus, string operatorName, string auditRemark) { string sql UPDATE Contract SET StatusnewStatus, LastOperatorop WHERE Idid; int rows DbHelper.ExecuteNonQuery(sql, new SqlParameter(newStatus, newStatus), new SqlParameter(op, operatorName), new SqlParameter(id, contractId)); if (rows 0) { string logSql INSERT INTO ContractAuditLog(ContractId, OperatorName, ActionTime, OldStatus, NewStatus, Remark) VALUES(cid,op,GETDATE(),old,new,remark); DbHelper.ExecuteNonQuery(logSql, /* 参数略 */); } return rows 0; }逻辑说明先改状态再写日志日志里的 OldStatus 是更新前查出来的值NewStatus 是本次目标值。这套设计保证任何时间点都能回答这个合同是谁在什么时候改成生效的。状态值用数字后界面显示用字典翻译成中文统计报表直接按 Status 分组不用再做文本清洗。4.2 审批权限怎么落到按钮上角色字段 按钮可见性小团队合同系统不需要重型工作流引擎最实用的是提交—审批—驳回两级流程。审批人通常是合同归口部门负责人或财务。实现上不需要在数据库里做一个通用权限表只要在用户表加 Role 字段0普通员工1审批人2管理员登录后把角色存在全局静态变量里界面加载时控制按钮public void LoadPermission(User currentUser) { btnSubmit.Visible currentUser.Role 0; btnApprove.Visible currentUser.Role 1; btnReject.Visible currentUser.Role 1; btnDelete.Visible currentUser.Role 2; btnImport.Visible currentUser.Role 2; }按钮可见性只是入口控制真正的校验要在 UpdateStatus 方法里再查一次当前用户角色否则懂行的用户直接调用按钮事件也能绕过界面。老一点的源码爱用 MessageBox 提示无权限老工程师更推荐直接置灰按钮加后端校验双重保障既友好又不给内鬼留口子。4.3 到期提醒定时器扫描 提醒表别在内存里弹窗到期提醒是合同管理系统能不能让老板觉得值的关键功能。最朴素的实现是不停弹窗提示启动时 Timer 每 24 小时扫一次库发现 30 天内到期的合同就 MessageBox。这个方案开发十秒上线半年后就会被骂——开机瞬间弹出一堆对话框业务人员得挨个点掉。我一般会改成提醒落库模式每日凌晨定时扫描一次符合条件的合同写入 ContractRemind 表用户在系统首页的待办列表里看到提醒点进去是合同详情。这种做法把提醒从骚扰弹窗变成待办事项领导还能查看哪些合同快到期了没处理。扫库的 SQL 在 SQL Server 和 SQLite 里写法不同-- SQL Server30天内到期 SELECT ContractNo, ContractName, PartyB, ExpireDate FROM Contract WHERE Status 2 AND DATEDIFF(day, GETDATE(), ExpireDate) BETWEEN 0 AND 30; -- SQLite用 julianday 计算日期差 SELECT ContractNo, ContractName, PartyB, ExpireDate FROM Contract WHERE Status 2 AND julianday(ExpireDate) - julianday(now) BETWEEN 0 AND 30;参数说明BETWEEN 0 AND 30 表示从今天开始到未来30天如果是当天到期也包含在0这一天。若要提前60天提醒把30改成60即可。定时器在WinForms里用 System.Windows.Forms.Timer服务类程序用 System.Threading.Timer注意定时器里不要直接弹UI跨线程会抛异常。4.4 源码包的另一道坎C#防反编译不能靠隐藏标题是源码包很多人在意的是源码被拿走怎么办。C#编译出来的程序集可以被ILSpy、dnSpy直接反编译回可读代码公司内部系统可能无所谓但对外交付的合同系统如果带敏感对接信息数据库连接串含密码至少要处理两层。第一层是连接字符串加密把 App.config 里 connectionStrings 用工具加密或自己写RSA加解密第二层是混淆程序集打乱命名和流程让反编译工具读到的类名变成 a/b/c。注意混淆不是万能的加了壳的程序可能被杀毒软件误报。要交付给甲方时我通常建议在发布目录里附一份部署说明明确说明主程序经过混淆让现场安装人员提前加入杀毒白名单免得部署当天被安全软件拦下。5. 接手的合同管理源码最容易翻车的五个坑现象、原因、解决5.1 .mdf附加失败报错数据库正在使用或文件权限不足现象双击 .mdf 附加SQL Server 提示文件被占用或者权限不足无法附加。 原因源码包里的 .mdf 是打包者从LocalDB实例分离出来的有些甚至是从另一台机器的 Express 实例来的文件权限继承自原环境在只读目录或系统盘 Program Files 下解压也会触发权限问题。 解决把整套源码解压到非系统盘例如 D:\ContractSystem用 sqllocaldb create 新建独立实例而不是用默认实例附加时右键文件给当前用户完全控制权限。如果你不想附加直接按第2章的脚本重新建库更省心。5.2 连接字符串写死绝对路径换电脑就崩现象源码在自己电脑上跑得好好发给同事解压后直接报建立与 SQL Server 的连接时发生错误。 原因App.config 里写的是 C:\Users\打包者\ContractDB.mdf换机器路径不存在。 解决把连接字符串改成相对路径或直接用 LocalDB 实例名例如 Server(localdb)\ContractLocalDB;DatabaseContract;Integrated Securitytrue数据库文件放在项目根目录的 App_Data 下部署时整包拷走即可。顺带把连接字符串抽到配置文件外发布时单独检查。5.3 到期日期用字符串存储跨年提醒全部失灵现象到期提醒在12月还能查出来到1月突然少了大量本该提醒的合同。 原因建表时 ExpireDate 用 varchar查询时 DATEDIFF(day, GETDATE(), ExpireDate) 把字符串隐式转日期跨年时字符串排序与日期排序不一致导致 BETWEEN 判断失效。 解决把表字段改为 date返回不了就写一条转换SQL更新旧数据例如 UPDATE Contract SET ExpireDate CONVERT(date, ExpireDateText)。以后写新代码一律用参数化日期类型不存字符串日期。5.4 登录后权限按钮控制失效普通员工能点删除现象界面隐藏了删除按钮但按住回车或调用方法仍然能删合同。 原因只在前端控制 Visible后端 UpdateStatus 没校验角色懂行的人直接调用内部方法或改按钮状态。 解决后端每个写操作都取当前登录用户校验角色后再执行删除合同改成逻辑删除——增加 IsDeleted 字段查询默认过滤 IsDeleted0这样即使误操作也能恢复是真正意义上的后悔药。5.5 历史数据批量导入后金额对不上报表少了几万现象用 SqlBulkCopy 导入Excel合同后汇总统计和财务核对差了数笔。 原因Excel 里金额列有千分位逗号、有空行、有约10万这类文本亦或导入时漏了主表-明细的外键关联明细金额没进汇总。 解决导入前写一段校验把非数字行挑出来人工修正金额列用 Convert.ToDecimal 而不是隐式转换导入后立刻跑一个分组SUM和一个 COUNT 与Excel原表对比不一致就回滚重导。这套校验写一次以后每次迁移都能复用。6. 从能跑到能交差上线前再补三个动作第一个动作是每天自动备份。合同数据是资产不是用来练手的玩具。WinForms 程序可以在启动时判断今天是否已备份用一个定时任务在凌晨执行一次 BACKUP DATABASE 或直接复制 SQLite 文件到备份目录。string backupSql BACKUP DATABASE ContractDB TO DISKpath; DbHelper.ExecuteNonQuery(backupSql, new SqlParameter(path, D:\ContractBackup\contract_ DateTime.Now.ToString(yyyyMMdd) .bak));第二个动作是导出台账Excel。老板和财务最爱看的就是合同台账别为了这个需求去引入报表控件DataTable 直接写入 CSV 或 Excel 就够用导出时注意金额格式化保留两位小数日期统一 yyyy-MM-ddExcel打开不乱码。第三个动作是给删除操作留审计。即便做了逻辑删除也要记日志ContractAuditLog 里加上删除动作谁在什么时候删了哪条合同都能查出来。权限越小的系统越要留这条线业务上出了纠纷它比口头解释靠谱。做合同系统交付最深的教训我是在第一次交付时踩的当时只顾着把功能写全连接字符串忘改成客户环境现场折腾了两天才跑起来。后来每套部署都把数据库初始化脚本和连接配置单独放一个checklist上线前逐个打勾。这个习惯帮我少加了很多班。希望帮到你。本文还有配套的精品资源点击获取
返回列表