工作小锦囊系列——如何实现一个车辆预定功能(上)

背景

今天我们来聊一个关于「车辆预定系统」的话题,话题来源于社区一位小伙伴的提问(原文请戳这里)。

这个场景是这样的:

现在需要开发一套车辆租赁系统,已知每辆入驻系统的汽车都会维护基本信息。其中有一个属性为汽车的「可租赁时间段」属性。什么意思呢?比如一些租金相对便宜的车,全年都对外租赁。而一些租金相对较高的车,仅在节假日或者周末租赁。根据租赁时间段不同共分为以下几种情况:
A. 全年都可租赁
B. 仅周六周日可租赁
C. 仅周六可租赁
D. 仅周日可租赁
E. 周六周日和节假日可租赁
当汽车在某个时间段被租赁以后,便不可被租赁,但是其他时间段是可被租赁的。现在的需求是,当用户输入日期区间以后,需要能够显示当前可以租赁的车辆信息,并且可以根据汽车的基本信息进行检索。

原文本来是一篇提问贴,但是总感觉三言两语也说不清楚。而且类似的场景还有很多,比如:会议室预定系统等等,其原理都是大致相同的。所以我们索性来个Reset,干脆当成一道考试题,来看看应该如何解决。

话不多说,让我们一起走进今天的「锦囊之旅」吧~

原型分析

首先我们需要确认一下有哪些模型。这里我们列举用到的几个模型以及一些基本的字段属性。

车辆模型 (Car)

「车辆模型」肯定是必不可少的。其中包含以下几个主要的属性:

字段 描述
car_id 车辆唯一 ID
lease_type 1. 全年都可租赁
2. 仅周六周日可租赁
3. 仅周六可租赁
4. 仅周日可租赁
5. 周六周日和节假日可租赁

租赁人模型(Leaseholder)

「租赁人模型」即租借人员的基本信息。其中包含以下几个主要属性:

字段 描述
leaseholder_id 租赁人唯一 ID
phone 租赁人手机号

租赁记录模型(Lease Record)

「租赁记录模型」即租赁人和车辆的关系模型。其中包含以下几个主要属性:

字段 描述
record_id 租赁记录唯一 ID
car_id 车辆唯一 ID
leaseholder_id 租赁人唯一 ID
lease_start_date 租赁起始日期
lease_end_date 租赁截止日期

假设现在有三辆车A,B 和 C 。A 全年可租赁,B 仅周六可租赁,C 周六周日和节假日可租赁。我们用下面一张日程图来表示三辆车的租赁记录:

现在我想查一下 10 月 4 号到 10 月 8 号之间有哪些可以租借的车辆。

从图上我们不难发现,这个时间段内只有 A 车辆满足条件。现在就让我们看看如何在程序中实现吧。Let’s go~

方案设计

常规设计方案

其实,在这个场景中,数据模型并不复杂,数据存储逻辑也不复杂。录入车辆信息,录入租赁人信息,当某辆车在某个时间段被租赁时,记录下租赁关系。看上去好像也没什么复杂的呢?

Really ???

想象一下,现在如果想查询某段时间内可以租赁的车辆应该怎么查询呢?

或许,我们需要通过以下几个步骤进行操作:

  • 查询出该时间段内所有符合租赁条件的车辆信息 A
  • 查询出该时间段内已经存在租赁记录的信息 B
  • 则该时间段内可租赁的车辆为:A - B = { x| x∈A 且 x∉B }

让我们分别来看看这三步如何实现。

步骤一

第一步是查询出该时间段内所有符合租赁条件的车辆信息。

首先,我们需要将给定的时间范围进行拆分。在拆分之前,我们先来定义一些变量。

  • t1:选定起始日期
  • t2:选定截止日期
  • lease_type: 租赁类型,类型定义同模型定义

这里我们需要根据车辆租赁类型和选中的起止日期来进行筛选。为方便理解,我们通过表格的形式进行描述。

集合 条件
A1 lease_type = 1
A2 条件1:lease_type = 2
条件2:(t1 = t2 且 (t1 = 周六 或者 t1 = 周日))或者(t2 - t1 = 1 且 t1 = 周六)
A3 条件1:lease_type = 3
条件2:t1 = t2 且 t1 = 周六
A4 条件1:lease_type = 4
条件2:t1 = t2 且 t1 = 周日
A5 条件1: lease_type = 5
条件2:t1 到 t2之间的每一天都是周六周日或者节假日

