工作小锦囊系列——如何实现一个车辆预定功能(上)
背景
今天我们来聊一个关于「车辆预定系统」的话题,话题来源于社区一位小伙伴的提问(原文请戳这里)。
这个场景是这样的:
现在需要开发一套车辆租赁系统,已知每辆入驻系统的汽车都会维护基本信息。其中有一个属性为汽车的「可租赁时间段」属性。什么意思呢?比如一些租金相对便宜的车,全年都对外租赁。而一些租金相对较高的车,仅在节假日或者周末租赁。根据租赁时间段不同共分为以下几种情况:
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 = A1 ∪ A2 ∪ A3 ∪ A4 ∪ A5 。
看到这些数学公式是不是感觉有点眼花撩乱。实际上,只有当你把问题抽象成最基本的数学模型的时候,你才不会陷入错综复杂的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 }。
所以,这里我们所求的「在指定日期范围内有预定记录」的车辆,就是 A1 和 A2 的交集,即 A1 ∩ A2。用动态的角度来看的话,就是当 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
语法排除了存在预定记录的车辆。这样,我们仍然可以在外层结构上使用翻页的逻辑。
总结
在本篇文章中,我们解决了一个「车辆预定」的问题。
这是一个很有意思的话题,虽然实现起来难度并不大,但是蕴涵了许多数学的思想在里面,特别是集合处理的思想。
当然,本篇文章仅限于从基本功能进行实现,并未考虑数据量带来的算法复杂度问题。在《工作小锦囊系列——如何实现一个车辆预定功能(下)》 一文中,我们将来讨论如何使用 Redis
的 Bitmap
来解决此类的问题。
感谢大家的持续关注~
本作品采用《CC 协议》,转载必须注明作者和本文链接
赞👍
一步一步 逻辑清晰 加油 期待更多作品
很高兴看到这个讨论,实际情况会还有一种情况。就比如说我设定了只有周一 到周五可以预定。但是刚好车辆在周二出现了问题,需要维修2天。本周周二 周三 周四 是不可租的。这个时候不能单纯的靠租赁记录来排除了。 :grin:
其实更简单的是提前生成该车可租赁时间记录,当该区间被租赁时,修改时间线状态状态即可,查询更方便,管理也更方便,以空间换时间,假设1000辆车,一年也就30多万记录,完全够用,定时清理过期时间未租赁的时间线~
步骤二的 SQL 错了,两个时间条件应该是 or 而不是 and。
:+1:
:joy: 重点是梳理逻辑