本文目录导读:

这是一个比较典型的业务场景,在 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,都能稳妥实现。
版权声明:足球直播
足球直播
足球直播
足球直播
足球直播
中超直播
中超直播
中超直播
中超直播
中超直播
英超直播
英超直播
英超直播
英超直播
英超直播
德甲直播
德甲直播
德甲直播
德甲直播
德甲直播
西甲直播
西甲直播
西甲直播
西甲直播
西甲直播
意甲直播
意甲直播
意甲直播
意甲直播
意甲直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播
足球直播
足球直播
足球直播
足球直播
足球直播
中超直播
中超直播
中超直播
中超直播
中超直播
英超直播
英超直播
英超直播
英超直播
英超直播
德甲直播
德甲直播
德甲直播
德甲直播
德甲直播
西甲直播
西甲直播
西甲直播
西甲直播
西甲直播
意甲直播
意甲直播
意甲直播
意甲直播
意甲直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播
足球直播
足球直播
足球直播
足球直播
足球直播
中超直播
中超直播
中超直播
中超直播
中超直播
英超直播
英超直播
英超直播
英超直播
英超直播
德甲直播
德甲直播
德甲直播
德甲直播
德甲直播
西甲直播
西甲直播
西甲直播
西甲直播
西甲直播
意甲直播
意甲直播
意甲直播
意甲直播
意甲直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播