iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
Modern Web

Laravel Filament 從入門到實戰系列 第 29 篇

Day 28:報表模組接上權限與租戶隔離,完工

  • 分享至 

  • xImage
  •  

Day 27:報表模組加上圖表與篩選條件 結尾留下一句話,報表模組接上權限與租戶隔離,完工,是接下來要處理的內容。今天要處理的正是這件事。

報表做得再漂亮,身分跟邊界不能忘記

先重述一次昨天結尾誠實點出的落差。這份報表現在有圖表,有篩選,看起來已經像一份能回答業務問題的正式報表,但不管誰打開,看到的都是全公司、全部經銷據點的完整數字。倉管、業務、財務理應關心的報表面向不盡相同,台北跟高雄兩個據點的人打開報表,看到的也還是同一份全公司彙總數字。角色權限矩陣跟多租戶隔離這兩層機制,目前都還沒有延伸到報表 Resource 上。

這個畫面熟不熟悉,值得先停下來想一下。Day 21:誰登入看到誰的儀表板,Widget 排版與權限呼應 處理過幾乎一模一樣的問題,當時 Panel 首頁的 Widget 不管誰登入都顯示同一套組合,訂單趨勢圖表也混雜著台北跟高雄兩個據點的資料,跟今天報表 Resource 卡住的位置,性質上是同一件事。今天要做的事情,說穿了就是把 Day 21 已經驗證過的處理思路,原封不動套用到報表這個新場景,只是套用的對象從 Panel 首頁的 Widget,換成報表 Resource 頁面。同一套鑰匙,能不能開新蓋好的這間房間的門,今天就是要現場給出答案。

任務範圍講清楚,今天要做的事情是把 Day 15:接上 Laravel 既有的 Policy,誰能看見這個操作按鈕 定案的 Policy 整合、Day 16:設計一份貼近真實團隊的角色權限矩陣 建立的角色權限矩陣、Day 17:多租戶隔離,讓每個經銷據點只看到自己的資料 定案的多租戶隔離,全部接上報表 Resource。三套機制怎麼運作,這三篇已經講得很清楚,今天不重複解釋,只示範怎麼把它們接上報表這個新場景。做完這件事,報表模組三天示範正式收尾,也是今天最直接的成果。

報表也該問 Policy 跟角色矩陣,這個人看得到這份報表嗎

先處理角色維度。報表裡不同的統計面向,理應對不同角色開放不同的內容,這正是 Day 16 角色權限矩陣一開始就在處理的問題,只是矩陣當時畫的是商品、訂單這類具體業務實體的操作權限,今天要問的問題換成「這個角色看不看得到報表裡的這個面向」。

拿 Day 27 建立的兩個報表面向來看,銷售趨勢圖表跟商品分類銷售量圖表,都是業務跟財務關心的營收類資訊,倉管的職責範圍是庫存夠不夠、該不該補貨,跟這兩張圖表沒有直接關係。反過來,報表裡如果有庫存偏低商品這個面向,理應只對倉管開放,業務跟財務平常不需要盯著這份清單。這跟 Day 21 讓 InventoryByCategoryChart 只對倉管角色開放、讓 OrderStatsOverview 只對業務財務開放,是同一個判斷邏輯,這裡直接照搬過來:

namespace App\Filament\Resources\SalesReportResource\Widgets;

use Filament\Widgets\ChartWidget;

class SalesReportTrendChart extends ChartWidget
{
    public static function canView(): bool
    {
        return in_array(auth()->user()->role, ['sales', 'finance']);
    }

    // getData()、getType() 沿用 Day 27 定案內容,不重複列出
}

商品分類銷售量圖表用同樣的判斷條件,庫存偏低清單則反過來只對倉管開放:

public static function canView(): bool
{
    return auth()->user()->role === 'warehouse';
}

role 這個欄位跟 warehouse、sales、finance 這三種值域,是 Day 16 已經定案的既有設計,這裡沒有新增任何角色,只是把原本寫在 ProductPolicy、OrderPolicy 裡的判斷句子,搬到報表的 Widget 類別裡問一次一樣的問題。這段程式碼跟 Day 21 canView() 的寫法幾乎逐字相同,差別只在套用對象從 Panel 首頁的 Widget 換成報表 Resource 底下的 Widget,這正是流程可複製性在權限這個面向最直接的證據。

