本文目录导读:

这是一个比较典型的业务场景,在 PHP 项目中实现年假管理,核心不在于复杂的算法,而在于业务规则的配置、数据的连续性(自然年/入职周年)以及审批流的串联。
下面从数据库设计、核心逻辑、代码示例、以及如何集成到现有框架(如 Laravel、ThinkPHP)这几个方面来详细说明。
核心业务规则
在写代码前,需要定义清楚规则,通常有这几种:
- 额度计算方式:
- 按自然年:每年1月1日发放全年额度(如每年5天)。
- 按入职周年:入职满一年后,在周年日发放新一年的额度。
- 首年按比例折算:新员工入职当年,按月或按天折算(如
5天 / 12 * 剩余月数)。
- 有效期:
- 当年有效,过期清零。
- 可顺延(如顺延至次年3月31日)。
- 余额计算:
总余额 = 发放额度 - 已使用 - 已申请(待审批)
数据库表设计(核心)
需要 3-4 张表来支撑,重点是 leave_balances 和 leave_records。
-- 1. 员工年假额度总表 (用于快速查询当前可用余额)
CREATE TABLE `leave_balances` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`user_id` INT UNSIGNED NOT NULL COMMENT '员工ID',
`year` YEAR NOT NULL COMMENT '所属年份(按自然年或按周年年)',
`total_days` DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '本年度发放的总天数',
`used_days` DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '已使用天数(含已审批和已申请待审批)',
`available_days` DECIMAL(5,2) NOT NULL COMMENT '可用余额 = total - used',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `unique_user_year` (`user_id`, `year`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 年假发放/调整记录 (细粒度记录每一笔变动)
CREATE TABLE `leave_grants` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`user_id` INT UNSIGNED NOT NULL,
`year` YEAR NOT NULL,
`grant_type` TINYINT NOT NULL COMMENT '1-系统发放, 2-手动调整, 3-上年度结转',
`days` DECIMAL(5,2) NOT NULL COMMENT '变动天数(正为增加,负为扣减)',
`remark` VARCHAR(255) DEFAULT NULL,
`granted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. 年假申请与使用记录 (核心业务表)
CREATE TABLE `leave_requests` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`user_id` INT UNSIGNED NOT NULL,
`leave_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1-年假, 2-事假, 3-病假...',
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`days` DECIMAL(5,2) NOT NULL COMMENT '申请天数(支持半天0.5)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审批, 1-已批准, 2-已拒绝, 3-已撤回',
`remark` TEXT DEFAULT NULL,
`approved_by` INT UNSIGNED DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_user_status` (`user_id`, `status`),
INDEX `idx_date` (`start_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. 员工基础信息表 (需要入职日期、年假规则)
CREATE TABLE `users` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(50) NOT NULL,
`hire_date` DATE NOT NULL COMMENT '入职日期',
`annual_leave_rule` TINYINT NOT NULL DEFAULT 1 COMMENT '1-按自然年, 2-按入职周年',
`annual_leave_days` DECIMAL(4,1) NOT NULL DEFAULT 5.0 COMMENT '每年标准天数(如5天/10天/15天)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
核心 PHP 代码逻辑(伪代码示例)
以下逻辑适用于 Laravel,如果你使用 ThinkPHP 或原生 PHP,思路完全一致,替换模型调用即可。
发放年假(定时任务 / Cron Job)
一般通过每日执行一次或在员工入职周年日触发。
<?php
// 伪代码 - Laravel Command
namespace App\Console\Commands;
use Carbon\Carbon;
use App\Models\User;
use App\Models\LeaveBalance;
use App\Models\LeaveGrant;
use Illuminate\Console\Command;
class GrantAnnualLeave extends Command
{
protected $signature = 'leave:grant';
public function handle()
{
// 1. 获取所有需要发放年假的用户
$users = User::where('status', 1)->get();
foreach ($users as $user) {
$today = Carbon::today();
$shouldGrant = false;
$year = $today->year;
if ($user->annual_leave_rule == 1) {
// 按自然年:1月1日发放
if ($today->isSameDay(Carbon::create($year, 1, 1))) {
$shouldGrant = true;
}
} elseif ($user->annual_leave_rule == 2) {
// 按入职周年:在对应的月份和日期发放
$hireDate = Carbon::parse($user->hire_date);
$anniversary = Carbon::create($year, $hireDate->month, $hireDate->day);
if ($today->isSameDay($anniversary)) {
$shouldGrant = true;
$year = $anniversary->year; // 按周年年
}
}
if (! $shouldGrant) {
continue;
}
// 避免重复发放
$exists = LeaveBalance::where('user_id', $user->id)
->where('year', $year)
->exists();
if ($exists) {
continue;
}
// 计算首次入职年份的折算
$grantDays = $user->annual_leave_days;
$hireYear = Carbon::parse($user->hire_date)->year;
if ($year == $hireYear) {
// 首年按比例折算 (假设当年每月1天)
$months = max(0, 12 - Carbon::parse($user->hire_date)->month + 1);
$grantDays = round(($user->annual_leave_days / 12) * $months, 2);
}
// 写库:插入发放记录 & 更新余额表
\DB::transaction(function () use ($user, $year, $grantDays) {
LeaveGrant::create([
'user_id' => $user->id,
'year' => $year,
'grant_type' => 1,
'days' => $grantDays,
'remark' => "{$year}年度年假自动发放",
]);
LeaveBalance::updateOrCreate(
['user_id' => $user->id, 'year' => $year],
['total_days' => $grantDays, 'available_days' => $grantDays, 'used_days' => 0]
);
});
}
}
}
用户提交年假申请
关键点:申请前必须校验可用余额。
<?php
// 伪代码 - 在 Controller 中
use Carbon\Carbon;
use App\Models\LeaveBalance;
use App\Models\LeaveRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class LeaveController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'days' => 'required|numeric|min:0.5|max:30', // 防止大额误提交
]);
$user = auth()->user();
$startDate = Carbon::parse($validated['start_date']);
$endDate = Carbon::parse($validated['end_date']);
$applyDays = $validated['days'];
// 1. 计算扣除哪个年度的额度 (简单处理:如果假期跨年,拆分成两个请求或算入开始年份)
$year = $startDate->year;
// 2. 获取该员工在对应年份的余额
$balance = LeaveBalance::where('user_id', $user->id)
->where('year', $year)
->first();
if (! $balance) {
throw ValidationException::withMessages(['days' => '该年度无可用年假额度']);
}
if ($balance->available_days < $applyDays) {
throw ValidationException::withMessages([
'days' => "可用余额不足,当前余额:{$balance->available_days}天,申请:{$applyDays}天"
]);
}
// 3. 写入申请记录,同时预扣余额 (通过事务保证数据一致性)
DB::transaction(function () use ($user, $request, $applyDays, $year, $balance) {
// 创建申请单
$leaveRequest = LeaveRequest::create([
'user_id' => $user->id,
'leave_type' => 1,
'start_date' => $request->start_date,
'end_date' => $request->end_date,
'days' => $applyDays,
'status' => 0, // 待审批
]);
// 预扣余额 (status=0 待审批也要扣掉,防止超额申请)
$balance->decrement('available_days', $applyDays);
$balance->increment('used_days', $applyDays);
});
return response()->json(['message' => '申请提交成功,等待审批']);
}
}
审批通过 / 拒绝
<?php
// 伪代码 - 审批通过
public function approve($id)
{
$leave = LeaveRequest::findOrFail($id);
if ($leave->status != 0) {
return back()->with('error', '只能审批待审批的申请');
}
DB::transaction(function () use ($leave) {
$leave->update([
'status' => 1, // 已批准
'approved_by' => auth()->id()
]);
// 余额已经在申请时预扣了,这里不需要再操作
});
return back()->with('success', '审批通过');
}
// 伪代码 - 审批拒绝(需要回滚余额)
public function reject($id)
{
$leave = LeaveRequest::findOrFail($id);
if ($leave->status != 0) {
return back()->with('error', '只能拒绝待审批的申请');
}
DB::transaction(function () use ($leave) {
$leave->update([
'status' => 2, // 已拒绝
'approved_by' => auth()->id()
]);
// 回滚余额!这里和申请时对应
$balance = LeaveBalance::where('user_id', $leave->user_id)
->where('year', $leave->start_date->year)
->first();
if ($balance) {
$balance->increment('available_days', $leave->days);
$balance->decrement('used_days', $leave->days);
}
});
return back()->with('success', '已拒绝');
}
查询当前余额
// 直接查 LeaveBalance 表
public function myBalance()
{
$user = auth()->user();
// 查看当前年份可用余额
$currentYear = now()->year;
$balance = LeaveBalance::where('user_id', $user->id)
->where('year', $currentYear)
->first();
return response()->json([
'total' => $balance->total_days ?? 0,
'used' => $balance->used_days ?? 0,
'available' => $balance->available_days ?? 0,
]);
}
用 Laravel / ThinkPHP 注意事项
| 场景 | 建议做法 |
|---|---|
| 定时任务 | Laravel 用 Kernel (schedule),ThinkPHP 用 php think 或系统 Cron Job。 |
| 事务处理 | 凡是涉及余额变更 + 记录写入,必须使用数据库事务。 |
| 余额字段类型 | 使用 DECIMAL(5,2),支持半天(0.5)的休假。 |
| 跨年假期 | 如果申请日期跨年(如 12月25日 - 1月3日),建议拆分成两笔记录,各占对应年份的额度。 |
| 手动调整 | 管理员后台可以调用 LeaveGrant 来直接加/减天数,并同步更新 LeaveBalance。 |
| 缓存 | 用户首页显示的余额可以缓存 1 分钟,但不建议缓存太久,因为提交申请后余额必须实时更新。 |
年假管理的 难点集中在余额变更的一致性 和 业务规则的配置。
- 不要试图在 SQL 查询时做复杂计算,应该维护一个
leave_balances表来冗余存储余额。 - 确保每个余额变更都有对应的记录(通过
leave_grants记录发放,通过leave_requests记录消耗)。 - PHP 本身并不限制,关键在于理解业务逻辑(自然年 vs 周年、按比例折算、跨年处理)。
按照上面这个数据结构 + 逻辑,不管是用 Laravel 的 Eloquent、ThinkPHP 的 Model,还是原生 PDO,都能稳妥实现。