工作小錦囊系列——如何實現一個車輛預定功能(上)

快樂的皮拉夫發表於2023-10-10

背景

今天我們來聊一個關於「車輛預定系統」的話題,話題來源於社群一位小夥伴的提問(原文請戳這裡)。

這個場景是這樣的:

現在需要開發一套車輛租賃系統,已知每輛入駐系統的汽車都會維護基本資訊。其中有一個屬性為汽車的「可租賃時間段」屬性。什麼意思呢?比如一些租金相對便宜的車,全年都對外租賃。而一些租金相對較高的車,僅在節假日或者週末租賃。根據租賃時間段不同共分為以下幾種情況:
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 協議》,轉載必須註明作者和本文連結
你應該瞭解真相,真相會讓你自由。

相關文章