支付 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');

运行测试用例,就可以让测试用例通过了:

image-20221122161604817

为时薪员工创建薪资支票

我们接着完成为时薪员工创建薪资支票,时薪员工需要根据工作时长统计薪资,这些时长记录保存在 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 设计原则中的开放封闭原则 —— 面向扩展开放,面向修改关闭。

运行测试用例,通过则表示为时薪员工创建薪资支票的功能也是没有问题的:

image-20221122165628282

更多测试用例

当然,以上都是正常场景下的薪资支票创建流程,我们还可以编写更多测试用例来验证多个不同的边缘场景:

// 只为时薪雇员创建当月的薪资支票
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(),
            ]);
        }
    }
}

这样,就可以让测试用例跑通了:

image-20221122170705061

获取员工薪资记录

和获取部门员工一样,我们可以这样获取指定员工的薪酬记录:

GET /api/v1/employees/a3b5ce95-c9c8-40f7-b8d7-06133c768a92/paychecks

针对这个 API 请求编写测试用例如下:

image-20221122170907751

<?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');

运行测试用例,通过则表示获取员工薪资记录接口可以正常工作:

image-20221122203302626

到这里,我们的测试驱动 API 开发就告一段落了,在这个系列教程中,我们先学习了有关 TDD、REST + JSON API 的理论基础,以及在 Laravel 项目中的实现方案,然后通过一个实际的薪酬 CRM 小项目演示了测试驱动 JSON API 开发的完整流程,并在其中引入了 Laravel 9、PHP 8 的一些新特性、应用了策略模式和工厂模式,还落地了一些简单的 DDD 概念,包括 DTO、值对象等,希望对你编写更加高质量、可维护的优雅代码能够有所帮助。

薪酬 CRM 系统的代码已经提交到 Github,感兴趣的可以自行查阅:https://github.com/geekr-dev/payroll


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 员工 API 开发(下)

>> 下一篇: 没有下一篇了