支付 API 开发
前面几篇教程学院君已经给大家演示了薪酬 CRM 系统的部门和员工 API 开发,今天我们来看最后一个功能模块 —— 支付模块的 API 开发。仍然基于 TDD 模式,通过如下 Artisan 命令创建新的测试文件:
php artisan pest:test PaycheckTest
创建薪资支票
这里的薪资支票可以对标为通过银行卡发工资,创建了支票,员工就可以凭借支票去银行兑付。
为工薪员工创建薪资支票
首先,我们来看如何为工薪制员工创建薪资支票,这需要通过名为 payday.store
的 API 接口,这个接口不需要任何参数,内部会遍历所有员工,检查计算薪资金额,对于工薪员工来说,每月薪资 = 年薪/12,然后保存每个员工的薪资支票到数据表 paychecks
,按照这个业务逻辑,编写对应的测试用例如下:
// 为工薪员工创建薪资支票
it('should create paychecks for salary employees', function () {
$employees = Employee::factory()
->count(2)
->sequence(
[
'salary' => 50000 * 100,
'payment_type' => PaymentTypes::SALARY->value
],
[
'salary' => 70000 * 100,
'payment_type' => PaymentTypes::SALARY->value
],
)->create();
postJson(route('payday.store'))
->assertNoContent();
assertDatabaseHas('paychecks', [
'employee_id' => $employees[0]->id,
'net_amount' => 416666,
]);
assertDatabaseHas('paychecks', [
'employee_id' => $employees[1]->id,
'net_amount' => 583333,
]);
});
测试用例分为如下三部分:
- 基于
sequence
创建两个雇员 - 调用
payday.store
API - 查询
paychecks
是否包含对应的雇员和薪资,net_amount
=salary
/ 12
接下来,我们需要编写业务代码让测试用例通过。
补全之前 Salary
策略类中的 monthlyAmount
实现:
// Payment/Salary
public function monthlyAmount(): int
{
// 月薪 = 年薪 / 12
return $this->employee->salary / 12;
}
编写一个 PaydayAction
,用于在薪酬日创建所有员工的薪资支票:
<?php
namespace App\Actions;
use App\Models\Employee;
class PaydayAction
{
public function execute(): void
{
foreach (Employee::all() as $employee) {
$employee->paychecks()->create([
'net_amount' => $employee->payment_type->monthlyAmount(),
'payed_at' => now(),
]);
}
}
}
创建 PaydayController
控制器,编写 store
方法,调用上面这个 Action:
<?php
namespace App\Http\Controllers;
use App\Actions\PaydayAction;
use Illuminate\Http\Request;
class PaydayController extends Controller
{
public function __construct(private readonly PaydayAction $payday)
{
}
public function store(Request $request)
{
$this->payday->execute();
return response()->noContent();
}
}
最后,在 API 路由文件中定义名为 payday.store
的路由:
Route::post(
'paycheck',
[PaydayController::class, 'store']
)->name('payday.store');
运行测试用例,就可以让测试用例通过了:
为时薪员工创建薪资支票
我们接着完成为时薪员工创建薪资支票,时薪员工需要根据工作时长统计薪资,这些时长记录保存在 time_logs
表中,其他和工薪员工处理流程一样,对应的测试用例如下:
// 为时薪员工创建薪资支票
it('should create paychecks for hourly rate employees', function () {
// 从指定时间开始(时光旅行)
travelTo(Carbon::parse('2022-02-10'), function () {
// 创建员工
$employee = Employee::factory([
'hourly_rate' => 10 * 100,
'payment_type' => PaymentTypes::HOURLY_RATE->value,
])->create(); // 创建于2022-02-10
$dayBeforeYesterday = now()->subDays(2); // 2022-02-08 00:00:00
$yesterday = now()->subDay(); // 2022-02-09 00:00:00
$today = now(); // 2022-02-10 00:00:00
// 创建工作时长记录
TimeLog::factory()
->count(3)
->sequence(
[
'employee_id' => $employee,
'minutes' => 90,
'started_at' => $dayBeforeYesterday, // 2022-02-08 00:00:00
'stopped_at' => $dayBeforeYesterday->copy()->addMinutes(90) // 2022-02-08 01:30:00
],
[
'employee_id' => $employee,
'minutes' => 15,
'started_at' => $yesterday, // 2022-02-09 00:00:00
'stopped_at' => $yesterday->copy()->addMinutes(15) // 2022-02-08 00:15:00
],
[
'employee_id' => $employee,
'minutes' => 51,
'started_at' => $today, // 2022-02-10 00:00:00
'stopped_at' => $today->copy()->addMinutes(51) // 2022-02-10 00:51:00
],
)
->create();
// 调用 API 创建薪资支票
// 本月总工作时长=90+15+51=156/60=3(四舍五入),应付薪资=3*1,000=3,000cents=$30.00
postJson(route('payday.store'))
->assertNoContent();
// 断言数据库是否存在对应的薪资支票记录
$this->assertDatabaseHas('paychecks', [
'employee_id' => $employee->id,
'net_amount' => 30 * 100,
]);
});
});
和工薪员工测试用例相比,多了一个创建工作时长记录流程。
接下来,我们来编写业务代码让测试用例通过。补全 HourlyRate
策略类的 monthllyAmount
方法实现即可,其他逻辑和工薪员工一样,包括 Action、控制器、路由:
// Payment/HourlyRate
public function monthlyAmount(): int
{
// 工作时长 = 每月工作总分钟数 / 60
$hoursWorked = TimeLog::query()
->whereBetween('stopped_at', [
now()->startOfMonth(),
now()->endOfMonth()
])
->sum('minutes') / 60;
// 月薪 = 四舍五入(时长) * 时薪
return round($hoursWorked) * $this->employee->hourly_rate;
}
这里就能体现出策略模式的优势了,以后新增任何薪资类型的员工,只需要新增一个策略类即可,不用修改上层的业务逻辑代码,这也符合 SOLID 设计原则中的开放封闭原则 —— 面向扩展开放,面向修改关闭。
运行测试用例,通过则表示为时薪员工创建薪资支票的功能也是没有问题的:
更多测试用例
当然,以上都是正常场景下的薪资支票创建流程,我们还可以编写更多测试用例来验证多个不同的边缘场景:
// 只为时薪雇员创建当月的薪资支票
it('should create paychecks for hourly rate employees only for current month', function () {
travelTo(Carbon::parse('2022-02-10'), function () {
// 当前时间 2022-02-10
$employee = Employee::factory([
'hourly_rate' => 10 * 100,
'payment_type' => PaymentTypes::HOURLY_RATE->value,
])->create();
Timelog::factory()
->count(2)
->sequence(
[
'employee_id' => $employee,
'minutes' => 60,
'started_at' => now()->subMonth(), // 2022-01-10 00:00:00
'stopped_at' => now()->subMonth()->addMinutes(60) // 2022-01-10 01:00:00
],
[
'employee_id' => $employee,
'minutes' => 60,
'started_at' => now(), // 2022-02-10 00:00:00
'stopped_at' => now()->addMinutes(60) // 2022-02-10 01:00:00
],
)
->create();
// 调用 API 创建薪资账单,只创建当月的(2月)
postJson(route('payday.store'))
->assertNoContent();
// 断言 paychecks 表中的薪资支票,总薪资应该是2月份的,不包含1月的
assertDatabaseHas('paychecks', [
'employee_id' => $employee->id,
'net_amount' => 10 * 100,
]);
});
});
// 不应该为没有工作时长记录的雇员创建薪资支票
it('should not create paychecks for hourly rate employees without time logs', function () {
travelTo(Carbon::parse('2022-02-10'), function () {
Employee::factory([
'hourly_rate' => 10 * 100,
'payment_type' => PaymentTypes::HOURLY_RATE->value,
])->create();
postJson(route('payday.store'))
->assertNoContent();
assertDatabaseCount('paychecks', 0);
});
});
此时测试用例不会通过,因为计算得到的薪资是 0,也会创建一条记录,为了让测试用例通过,需要在 PaydayAction
中加一个判断:
<?php
namespace App\Actions;
use App\Models\Employee;
class PaydayAction
{
public function execute(): void
{
foreach (Employee::all() as $employee) {
$amount = $employee->payment_type->monthlyAmount();
if ($amount == 0) {
continue;
}
$employee->paychecks()->create([
'net_amount' => $amount,
'payed_at' => now(),
]);
}
}
}
这样,就可以让测试用例跑通了:
获取员工薪资记录
和获取部门员工一样,我们可以这样获取指定员工的薪酬记录:
GET /api/v1/employees/a3b5ce95-c9c8-40f7-b8d7-06133c768a92/paychecks
针对这个 API 请求编写测试用例如下:
<?php
use App\Models\Employee;
use App\Models\Paycheck;
it('should return all paychecks for an employee', function () {
$employee = Employee::factory([
'payment_type' => PaymentTypes::SALARY->value
])->create();
$paychecks = Paycheck::factory([
'empployee_id' => $developer->id,
])->count(5)->create();
$paychecks = getJson(route('employee.paychecks.index', ['employee' => $employee, 'include' => 'employee']))
->json('data');
expect($employees)->toHaveCount(5);
expect($employees)
->each(fn ($paycheck) => $paycheck->relationships->employee->data->id->toBe($employee->uuid));
});
然后编写业务代码让测试用例通过。
创建一个 PaycheckResource
对响应数据进行封装,让其符合 JSON API 的规范:
<?php
namespace App\Http\Resources;
use App\VOs\Amount;
use Illuminate\Http\Request;
use TiMacDonald\JsonApi\JsonApiResource;
class PaycheckResource extends JsonApiResource
{
public function toAttributes($request): array
{
return [
'amount' => Amount::from($this->net_amount)->toArray(),
'payed_at' => $this->payed_at,
];
}
public function toId(Request $request): string
{
return $this->uuid;
}
public function toRelationships($request): array
{
return [
'employee' => fn() => EmployeeResource::make($this->employee)
];
}
}
新建一个 EmployeePaycheckController
控制器,在 index
方法中通过 QueryBuilder
查询指定员工的所有薪资支票,最后通过 PaycheckResource
对结果进行封装后返回:
<?php
namespace App\Http\Controllers;
use App\Http\Resources\PaycheckResource;
use App\Models\Employee;
use App\Models\Paycheck;
use Illuminate\Http\Request;
use Spatie\QueryBuilder\QueryBuilder;
class EmployeePaycheckController extends Controller
{
public function index(Request $request, Employee $employee)
{
$paychecks = QueryBuilder::for(Paycheck::class)
->allowedIncludes(['employee'])
->whereBelongsTo($employee)
->get();
return PaycheckResource::collection($paychecks);
}
}
最后定义 employee.paychecks.index
路由入口:
Route::get(
'employees/{employee}/paychecks',
[EmployeePaycheckController::class, 'index']
)->name('employee.paychecks.index');
运行测试用例,通过则表示获取员工薪资记录接口可以正常工作:
到这里,我们的测试驱动 API 开发就告一段落了,在这个系列教程中,我们先学习了有关 TDD、REST + JSON API 的理论基础,以及在 Laravel 项目中的实现方案,然后通过一个实际的薪酬 CRM 小项目演示了测试驱动 JSON API 开发的完整流程,并在其中引入了 Laravel 9、PHP 8 的一些新特性、应用了策略模式和工厂模式,还落地了一些简单的 DDD 概念,包括 DTO、值对象等,希望对你编写更加高质量、可维护的优雅代码能够有所帮助。
薪酬 CRM 系统的代码已经提交到 Github,感兴趣的可以自行查阅:https://github.com/geekr-dev/payroll。
7 Comments
git代码
这个幂等问题怎么解决?post本身是不幂等的,但要求(已经payCheck的员工在相同的月份下)重发不能重复创建payCheck。 辛苦一下,今天加个班,明天周六部署
时间旅行还是挺有意思的
没有修改接口是不实际的,员工得签字,全勤奖或迟到,当然可以通过补票,实际情况确实会复杂一些