尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ASP.NET排班系统源码解析:三层架构与环形指针调度

ASP.NET排班系统源码解析:三层架构与环形指针调度 简介这是一套基于 ASP.NET 的自动排班系统源码与数据库整合包整体采用三层架构核心业务逻辑集中在 App_Code数据库脚本放在 App_Data。系统支持按年份自动排班值班员工轮流上岗周六、周日每天安排两班其余日期每天一班排班规则清晰适合练习排班业务、学习分层开发以及准备课程设计的初学者。压缩包共 26 个文件、约 1.9MB其中 11 个 cs 源文件对应数据访问层与业务处理逻辑4 个 aspx 页面构成前端交互界面另有 mdf/ldf/sql 数据库文件、MasterPage 母版页和 Web.config 配置SQL Server 数据库可直接附加方便快速查看表结构和示例数据。目前已有 545 人学习下载。这份轻量完整的样例能够帮助读者理解 ASP.NET 三层结构中表现层、业务层、数据层的协作方式掌握自动排班逻辑的实现思路并学会将数据库脚本与源码配合部署到本地环境。1. 排班系统不是查表是调度状态机一个排班系统如果只是用一个表装每天值班人三个月后维护成本就会翻倍。这套 ASP.NET 自动排班系统源码难得之处在于把“排班”从页面浸泡中抽离出来变成你可以看懂的调度逻辑按年份遍历日历员工轮流上班周六周日拆成两个班次。对要用 ASP.NET WebForms 做信息化改造的团队说这个 demo 恰好覆盖了三层架构、数据访问、日历循环和批量入库几个高频场景是一个能跑、能拆、能改的起点。不堆概念直接看 App_Code 下的几个类。三层不是三个文件夹而是三个边界。2. App_Code 里的三层边界从 DutyPo 到 DutyDao拿到源码之后第一件事不是打开 Default.aspx而是看 App_Code 下的三个目录PO、DAL、Common。这套自动排班系统的数据访问被收拢在 DAL 里实体类收在 PO 里连接串和通用方法放进 Common。这样加一个新页面时aspx.cs 里只需要调用 DAO不需要再把 SQL 散落在各个页面文件中。2.1 DutyPo 与 StaffPo先定义结果再写算法DutyPo 表示一天里的一个排班记录。它既不是数据库表结构的照搬也不是页面视图模型仅仅是算法输出的一次调度单元。源码里 DutyPo 可以还原为下面这个结构public class DutyPo { public DateTime DutyDate { get; set; } public int WeekDay { get; set; } public int ShiftNo { get; set; } public int StaffId { get; set; } public string StaffName { get; set; } public bool IsWeekend { get; set; } }这里 ShiftNo 是关键字段。这个自动排班系统的业务规则是周六、周日排两班其余每天一班ShiftNo 就是用来区分“同一日期的第几个班次”的平时只生成 ShiftNo1 的一条记录周末生成 ShiftNo1 和 ShiftNo2 两条记录。WeekDay 看起来冗余实际上很省事后续查询“周末一共多少人值班”不用再依赖 DATEPART 这种方言函数。StaffPo 更简单通常只有 StaffId、StaffName、OrderNo 三个字段。OrderNo 是轮值顺序算法里最重要的就是它。没有顺序号“轮流值班”就只能靠数据库主键随机排序结果不可控。字段类型作用典型值StaffIdint员工主键1StaffNamenvarchar(50)显示名张三OrderNoint轮值顺序位32.2 Common 层连接串与通用工具不参与业务DAL 里要访问数据库Common 层至少承担两个职责把连接串从 Web.config 读出来并且提供一批几乎每个页面都要用的公共函数。SqlCommon.cs 里最直接的做法是静态属性public static class SqlCommon { public static string ConnString { get; set; } static SqlCommon() { ConnString ConfigurationManager.ConnectionStrings[DutyConnection].ConnectionString; } public static DataTable ExecuteDataTable(string sql) { using (SqlConnection conn new SqlConnection(ConnString)) { SqlDataAdapter adapter new SqlDataAdapter(sql, conn); DataTable table new DataTable(); adapter.Fill(table); return table; } } }UtilCommon.cs 里通常放的是字符串处理、类型转换这类辅助方法。比如页面层拿到 Request[year]它是 string 类型直接传给算法会埋下类型异常。常见做法是在 Common 层封装一个安全的年份解析方法转换失败时返回默认年。从代码组织上看Common 层只做“管道”不做“判断”排班逻辑不应该写在这里否则后续换数据库或者改业务规则时公共类会越来越大最后变成没有人敢改的 God Class。2.3 DutyDao把查询结果装回 PO 列表DAL 层的 DutyDao.cs 是整个排班系统的数据出口。源码里的写法偏教学风格查询条件直接拼接字符串运行起来直观。一个典型方法如下public ListDutyPo FindByCondition(string year, string staffName) { string sql SELECT DutyDate, WeekDay, ShiftNo, StaffId, StaffName, IsWeekend FROM Duty WHERE 11; if (!string.IsNullOrEmpty(year)) { sql AND YEAR(DutyDate) year; } if (!string.IsNullOrEmpty(staffName)) { sql AND StaffName LIKE N% staffName %; } DataTable table SqlCommon.ExecuteDataTable(sql); ListDutyPo result new ListDutyPo(); foreach (DataRow row in table.Rows) { result.Add(new DutyPo { DutyDate Convert.ToDateTime(row[DutyDate]), WeekDay Convert.ToInt32(row[WeekDay]), ShiftNo Convert.ToInt32(row[ShiftNo]), StaffId Convert.ToInt32(row[StaffId]), StaffName row[StaffName].ToString() }); } return result; }这里的字符串拼接是为了让初学者看到 SQL 的生成过程但真正往生产环境放之前我会把它改成参数化查询。变更点很小把条件里的直接拼接替换成带 year 和 staffName 的参数再给 SqlCommand 加上 AddWithValue。这样既能挡住最简单的注入问题也不会影响原来 DAL 的调用方式。DutyDao 里还可能放着 BatchInsert 方法目的是把算法生成的一年排班计划一次性写入 Duty 表。循环里固然可以一条一条 Insert但一年 365 天加周末双班之后接近 500 条记录逐条插入的事务边界很难控制。常见做法是在 DAO 里显式开启 SqlTransaction要么全部写成功要么全部回滚。3. 自动排班算法的正确形态指针轮转与双班展开很多新手一上来就用 Random 随机抽人结果两周之后有人值班三次有人一次没有。这套源码里真正值钱的是那一段自动排班业务逻辑不需要随机数也不需要重量级调度框架一个环形指针就能解决“轮流”问题。3.1 为什么用环形指针而不是随机分配环形指针的优点是公平性、可预测、可审计。员工列表按 OrderNo 排序后算法维护一个不断递增的游标每次取_staffs[_cursor % _staffs.Count]作为当天值班人。只要员工列表不变同一年的排班结果固定管理人员可以提前知道某个人未来哪天值班。随机分配做不到这一点随机结果无法向员工解释“为什么下个月我这么多班”。指针轮转的另一个好处是容易做跨年衔接。把上一年的游标位置保存下来下一年的排班从那个位置继续员工就会觉得排班是连续的而不是每年一月一号突然重新洗牌。3.2 主循环生成一年的排班记录核心逻辑放在 DutyScheduler 类里输入年份和员工列表输出 List 。实现如下public class DutyScheduler { private readonly ListStaffPo _staffs; private int _cursor; public DutyScheduler(ListStaffPo staffs, int startCursor 0) { _staffs staffs; _cursor startCursor; } public ListDutyPo Generate(int year) { if (_staffs null || _staffs.Count 0) { throw new InvalidOperationException(值班员工表不能为空); } ListDutyPo result new ListDutyPo(); DateTime start new DateTime(year, 1, 1); DateTime end new DateTime(year, 12, 31); for (DateTime day start; day end; day day.AddDays(1)) { bool isWeekend day.DayOfWeek DayOfWeek.Saturday || day.DayOfWeek DayOfWeek.Sunday; // 周末两天每天排两班平时只排一班 int shiftCount isWeekend ? 2 : 1; for (int shift 1; shift shiftCount; shift) { StaffPo staff _staffs[_cursor % _staffs.Count]; _cursor; result.Add(new DutyPo { DutyDate day, WeekDay (int)day.DayOfWeek, ShiftNo shift, StaffId staff.StaffId, StaffName staff.StaffName, IsWeekend isWeekend }); } } return result; } }这个循环的逻辑是先判断当天是工作日还是周末再决定内层循环跑一遍还是两遍。外层循环每次加一天用day.AddDays(1)而不是手动计算天数可以自动处理 12 月 31 日跨年问题。内层循环的 shiftCount 是 2 时会先取一个人排上午班再取一个人排下午班。参数 startCursor 用来指定员工池从哪一位开始。上一年排到第五个人第二年生成时 startCursor 就传 5。这里有一个容易出错的地方如果周末双班时连续取两次指针会跳过一个人。这个行为是刻意的目的是让坐班的两个不同员工都参与轮转而不是同一个人一天值两个班。如果你希望周末两班由同一个人负责那就要把取值放在 shift 循环外面每天只移动一次指针。注意_staffs.Count 为 0 时取模会抛 DivideByZeroException所以 Generate 方法入口必须做空集合检查。3.3 输出结果的可读性验证生成之后DutyDao 把 List 批量写入数据库。拿 2024 年 6 月第一个周末举例排出来的数据应该是这个样子DutyDateWeekDayShiftNoStaffName2024-06-01周六1张三2024-06-01周六2李四2024-06-02周日1王五2024-06-02周日2赵六2024-06-03周一1陈七这个结果表直接反映两个规则周六周日每天两条记录且 ShiftNo 不重复周一的第一个班次紧接周日第二个班次之后。通过 GigGridView 绑定这个结果页面端不需要再做任何二次计算。4. 页面层只做两件事把年月交给算法把结果交给 GridView页面层涉及 Default.aspx、DutySubmit.aspx、DutySearch.aspx、DutyStaff.aspx。MasterPage.master 提供统一外壳具体页面把内容填进 ContentPlaceHolder。aspx.cs 里的代码量应该非常薄因为三层结构已经分担了主要工作。4.1 MasterPage统一回发与页面壳MasterPage.master.cs 的 Page_Load 里通常做菜单绑定、用户登录态检查、或者页面标题输出。子页面只需要在 Content 区域写自己的控件。这个模式在 WebForms 里很经典也适合新手理解“母版页不是模板而是一个可继承的页面基类”。DutySubmit.aspx 是自动排班的操作入口页面上放一个年份下拉框和一个生成按钮。下拉框只需要展示当前年份附近几年不需要从数据库读取因为排班是纯算法生成。4.2 DutySubmit生成按钮背后的调用链生成按钮的 Click 事件可以写成这样protected void btnGenerate_Click(object sender, EventArgs e) { ListStaffPo staffs new StaffDao().GetAllStaff(); DutyScheduler scheduler new DutyScheduler(staffs, GetLastCursor()); ListDutyPo duties scheduler.Generate(Convert.ToInt32(ddlYear.SelectedValue)); new DutyDao().BatchInsert(duties); Response.Redirect(DutySearch.aspx?year ddlYear.SelectedValue); }这段代码的调用链非常清楚页面层先拿员工池再把年份交给 DutyScheduler得到 List 最后交给 DutyDao 批量入库。页面本身没有写任何一条 INSERT SQL也没有写排班规则这就是三层架构的好处。GetLastCursor 方法用来读取上一年最后一条记录对应的员工序号保证跨年连续。如果没有这个方法每一年都从第一个员工开始最后的排班结果会非常不平衡。BatchInsert 里我习惯用事务包起来。生成一年的排班记录本身就是一次批量操作中间任何一条插入失败都应该让前面已经插入的全部回滚否则数据库里会出现“上半年有数据、下半年空缺”的半成品排班。4.3 DutySearch 与 DutyStaff查询和员工池维护DutySearch.aspx.cs 负责按条件过滤排班结果。常用查询条件有三个年份、月份、员工姓名。查询后把 DataTable 或 List 绑定到 GridViewprotected void btnSearch_Click(object sender, EventArgs e) { string year ddlYear.SelectedValue; string month ddlMonth.SelectedValue; string staffName txtStaffName.Text.Trim(); ListDutyPo data new DutyDao().FindByCondition(year, month, staffName); gvDuty.DataSource data; gvDuty.DataBind(); }这里有一个容易被忽略的点月份筛选不应该依赖字符串 LIKE而应该用MONTH(DutyDate) month的 SQL 条件。年份筛选也不应该拼DATE_FORMAT否则换到 SQL Server 就无法运行。源码里的 DutyDao 已经展示了 WHERE 条件动态拼接的写法照着把 year 和 month 都变成拼接条件即可。DutyStaff.aspx 是员工池管理页面核心操作是增删改员工。用 GridView 编辑模板就可以完成不需要额外写 JavaScript。员工排序在这里很重要因为 OrderNo 直接决定排班循环的起点。4.4 Web.config 连接串三层架构的入口契约源码附带的是 Sql Server 数据库文件 DutyDemo.mdf连接串示例connectionStrings add nameDutyConnection connectionStringData Source.;Initial CatalogDutyDemo;Integrated SecurityTrue providerNameSystem.Data.SqlClient / /connectionStringsSqlCommon 在静态构造函数里读取这个连接串DAO 层再通过 SqlCommon 访问数据库。平时换数据库服务器只需要改这一处不需要翻页面代码。5. 验证排班结果的三个 SQL 习惯用法排班数据生成之后不能只看页面有没有行还要用 SQL 做对账。这套源码里排班表的核心字段是 DutyDate、ShiftNo、StaffName下面三个验证方法只用这三列就能完成。5.1 总数对账用全年天数反推记录数检查全年记录数是否等于“工作日 2 × 周末天数”DECLARE year INT 2024; SELECT COUNT(*) AS Total, SUM(CASE WHEN DATEPART(dw, DutyDate) IN (1, 7) THEN 1 ELSE 0 END) AS WeekendDayCount FROM Duty WHERE YEAR(DutyDate) year;查询结果里 Total 应该等于366 WeekendDayCount。如果 Total 小于这个值说明有日期漏排如果大于说明某个普通日被错误插入了两条班次。注意 DATEPART(dw) 的结果受数据库语言设置影响最稳妥的办法是先把 DATEFIRST 设为 1SET DATEFIRST 1;这样周六对应 6周日对应 7。5.2 连续重复排班的快速筛查轮转逻辑正常时同一个员工不会在连续两天同班次反复出现。用自连接查出连续两天的同人记录SELECT d1.DutyDate, d1.ShiftNo, d1.StaffName FROM Duty d1 INNER JOIN Duty d2 ON d2.StaffName d1.StaffName AND d2.ShiftNo d1.ShiftNo AND d2.DutyDate DATEADD(day, 1, d1.DutyDate) ORDER BY d1.DutyDate;如果结果不为空先别急着改数据库应该回到 DutyScheduler 检查 startCursor 是否计算有误。跨年指针没有偏移时第二年开头很容易出现同一个员工连续两天值班。5.3 按年份清理而不破坏整体结构重新生成排班前不需要删库重建。按年份清理即可DELETE FROM Duty WHERE YEAR(DutyDate) 2024;清理之后如果要从上一年的位置继续需要先查出 2023 年最后一条排班记录的 StaffName 在员工列表中的排序位再把该值传给 DutyScheduler 的 startCursor否则新一年的第一班又会从员工列表第一个位置开始前后两年连续性丢失。本文还有配套的精品资源点击获取
返回列表