登入驗證一次,用倉管帳號打開報表頁面,只看得到庫存偏低商品清單,銷售趨勢圖表跟商品分類銷售量圖表都不會出現。換成業務或財務帳號,情況正好相反,兩張營收類圖表都看得到,庫存偏低清單則消失。角色維度的顯示差異,今天第一次在報表 Resource 上被實際做出來,跟 Day 21 在 Panel 首頁做到的事情,本質上是同一件事的第二次實作。

報表的數字也該記得自己是哪個據點的

角色維度處理完,換據點維度。這一段要先誠實講清楚一件事,報表模組在多租戶隔離的套用方式上,跟一般 Resource 有一處實作細節上的差異,值得攤開來說。

Day 17 建立 BranchScope 的做法,是把這個 Global Scope 直接掛在 Order、Customer 這類 Model 的 booted() 方法裡,任何地方對 Order::query() 發出的查詢,都會自動被限制在登入者所屬的據點範圍內。商品、訂單這類具體業務實體的 Resource,背後查詢的對象就是這些掛了 BranchScope 的 Model 本身,隔離效果自動生效,不需要另外處理。

報表模組不是這樣。回頭看 Day 26:報表模組從零開始,從需求到 Resource 骨架 建立的 SalesReport,這個類別本身不是繼承 Model 的 Eloquent 物件,而是一段封裝彙整查詢的靜態方法集合,內部雖然會呼叫 Order::query()、OrderItem::query(),但一旦查詢裡用了 join()、selectRaw()、groupBy() 這類跨模組彙整運算,BranchScope 是否還能完整套用到最終的統計結果,不能只憑理論放心,得回去確認一次彙整邏輯本身有沒有漏接這層隔離。

實際檢查 Day 27 建立的 SalesReportTrendChart,裡面依日期迴圈統計金額的查詢是直接呼叫 Order::query(),這一段確實會自動套用 BranchScope。但 SalesReportByCategoryChart 就沒這麼幸運,它查的對象是 OrderItem,並用 join() 接上 products 資料表做分組加總,OrderItem 本身沒有 branch_id 欄位,也沒有掛載 BranchScope,這段彙整查詢實際上完全沒有被據點隔離限制住,需要明確補上一段篩選條件:

namespace App\Filament\Resources\SalesReportResource\Widgets;

use App\Models\OrderItem;
use Filament\Widgets\ChartWidget;

class SalesReportByCategoryChart extends ChartWidget
{
    public static function canView(): bool
    {
        return in_array(auth()->user()->role, ['sales', 'finance']);
    }

    protected function getData(): array
    {
        $sales = OrderItem::query()
            ->join('orders', 'orders.id', '=', 'order_items.order_id')
            ->join('products', 'products.id', '=', 'order_items.product_id')
            ->where('orders.branch_id', auth()->user()->branch_id)
            ->selectRaw('products.category, sum(order_items.quantity) as total_quantity')
            ->groupBy('products.category')
            ->pluck('total_quantity', 'category');

        return [
            'datasets' => [
                [
                    'label' => '銷售數量',
                    'data' => $sales->values()->toArray(),
                ],
            ],
            'labels' => $sales->keys()->toArray(),
        ];
    }

    protected function getType(): string
    {
        return 'bar';
    }
}

多加的兩處分別是 join('orders', ...) 把訂單明細跟訂單資料表串起來,以及 where('orders.branch_id', auth()->user()->branch_id) 明確帶入據點篩選條件。這正是報表模組跟一般 Resource 在多租戶隔離實作細節上的真實差異,一般 Resource 靠 Global Scope 自動套用,報表的彙整查詢因為橫跨多張資料表、且查詢對象未必是掛了 Scope 的 Model 本身,需要在彙整邏輯裡明確寫出據點篩選條件。手法不同,但背後要達成的效果,只看得到自己範圍內的資料,跟一般 Resource 完全一致。

