用Flask从零搭建考勤系统:打卡接口、数据库设计与加班计算实践
考勤打卡系统这活儿看着简单做起来全是细节。我刚接手的时候公司用的还是钉钉但管理层后来提了一堆需求——加班要按工时分段算、不同部门要套不同的考勤规则、节假日倒班得单独配置……在钉钉上绕了一圈发现定制成本太高最后决定自己用Python和Flask搭一套内部系统。这篇文章就是我当时从零搭建的完整复盘包括数据表怎么设计、打卡接口怎么避坑、加班工时为什么总是算不准、以及部署到公司内网以后踩过的那些雷。不管你是想给小型团队做个轻量方案还是纯粹想练手Flask这篇都能给你省不少事。1. 为什么选Flask而不是Django也不是直接买现成的先说结论20人以下的团队用钉钉或企业微信飞书的免费考勤就够了。但一旦超过50人或者公司有排班制、综合工时制、跨天加班这类非主流规则免费工具就开始各种不合身。我们公司当时的痛点就在这几个核心业务部门的上班时间压根不一样——研发是弹性打卡客服是三班倒仓库那边是两班制还要算节假日加班。钉钉的高级版要按人头付费一年算下来也不少钱。于是IT部门被问了一句能不能做个内部的小系统这种需求落到程序员头上本质上就是那句话能用就行别太贵。1.1 Flask在考勤系统场景下的说服力选Flask不是因为它比Django强而是在这个场景下它恰好切中要害考勤系统的核心是大量小而分散的接口比如打卡、查记录、提加班申请、审批每个接口逻辑不长Flask的路由组织方式非常直白。Django自带的Admin后台和ORM确实香但它的项目结构和模型注册机制对一个小型内部工具来说反而是多余的脚手架。Flask SQLAlchemy的组合足够应对复杂的多表关联查询报表统计需要用到的分组、日期函数、条件聚合SQLAlchemy都能搞定。部署省心一个Gunicorn进程拖起来就能跑不挑服务器。我当时给管理层的说法是用Django等于开一台重型卡车去运几箱货Flask是辆皮卡灵活够用还好维修。当然这个类比不是很严谨但领导听懂了。注意选型这件事上不要为了技术好看而选重型框架。内部系统生命周期通常三年起步每多一层抽象后续维护的人就多一份负担。Flask的直白让后来接手的同事也能快速看懂。1.2 需求边界要先划清楚动手写代码之前我跟HR、行政、财务分别聊了一轮最后整理出来的核心需求其实只有五条员工打卡支持上下班打卡记录打卡时间、IP地址、定位信息可配置是否需要。考勤规则支持多套规则并存比如固定班次、弹性班次、排班制。加班管理员工提交加班申请审批通过后自动关联打卡时长按规则折算加班工时。请假对接先把请假表做好考勤统计时把请假时段排除掉。报表导出主管能看到团队日报月报HR能导出全员Excel。这里我要特别强调一条经验需求不要一次聊完考勤系统这种东西上线后一定会有规则调整。所以架构上后面要有意识留出规则配置的扩展口子。2. 数据库表结构五张核心表撑起全套考勤业务考勤系统的地基就是数据库表设计。我当时参考了几套开源方案又结合我们的实际场景最终设计出下面这五张核心表简化后的结构。2.1 员工表与部门表员工表不用多说除了基本字段以外有两个字段很关键work_schedule_type班次类型和hire_date入职日期。class Employee(db.Model): __tablename__ employees id db.Column(db.Integer, primary_keyTrue) emp_no db.Column(db.String(20), uniqueTrue, nullableFalse) name db.Column(db.String(50), nullableFalse) department_id db.Column(db.Integer, db.ForeignKey(departments.id)) work_schedule_type db.Column(db.String(20), defaultfixed) hire_date db.Column(db.Date) is_active db.Column(db.Boolean, defaultTrue)work_schedule_type我用了字符串而不是外键关联一张班次表是因为班次规则是走另一张表的这里只需要做一个类型标记。实际操作中类型最好和规则表解耦不然行政调一个班次时间你就要回头改员工数据。2.2 考勤规则表不同部门不同班次的Timer这是整个系统里最容易被低估的表。很多新手做考勤只想到上班9点下班6点但现实里一个公司会有好几套时间规则研发部弹性上班10:00前打卡不算迟到但每天必须满8小时。客服部三班倒早班8:00-16:00中班16:00-24:00夜班0:00-8:00。仓库两班制早班7:00-19:00晚班19:00-7:00中间有排休。我设计的规则表长这样class AttendanceRule(db.Model): __tablename__ attendance_rules id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(50), nullableFalse) # 规则名如研发弹性班 start_time db.Column(db.Time, nullableFalse) # 标准上班时间 end_time db.Column(db.Time, nullableFalse) # 标准下班时间 late_threshold db.Column(db.Integer, default0) # 迟到容错分钟 early_leeway db.Column(db.Integer, default0) # 下班提前容错分钟 is_flexible db.Column(db.Boolean, defaultFalse) # 是否弹性班次 work_hours db.Column(db.Float, default8.0) # 每日标准工时 applicable_dept_ids db.Column(db.String(255)) # 适用部门ID逗号分隔这里注意两个细节late_threshold和弹性班次是两回事。固定班次也可以有5分钟迟到容错弹性班次意味着只要晚到下班时间必须相应顺延算法上要特殊处理。applicable_dept_ids我用逗号分隔字符串存储说白了就是为了省一张关联表。有人说这样不规范但对于内部系统完全够用查的时候一行FIND_IN_SETMySQL或者先取出再拆就行。2.3 打卡记录表别漏掉日期偏移这种魔鬼细节打卡记录表是最容易出问题的表甚至可以说是整个系统的事故高发地。class PunchRecord(db.Model): __tablename__ punch_records id db.Column(db.Integer, primary_keyTrue) employee_id db.Column(db.Integer, db.ForeignKey(employees.id)) punch_time db.Column(db.DateTime, nullableFalse) punch_date db.Column(db.Date, nullableFalse) # 业务日期注意不是自然日 punch_type db.Column(db.String(10)) # check_in / check_out ip_address db.Column(db.String(64)) location db.Column(db.String(255)) # 定位信息可空 source db.Column(db.String(20), defaultweb) # 打卡渠道关键的坑在punch_date这个字段。夜班比如仓库晚班19:00到次日7:00在打卡记录里凌晨2点打的卡业务日期应该归属到上班那天而不是自然日期。所以我处理夜班班次的时候会在形成记录时把punch_date往前推一天所有加班和工时统计都以这个业务日期为准。我在打卡接口里专门写了这么一段def get_business_date(rule, punch_datetime): 根据班次规则判断本次打卡归属于哪个业务日期 # 如果下班时间早于上班时间说明是跨天班次晚班/夜班 if rule.end_time rule.start_time: # 凌晨0点到上班时间之前的打卡归属到前一天 if punch_datetime.time() rule.start_time: return punch_datetime.date() - timedelta(days1) return punch_datetime.date()这段逻辑当时调试了很久因为测试人员老觉得我凌晨打卡怎么日期错了。其实不是错是业务归属不同。2.4 加班申请单与请假单考勤不是打卡记录单打独斗加班管理一定要有申请—审批这个前置动作不能只依赖打卡记录。原因很简单有人干活到晚上8点打卡下班不代表他申请了加班反过来有人申请了加班但临时有事提前走了。所以加班工时应该以审批通过的申请单为基础再拿打卡记录去校正。加班申请单的核心字段class OvertimeApplication(db.Model): __tablename__ overtime_applications id db.Column(db.Integer, primary_keyTrue) employee_id db.Column(db.Integer, db.ForeignKey(employees.id)) overtime_date db.Column(db.Date, nullableFalse) start_time db.Column(db.DateTime, nullableFalse) end_time db.Column(db.DateTime, nullableFalse) hours db.Column(db.Float) # 系统按规则自动算 status db.Column(db.String(20), defaultpending) # pending/approved/rejected approved_by db.Column(db.Integer)请假表类似核心就一个时间段字段考勤统计的时候把请假时段从应出勤里踢掉。2.5 月汇总表提前算好别等报表时现算最后一个核心表是月度汇总表这张表是给报表和薪资核算用的。设计思路很简单所有明细数据实时计算月底定时任务跑一次汇总写进汇总表。报表页读汇总表速度飞快点了重新计算可以随时刷新。class MonthlySummary(db.Model): __tablename__ monthly_summaries id db.Column(db.Integer, primary_keyTrue) employee_id db.Column(db.Integer, db.ForeignKey(employees.id)) summary_month db.Column(db.String(7)) # YYYY-MM work_days db.Column(db.Integer) # 应出勤天数 actual_days db.Column(db.Float) # 实际出勤天数 late_count db.Column(db.Integer) early_count db.Column(db.Integer) overtime_hours db.Column(db.Float) # 已核准加班工时 leave_hours db.Column(db.Float) # 请假时长小时 absences db.Column(db.Integer) # 旷工天数汇总表的好处你上线后就知道了财务月底催数据的时候你不想因为报表页一个慢查询被钉在座位上加班。3. 打卡接口的业务逻辑一个简单的路由藏着五个鸡蛋里挑骨头的细节打卡接口从路由上看很简单一个POST请求就完了。但真正写业务逻辑的时候我发现自己低估了打卡这种看起来无比简单的动作里到底有多少边界条件。3.1 基础打卡接口实现app.route(/api/punch, methods[POST]) login_required def punch(): data request.get_json() emp Employee.query.get(current_user.id) rule get_rule_for_employee(emp) now datetime.now() # 1. 判断打卡类型上午第一次打卡为上班下午/晚上的打卡为下班 punch_type determine_punch_type(emp.id, now, rule) # 2. 业务日期归属 biz_date get_business_date(rule, now) # 3. 查重同一员工同一业务日期同一类型不允许重复打卡 exists PunchRecord.query.filter_by( employee_idemp.id, punch_datebiz_date, punch_typepunch_type ).first() if exists: return jsonify({code: 400, msg: 今日已打过该类型卡}), 400 # 4. 是否允许补卡如果员工说我忘了打卡走补卡流程不直接入库 # 5. 记录IP和定位可选 record PunchRecord( employee_idemp.id, punch_timenow, punch_datebiz_date, punch_typepunch_type, ip_addressrequest.remote_addr, locationdata.get(location, ) ) db.session.add(record) db.session.commit() # 返回考勤状态给前端展示 return jsonify({code: 200, data: { punch_time: now.strftime(%Y-%m-%d %H:%M:%S), punch_type: punch_type, status_text: get_status_text(now, rule, punch_type) }})3.2 判断上班卡还是下班卡比你想的复杂determine_punch_type这个函数是打卡接口里最容易写错的。直观做法是上午12点前算上班12点后算下班但三班倒一下就崩了——中班下午4点上班凌晨下班照这个判断逻辑下午打开的打卡面板会识别成下班。我的做法是固定班次朝九晚六用时间窗口中点划分比如上午12:00前打卡算上班12:00后算下班。跨天晚班如19:00到次日7:0019:00前后各2小时内打卡为上班凌晨3点后打卡为下班。夜班0:00-8:00由于班次本身就跨天了上班卡判断就得用当日是否有上班打卡记录来做兜底如果没有就允许打上班卡。最稳妥的方案其实不是算法多聪明而是组合两个规则时间窗口当天已有记录类型。如果今天还没有任何记录那么任何时间点的第一次打卡都算上班卡如果已经有上班卡记录后面的都是下班卡。这个逻辑配合特殊班次的时间窗口配置基本能覆盖所有情况。def determine_punch_type(employee_id, now, rule): # 先查今天已有记录 today_records PunchRecord.query.filter_by( employee_idemployee_id, punch_dateget_business_date(rule, now) ).all() # 今天没有任何打卡本次是上班卡 if not today_records: return check_in # 已有上班卡本次是下班卡 has_check_in any(r.punch_type check_in for r in today_records) if has_check_in: return check_out # 特殊情况只有下班记录比如补下班卡 return check_in3.3 防重复打卡、防代打卡、补卡流程防重复打卡上面代码里已经有了但还要考虑极短时间内连续点击的并发问题。Flask开发模式下单线程没事生产环境用Gunicorn多worker跑起来两个请求同时进来就可能两个都查不到记录然后插入两条。解决办法是在数据库层面加唯一约束__table_args__ ( db.UniqueConstraint(employee_id, punch_date, punch_type, nameuq_emp_date_type), )再配合IntegrityError捕获双保险才稳。防代打卡经纬度定位和绑定手机MAC这些方案对我们内部系统来说太重了。我用的是IP白名单同一IP多账号告警。不同部门在不同网段打卡IP段合法就通过异常打卡直接标记为待人工审核状态。补卡流程最容易被忽略。员工忘打卡是常态行政每天都会收到补卡申请。我在系统里做的是打卡记录表加一个is_makeup字段补卡走单独接口必须上传理由由主管审批后自动写入记录同时在记录里留痕补卡标记月底报表可以区分正常打卡和补卡。3.4 弹性班次的迟到/早退计算弹性班次研发部那种是老考勤系统的重灾区。规则是你10:30到你下班也得顺延到18:30。实现思路每天对弹性班次的员工计算实际工作时长。实际工作时长达到标准工时比如8小时就算正常出勤。是否迟到要看有没有超过最晚到岗时间这个配置而不是看与标准上班时间的时间差。早退的判断是下班打卡时间早于标准下班时间顺延补时前的正常时间。用SQL算每天的工时SELECT employee_id, punch_date, TIMESTAMPDIFF(MINUTE, MAX(CASE WHEN punch_typecheck_in THEN punch_time END), MAX(CASE WHEN punch_typecheck_out THEN punch_time END) ) AS work_minutes FROM punch_records GROUP BY employee_id, punch_date这里只取第一次上班卡和最后一次下班卡中间的多次打卡忽略。别小看这个细节我们上线后发现有员工中午出去吃饭打一次卡下午进来又打一次如果取平均值就废了。经验之谈考勤统计里永远只取最早的上班卡和最晚的下班卡中间的记录只作为异常检测参考。这个原则帮我避免了很多扯皮。4. 加班工时计算为什么你算出来的加班时长总被吐槽加班是考勤系统里隐藏最深的一堆算术题。你以为的加班很简单下班打卡时间减下班时间超过时间就是加班。但现实是——加班最小单位是多少公司规定0.5小时起算不足0.5小时不计。工作日加班、休息日加班、法定节假日加班计算倍率完全不同1.5倍/2倍/3倍。有些部门是审批前置没申请就不算加班打卡再晚也没用。倒班的员工休息日可能是周三跟你周六日没关系。4.1 加班规则的配置化设计我设计了一个简单的加班规则配置。其实本质上是倍率表起算阈值取整规则的组合加班类型计算倍率起算阈值取整规则工作日加班1.5超过18:30不足0.5小时不计休息日加班2.0只要出勤就按实际不足4小时按4小时计法定节假日3.0按实际打卡不足1小时不计每个公司规则都不一样所以我索性把规则表单独拉出来让行政页面可视化配置class OvertimeRule(db.Model): __tablename__ overtime_rules id db.Column(db.Integer, primary_keyTrue) rule_name db.Column(db.String(50)) day_type db.Column(db.String(20)) # workday / weekend / holiday multiplier db.Column(db.Float) # 1.5 / 2.0 / 3.0 min_threshold db.Column(db.Integer) # 加班起算分钟 rounding db.Column(db.String(20)) # none / up_to_hour / up_to_half_hour4.2 工作日加班的计算逻辑工作日加班很简单下班打卡时间 - 标准下班时间减去中间休息时间如果中间有打卡再按阈值和取整规则处理。def calc_workday_overtime(check_out_time, rule, emp): standard_end datetime.combine(check_out_time.date(), rule.end_time) diff check_out_time - standard_end overtime_minutes int(diff.total_seconds() // 60) # 扣除中午休息时间如果下班卡前有长时间离岗记录 # 应用阈值 min_threshold emp.dept_overtime_rule.min_threshold if overtime_minutes min_threshold: return 0 # 取整逻辑 if emp.dept_overtime_rule.rounding up_to_half_hour: overtime_minutes math.ceil(overtime_minutes / 30) * 30 return round(overtime_minutes / 60, 1)这里有个容易踩坑的点中午休息时段。有的人中午出去吃饭时间很长回来接着上班。系统如果只取第一次上班卡和最后一次下班卡工时会虚高。所以我加了离岗过滤——如果上班卡和下班卡之间只有一个很长的午休区间要能识别出来。4.3 休息日与法定节假日的判定判定休息日不能只靠date.weekday()因为调休周末上班、工作日放假在中国职场太常见了。我维护了一张节假日配置表class HolidayConfig(db.Model): __tablename__ holiday_configs id db.Column(db.Integer, primary_keyTrue) holiday_date db.Column(db.Date, uniqueTrue) day_type db.Column(db.String(20)) # holiday 法定假日 / offday 调休 / workday 调班上班计算加班倍率前先查这张表而不是直接看星期几def get_day_type(date_obj): config HolidayConfig.query.filter_by(holiday_datedate_obj).first() if config: return config.day_type # 没有配置默认周一到周五是工作日周六日是休息日 if date_obj.weekday() 5: return workday return weekend调休上班这天员工实际上班了但这不是加班公司规定调休上班不算加班只算正常出勤。这个判断题必须事先跟HR确认清楚不然月底财务会拿着报表来找你。4.4 加班审批和打卡记录的联动这可能是整个系统价值最直观的模块。流程这样走员工提交加班申请预填写日期、预计起止时间、事由。主管审批状态变为approved。员工在加班时段内打卡下班后打卡。日终跑批任务把approved申请单和实际打卡记录做匹配得出最终核准加班时长。核准时长写入月汇总表。匹配逻辑有一个原则实际加班以打卡记录为准但不超过申请时长。比如申请了2小时加班结果实际待到3小时系统只算2小时反过来申请了3小时实际只待了1小时系统只算1小时。我当时做这个匹配的时候还被财务要求加了一条提前到达的情况不算加班。员工18:00下班18:30才算加班起算点那18:00到18:30这半小时不算。这个中间地带靠起算阈值处理。配合这个场景加工时的最终代码如下def match_overtime(application, punch_out): # 核准时长 min(申请结束时间, 实际打卡时间) - max(申请开始时间, 标准下班时间) effective_start max(application.start_time, standard_end(application.overtime_date)) effective_end min(application.end_time, punch_out.punch_time) overtime_minutes (effective_end - effective_start).total_seconds() / 60 # 取整 return round_to_rule(overtime_minutes, application.rule)核心提醒加班工时算得准不准80%取决于规则定义是否清晰剩下20%才是代码。你写代码之前一定要拉着财务把提前到算不算不足半小时计不计上限有没有这几个问题问死不然后面改配置加字段你会想掐死当时的自己。5. 报表统计和Excel导出行政财务最关心的一环考勤系统不用做得很花哨但报表一定要好用。行政和财务阿姨们每天跟Excel打交道你给她们一个好看的后台列表页不如给一个一键导出Excel的按钮。5.1 日报/月报的统计口径我做的报表分三个层级员工自查询自己当天的打卡时间、迟到早退状态、本周加班累计。部门主管视图团队日考勤列表今天的迟到名单一眼可见。HR/财务视图全公司月度汇总表支持按月/按部门筛选导出Excel。统计口的计算就是我在前面设计的月汇总表月底最后一天晚上跑定时任务一次性算出所有员工的出勤、迟到、早退、加班、请假数据。用SQLAlchemy写月度汇总的核心查询from sqlalchemy import func, extract summary db.session.query( PunchRecord.employee_id, func.count(func.distinct(PunchRecord.punch_date)).label(actual_days), func.sum(db.case((PunchRecord.is_late True, 1), else_0)).label(late_count) ).filter( extract(year, PunchRecord.punch_date) year, extract(month, PunchRecord.punch_date) month ).group_by(PunchRecord.employee_id).all()实际上迟到不应该是打卡记录表上的布尔字段而是每天跑批时算出来的状态存到日汇总表里避免报表页每次都现场算。日记表可以这样class DailyAttendance(db.Model): __tablename__ daily_attendance id db.Column(db.Integer, primary_keyTrue) employee_id db.Column(db.Integer, db.ForeignKey(employees.id)) att_date db.Column(db.Date, nullableFalse) first_punch_in db.Column(db.DateTime) last_punch_out db.Column(db.DateTime) status db.Column(db.String(20)) # normal / late / early_leave / absent work_hours db.Column(db.Float) overtime_hours db.Column(db.Float) leave_hours db.Column(db.Float)每天凌晨跑一次前一天的汇总把所有判断结果固化。这样做的好处是月底报表查询再复杂也只是一张表的SELECT不会出现多表JOIN把数据库拖垮的情况。5.2 导出Excel用openpyxl还是pandas导出Excel这一步我纠结了一会儿。pandas的to_excel确实是最快的方案几行代码搞定。但对于考勤导出这种表格样式有明确要求合并单元格、特殊表头、列宽固定的场景pandas的输出就比较粗糙了。我最后选了openpyxl直接操作因为要合并表头、设置边框、冻结首行这些细节。一个实用的导出片段from openpyxl import Workbook from openpyxl.styles import Font, Alignment, Border, Side from openpyxl.utils import get_column_letter def export_monthly_excel(year, month, dept_idNone): wb Workbook() ws wb.active ws.title f{year}-{month}月度考勤 headers [工号, 姓名, 部门, 应出勤, 实际出勤, 迟到, 早退, 加班工时, 请假工时, 旷工] thin_border Border( leftSide(stylethin), rightSide(stylethin), topSide(stylethin), bottomSide(stylethin) ) # 写入表头并设置样式 for col, header in enumerate(headers, 1): cell ws.cell(row1, columncol, valueheader) cell.font Font(boldTrue) cell.border thin_border cell.alignment Alignment(horizontalcenter) # 写入数据 row 2 for emp in employees: ws.cell(rowrow, column1, valueemp.emp_no) ws.cell(rowrow, column2, valueemp.name) # ... 依次写入 # 给迟到0的行标红 if late_count 0: ws.cell(rowrow, column6).font Font(colorFF0000) row 1 # 固定列宽和冻结首行 for i in range(1, len(headers)1): ws.column_dimensions[get_column_letter(i)].width 14 ws.freeze_panes A2 # 返回给前端下载 bio BytesIO() wb.save(bio) return send_file(bio, as_attachmentTrue, download_namef考勤汇总_{year}_{month}.xlsx)5.3 报表页性能优化不要在列表页做聚合本来想偷懒在列表页每次请求时现场聚合结果公司150号人、3个月的数据列表页直接卡了2秒多。后来把逻辑改成上面说的每日跑批月底汇总页面秒开。另外一个优化是加Redis缓存。Excel导出的数据如果参数相同结果缓存30分钟。财务反复改筛选条件的时候就能明显感觉到区别。6. 部署上线复盘内网系统的那些坑内网部署这套系统看着简单但等你真的下手就知道问题全在你想不到的地方。6.1 Gunicorn Nginx并发和静态资源Flask自带的开发服务器werkzeug绝对不能用于生产这个是老生常谈了。我用的是gunicorn nginx的组合进程数设成服务器的CPU核数×21。gunicorn -w 4 -b 127.0.0.1:5000 app:appnginx反向代理同时接管静态文件和前端页面server { listen 8080; server_name attendance.company.local; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static/ { alias /data/attendance/static/; expires 7d; } }注意内网系统也必须加Token过期机制和操作日志。我上线第一周就被吐槽为什么没有登录日志后来补了操作日志表每个关键操作打卡异常处理、补卡审批、规则修改都记一条。这东西平时没用一旦出现考勤纠纷就是你跟员工摆事实讲道理的底气。6.2 同一台服务器上MySQL和SQLite怎么选我一开始图省事用了SQLite因为零配置、本地文件即数据库。结果不到一个月就遇到锁冲突——几个部门同事同时打卡SQLite的写入锁处理开始报database is locked。后来还是切到MySQL了。原因很简单并发打卡写入 月底聚合查询需要一个真正的数据库引擎。如果你团队规模不超过30人SQLite勉强可以撑住但一旦超过别犹豫直接上MySQL。6.3 时区和服务器时间同步这个坑特别隐蔽。考勤系统对时间极度敏感服务器时间不准所有打卡记录全废。我遇到过两起服务器是UTC时间没设置Asia/Shanghai前端显示的时间整整快了8小时。服务器有硬件时钟漂移一个多月下来慢了3分钟导致有员工明明准点打卡却被判迟到。解决办法其实很简单但很容易被忽略服务器安装NTP服务每天自动同步时间。所有时间存储统一用datetime.now()还是datetime.utcnow()要想清楚。我用的是datetime.now()直接存本地时间因为系统只服务国内一个时区没必要引入复杂的时间转换层。如果哪天要跨时区部署再改UTC存储、展示时本地化。前端展示建议function formatTime(dt) { // 后端返回的已经是Asia/Shanghai时间直接用 return dt.replace(T, ).substring(0, 19); }6.4 打卡高峰期并发一个看起来不是问题的问题每天早上8:50到9:10是打卡高峰同时在线人数可能从30人瞬间飙到120人。Gunicorn同步worker模式在这时候每个worker一次只能处理一个请求如果打卡接口刚好在做数据库查询请求就排队了。用户端表现为点击打卡按钮转圈圈。解决办法Gunicorn加--threads 4启用多线程模式。打卡接口的所有数据库操作用事务但尽量缩短事务时间。静态页面、HTML模板交给nginx直接返回不要经过Flask。实测调整后打卡接口的P95响应时间从1.2秒降到了200毫秒以内完全够用。7. 数据看板管理者要的一眼看清做了报表和导出以后管理层的下一个需求自然就是能不能有个首页一眼看到全公司的考勤情况。7.1 迟到排行榜和无人在岗统计管理者最关心两个问题今天有谁迟到了现在有多少人在上班实现也不复杂一个首页接口返回当天实时状态app.route(/api/dashboard/today) admin_required def dashboard_today(): today date.today() # 今日迟到名单 late_list DailyAttendance.query.filter_by( att_datetoday, statuslate ).all() # 当前在岗人数 已打过上班卡且还没打过下班卡 now datetime.now() on_duty db.session.query(PunchRecord.employee_id).filter( PunchRecord.punch_date today, PunchRecord.punch_type check_in, ~PunchRecord.employee_id.in_( db.session.query(PunchRecord.employee_id).filter( PunchRecord.punch_date today, PunchRecord.punch_type check_out ) ) ).count() return jsonify({ late_count: len(late_list), late_names: [e.name for e in late_list], on_duty_count: on_duty, total_employees: Employee.query.filter_by(is_activeTrue).count() })7.2 用ECharts画个简版趋势图考勤数据适合展示的图有两类每日出勤率折线图和部门加班时长对比柱状图。前端用ECharts后端只给聚合数据所有图表渲染都交给浏览器。app.route(/api/dashboard/daily_trend) def daily_trend(): 过去30天的出勤率 start_date date.today() - timedelta(days30) rows db.session.query( DailyAttendance.att_date, func.count(DailyAttendance.id).label(total), func.sum(db.case((DailyAttendance.status normal, 1), else_0)).label(normal) ).filter( DailyAttendance.att_date start_date ).group_by(DailyAttendance.att_date).all() return jsonify({ dates: [str(r.att_date) for r in rows], rates: [round(r.normal / r.total * 100, 1) if r.total else 0 for r in rows] })这里的率我用的是正常出勤人数除以当天应出勤总人数设定口径的时候跟HR确认过避免后续解释分歧。8. 防呆设计和权限设计管理者想看到的成熟度到这里功能已经齐全了但能不能给管理者留下这系统靠谱的印象关键还差最后两块拼图。8.1 角色权限员工、主管、HR、超管我用Flask-Login管理登录态自己写了一个简单的装饰器做角色控制from functools import wraps from flask import session, jsonify def role_required(*roles): def decorator(f): wraps(f) def wrapper(*args, **kwargs): if not session.get(user_id): return jsonify({code: 401, msg: 未登录}), 401 user_role session.get(user_role) if user_role not in roles: return jsonify({code: 403, msg: 没有权限}), 403 return f(*args, **kwargs) return wrapper return decorator权限矩阵是员工打卡查询自己的考勤记录提交加班/补卡申请。部门主管查看本部门考勤列表审批加班/补卡。HR管理员工信息、规则配置、查看全公司报表及导出。超管全部权限包括日志管理、部门配置。有个细节值得提一下主管查部门数据时SQL里一定要限制department_id current_user.department_id不然主管直接拼URL把ID改了就能查别的部门这种漏洞很Low但很容易犯。8.2 操作日志考勤系统的后悔药我加了一个简单的操作日志表记录谁在什么时间干了什么class OperationLog(db.Model): __tablename__ operation_logs id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(employees.id)) action db.Column(db.String(20)) # create / update / delete / approve target db.Column(db.String(50)) # 操作对象类型 target_id db.Column(db.Integer) # 对象ID detail db.Column(db.Text) # 变更详情 created_at db.Column(db.DateTime, defaultdatetime.now)别小看这个表考勤纠纷处理的时候谁能改数据一目了然。我之前在另一个项目里因为没做日志被财务怀疑偷偷改了别人的考勤记录虽然最后查清楚了但过程非常尴尬。给所有修改操作做留痕是最简单的信任保险。9. 上线后最常见的几个反馈和对应的优化系统上线两个月我收了一箩筐反馈挑几个有代表性的说说。9.1 我明明打了卡为什么显示未打卡这类问题90%出在打卡记录归属的业务日期上。客服部上夜班的小姑娘凌晨0点30分打卡系统归到前一天。她打开系统看到今天没有打卡记录立刻慌了。解决办法是把前端页面默认显示逻辑改成显示最近7天记录按业务日期展示并且在日期旁标注班次名称让员工看得明白系统记到了昨天。9.2 弹性班次为什么我早走了10分钟就算早退弹性班次的核心是工作时长而不是上下班时间。但员工的心态是我今天来得早走得早凭什么算早退。这种问题本质是规则沟通不到位。我在前端考勤日历上给每一天加了一个小图标绿色代表正常黄色代表迟到/早退但工时达标红色代表确认为异常。再点进去能看到系统判定依据今日工作时长7小时50分不足标准8小时。把判定依据摊开争议少了一大半。9.3 加班时长能不能自动算不要每次提交申请这句建议是我最不能接受的。考勤系统里自动和可靠经常是矛盾的。如果完全按打卡记录自动算加班员工只是下班后留在工位上玩手机到9点打卡记录就自动加班了财务那边会有巨大争议。所以我宁可让员工提交申请、主管审批虽然多了一步操作但流程上站得住脚。当然对于研发这种加班属于常态的部门我加了一个批量补提加班申请功能月底让研发主管勾选加班日期系统自动拉起申请单并批量审批。这样既保证了流程完整又减少了重复劳动。10. 最后的几句实在话这套系统从头到尾大概花了两周多其中纯写代码时间其实只有一半另一半全花在和HR、财务对规则上了。现在回看最大的心得就是考勤系统的技术难度真的不大真正的复杂度全在业务规则的梳理和沟通上。如果你也打算自己动手做一套我的建议是先写规则文档再写代码。把迟到怎么算加班怎么算调休怎么算先跟业务方一条条确认清楚拿他们签字确认的文档再开工。预留配置项。无论你现在觉得规则多清晰上线后一定会改。把班次、节假日、加班起算阈值、迟到容错全做成可配置的别写死在代码里。打卡数据只增不改。物理删除要绝对禁止所有修改走补卡流程和操作日志这是考勤系统的铁律。给行政和HR足够的自助能力。别让她们每次遇到规则调整都来找你改数据库。你多做一天后台配置页面以后少接十个需求电话。说到底考勤打卡系统是那种没用的时候觉得无所谓用起来处处是细节的项目。真正让它稳定运行的从来不是某个炫酷的算法而是一堆不起眼的边界情况都被妥善处理了。如果你也在做类似的东西希望这篇文章能帮你少踩几个坑。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →