1. 从手工Excel考勤表到自研系统:这个项目要解决的真实问题
1.1 手工考勤的三大痛点
先说说我为什么碰这个项目。当时团队大概四五十人,有固定坐班的,也有常年在外跑客户的销售。考勤一直用最原始的办法:办公室放一台指纹机,外勤人员每天在微信群里发定位截图,月底行政把指纹机导出的记录和截图汇总到Excel里人工核对。这套流程撑到三十人以内还行,人一多立刻露馅——漏截图、忘打卡、核对错行,每个月总有几笔说不清的账。
真正压垮我的是一次月底对账:一个销售明明在外地见了客户,但微信定位截图发到了群里没人保存,月底行政说缺卡要扣钱,销售不服,来回扯了三天。那一刻我意识到,考勤系统的核心不是"记录打卡"这个动作,而是把打卡这个行为变成可追溯、可校验、可自动汇总的数据。谁在哪儿、什么时间打的卡,后端一查便知,不用人肉对账。
所以当决定自研考勤系统时,我给自己定了三个硬指标:
- 员工打卡必须简单到"打开即打",不能有额外学习成本;
- 打卡位置和时间必须有服务端校验,不能只靠前端传什么就信什么;
- 月底报表要一键导出,管理员不用手工处理原始数据。
1.2 技术选型:Python+Django+小程序为什么是黄金组合
技术选型这事,我一开始也纠结过。备选方案有原生Android App、H5网页、Flutter跨平台,但最后全部排除,选了微信小程序 + Python Django的组合。
小程序的优势非常明显:员工不需要安装任何App。微信人人都有,小程序搜索即用、用完即走。对外勤销售来说,微信里已经堆了几十个群,让他们再装一个考勤App,大概率刚装完就被清理掉。而小程序挂在微信里,不容易被误删,打卡路径也短:下拉微信首页,点一下就进去了。
Django这边则是典型的"开发效率优先"选择。考勤系统虽然业务逻辑不算复杂,但涉及用户体系、权限管理、后台管理、数据统计,这些都是Django的强项。Django自带的Admin后台可以快速搭建管理界面,内置的ORM省去手写SQL的体力活,用户认证、CSRF防护、分页这些也都有现成方案。对一个小型团队的内部系统来说,Django的"全家桶"特性正好击中需求,不需要像Spring那样拼一堆组件。
Python生态还有个隐性红利:后续如果要加人脸识别打卡、智能排班、异常检测这些功能,Python的AI库能直接接进来。我这个系统的二期规划了人脸打卡,选Python等于提前铺好了路。
1.3 系统整体架构与数据流
整套系统的架构,我用一句话概括:小程序负责采集数据,Django负责校验和存储,Admin后台负责管理和呈现。数据流是这样的:
员工在小程序端操作,小程序调用微信的登录接口拿到临时凭证,交给Django后端换取用户身份。打卡时,小程序通过微信的定位API获取当前经纬度,连同打卡类型(上班/下班)一起提交到后端。后端拿到数据后做三道校验:身份是否合法、时间是否在允许范围内、位置是否在打卡半径内。全部通过则写入考勤记录表,同时触发迟到/早退/外勤等状态的自动标记。
管理员这边走的是另一个入口:Django Admin后台。管理员可以配置部门、员工、打卡规则(上下班时间、打卡地点坐标、允许的打卡半径),也可以查看实时考勤记录、处理补卡申请、导出月度报表。前后端通过RESTful API通信,数据格式统一用JSON。
这里有个设计心得:打卡这件事,前端只负责采集,所有判定逻辑必须放后端。我见过不少考勤项目把"是否迟到"的判断写在小程序里,结果员工改一下手机时间、改一下定位,就能伪造打卡记录。服务端校验虽然不能完全杜绝作弊,但至少把门槛提高了一大截。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模:把考勤规则变成可执行的表结构
2.1 核心表设计
考勤系统的数据模型,核心就四张表:部门表、员工表、打卡规则表、考勤记录表。先把建表代码贴出来,再逐个讲设计理由。
python复制# models.py
from django.db import models
from django.contrib.auth.models import User
class Department(models.Model):
name = models.CharField(max_length=50, unique=True, verbose_name="部门名称")
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, verbose_name="上级部门")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "部门"
verbose_name_plural = "部门"
def __str__(self):
return self.name
class Employee(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, verbose_name="关联系统账号")
name = models.CharField(max_length=30, verbose_name="姓名")
employee_no = models.CharField(max_length=20, unique=True, verbose_name="工号")
department = models.ForeignKey(Department, on_delete=models.PROTECT, verbose_name="所属部门")
phone = models.CharField(max_length=20, blank=True, verbose_name="手机号")
is_active = models.BooleanField(default=True, verbose_name="在职状态")
joined_date = models.DateField(null=True, blank=True, verbose_name="入职日期")
class Meta:
verbose_name = "员工"
verbose_name_plural = "员工"
def __str__(self):
return f"{self.name}({self.employee_no})"
class AttendanceRule(models.Model):
name = models.CharField(max_length=50, verbose_name="规则名称")
department = models.ForeignKey(Department, null=True, blank=True, on_delete=models.CASCADE, verbose_name="适用部门")
work_start_time = models.TimeField(verbose_name="上班时间")
work_end_time = models.TimeField(verbose_name="下班时间")
late_minutes = models.IntegerField(default=0, verbose_name="宽限迟到分钟数")
early_leave_minutes = models.IntegerField(default=0, verbose_name="宽限早退分钟数")
checkin_radius = models.FloatField(default=300, verbose_name="打卡半径(米)")
latitude = models.FloatField(verbose_name="打卡点纬度")
longitude = models.FloatField(verbose_name="打卡点经度")
address = models.CharField(max_length=200, blank=True, verbose_name="打卡点地址")
is_active = models.BooleanField(default=True, verbose_name="是否启用")
class Meta:
verbose_name = "考勤规则"
verbose_name_plural = "考勤规则"
def __str__(self):
return self.name
class AttendanceRecord(models.Model):
STATUS_CHOICES = [
('normal', '正常'),
('late', '迟到'),
('early', '早退'),
('absent', '缺卡'),
('field', '外勤'),
('leave', '请假'),
]
TYPE_CHOICES = [
('checkin', '上班打卡'),
('checkout', '下班打卡'),
]
employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name='attendance_records', verbose_name="员工")
rule = models.ForeignKey(AttendanceRule, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="打卡规则")
date = models.DateField(verbose_name="打卡日期")
checkin_time = models.DateTimeField(null=True, blank=True, verbose_name="上班打卡时间")
checkout_time = models.DateTimeField(null=True, blank=True, verbose_name="下班打卡时间")
checkin_location = models.CharField(max_length=200, blank=True, verbose_name="上班打卡位置")
