VB.NET WinForms考勤系统源码:三层架构与考勤核算实战
简介:面向需要了解企业级考勤系统实现思路的 VB.NET 开发者与课程设计人员,这份项目源码围绕员工出勤管理场景展开,覆盖员工信息维护、每日打卡记录、迟到早退与旷工判定、月度报表汇总及基于角色的权限控制等常见模块,可帮助读者从界面设计、数据库访问到业务逻辑分层理解一套考勤系统的落地方式。压缩包共138个文件,约30.87MB,内含21个vb源码、2个vbproj项目文件、2个sln解决方案文件,以及26个dll、6个exe、8个resx等编译与界面资源,另含config、xml等配置和说明性文件,基本保留了完整工程结构与可运行产物,便于对照学习Windows Forms界面组织、数据存储与异常处理逻辑。目前已有289人学习下载,适合有一定VB.NET基础、希望参考完整项目源码完成课程设计或企业考勤管理原型开发的读者。目录中的报表生成、权限分配和事件驱动打卡等实现,也能为后续功能扩展与代码调试提供可借鉴的思路。
1. 接手一套 VB.NET 考勤系统,难点从来不在打卡按钮
很多人看到 VB.NET 考勤系统这四个字,脑子里浮现的画面是:一个窗体、一个输入框、一个 DataGridView,敲个工号点确定,完事。真把源码跑起来、喂两天真实数据之后,你会发现麻烦全在别处——班次跨零点、一天打四次卡、请假单和加班单跟打卡流水打架、补卡审批之后要重算工时,这些逻辑纠缠在一起,才是真正磨人的部分。这套系统走的是 WinForms 客户端加 ADO.NET 数据访问、SQL Server 存业务数据的路子,主线覆盖员工档案、部门组织、班次与排班、原始打卡流水、考勤结果核算、报表导出六块内容,定位在中小规模的单机或局域网部署。它适合两类开发者:一类是需要在客户现场快速交付一套能用的考勤工具,另一类是手里缺一个完整的桌面端三层结构脚手架,想往上叠自己业务的人。桌面端最实在的好处是双击 exe 就能跑,不用给客户端单独架一台 Web 服务器,这一点在做现场实施时往往比技术栈新不新更管用。
2. 技术栈与分层结构:WinForms 三层的项目边界怎么划
2.1 为什么是 WinForms 而不是 Web
先把选型这件事说清楚,否则后面看代码会一直觉得别扭。考勤这个场景有几个硬约束:第一,很多中低端打卡机只提供 Windows 下的 DLL 或 OCX 接口,靠串口、USB 或者局域网 UDP 广播推数据,Web 端根本没法直接调;第二,门卫室、车间门口这类地方,往往只有一台老机器,装个浏览器还得考虑版本兼容;第三,打卡要能离线兜底,网络断了不能让人打不上卡。这三条加起来,WinForms 基本是唯一解。
这套项目的技术组合是:VB.NET + .NET Framework + WinForms 做表现层,ADO.NET(SqlClient)做数据访问,SQL Server 做存储。没有用 Entity Framework,也没上 WCF,这不是技术落后,而是部署环境决定的——客户机可能连 .NET Framework 4.x 都才是刚装上的,再叠 ORM 的依赖会显著提高实施成本。我一般跟人说,这套组合的优势是"零外部依赖",编译出来的 exe 拷过去,装个 .NET Framework 就能跑。
要提醒的是,VB.NET 在 WinForms 里的语法糖比 C# 少一些,但有它自己的便利:My.Settings存配置、WithEvents声明控件事件、My.Forms直接访问窗体、默认的后期绑定在调 COM 组件(比如 Excel Interop)时省掉不少转换代码。这些在后面几章的代码里都会出现,别觉得是"老写法",在这个技术栈里它们就是最顺手的写法。
2.2 目录结构与项目分层
源码包是一套标准的四项目解决方案,用 Visual Studio 打开.sln就能看到边界划分。结构大致如下:
AttendanceSystem.sln ├── Attendance.UI/ # 表现层:窗体、用户控件、资源 │ ├── Forms/ │ │ ├── FrmMain.vb # 主框架(MDI 容器 + 菜单) │ │ ├── FrmClock.vb # 打卡界面(全屏可切换) │ │ ├── FrmEmployee.vb # 员工档案维护 │ │ ├── FrmShift.vb # 班次与排班设置 │ │ ├── FrmAttCalc.vb # 考勤核算与手工调整 │ │ └── FrmReport.vb # 报表查询与导出 │ ├── Controls/ # 自定义控件(状态灯、日期选择等) │ ├── App.config # 连接字符串、界面参数 │ └── My Project/ # 程序集信息、资源、设置 ├── Attendance.BLL/ # 业务层:规则判定、核算、校验 │ ├── AttCalculator.vb # 考勤结果计算核心 │ ├── ShiftResolver.vb # 某员工某天适用哪个班次 │ └── LeaveChecker.vb # 请假/加班与打卡的冲抵 ├── Attendance.DAL/ # 数据访问层:SqlHelper + 各表 DAO │ ├── SqlHelper.vb # 连接、执行、事务封装 │ ├── EmployeeDao.vb │ ├── AttLogDao.vb │ └── AttResultDao.vb └── Attendance.Common/ # 公共:常量、枚举、日志、导出工具 ├── AppConst.vb ├── Logger.vb └── ExcelExporter.vb这个划分的关键在于:UI 层不写一句 SQL,BLL 层不引用System.Windows.Forms,DAL 层不认识"考勤"这个概念只认识表。这条线一旦破了,后面想换数据库、想加接口、想跑单元测试都会很难受。翻源码的时候可以拿这个当尺子量:如果某个窗体的按钮事件里出现New SqlConnection,那就是分层被穿透了,二次开发时要先把它挪回 DAL。
2.3 数据库表设计与核心字段
考勤系统的表不难,难在字段语义要一次定死。改字段比加字段贵十倍,尤其是历史流水表。核心表大致是这几张:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| tb_Employee | 员工档案 | EmpID、EmpNo(工号)、EmpName、DeptID、CardNo(卡号)、InDate、Status |
| tb_Dept | 部门树 | DeptID、DeptName、ParentID |
| tb_Shift | 班次定义 | ShiftID、WorkStart、WorkEnd、CheckInStart、CheckInEnd、CrossDay、LateGrace |
| tb_EmpShift | 员工排班 | EmpID、ShiftID、BeginDate、EndDate |
| tb_AttLog | 原始打卡流水 | LogID、EmpNo、LogTime、DeviceNo、Source |
| tb_AttResult | 核算结果 | EmpID、AttDate、ShiftID、InTime、OutTime、Status、LateMin、EarlyMin |
| tb_Leave | 请假单 | EmpID、LeaveType、BeginTime、EndTime、Hours、Status |
| tb_SysConfig | 全局参数 | ConfigKey、ConfigValue |
有两处设计值得单独拎出来说。一是tb_AttLog只存原始流水,不做任何判定,核算结果全部落在tb_AttResult里——这是"原始数据不可变、结果可重算"的思路,出了问题能一键重跑当月,不用手工修修补补。二是tb_Shift里带CrossDay布尔位,用来标记这个班次是否跨零点,后面算工时全靠它,漏了这一个字段,夜班永远算不对。
tb_AttResult.Status我一般定义成整型枚举:0 正常、1 迟到、2 早退、3 迟到且早退、4 缺卡、5 旷工、6 请假、7 加班、8 手工修正。用整数不用字符串,是为了排序和统计方便,界面上再翻译成中文。源码的AppConst.vb里就有这套枚举,改的时候别只改一边。
3. 核心模块拆解:一次打卡从刷卡到落库的完整链路
3.1 打卡入口:工号校验与流水写入
打卡界面是整个系统最容易被低估的一块。它不是"输入工号回车就完事",要考虑:工号输错怎么办、同一人多久之内重复刷算不算、设备时间跟服务器时间不一致怎么处理、断网时能不能先写本地缓存。先看主流程代码:
' FrmClock.vb —— 打卡主流程 Private Sub btnClock_Click(sender As Object, e As EventArgs) Handles btnClock.Click Dim empNo As String = txtEmpNo.Text.Trim() ' 1. 格式校验:工号只允许数字和字母,长度 4~12 If Not Regex.IsMatch(empNo, "^[A-Za-z0-9]{4,12}$") Then ShowTip("工号格式不对,请重新输入", TipLevel.Warn) Return End If ' 2. 存在性校验:查员工表,同时把状态为离职的挡掉 Dim emp As Employee = EmployeeDao.GetByNo(empNo) If emp Is Nothing Then ShowTip("查无此工号", TipLevel.Error) Return ElseIf emp.Status <> 1 Then ShowTip("该工号已停用,请联系人事", TipLevel.Error) Return End If ' 3. 防重复:同一人 60 秒内连续刷,只提示不写库,避免手抖 If AttLogDao.ExistsWithin(empNo, 60) Then ShowTip($"{emp.EmpName},刚刚已经打过卡了", TipLevel.Info) Return End If ' 4. 写流水:时间统一取服务器时间,不取客户端时间 Dim log As New AttLog With { .EmpNo = empNo, .LogTime = DateTime.Now, .DeviceNo = "MANUAL-01", .Source = 1 ' 1=手工输入 2=读卡器 3=设备同步 } If AttLogDao.Insert(log) > 0 Then ShowTip($"{emp.EmpName} 打卡成功 {log.LogTime:HH:mm:ss}", TipLevel.Success) txtEmpNo.Clear() RefreshTodayPanel(empNo) ' 右侧刷新"我今天打了哪些卡" Else ShowTip("写入失败,请重试或联系管理员", TipLevel.Error) End If End Sub这段代码里有两个刻意的决定。LogTime取的是DateTime.Now,也就是程序运行这台机器的本地时间,而不是数据库的GETDATE(),原因是打卡终端和数据库可能不在同一台机器上,取谁的时间要在项目里统一约定,这里约定取终端时间,因为终端通常就是那台摆在门口的机器,跟人的实际行为最贴近。第二个是ExistsWithin的 60 秒窗口,这是血泪经验——不设窗口的话,员工刷错工号又刷对,两条流水都在,核算时会把第一条当成有效上班卡,结果算出一堆莫名其妙的早到。
Insert里Source字段别省,后面排查问题时它能把手工补卡、读卡器刷卡、设备同步三类数据分得清清楚楚。没有这个字段,出了问题只能靠时间戳瞎猜。
3.2 考勤规则引擎:班次解析与迟到早退判定
这是整个项目最有含金量的部分,也是最容易写乱的地方。核算的基本单元是"某员工某天"的考勤结果,取数顺序是:先确定这一天他排的是哪个班次,再从这个班次上取出上班卡窗口和下班卡窗口,然后在流水表里捞落入窗口的最早和最晚两条记录,最后跟班次时间比对算出迟到分钟、早退分钟、缺卡状态。
先看班次解析:
' ShiftResolver.vb —— 确定某员工某天适用的班次 Public Shared Function Resolve(empId As Integer, attDate As Date) As Shift ' 1. 优先取排班表里覆盖该日期的记录(一个员工可能先上白班后调夜班) Dim s As Shift = EmpShiftDao.GetByDate(empId, attDate) If s IsNot Nothing Then Return s ' 2. 排班表没覆盖,退化到部门默认班次 s = DeptDefaultShiftDao.GetByEmp(empId) If s IsNot Nothing Then Return s ' 3. 都没有就用系统兜底班次(SysConfig 里配置的 ShiftID) Return ShiftDao.GetById(CInt(SysConfigDao.Get("DefaultShiftID"))) End Function这个三级兜底很重要。实际项目里排班数据经常是不全的——新员工入职当天没人给他排班、调岗中间的过渡期是空白,如果没有兜底,核算时直接抛空引用,一整批人就都漏算了。兜底班次放在tb_SysConfig里而不是硬编码,是为了让实施人员自己改。
再看判定主逻辑:
' AttCalculator.vb —— 单日单人考勤结果计算 Public Shared Function CalcOne(empId As Integer, attDate As Date) As AttResult Dim shift As Shift = ShiftResolver.Resolve(empId, attDate) Dim r As New AttResult With {.EmpID = empId, .AttDate = attDate, .ShiftID = shift.ShiftID} ' 跨天班次:下班日期要 +1 天,比如 22:00 上班、06:00 下班 Dim offDate As Date = If(shift.CrossDay, attDate.AddDays(1), attDate) ' 上班卡窗口:允许提前 CheckInEarlyMin 分钟开始刷 Dim inFrom As Date = attDate.AddMinutes(shift.CheckInStart - shift.CheckInEarlyMin) Dim inTo As Date = attDate.AddMinutes(shift.CheckInEnd) Dim inLog = AttLogDao.GetFirst(empId, inFrom, inTo) ' 下班卡窗口:跨天时基准日期换成 offDate Dim outFrom As Date = offDate.AddMinutes(shift.CheckOutStart) Dim outTo As Date = offDate.AddMinutes(shift.CheckOutEnd) Dim outLog = AttLogDao.GetLast(empId, outFrom, outTo) ' 缺卡判定:两头都没有 = 旷工;只有一头 = 缺卡 If inLog Is Nothing AndAlso outLog Is Nothing Then r.Status = AttStatus.Absent r.AbsentHours = 8D Return r ElseIf inLog Is Nothing OrElse outLog Is Nothing Then r.Status = AttStatus.MissingCard End If If inLog IsNot Nothing Then r.InTime = inLog.LogTime If outLog IsNot Nothing Then r.OutTime = outLog.LogTime ' 迟到:实际上班时间晚于 班次上班时间 + 宽限分钟数 If inLog IsNot Nothing Then Dim lateMinutes = DateDiff(DateInterval.Minute, attDate.AddMinutes(shift.WorkStart), inLog.LogTime) - shift.LateGrace If lateMinutes > 0 Then r.LateMin = lateMinutes End If ' 早退:实际下班时间早于 班次下班时间 - 宽限分钟数 If outLog IsNot Nothing Then Dim earlyMinutes = DateDiff(DateInterval.Minute, outLog.LogTime, offDate.AddMinutes(shift.WorkEnd)) - shift.EarlyGrace If earlyMinutes > 0 Then r.EarlyMin = earlyMinutes End If ' 汇总状态 If r.LateMin > 0 AndAlso r.EarlyMin > 0 Then r.Status = AttStatus.LateAndEarly ElseIf r.LateMin > 0 Then r.Status = AttStatus.Late ElseIf r.EarlyMin > 0 Then r.Status = AttStatus.Early ElseIf r.Status <> AttStatus.MissingCard Then r.Status = AttStatus.Normal End If Return r End Function几个参数必须说清楚。CheckInEarlyMin是"最多提前多少分钟可以刷上班卡",常见值 120,意思是 8 点上班,6 点来刷卡也认;CheckInStart/CheckInEnd是相对当天零点的分钟偏移量,比如 8:00 就是 480、17:30 就是 1050;LateGrace是迟到宽限,一般配 5 或 10 分钟,配 0 的话员工会天天找你吵。CrossDay为真时,offDate换成次日,这是跨零点班次唯一正确的处理方式——很多实现图省事,直接用attDate.AddMinutes(shift.WorkEnd),夜班下班时间会变成当天凌晨 6 点,算出来早退 16 小时,这种 bug 上线第一天就会被发现。
DateDiff返回分钟差,注意它是"按跨过多少分钟数"算的,不是四舍五入,所以 8:05:59 打卡在 8:00 上班、宽限 5 分钟时,算出来是 5 - 5 = 0,正常。这个细节决定了很多争议单的走向,改宽限策略之前先想清楚。
还有一个隐含前提:这套逻辑假设一天只有一个班次。如果客户有"上午半班 + 下午半班"的需求,要改成按班次段循环,tb_Shift也得拆成主表加明细表。源码里预留了ShiftSegment的相关枚举,但没实现,属于典型的二次开发入口。
3.3 数据访问层:参数化 SQL 与事务
DAL 层统一收口在SqlHelper.vb,所有 DAO 都通过它访问数据库。核心是三个方法:ExecuteNonQuery、ExecuteDataTable、ExecuteScalar,加上一个ExecuteTransaction处理批量操作。这里贴出最关键的参数化执行部分:
' SqlHelper.vb —— 统一的数据执行入口 Public Shared Function ExecuteDataTable(sql As String, ParamArray ps As SqlParameter()) As DataTable Dim dt As New DataTable() ' 连接字符串从 App.config 读,绝不写死在代码里 Using conn As New SqlConnection(GetConnStr()) Using cmd As New SqlCommand(sql, conn) cmd.CommandTimeout = 30 If ps IsNot Nothing AndAlso ps.Length > 0 Then cmd.Parameters.AddRange(ps) Using da As New SqlDataAdapter(cmd) da.Fill(dt) End Using End Using End Using Return dt End Function ' 批量核算用事务:一批人要么全算完,要么全回滚 Public Shared Function ExecuteTransaction(actions As List(Of TranItem)) As Boolean Using conn As New SqlConnection(GetConnStr()) conn.Open() Using tran As SqlTransaction = conn.BeginTransaction() Try For Each item In actions Using cmd As New SqlCommand(item.Sql, conn, tran) If item.Params IsNot Nothing Then cmd.Parameters.AddRange(item.Params) cmd.ExecuteNonQuery() End Using Next tran.Commit() Return True Catch ex As Exception tran.Rollback() Logger.Error("批量核算失败", ex) Return False End Try End Using End Using End FunctionGetConnStr()内部读的是My.Settings("AttConnStr"),对应App.config里的<connectionStrings>节点。这是整套代码里唯一允许出现连接字符串的地方,改数据库只改这一处。
参数化这件事必须强调一遍:这套项目的所有 SQL 都是@参数形式,一个字符串拼接都没有。不是为了防注入好看,是因为工号、姓名里带单引号的情况真的会崩——某次实施就遇到一个姓名字段里带了英文单引号,拼接 SQL 直接语法错误,整个员工列表页白屏。从那以后我审代码,看到"... '" & txt.Text & "'"这种写法一律打回。
ExecuteTransaction的用法是核算模块先查出当月所有员工的打卡数据,在内存里CalcOne跑完,把结果列表打包成TranItem集合一次性提交。这样做的原因是批量核算动辄几千条 UPDATE,每条约一次往返,不开事务的话中途失败会留下半截数据,重跑又得先清理,非常难受。
3.4 报表与导出:DataGridView 绑定与 Excel 输出
报表界面用的是DataGridView直接绑DataTable,这种方式开发快,但有两个必须处理的问题:一是列的顺序和中文表头要在DataBindingComplete里统一设,不要在 Designer 里一条条改;二是大数据量要开虚拟模式,否则一万行以上滚动会明显卡。
导出走的是 CSV 而不是 Excel Interop。原因很实在:Interop 要求客户机装 Office,版本还得对得上,装个 WPS 就报 COM 异常。CSV 谁都能开,实现也干净:
' ExcelExporter.vb —— 导出 CSV,带 UTF-8 BOM Public Shared Sub ExportToCsv(dt As DataTable, filePath As String) ' 关键:用带 BOM 的 UTF8,否则 Excel 打开中文全是乱码 Using sw As New IO.StreamWriter(filePath, False, New Text.UTF8Encoding(True)) ' 第一行写表头 Dim heads As New List(Of String) For Each col As DataColumn In dt.Columns heads.Add(EscapeCsv(col.Caption)) Next sw.WriteLine(String.Join(",", heads)) ' 数据行 For Each row As DataRow In dt.Rows Dim cells As New List(Of String) For Each col As DataColumn In dt.Columns cells.Add(EscapeCsv(row(col).ToString())) Next sw.WriteLine(String.Join(",", cells)) Next End Using End Sub ' 字段里含逗号、引号、换行的必须转义,否则列会错位 Private Shared Function EscapeCsv(v As String) As String If String.IsNullOrEmpty(v) Then Return "" If v.Contains(",") OrElse v.Contains("""") OrElse v.Contains(vbCr) OrElse v.Contains(vbLf) Then Return """" & v.Replace("""", """""") & """" End If Return v End FunctionNew Text.UTF8Encoding(True)里的True是"写入 BOM 头",就这一个参数,决定了用 Excel 双击打开时中文是正常显示还是一堆方框。这个坑几乎每个做导出功能的人都踩过,翻车成本极低,踩过一次记一辈子。EscapeCsv处理的是备注字段里带逗号的情况,比如"迟到,已补卡审批",不转义的话这一行会被拆成多列,后面所有列全部右移。
导出文件的默认路径放在Application.StartupPath & "\Export\",文件名带日期和操作员,避免多人同时导出互相覆盖。
4. 环境搭建与首次运行:从附加数据库到跑通第一笔打卡
4.1 开发环境与依赖清单
这套源码基于 .NET Framework 的 WinForms 项目,用 Visual Studio 打开即可,不需要额外装 SDK。环境清单如下:
| 项目 | 版本要求 | 说明 |
|---|---|---|
| Visual Studio | 2017 及以上 | 需要勾选「.NET 桌面开发」工作负载 |
| .NET Framework | 4.6.2 及以上 | 工程文件里的 TargetFrameworkVersion 别乱降 |
| SQL Server | 2012 及以上(Express 也行) | 需要能附加 .mdf 或用脚本建库 |
| Office / WPS | 非必需 | 只有导出 Excel 的老版本才会用到 |
如果只装了较新版本的 Visual Studio,打开时提示"项目需要重新定向",点确定让它升到当前可用的框架版本就行;但注意升完之后App.config里的supportedRuntime节点也要跟着改,否则运行时会提示找不到运行时。
4.2 连接字符串与数据库初始化
数据库有两种初始化方式:附加.mdf文件,或者跑Script\InitDatabase.sql。推荐第二种,因为附加文件对 SQL Server 版本很敏感,低版本实例附加高版本 .mdf 会直接失败。
先改配置文件:
<!-- Attendance.UI/App.config --> <configuration> <connectionStrings> <!-- Data Source 改成你自己的实例名; LocalDB 用户写 (localdb)\MSSQLLocalDB Express 用户写 .\SQLEXPRESS --> <add name="AttConnStr" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=AttDB;Integrated Security=True;" providerName="System.Data.SqlClient" /> </connectionStrings> <appSettings> <!-- 打卡机类型:0=不接设备 1=读卡器 2=网络设备同步 --> <add key="DeviceType" value="0" /> <!-- 核算起始日期,避免第一次算全量数据 --> <add key="CalcStartDate" value="2024-01-01" /> </appSettings> </configuration>Integrated Security=True走的是 Windows 身份验证,本机开发最省事;如果部署到客户的域环境里连不上,换成User ID=sa;Password=***的 SQL 验证,但密码别写进源码,用aspnet_regiis加密配置节或者干脆让实施人员现场填。DeviceType设成 0 表示纯手工打卡模式,第一次跑通务必先用这个值,把设备因素排除掉。
建库脚本执行完之后会自带一个管理员账号,用户名admin,初始密码写在脚本注释里,登录后强制改。别偷懒跳过改密码这一步,这类系统在很多单位是放在内网任意一台机器上都能访问的。
4.3 编译运行与第一笔打卡
按顺序走一遍:
# 1. 建库(在 SQL Server Management Studio 里打开执行) # Script/InitDatabase.sql # 会创建 AttDB 库、所有表、索引和初始数据 # 2. 还原 NuGet 包(如果工程引用了 Newtonsoft.Json 之类) nuget restore AttendanceSystem.sln # 3. 编译(MSBuild 路径按自己的 VS 版本调整) msbuild AttendanceSystem.sln /p:Configuration=Debug /p:Platform="Any CPU" # 4. 运行 # 输出目录:Attendance.UI\bin\Debug\Attendance.UI.exe启动后先用admin登录,进「员工档案」新增一条测试员工,工号1001,状态设为在职,部门随便选一个。然后切到「打卡」界面,输入1001回车,右下角应该出现"打卡成功"的提示和当前时间。再进「考勤核算」,选今天日期,点「重新核算」,tb_AttResult里就会多出一条记录。能看到这条记录,说明从界面到 BLL 再到 DAL 的整条链路是通的,剩下的就是业务配置了。
需要注意的是,核算功能默认只处理有排班的人。测试员工没排班时会走兜底班次,如果连兜底班次都没配,核算会静默跳过这个人——不报错,但也没结果。这时候去tb_SysConfig里检查DefaultShiftID有没有值,是排查的第一站。
4.4 硬件设备接入的两种方式
设备这块分两条路。第一条是读卡器,通常是一个 HID 键盘模拟设备,插上电脑后刷卡等于键入一串卡号加回车,代码上只需要在打卡窗体的KeyPress里监听回车,把缓冲区内容当工号处理,一行设备驱动都不用写。第二条是网络考勤机,一般提供 UDP 广播或者 HTTP 推送,需要在项目里起一个后台线程或Timer定时去拉数据,拉回来写进tb_AttLog并打上Source=3。
源码里Device\IDeviceReader.vb定义了一个接口,ManualReader、HidCardReader、NetDeviceReader三个实现类,通过App.config的DeviceType反射创建。这种写法是为了让新增设备型号不用改主流程,加个类改个配置就行。真要接某品牌设备的时候,把厂商 SDK 的 DLL 放到输出目录,在NetDeviceReader里调它的GetRecord方法,把返回值映射成AttLog就完事。
5. 避坑与常见问题排查:这几处我全踩过
5.1 导出 CSV 用 Excel 打开全是乱码
现象:程序里点导出,用记事本打开中文正常,双击用 Excel 打开一片问号和方块。
原因:StreamWriter用的是New UTF8Encoding(False)或者干脆用了默认编码,写出来的是不带 BOM 的 UTF-8,Excel 在没有 BOM 的情况下会按系统 ANSI 代码页解析,中文自然全乱。
解决:把编码对象构造成New UTF8Encoding(True),让文件头带上三个字节的 BOM。另一个方向是导出成 GB2312,但这在跨语言环境里更麻烦,不推荐。改完记得验证:用 Excel 双击打开、再用记事本打开对比,两边都正常才算过。
5.2 夜班工时算出来是负数
现象:22:00 上班、次日 06:00 下班的班次,核算结果显示早退 960 分钟,工时 -16 小时。
原因:下班时间用的是attDate.AddMinutes(shift.WorkEnd),attDate是当天零点,WorkEnd是 360(6 点),算出来是当天早上 6 点,比上班时间还早 16 小时。
解决:tb_Shift必须带CrossDay位,为真时下班基准日换成attDate.AddDays(1)。补这个字段的时候还要注意历史数据,已经算过的记录需要重跑一遍,否则新旧结果混在一张表里,报表统计对不上。重跑前先备份tb_AttResult,这是后悔药。
5.3 同一人一天出现多条考勤结果
现象:报表里同一个人同一天出现两三行,工时翻倍。
原因:核算按钮被连点,或者定时任务和手工核算同时跑,tb_AttResult没有唯一约束,INSERT就直接进了两条。
解决:给(EmpID, AttDate)建唯一索引,核算逻辑改成MERGE或者"先 DELETE 再 INSERT"包在同一个事务里。另外界面上核算按钮点完立刻Enabled = False,算完再打开,这属于界面层的防抖,虽然土但有效。
5.4 换台机器就报连接数据库失败
现象:开发机上一切正常,拷到客户机上启动就弹"未找到或无法访问服务器"。
原因:连接字符串写在了某个窗体的常量里,或者用了(local)这种实例名,客户机上的实例名不是这个。
解决:全项目搜索Data Source=,确保只有App.config一处;实例名改成客户机实际的,或者干脆让实施人员第一次启动时在设置界面里填。SqlHelper.GetConnStr()里加一层判断——如果配置为空就弹配置窗体,而不是直接抛异常。
5.5 引用了设备 SDK 后编译报位数不匹配
现象:BadImageFormatException,或者运行时提示"试图加载格式不正确的程序"。
原因:工程是 Any CPU,在 64 位系统上跑成了 64 位进程,而厂商给的 SDK DLL 是 32 位的。
解决:把 UI 项目的目标平台从 Any CPU 改成 x86,或者在App.config里加<startup useLegacyV2RuntimeActivationPolicy="true">配合Prefer32Bit。改平台之后记得把所有依赖项目也一起改,只改一个会导致项目引用加载失败。这一条属于玄学范畴,报错信息完全不指向真实原因,只能靠经验。
6. 二次开发改造:把考勤规则从代码里挪出来
这套源码最值得动手改的地方,是考勤规则写死在AttCalculator.CalcOne里。迟到宽限、早退宽限、缺卡阈值这些参数虽然已经从tb_Shift读了,但"什么算迟到""缺卡要不要扣钱""加班从几点起算"这些判定逻辑还是硬编码。客户一多,每个客户都想改一句,最后你会有一堆分支版本,维护成本爆炸。
我的做法是抽一层规则表。加一张tb_AttRule,字段RuleKey、RuleValue、DeptID(为空表示全局),把LateGrace、EarlyGrace、OvertimeStartAfterMin、AbsentDeductHours这些全挪进去,CalcOne开头先根据员工部门捞一份规则集合,后面所有判定都从集合里取值。这样不同部门可以有不同政策,比如车间宽限 10 分钟、办公室宽限 5 分钟,改配置不用重新编译。
' 改造后的取值方式:部门级覆盖全局 Private Shared Function GetRule(ruleKey As String, deptId As Integer) As Decimal Dim dt = AttRuleDao.Get(ruleKey, deptId) If dt IsNot Nothing Then Return CDec(dt("RuleValue")) ' 部门没配就退回全局,全局也没有就用代码里的默认值兜底 Return CDec(AppConst.DefaultRules(ruleKey)) End Function验证方法很简单:拿一个测试员工,把部门宽限从 5 改成 15,手动往tb_AttLog插一条 8:10 的打卡记录,重算当天结果,看LateMin是不是从 5 变成 0。改配置不重启程序就能生效,说明规则是运行时读的,没被缓存死。
另一个改造点是核算性能。现在是一人一天一次数据库往返,500 人一个月就是 15000 次,跑起来要几分钟。改成先把整个月的tb_AttLog一次性捞进Dictionary(Of String, List(Of AttLog)),键用工号_日期,然后在内存里循环,数据库只负责最后一次批量写入,实测能快一个数量级。这里要注意内存占用,一个月 500 人大概十几万条流水,完全扛得住。
最后提醒一句:改核算逻辑之前,先把上个月的tb_AttResult完整备份一份。考勤数据是有法律效力的,员工拿工资条来找你对账的时候,你得能说清楚每一分钟是怎么来的。从那以后我每次动AttCalculator里的任何一行,都强制先在测试库上跑一遍历史对比,把新旧结果的差异导出来逐条看,确认无误才敢往生产上放。希望帮到你。
本文还有配套的精品资源,点击获取