理論確認完,實際操作驗證一次才算數。準備兩組帳號,都是業務角色,一組 branch_id 指向台北據點,一組指向高雄據點。用台北據點的業務帳號登入,銷售趨勢圖表的折線只反映台北據點的訂單金額,商品分類銷售量圖表的長條也只計入台北據點的銷售數量。登出換成高雄據點的業務帳號,兩張圖表呈現的數字跟折線完全不同,只反映高雄據點實際的銷售狀況,兩邊互不重疊。

台北與高雄據點業務帳號登入後,報表兩張圖表的據點隔離來源,銷售趨勢圖表由 Order 掛載的 BranchScope 自動套用,商品分類銷售量圖表需在彙整查詢裡手動帶入據點篩選條件

這次驗證確認了一件事,多租戶隔離的原則,看得到的資料範圍只限於自己所屬的據點,今天在報表這個資料來源特性不同的新場景上依然成立,即使實作手法因應報表的彙整查詢性質做了局部調整。原則沒有變,寫法因地制宜,這正是驗證流程可複製性時該有的誠實態度,不需要假裝報表模組完全不用調整就能直接套用既有機制。

報表模組完工,五個模組終於到齊了

報表 Resource 現在依角色呈現不同面向,依據點呈現不同範圍的數字,跟其他模組一樣具備完整的身分與邊界。趁著這個時間點,把報表模組三天走過的路徑完整回顧一次。

Day 26:報表模組從零開始,從需求到 Resource 骨架 先做的不是動手寫程式,而是把管理者實際想從報表裡看到什麼問題具體列出來,訂單總數與總金額、哪些商品賣得最好、哪些商品庫存偏低,再依循系列已經建立的流程建立 SalesReport 這個彙整查詢類別跟報表 Resource 骨架。Day 27:報表模組加上圖表與篩選條件 接手把骨架轉化成有圖表、有篩選條件的視覺化報表,銷售趨勢圖表沿用 Day 20 建立圖表 Widget 的做法,時間區間篩選沿用 Day 07 建立自訂 Filter 的做法,全部直接複製系列前面已經走過的路。今天把 Policy 整合、角色權限矩陣、多租戶隔離接上報表,讓報表跟商品、訂單這些模組一樣,具備完整的身分判斷與資料邊界。

這三天走完的,是報表模組從需求釐清、Resource 骨架、圖表視覺化、篩選條件到權限與租戶隔離的完整生命週期。攤開來看,絕大部分步驟都是直接複製系列前面二十五天已經建立的做法,make:filament-resource 這道指令、ChartWidget 這套圖表寫法、Filter 搭配 query() 的篩選手法、canView() 問角色矩陣的方式,沒有一處是重新發明出來的新技巧。真正需要因應報表特性局部調整的環節,只有少數幾處,SalesReport 用純粹類別封裝彙整邏輯而不是繼承 Model,報表 Resource 拿掉建立編輯頁面只留列表頁,以及今天在 SalesReportByCategoryChart 裡明確帶入的據點篩選條件。這三天的經驗證明,這套建置流程具備真正的可複製性,不是只在商品、訂單這幾個模組上湊巧管用。

順著這個結果,示範情境從第一天就定案的商品、訂單、客戶、庫存、報表五個模組,今天首次全部到齊。每個模組都具備完整的 CRUD、關聯、驗證、權限、多租戶隔離,報表模組是最後一塊補齊的拼圖。

模組都到齊了,但沒人真正從頭到尾走過一次

五個模組到齊,值得高興,但這裡有個容易被忽略的事實,得誠實點出來。這五個模組是分開、分批在不同天數建置出來的,商品在 Day 03、Day 04 就有了雛形,訂單跟客戶在 Day 09 到 Day 12 陸續串起關聯,報表則是這三天才從零開始做出來。從第一天到今天,從來沒有人真正從頭到尾走過一次完整的業務流程,從新增一筆商品開始,一路做到最後看到報表反映出這筆商品的銷售成果。

全系列整合驗收,跑一次從新增商品到產生報表的完整流程,是接下來要處理的內容。


上一篇
Day 27:報表模組加上圖表與篩選條件
下一篇
Day 29:全系列整合驗收,跑一次從新增商品到產生報表的完整流程
系列文
Laravel Filament 從入門到實戰 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言