上面表格中,A1 ~ A5 表示可预定车辆的不同集合,如果用集合 A 表示所有可预定车辆的话, 则 A = A1A2A3A4A5

看到这些数学公式是不是感觉有点眼花撩乱。实际上,只有当你把问题抽象成最基本的数学模型的时候,你才不会陷入错综复杂的if else循环中。

从表格中不难看出,每一组条件都是由lease_type和起止时间两个条件决定(A1 除外)。这里lease_type是表中的字段属性条件,而第二个条件可以通过代码进行判断,代码示例如下:

车辆预定类 CarLease.php

class CarLease
{
    /**
     * @var string[] 节假日
     */
    protected $holidays = [
        '2022-12-31',
        '2023-01-01',
        ...
        '2023-10-01',
        '2023-10-02',
        '2023-10-03',
        '2023-10-04',
        '2023-10-05',
        '2023-10-06',
    ];

    /**
     * 判断是否周六
     *
     * @param string $date 日期
     * @return bool
     */
    public function checkSaturday(string $date): bool
    {
        return date('w', strtotime($date)) == 5;
    }

    /**
     * 判断是否周日
     *
     * @param string $date 日期
     * @return bool
     */
    public function checkSunday(string $date): bool
    {
        return date('w', strtotime($date)) == 6;
    }

    /**
     * 判断是否周末
     *
     * @param string $date 日期
     * @return bool
     */
    public function checkWeekend(string $date): bool
    {
        return in_array(date('w', strtotime($date)), [5, 6]);
    }

    /**
     * 判断是否节假日
     *
     * @param string $date 日期
     * @return bool
     */
    public function checkHoliday(string $date): bool
    {
        return in_array($date, $this->holidays);
    }

    /**
     * 批量检查是否周末或者假期
     *
     * @param string $startDate 起始日期
     * @param string $endDate 截止日期
     * @return bool
     */
    public function batchCheckWeekendOrHoliday(string $startDate, string $endDate): bool
    {
        $i = $startDate;
        while($i <= $endDate){
            if(!$this->checkWeekend($i) && !$this->checkHoliday($i)){
                return false;
            }
            $i = date('Y-m-d', strtotime("{$i} +1 day"));
        }
        return true;
    }

}

汽车模型Car.php结构如下

Car.php

class Car extends Model
{
    const LEASE_TYPE_EVERY_DAY = 1;             //全年都可租赁
    const LEASE_TYPE_ONLY_WEEKEND = 2;          //仅周六周日可租赁
    const LEASE_TYPE_ONLY_SATURDAY = 3;         //仅周六可租赁
    const LEASE_TYPE_ONLY_SUNDAY = 4;           //仅周日可租赁
    const LEASE_TYPE_WEEKEND_OR_HOLIDAY = 5;    //周六周日和节假日可租赁
}

以下是调用逻辑:

//初始化起止日期
$startDate = '2023-10-04';
$endDate = '2023-10-08';

//计算起止日期差值
$diffDays = date_diff(date_create($startDate), date_create($endDate))->days;

//实例化车辆预定类
$carLease = new CarLease();

$query = Car::query();
//A1
//条件1:lease_type = 1
$query = $query->where('lease_type', Car::LEASE_TYPE_EVERY_DAY);
//A2
//条件1:lease_type = 2
//条件2:(t1 = t2 且 (t1 = 周六 或者 t1 = 周日))或者(t2 - t1 = 1 且 t1 = 周六)
if($diffDays == 0 && ($carLease->checkWeekend($startDate)) || $diffDays == 1 && $carLease->checkSaturday($startDate)){
    $query = $query->orWhere('lease_type', Car::LEASE_TYPE_ONLY_WEEKEND);
}
//A3
//条件1:lease_type = 3
//条件2:t1 = t2 且 t1 = 周六
if($diffDays == 0 && $carLease->checkSaturday($startDate)){
    $query = $query->orWhere('lease_type', Car::LEASE_TYPE_ONLY_SATURDAY);
}
//A4
//条件1:lease_type = 4
//条件2:t1 = t2 且 t1 = 周日
if($diffDays == 0 && $carLease->checkSunday($startDate)){
    $query = $query->orWhere('lease_type', Car::LEASE_TYPE_ONLY_SUNDAY);
}
//A5
//条件1: lease_type = 5
//条件2:t1 到 t2 之间的每一天都是周六周日或者节假日
if($carLease->batchCheckWeekendOrHoliday($startDate, $endDate)){
    $query = $query->orWhere('lease_type', Car::LEASE_TYPE_WEEKEND_OR_HOLIDAY);
}

