refactor: 重構模組通訊與調整儀表板功能
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s

- 依循跨模組通訊規範,將 Sales 與 Production 模組中對 Inventory 的直接模型關聯改為透過 InventoryServiceInterface 取得
- 於 InventoryService 實作獲取最高庫存價值、即將過期商品等方法,供儀表板使用
- 確保所有跨模組調用皆採用手動水和(Manual Hydration)方式組合資料
- 移除本地已歸檔的 .agent 規範檔案
This commit is contained in:
2026-02-25 11:48:52 +08:00
parent ad91b08dbc
commit deef3baacc
22 changed files with 826 additions and 1396 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
---
trigger: always_on
---
--- ---
name: 操作紀錄實作規範 name: 操作紀錄實作規範
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。 description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。

View File

@@ -1,3 +1,7 @@
---
trigger: always_on
---
--- ---
name: 跨模組調用與通訊規範 (Cross-Module Communication) name: 跨模組調用與通訊規範 (Cross-Module Communication)
description: 規範 Laravel Modular Monolith 架構下不同業務模組中如何彼此調用資料與邏輯包含禁止項目、Interface 實作、與 Service 綁定規則。 description: 規範 Laravel Modular Monolith 架構下不同業務模組中如何彼此調用資料與邏輯包含禁止項目、Interface 實作、與 Service 綁定規則。
@@ -9,7 +13,7 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
## 🚫 絕對禁止的行為 (Strict Prohibitions) ## 🚫 絕對禁止的行為 (Strict Prohibitions)
* **禁止跨模組 Eloquent 關聯** * **禁止跨模組 Eloquent 關聯(例外除外)**
* **錯誤**:在 `app/Modules/Sales/Models/Order.php` 中撰寫 `public function product() { return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); }` * **錯誤**:在 `app/Modules/Sales/Models/Order.php` 中撰寫 `public function product() { return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); }`
* **原因**:這會造成資料庫查詢的強耦合。如果 `Inventory` 模組修改了 Schema`Sales` 模組會無預警崩壞。 * **原因**:這會造成資料庫查詢的強耦合。如果 `Inventory` 模組修改了 Schema`Sales` 模組會無預警崩壞。
* **禁止跨模組直接引入 (use) Model** * **禁止跨模組直接引入 (use) Model**
@@ -19,6 +23,19 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
--- ---
## 🌟 允許的全域例外 (Global Exceptions)
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model
1. **`App\Modules\Core\Models\User`**:因為幾乎所有表都有 `created_by` / `updated_by`,直接關聯可保留 `with('creator')` 等便利性。
2. **`App\Modules\Core\Models\Role`**:權限判定已深度整合至系統底層。
3. **`App\Modules\Core\Models\Tenant`**:多租戶架構 (Tenancy) 的核心基石,底層查詢會頻繁依賴。
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`**絕對不能**反過來直接 `use` 外部業務模組的 Model仍必須透過外部模組的 Service Interface 來索取資料。
---
## ✅ 正確的跨模組調用流程:合約與依賴反轉 ## ✅ 正確的跨模組調用流程:合約與依賴反轉
所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。 所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。

View File

@@ -2,10 +2,6 @@
trigger: always_on trigger: always_on
--- ---
---
trigger: always_on
---
# 開發框架規範說明書ERP 系統 (star-erp) # 開發框架規範說明書ERP 系統 (star-erp)
## 1. 專案概述 ## 1. 專案概述

View File

@@ -1,3 +1,7 @@
---
trigger: always_on
---
--- ---
name: 權限管理與實作規範 name: 權限管理與實作規範
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。 description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。

View File

@@ -0,0 +1,512 @@
---
trigger: always_on
---
---
name: 客戶端後台 UI 統一規範
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
---
## 概述
本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。
> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
## 核心原則
1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件
2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別
3. **統一的圖標系統**:全面使用 `lucide-react` 圖標
4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構
5. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
---
## 1. 專案結構
### 1.1 關鍵目錄
```
resources/
├── css/
│ └── app.css # 全域樣式與設計 Token
├── js/
│ ├── Components/
│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui)
│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等)
│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll)
│ ├── Layouts/
│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用
│ │ └── LandlordLayout.tsx # 中央管理後台佈局
│ └── Pages/ # 頁面元件
```
### 1.2 可用 UI 元件清單
```
accordion, alert, alert-dialog, avatar, badge, breadcrumb, button,
calendar, card, carousel, chart, checkbox, collapsible, command,
context-menu, dialog, drawer, dropdown-menu, form, hover-card,
input, input-otp, label, menubar, navigation-menu, pagination,
popover, progress, radio-group, resizable, scroll-area,
searchable-select, select, separator, sheet, sidebar, skeleton,
slider, sonner, switch, table, tabs, textarea, toggle, toggle-group,
tooltip
```
---
## 2. 色彩系統
### 2.1 主題色 (Primary) - **動態租戶品牌色**
> **注意**主題色會根據租戶設定Branding動態改變**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。
> 請務必使用 Tailwind Utility Class 或 CSS 變數。
| Tailwind Class | CSS Variable | 說明 |
|----------------|--------------|------|
| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 |
| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 |
| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 |
| `*-primary-lightest` | `--primary-lightest` | **最淺色**系統自動計算用於背景底色、Active 狀態 |
**運作機制**
`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。
```tsx
// ✅ 正確:使用 Tailwind Class
<div className="text-primary-main">...</div>
// ✅ 正確:使用 CSS 變數 (自定義樣式時)
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
<div className="text-[#01ab83]">...</div>
```
### 2.2 灰階 (Grey Scale)
```css
--grey-0: #1a1a1a; /* 深黑 - 標題文字 */
--grey-1: #4a4a4a; /* 深灰 - 主要內文 */
--grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */
--grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */
--grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */
--grey-5: #fff; /* 白色 - 背景、按鈕文字 */
```
### 2.3 狀態色 (State Colors)
```css
--other-success: #01ab83; /* 成功 - 同主題色 */
--other-error: #dc2626; /* 錯誤 - 刪除、警示 */
--other-warning: #f59e0b; /* 警告 - 提醒、注意 */
--other-info: #3b82f6; /* 資訊 - 說明、提示 */
```
---
## 3. 按鈕規範
### 3.1 按鈕樣式類別
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別:
#### Filled 按鈕(實心按鈕)— 用於主要操作
```tsx
// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增項目
</Button>
// ✅ 成功操作
<Button className="button-filled-success">確認</Button>
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
<Button className="button-filled-info">系統資訊</Button>
// ✅ 警告操作
<Button className="button-filled-warning">警告</Button>
// ✅ 錯誤/刪除操作AlertDialog 內確認按鈕)
<Button className="button-filled-error">刪除</Button>
```
#### Outlined 按鈕(邊框按鈕)— 用於次要操作
```tsx
// ✅ 編輯按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" />
</Button>
// ✅ 刪除按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-error">
<Trash2 className="h-4 w-4" />
</Button>
```
#### Text 按鈕(文字按鈕)
```tsx
<Button className="button-text-primary">查看更多</Button>
```
### 3.2 按鈕大小
| Size | 高度 | 使用情境 |
|------|------|----------|
| `size="sm"` | h-8 | 表格操作列、緊湊佈局 |
| `size="default"` | h-9 | 一般操作、表單提交 |
| `size="lg"` | h-10 | 主要 CTA、頁面主操作 |
| `size="icon"` | 9×9 | 純圖標按鈕 |
### 3.3 常見操作按鈕模式
#### 頁面頂部新增按鈕
```tsx
<Can permission="resource.create">
<Link href={route('resource.create')}>
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增XXX
</Button>
</Link>
</Can>
```
#### 表格操作列檢視按鈕
```tsx
<Can permission="resource.view">
<Link href={route('resource.show', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="檢視"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
</Can>
```
#### 表格操作列編輯按鈕
```tsx
<Can permission="resource.edit">
<Link href={route('resource.edit', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
```
#### 表格操作列刪除按鈕(帶確認對話框)
```tsx
<Can permission="resource.delete">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>確認刪除</AlertDialogTitle>
<AlertDialogDescription>
確定要刪除「{item.name}」嗎?此操作無法復原。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(item.id)}
className="bg-red-600 hover:bg-red-700"
>
刪除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
```
### 3.4 返回按鈕規範
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
**樣式規格**
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
- **樣式**`variant="outline"` + `className="gap-2 button-outlined-primary"`
- **圖標**`<ArrowLeft className="h-4 w-4" />`
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
```tsx
<div className="mb-6">
<Link href={route('resource.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary"
>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Link>
</div>
```
---
## 3.5 頁面佈局規範(新增/編輯頁面)
### 標準結構
新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構:
```tsx
<AuthenticatedLayout breadcrumbs={...}>
<Head title="..." />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
{/* 返回按鈕 */}
<Link href={route('resource.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-4"
>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Link>
{/* 頁面標題區塊 */}
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Icon className="h-6 w-6 text-primary-main" />
頁面標題
</h1>
<p className="text-gray-500 mt-1">
頁面說明文字
</p>
</div>
</div>
{/* 表單或內容區塊 */}
<FormComponent ... />
</div>
</AuthenticatedLayout>
```
### 關鍵規範
1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致
2. **Header 包裹**:使用 `<div className="mb-6">` 包裹返回按鈕與標題區塊
3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔
4. **標題區塊**:使用 `<div className="mb-4">` 包裹 h1 和 p 標籤
5. **標題樣式**`text-2xl font-bold text-grey-0 flex items-center gap-2`
6. **說明文字**`text-gray-500 mt-1`
### 範例頁面
- ✅ `/resources/js/Pages/PurchaseOrder/Create.tsx`(建立採購單)
- ✅ `/resources/js/Pages/Product/Create.tsx`(新增商品)
- ✅ `/resources/js/Pages/Product/Edit.tsx`(編輯商品)
---
## 4. 圖標規範
### 4.1 統一使用 lucide-react
**統一使用 `lucide-react`**,禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。
### 4.2 圖標尺寸標準
| 尺寸 | 類別 | 使用情境 |
|------|------|----------|
| 小型 | `h-3 w-3` | Badge 內、小文字旁 |
| 標準 | `h-4 w-4` | 按鈕內、表格操作 |
| 標題 | `h-5 w-5` | 側邊欄選單 |
| 大型 | `h-6 w-6` | 頁面標題 |
### 4.3 常用操作圖標映射
| 操作 | 圖標組件 | 使用情境 |
|------|----------|----------|
| 新增 | `<Plus />` | 新增按鈕 |
| 編輯 | `<Pencil />` | 編輯按鈕 |
| 刪除 | `<Trash2 />` | 刪除按鈕 |
| 查看 | `<Eye />` | 查看詳情 |
| 搜尋 | `<Search />` | 搜尋欄位 |
| 篩選 | `<Filter />` | 篩選功能 |
| 下載 | `<Download />` | 下載/匯出 |
| 上傳 | `<Upload />` | 上傳/匯入 |
| 設定 | `<Settings />` | 設定功能 |
| 複製 | `<Copy />` | 複製內容 |
| 郵件 | `<Mail />` | Email 顯示 |
| 使用者 | `<Users />`, `<User />` | 使用者管理 |
| 權限 | `<Shield />` | 角色/權限 |
| 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 |
| 儀表板 | `<LayoutDashboard />` | 首頁/總覽 |
| 商品 | `<Package />` | 商品管理 |
| 倉庫 | `<Warehouse />` | 倉庫管理 |
| 廠商 | `<Truck />`, `<Contact2 />` | 廠商管理 |
| 採購 | `<ShoppingCart />` | 採購管理 |
### 4.4 圖標使用範例
```tsx
import { Plus, Pencil, Trash2, Users } from 'lucide-react';
// 頁面標題
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Users className="h-6 w-6 text-[#01ab83]" />
使用者管理
</h1>
// 按鈕內圖標(圖標在左,帶文字)
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增使用者
</Button>
// 純圖標按鈕(表格操作列)
<Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" />
</Button>
```
---
## 5. 表格規範
### 5.1 表格容器
```tsx
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
{/* 表格內容 */}
</Table>
</div>
```
### 5.2 表格標題列
```tsx
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>名稱</TableHead>
<TableHead className="text-center">操作</TableHead>
</TableRow>
</TableHeader>
```
**關鍵要點**
- 使用 `bg-gray-50` 背景色
- 序號欄位固定寬度 `w-[50px]` 並置中
- 操作欄位置中顯示
### 5.3 表格主體
```tsx
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
無符合條件的資料
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{startIndex + index}
</TableCell>
{/* 其他欄位 */}
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
{/* 操作按鈕 */}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
```
**關鍵要點**
- 空狀態訊息使用置中、灰色文字
- 序號欄使用 `text-gray-500 font-medium text-center`
- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕
### 5.4 欄位排序規範
當表格需要支援排序時,請遵循以下模式:
1. **圖標邏輯**
* 未排序:`ArrowUpDown` (class: `text-muted-foreground`)
* 升冪 (asc)`ArrowUp` (class: `text-primary`)
* 降冪 (desc)`ArrowDown` (class: `text-primary`)
2. **結構**:在 `TableHead` 內使用 `button` 元素。
3. **後端配合**:後端 Controller **必須** 處理 `sort_by``sort_order` 參數。
```tsx
// 1. 定義 Helper Component (在元件內部)
const SortIcon = ({ field }: { field: string }) => {
if (filters.sort_by !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (filters.sort_order === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
};
// 2. 表格標題應用
<TableHead>
<button
onClick={() => handleSort('created_at')}
className="flex items-center hover:text-gray-900"
>
建立時間 <SortIcon field="created_at" />
</button>
</TableHead>
// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序)
const handleSort = (field: string) => {
let newSortBy: string | undefined = field;
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
if (filters.sort_by === field) {
if (filters.sort_order === 'asc') {
newSortOrder = 'desc';
} else {
// desc -> reset (回到預設排序)
newSortBy = undefined;
newSortOrder = undefined;
}
}
router.get(
route(route().curr

View File

@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Modules\Inventory\Contracts\InventoryServiceInterface; use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Sales\Contracts\SalesServiceInterface;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use Inertia\Inertia; use Inertia\Inertia;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -13,13 +15,19 @@ class DashboardController extends Controller
{ {
protected $inventoryService; protected $inventoryService;
protected $procurementService; protected $procurementService;
protected $salesService;
protected $productionService;
public function __construct( public function __construct(
InventoryServiceInterface $inventoryService, InventoryServiceInterface $inventoryService,
ProcurementServiceInterface $procurementService ProcurementServiceInterface $procurementService,
SalesServiceInterface $salesService,
ProductionServiceInterface $productionService
) { ) {
$this->inventoryService = $inventoryService; $this->inventoryService = $inventoryService;
$this->procurementService = $procurementService; $this->procurementService = $procurementService;
$this->salesService = $salesService;
$this->productionService = $productionService;
} }
public function index() public function index()
@@ -35,99 +43,70 @@ class DashboardController extends Controller
$procStats = $this->procurementService->getDashboardStats(); $procStats = $this->procurementService->getDashboardStats();
// 銷售統計 (本月營收) // 銷售統計 (本月營收)
$thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month) $thisMonthRevenue = $this->salesService->getThisMonthRevenue();
->whereYear('transaction_at', now()->year)
->sum('amount');
// 生產統計 (待核准工單) // 生產統計 (待核准工單)
$pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count(); $pendingProductionCount = $this->productionService->getPendingProductionCount();
// 生產狀態分佈 // 生產狀態分佈
// 近30日銷售趨勢 (Area Chart) // 近30日銷售趨勢 (Area Chart)
$startDate = now()->subDays(29)->startOfDay(); $salesTrend = $this->salesService->getSalesTrend();
$salesData = \App\Modules\Sales\Models\SalesImportItem::where('transaction_at', '>=', $startDate)
->selectRaw('DATE(transaction_at) as date, SUM(amount) as total')
->groupBy('date')
->orderBy('date')
->get()
->mapWithKeys(function ($item) {
return [$item->date => (int)$item->total];
});
$salesTrend = [];
for ($i = 0; $i < 30; $i++) {
$date = $startDate->copy()->addDays($i)->format('Y-m-d');
$salesTrend[] = [
'date' => $startDate->copy()->addDays($i)->format('m/d'),
'amount' => $salesData[$date] ?? 0,
];
}
// 本月熱銷商品 Top 5 (Bar Chart) // 本月熱銷商品 Top 5 (Bar Chart)
$topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product') $topSellingItems = $this->salesService->getTopSellingProducts();
->whereMonth('transaction_at', now()->month) $productIds = $topSellingItems->pluck('product_id')->filter()->unique()->toArray();
->whereYear('transaction_at', now()->year) $productsMap = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount'))
->groupBy('product_code', 'product_id') $topSellingProducts = $topSellingItems->map(function ($item) use ($productsMap) {
->orderByDesc('total_amount') $product = $productsMap->get($item->product_id);
->limit(5) return [
->get() 'name' => $product ? $product->name : $item->product_code,
->map(function ($item) { 'amount' => (int)$item->total_amount,
return [ ];
'name' => $item->product ? $item->product->name : $item->product_code, });
'amount' => (int)$item->total_amount,
];
});
// 庫存積壓排行 (Top Inventory Value) // 庫存積壓排行 (Top Inventory Value)
$topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product') $topInventoryValueItems = $this->inventoryService->getTopInventoryValue();
->select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value')) $invProductIds = $topInventoryValueItems->pluck('product_id')->filter()->unique()->toArray();
->where('quantity', '>', 0) $invProductsMap = $this->inventoryService->getProductsByIds($invProductIds)->keyBy('id');
->groupBy('product_id')
->orderByDesc('total_value') $topInventoryValue = $topInventoryValueItems->map(function ($item) use ($invProductsMap) {
->limit(5) $product = $invProductsMap->get($item->product_id);
->get() return [
->map(function ($item) { 'name' => $product ? $product->name : 'Unknown Product',
return [ 'code' => $product ? $product->code : '',
'name' => $item->product ? $item->product->name : 'Unknown Product', 'value' => (int)$item->total_value,
'code' => $item->product ? $item->product->code : '', ];
'value' => (int)$item->total_value, });
];
});
// 熱銷數量排行 (Top Selling by Quantity) // 熱銷數量排行 (Top Selling by Quantity)
$topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product') $topSellingQtyItems = $this->salesService->getTopSellingByQuantity();
->whereMonth('transaction_at', now()->month) $qtyProductIds = $topSellingQtyItems->pluck('product_id')->filter()->unique()->toArray();
->whereYear('transaction_at', now()->year) $qtyProductsMap = $this->inventoryService->getProductsByIds($qtyProductIds)->keyBy('id');
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_code', 'product_id') $topSellingByQuantity = $topSellingQtyItems->map(function ($item) use ($qtyProductsMap) {
->orderByDesc('total_quantity') $product = $qtyProductsMap->get($item->product_id);
->limit(5) return [
->get() 'name' => $product ? $product->name : $item->product_code,
->map(function ($item) { 'code' => $item->product_code,
return [ 'value' => (int)$item->total_quantity,
'name' => $item->product ? $item->product->name : $item->product_code, ];
'code' => $item->product_code, });
'value' => (int)$item->total_quantity,
];
});
// 即將過期商品 (Expiring Soon) // 即將過期商品 (Expiring Soon)
$expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product') $expiringItems = $this->inventoryService->getExpiringSoon();
->where('quantity', '>', 0) $expiringProductIds = $expiringItems->pluck('product_id')->filter()->unique()->toArray();
->whereNotNull('expiry_date') $expiringProductsMap = $this->inventoryService->getProductsByIds($expiringProductIds)->keyBy('id');
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
->orderBy('expiry_date', 'asc') $expiringSoon = $expiringItems->map(function ($item) use ($expiringProductsMap) {
->limit(5) $product = $expiringProductsMap->get($item->product_id);
->get() return [
->map(function ($item) { 'name' => $product ? $product->name : 'Unknown Product',
return [ 'batch_number' => $item->batch_number,
'name' => $item->product ? $item->product->name : 'Unknown Product', 'expiry_date' => $item->expiry_date->format('Y-m-d'),
'batch_number' => $item->batch_number, 'quantity' => (int)$item->quantity,
'expiry_date' => $item->expiry_date->format('Y-m-d'), ];
'quantity' => (int)$item->quantity, });
];
});
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard', [
'stats' => [ 'stats' => [

View File

@@ -7,23 +7,41 @@ use App\Modules\Finance\Models\AccountPayable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
class AccountPayableController extends Controller class AccountPayableController extends Controller
{ {
protected $procurementService;
protected $inventoryService;
public function __construct(
ProcurementServiceInterface $procurementService,
InventoryServiceInterface $inventoryService
) {
$this->procurementService = $procurementService;
$this->inventoryService = $inventoryService;
}
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$query = AccountPayable::with(['vendor', 'creator']); $query = AccountPayable::with(['creator']);
// 關鍵字搜尋 (單號、供應商名稱) // 關鍵字搜尋 (單號、供應商名稱)
if ($request->filled('search')) { if ($request->filled('search')) {
$search = $request->search; $search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('document_number', 'like', "%{$search}%") // 透過 ProcurementService 查詢符合關鍵字的 Vendor IDs
->orWhereHas('vendor', function ($q) use ($search) { $matchedVendors = $this->procurementService->searchVendors($search);
$q->where('name', 'like', "%{$search}%"); $vendorIds = $matchedVendors->pluck('id')->toArray();
});
$query->where(function ($q) use ($search, $vendorIds) {
$q->where('document_number', 'like', "%{$search}%");
if (!empty($vendorIds)) {
$q->orWhereIn('vendor_id', $vendorIds);
}
}); });
} }
@@ -48,7 +66,16 @@ class AccountPayableController extends Controller
$perPage = $request->input('per_page', 10); $perPage = $request->input('per_page', 10);
$payables = $query->latest()->paginate($perPage)->withQueryString(); $payables = $query->latest()->paginate($perPage)->withQueryString();
$vendors = \App\Modules\Procurement\Models\Vendor::select('id', 'name')->get(); // Manual Hydration for Vendors
$allVendorIds = collect($payables->items())->pluck('vendor_id')->unique()->filter()->toArray();
$vendorsMap = $this->procurementService->getVendorsByIds($allVendorIds)->keyBy('id');
$payables->getCollection()->transform(function ($item) use ($vendorsMap) {
$item->vendor = $vendorsMap->get($item->vendor_id);
return $item;
});
$vendors = $this->procurementService->getAllVendors();
return Inertia::render('AccountPayable/Index', [ return Inertia::render('AccountPayable/Index', [
'payables' => $payables, 'payables' => $payables,
@@ -62,14 +89,19 @@ class AccountPayableController extends Controller
*/ */
public function show(AccountPayable $accountPayable) public function show(AccountPayable $accountPayable)
{ {
$accountPayable->load(['vendor', 'creator']); $accountPayable->load(['creator']);
if ($accountPayable->vendor_id) {
$accountPayable->vendor = $this->procurementService->getVendorsByIds([$accountPayable->vendor_id])->first();
}
// 嘗試加載來源單據資訊 (目前支援 goods_receipt) // 嘗試加載來源單據資訊 (目前支援 goods_receipt)
$sourceDocumentCode = null; $sourceDocumentCode = null;
if ($accountPayable->source_document_type === 'goods_receipt') { if ($accountPayable->source_document_type === 'goods_receipt') {
$receipt = \App\Modules\Inventory\Models\GoodsReceipt::find($accountPayable->source_document_id); $receiptData = app(\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class)
if ($receipt) { ->getGoodsReceiptData($accountPayable->source_document_id);
$sourceDocumentCode = $receipt->code; if ($receiptData) {
$sourceDocumentCode = $receiptData['code'] ?? null;
} }
} }

View File

@@ -41,14 +41,7 @@ class AccountPayable extends Model
'paid_at' => 'datetime', 'paid_at' => 'datetime',
]; ];
/** // vendor 關聯移至 service (跨模組)
* 關聯:供應商
* @return BelongsTo
*/
public function vendor(): BelongsTo
{
return $this->belongsTo(\App\Modules\Procurement\Models\Vendor::class, 'vendor_id');
}
/** /**
* 關聯:建立者 * 關聯:建立者

View File

@@ -40,6 +40,14 @@ interface InventoryServiceInterface
*/ */
public function getProductsByIds(array $ids); public function getProductsByIds(array $ids);
/**
* Get multiple warehouses by their codes.
*
* @param array $codes
* @return \Illuminate\Support\Collection
*/
public function getWarehousesByCodes(array $codes);
/** /**
* Search products by name. * Search products by name.
* *
@@ -139,4 +147,14 @@ interface InventoryServiceInterface
* @return object * @return object
*/ */
public function findOrCreateWarehouseByName(string $warehouseName); public function findOrCreateWarehouseByName(string $warehouseName);
/**
* Get top inventory value for dashboard.
*/
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection;
/**
* Get items expiring soon for dashboard.
*/
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection;
} }

View File

@@ -7,11 +7,10 @@ use App\Modules\Inventory\Services\GoodsReceiptService;
use App\Modules\Inventory\Services\InventoryService; use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Services\DuplicateCheckService;
use Inertia\Inertia; use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt; use App\Modules\Inventory\Models\GoodsReceipt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Modules\Inventory\Services\DuplicateCheckService;
class GoodsReceiptController extends Controller class GoodsReceiptController extends Controller
{ {

View File

@@ -16,6 +16,26 @@ class InventoryService implements InventoryServiceInterface
return Warehouse::all(); return Warehouse::all();
} }
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
{
return Inventory::select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value'))
->where('quantity', '>', 0)
->groupBy('product_id')
->orderByDesc('total_value')
->limit($limit)
->get();
}
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection
{
return Inventory::where('quantity', '>', 0)
->whereNotNull('expiry_date')
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
->orderBy('expiry_date', 'asc')
->limit($limit)
->get();
}
public function getAllProducts() public function getAllProducts()
{ {
return Product::with(['baseUnit', 'largeUnit'])->get(); return Product::with(['baseUnit', 'largeUnit'])->get();
@@ -41,6 +61,11 @@ class InventoryService implements InventoryServiceInterface
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get(); return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
} }
public function getWarehousesByCodes(array $codes)
{
return Warehouse::whereIn('code', $codes)->get();
}
public function getProductsByName(string $name) public function getProductsByName(string $name)
{ {
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get(); return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Modules\Production\Contracts;
interface ProductionServiceInterface
{
public function getPendingProductionCount(): int;
}

View File

@@ -27,13 +27,5 @@ class RecipeItem extends Model
return $this->belongsTo(Recipe::class); return $this->belongsTo(Recipe::class);
} }
public function product() // product 和 unit 關聯移至 service (跨模組)
{
return $this->belongsTo(\App\Modules\Inventory\Models\Product::class);
}
public function unit()
{
return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class);
}
} }

View File

@@ -10,7 +10,10 @@ class ProductionServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
// $this->app->bind(
\App\Modules\Production\Contracts\ProductionServiceInterface::class,
\App\Modules\Production\Services\ProductionService::class
);
} }
public function boot(): void public function boot(): void

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Modules\Production\Services;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use App\Modules\Production\Models\ProductionOrder;
class ProductionService implements ProductionServiceInterface
{
public function getPendingProductionCount(): int
{
return ProductionOrder::where('status', 'pending')->count();
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Modules\Sales\Contracts;
interface SalesServiceInterface
{
public function getThisMonthRevenue(): float;
public function getSalesTrend(int $days = 30): array;
public function getTopSellingProducts(int $limit = 5): \Illuminate\Support\Collection;
public function getTopSellingByQuantity(int $limit = 5): \Illuminate\Support\Collection;
}

View File

@@ -5,7 +5,7 @@ namespace App\Modules\Sales\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Modules\Sales\Models\SalesImportBatch; use App\Modules\Sales\Models\SalesImportBatch;
use App\Modules\Sales\Imports\SalesImport; use App\Modules\Sales\Imports\SalesImport;
use App\Modules\Inventory\Services\InventoryService; // Assuming this exists or we need to use ProductService use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
@@ -66,17 +66,32 @@ class SalesImportController extends Controller
$import->load(['items', 'importer']); $import->load(['items', 'importer']);
$perPage = $request->input('per_page', 10); $perPage = $request->input('per_page', 10);
$paginatedItems = $import->items()->paginate($perPage)->withQueryString();
// Manual Hydration for Products and Warehouses
$inventoryService = app(InventoryServiceInterface::class);
$productIds = collect($paginatedItems->items())->pluck('product_id')->filter()->unique()->toArray();
$machineCodes = collect($paginatedItems->items())->pluck('machine_id')->filter()->unique()->toArray();
$products = $inventoryService->getProductsByIds($productIds)->keyBy('id');
$warehouses = $inventoryService->getWarehousesByCodes($machineCodes)->keyBy('code');
$paginatedItems->getCollection()->transform(function ($item) use ($products, $warehouses) {
$item->product = $products->get($item->product_id);
$item->warehouse = $warehouses->get($item->machine_id);
return $item;
});
return Inertia::render('Sales/Import/Show', [ return Inertia::render('Sales/Import/Show', [
'import' => $import, 'import' => $import,
'items' => $import->items()->with(['product', 'warehouse'])->paginate($perPage)->withQueryString(), 'items' => $paginatedItems,
'filters' => [ 'filters' => [
'per_page' => $perPage, 'per_page' => $perPage,
], ],
]); ]);
} }
public function confirm(SalesImportBatch $import, InventoryService $inventoryService) public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
{ {
if ($import->status !== 'pending') { if ($import->status !== 'pending') {
return back()->with('error', '此批次無法確認。'); return back()->with('error', '此批次無法確認。');
@@ -87,8 +102,8 @@ class SalesImportController extends Controller
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot" $aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
// Pre-load necessary warehouses for matching // Pre-load necessary warehouses for matching
$machineIds = $import->items->pluck('machine_id')->filter()->unique(); $machineIds = $import->items->pluck('machine_id')->filter()->unique()->toArray();
$warehouses = \App\Modules\Inventory\Models\Warehouse::whereIn('code', $machineIds)->get()->keyBy('code'); $warehouses = $inventoryService->getWarehousesByCodes($machineIds)->keyBy('code');
foreach ($import->items as $item) { foreach ($import->items as $item) {
// Only process shipped items with a valid product // Only process shipped items with a valid product

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Sales\Imports;
use App\Modules\Sales\Models\SalesImportBatch; use App\Modules\Sales\Models\SalesImportBatch;
use App\Modules\Sales\Models\SalesImportItem; use App\Modules\Sales\Models\SalesImportItem;
use App\Modules\Inventory\Models\Product; use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection; use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithStartRow; use Maatwebsite\Excel\Concerns\WithStartRow;
@@ -19,7 +19,8 @@ class SalesImportSheet implements ToCollection, WithStartRow
{ {
$this->batch = $batch; $this->batch = $batch;
// Pre-load all products to minimize queries (keyed by code) // Pre-load all products to minimize queries (keyed by code)
$this->products = Product::pluck('id', 'code'); // assumes code is unique $inventoryService = app(InventoryServiceInterface::class);
$this->products = $inventoryService->getAllProducts()->pluck('id', 'code')->toArray(); // assumes code is unique
} }
public function startRow(): int public function startRow(): int

View File

@@ -2,8 +2,7 @@
namespace App\Modules\Sales\Models; namespace App\Modules\Sales\Models;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -38,14 +37,4 @@ class SalesImportItem extends Model
{ {
return $this->belongsTo(SalesImportBatch::class, 'batch_id'); return $this->belongsTo(SalesImportBatch::class, 'batch_id');
} }
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'machine_id', 'code');
}
} }

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Modules\Sales;
use Illuminate\Support\ServiceProvider;
use App\Modules\Sales\Contracts\SalesServiceInterface;
use App\Modules\Sales\Services\SalesService;
class SalesServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(SalesServiceInterface::class, SalesService::class);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Modules\Sales\Services;
use App\Modules\Sales\Contracts\SalesServiceInterface;
use App\Modules\Sales\Models\SalesImportItem;
use Illuminate\Support\Facades\DB;
class SalesService implements SalesServiceInterface
{
public function getThisMonthRevenue(): float
{
return (float) SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->sum('amount');
}
public function getSalesTrend(int $days = 30): array
{
$startDate = now()->subDays($days - 1)->startOfDay();
$salesData = SalesImportItem::where('transaction_at', '>=', $startDate)
->selectRaw('DATE(transaction_at) as date, SUM(amount) as total')
->groupBy('date')
->orderBy('date')
->get()
->mapWithKeys(function ($item) {
return [$item->date => (int)$item->total];
});
$salesTrend = [];
for ($i = 0; $i < $days; $i++) {
$date = $startDate->copy()->addDays($i)->format('Y-m-d');
$salesTrend[] = [
'date' => $startDate->copy()->addDays($i)->format('m/d'),
'amount' => $salesData[$date] ?? 0,
];
}
return $salesTrend;
}
public function getTopSellingProducts(int $limit = 5): \Illuminate\Support\Collection
{
return SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', DB::raw('SUM(amount) as total_amount'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_amount')
->limit($limit)
->get();
}
public function getTopSellingByQuantity(int $limit = 5): \Illuminate\Support\Collection
{
return SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_quantity')
->limit($limit)
->get();
}
}