//查询所有满足条件车辆
$cars = $query->get();

至此,我们就完成了根据日期条件筛选所有满足条件的车辆的操作了。

接下来,我们需要查询出所有已经被预定的车辆信息。

步骤二

在这一步中,我们需要查询出所有「在指定日期范围内有预定记录」的车辆,并进行去重。

我们先来看一个集合示意图:

这里我们用以下变量表示上图各临界点:

  • t:时间轴
  • t1指定起始日期
  • t2指定截止日期
  • t′:动态截止日期
  • t″:动态起始日期

使用 A1 表示指定开始日期和动态截止日期之间的元素的集合,则用集合描述法表示为:{ x ∈ A1 | t1 ≤ x ≤ t′ }。

使用 A2 表示动态起始日期和指定截止日期之间的元素的集合,则用集合描述法表示为:{ x ∈ A2 | t″ ≤ x ≤ t2 }。

所以,这里我们所求的「在指定日期范围内有预定记录」的车辆,就是 A1A2 的交集,即 A1A2。用动态的角度来看的话,就是当 t′ 向右运动,而 t″ 向左运动,形成的相交区域。

代码实现如下:

$leasedCars = LeaseRecord::where('lease_start_date', '<=', $endDate)
    ->where('lease_end_date', '>=', $startDate)
    ->get();

这样,我们就统计出了所有「在指定日期范围内有预定记录」的车辆信息。

步骤三

因为我们的诉求是按照指定的日期范围预定车辆,所以需要保证范围内的每一天车辆都是可预定的。因此,这里需要取步骤一和步骤三两个结果的差集。

代码逻辑如下:

$cars = $query->whereNotExists(function ($query) use ($startDate, $endDate) {
    $query->select(DB::raw(1))
        ->from('lease_records')
        ->where('lease_start_date', '<=', $endDate)
        ->where('lease_end_date', '>=', $startDate)
        ->whereColumn('lease_records.car_id', 'cars.car_id');
})->get();

这里我们使用了not exists语法排除了存在预定记录的车辆。这样,我们仍然可以在外层结构上使用翻页的逻辑。

总结

在本篇文章中,我们解决了一个「车辆预定」的问题。

这是一个很有意思的话题,虽然实现起来难度并不大,但是蕴涵了许多数学的思想在里面,特别是集合处理的思想。

当然,本篇文章仅限于从基本功能进行实现,并未考虑数据量带来的算法复杂度问题。在《工作小锦囊系列——如何实现一个车辆预定功能(下)》 一文中,我们将来讨论如何使用 RedisBitmap 来解决此类的问题。

感谢大家的持续关注~

本作品采用《CC 协议》,转载必须注明作者和本文链接
你应该了解真相,真相会让你自由。
本帖由 MArtian 于 6个月前 加精
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 12

赞👍

6个月前 评论
快乐的皮拉夫 (楼主) 6个月前

一步一步 逻辑清晰 加油 期待更多作品

6个月前 评论
快乐的皮拉夫 (楼主) 6个月前

很高兴看到这个讨论,实际情况会还有一种情况。就比如说我设定了只有周一 到周五可以预定。但是刚好车辆在周二出现了问题,需要维修2天。本周周二 周三 周四 是不可租的。这个时候不能单纯的靠租赁记录来排除了。 :grin:

6个月前 评论
Imuyu 6个月前

其实更简单的是提前生成该车可租赁时间记录,当该区间被租赁时,修改时间线状态状态即可,查询更方便,管理也更方便,以空间换时间,假设1000辆车,一年也就30多万记录,完全够用,定时清理过期时间未租赁的时间线~

6个月前 评论
xiaofeishu 6个月前
Imuyu (作者) 6个月前
leo

步骤二的 SQL 错了,两个时间条件应该是 or 而不是 and。

6个月前 评论

:joy: 重点是梳理逻辑

4个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!