refactor: 重構模組通訊與調整儀表板功能
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
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:
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
name: 操作紀錄實作規範
|
||||
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
name: 跨模組調用與通訊規範 (Cross-Module Communication)
|
||||
description: 規範 Laravel Modular Monolith 架構下,不同業務模組中如何彼此調用資料與邏輯,包含禁止項目、Interface 實作、與 Service 綁定規則。
|
||||
@@ -9,7 +13,7 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中
|
||||
|
||||
## 🚫 絕對禁止的行為 (Strict Prohibitions)
|
||||
|
||||
* **禁止跨模組 Eloquent 關聯**
|
||||
* **禁止跨模組 Eloquent 關聯(例外除外)**
|
||||
* **錯誤**:在 `app/Modules/Sales/Models/Order.php` 中撰寫 `public function product() { return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); }`。
|
||||
* **原因**:這會造成資料庫查詢的強耦合。如果 `Inventory` 模組修改了 Schema,`Sales` 模組會無預警崩壞。
|
||||
* **禁止跨模組直接引入 (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)** 進行。
|
||||
@@ -2,10 +2,6 @@
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 開發框架規範說明書:ERP 系統 (star-erp)
|
||||
|
||||
## 1. 專案概述
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
---
|
||||
name: 權限管理與實作規範
|
||||
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||
512
.agents/rules/ui-consistency.md
Normal file
512
.agents/rules/ui-consistency.md
Normal 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
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Sales\Contracts\SalesServiceInterface;
|
||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -13,13 +15,19 @@ class DashboardController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
protected $salesService;
|
||||
protected $productionService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
ProcurementServiceInterface $procurementService,
|
||||
SalesServiceInterface $salesService,
|
||||
ProductionServiceInterface $productionService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
$this->salesService = $salesService;
|
||||
$this->productionService = $productionService;
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -35,99 +43,70 @@ class DashboardController extends Controller
|
||||
$procStats = $this->procurementService->getDashboardStats();
|
||||
|
||||
// 銷售統計 (本月營收)
|
||||
$thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month)
|
||||
->whereYear('transaction_at', now()->year)
|
||||
->sum('amount');
|
||||
$thisMonthRevenue = $this->salesService->getThisMonthRevenue();
|
||||
|
||||
// 生產統計 (待核准工單)
|
||||
$pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count();
|
||||
$pendingProductionCount = $this->productionService->getPendingProductionCount();
|
||||
|
||||
// 生產狀態分佈
|
||||
// 近30日銷售趨勢 (Area Chart)
|
||||
$startDate = now()->subDays(29)->startOfDay();
|
||||
$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,
|
||||
];
|
||||
}
|
||||
$salesTrend = $this->salesService->getSalesTrend();
|
||||
|
||||
// 本月熱銷商品 Top 5 (Bar Chart)
|
||||
$topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product')
|
||||
->whereMonth('transaction_at', now()->month)
|
||||
->whereYear('transaction_at', now()->year)
|
||||
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount'))
|
||||
->groupBy('product_code', 'product_id')
|
||||
->orderByDesc('total_amount')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : $item->product_code,
|
||||
'amount' => (int)$item->total_amount,
|
||||
];
|
||||
});
|
||||
$topSellingItems = $this->salesService->getTopSellingProducts();
|
||||
$productIds = $topSellingItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$productsMap = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
$topSellingProducts = $topSellingItems->map(function ($item) use ($productsMap) {
|
||||
$product = $productsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : $item->product_code,
|
||||
'amount' => (int)$item->total_amount,
|
||||
];
|
||||
});
|
||||
|
||||
// 庫存積壓排行 (Top Inventory Value)
|
||||
$topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product')
|
||||
->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(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : 'Unknown Product',
|
||||
'code' => $item->product ? $item->product->code : '',
|
||||
'value' => (int)$item->total_value,
|
||||
];
|
||||
});
|
||||
$topInventoryValueItems = $this->inventoryService->getTopInventoryValue();
|
||||
$invProductIds = $topInventoryValueItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$invProductsMap = $this->inventoryService->getProductsByIds($invProductIds)->keyBy('id');
|
||||
|
||||
$topInventoryValue = $topInventoryValueItems->map(function ($item) use ($invProductsMap) {
|
||||
$product = $invProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : 'Unknown Product',
|
||||
'code' => $product ? $product->code : '',
|
||||
'value' => (int)$item->total_value,
|
||||
];
|
||||
});
|
||||
|
||||
// 熱銷數量排行 (Top Selling by Quantity)
|
||||
$topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product')
|
||||
->whereMonth('transaction_at', now()->month)
|
||||
->whereYear('transaction_at', now()->year)
|
||||
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity'))
|
||||
->groupBy('product_code', 'product_id')
|
||||
->orderByDesc('total_quantity')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : $item->product_code,
|
||||
'code' => $item->product_code,
|
||||
'value' => (int)$item->total_quantity,
|
||||
];
|
||||
});
|
||||
$topSellingQtyItems = $this->salesService->getTopSellingByQuantity();
|
||||
$qtyProductIds = $topSellingQtyItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$qtyProductsMap = $this->inventoryService->getProductsByIds($qtyProductIds)->keyBy('id');
|
||||
|
||||
$topSellingByQuantity = $topSellingQtyItems->map(function ($item) use ($qtyProductsMap) {
|
||||
$product = $qtyProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : $item->product_code,
|
||||
'code' => $item->product_code,
|
||||
'value' => (int)$item->total_quantity,
|
||||
];
|
||||
});
|
||||
|
||||
// 即將過期商品 (Expiring Soon)
|
||||
$expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product')
|
||||
->where('quantity', '>', 0)
|
||||
->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
|
||||
->orderBy('expiry_date', 'asc')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'name' => $item->product ? $item->product->name : 'Unknown Product',
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $item->expiry_date->format('Y-m-d'),
|
||||
'quantity' => (int)$item->quantity,
|
||||
];
|
||||
});
|
||||
$expiringItems = $this->inventoryService->getExpiringSoon();
|
||||
$expiringProductIds = $expiringItems->pluck('product_id')->filter()->unique()->toArray();
|
||||
$expiringProductsMap = $this->inventoryService->getProductsByIds($expiringProductIds)->keyBy('id');
|
||||
|
||||
$expiringSoon = $expiringItems->map(function ($item) use ($expiringProductsMap) {
|
||||
$product = $expiringProductsMap->get($item->product_id);
|
||||
return [
|
||||
'name' => $product ? $product->name : 'Unknown Product',
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $item->expiry_date->format('Y-m-d'),
|
||||
'quantity' => (int)$item->quantity,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'stats' => [
|
||||
|
||||
@@ -7,23 +7,41 @@ use App\Modules\Finance\Models\AccountPayable;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
|
||||
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.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = AccountPayable::with(['vendor', 'creator']);
|
||||
$query = AccountPayable::with(['creator']);
|
||||
|
||||
// 關鍵字搜尋 (單號、供應商名稱)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('document_number', 'like', "%{$search}%")
|
||||
->orWhereHas('vendor', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
|
||||
// 透過 ProcurementService 查詢符合關鍵字的 Vendor IDs
|
||||
$matchedVendors = $this->procurementService->searchVendors($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);
|
||||
$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', [
|
||||
'payables' => $payables,
|
||||
@@ -62,14 +89,19 @@ class AccountPayableController extends Controller
|
||||
*/
|
||||
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)
|
||||
$sourceDocumentCode = null;
|
||||
if ($accountPayable->source_document_type === 'goods_receipt') {
|
||||
$receipt = \App\Modules\Inventory\Models\GoodsReceipt::find($accountPayable->source_document_id);
|
||||
if ($receipt) {
|
||||
$sourceDocumentCode = $receipt->code;
|
||||
$receiptData = app(\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class)
|
||||
->getGoodsReceiptData($accountPayable->source_document_id);
|
||||
if ($receiptData) {
|
||||
$sourceDocumentCode = $receiptData['code'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,14 +41,7 @@ class AccountPayable extends Model
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 關聯:供應商
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function vendor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Procurement\Models\Vendor::class, 'vendor_id');
|
||||
}
|
||||
// vendor 關聯移至 service (跨模組)
|
||||
|
||||
/**
|
||||
* 關聯:建立者
|
||||
|
||||
@@ -40,6 +40,14 @@ interface InventoryServiceInterface
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -139,4 +147,14 @@ interface InventoryServiceInterface
|
||||
* @return object
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -7,11 +7,10 @@ use App\Modules\Inventory\Services\GoodsReceiptService;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
use App\Modules\Inventory\Services\DuplicateCheckService;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Inventory\Services\DuplicateCheckService;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
|
||||
@@ -16,6 +16,26 @@ class InventoryService implements InventoryServiceInterface
|
||||
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()
|
||||
{
|
||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
||||
@@ -41,6 +61,11 @@ class InventoryService implements InventoryServiceInterface
|
||||
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)
|
||||
{
|
||||
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Production\Contracts;
|
||||
|
||||
interface ProductionServiceInterface
|
||||
{
|
||||
public function getPendingProductionCount(): int;
|
||||
}
|
||||
@@ -27,13 +27,5 @@ class RecipeItem extends Model
|
||||
return $this->belongsTo(Recipe::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Inventory\Models\Product::class);
|
||||
}
|
||||
|
||||
public function unit()
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class);
|
||||
}
|
||||
// product 和 unit 關聯移至 service (跨模組)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ class ProductionServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->bind(
|
||||
\App\Modules\Production\Contracts\ProductionServiceInterface::class,
|
||||
\App\Modules\Production\Services\ProductionService::class
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
14
app/Modules/Production/Services/ProductionService.php
Normal file
14
app/Modules/Production/Services/ProductionService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
11
app/Modules/Sales/Contracts/SalesServiceInterface.php
Normal file
11
app/Modules/Sales/Contracts/SalesServiceInterface.php
Normal 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;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace App\Modules\Sales\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Sales\Models\SalesImportBatch;
|
||||
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 Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
@@ -66,17 +66,32 @@ class SalesImportController extends Controller
|
||||
$import->load(['items', 'importer']);
|
||||
|
||||
$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', [
|
||||
'import' => $import,
|
||||
'items' => $import->items()->with(['product', 'warehouse'])->paginate($perPage)->withQueryString(),
|
||||
'items' => $paginatedItems,
|
||||
'filters' => [
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function confirm(SalesImportBatch $import, InventoryService $inventoryService)
|
||||
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
if ($import->status !== 'pending') {
|
||||
return back()->with('error', '此批次無法確認。');
|
||||
@@ -87,8 +102,8 @@ class SalesImportController extends Controller
|
||||
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
|
||||
|
||||
// Pre-load necessary warehouses for matching
|
||||
$machineIds = $import->items->pluck('machine_id')->filter()->unique();
|
||||
$warehouses = \App\Modules\Inventory\Models\Warehouse::whereIn('code', $machineIds)->get()->keyBy('code');
|
||||
$machineIds = $import->items->pluck('machine_id')->filter()->unique()->toArray();
|
||||
$warehouses = $inventoryService->getWarehousesByCodes($machineIds)->keyBy('code');
|
||||
|
||||
foreach ($import->items as $item) {
|
||||
// Only process shipped items with a valid product
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Modules\Sales\Imports;
|
||||
|
||||
use App\Modules\Sales\Models\SalesImportBatch;
|
||||
use App\Modules\Sales\Models\SalesImportItem;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStartRow;
|
||||
@@ -19,7 +19,8 @@ class SalesImportSheet implements ToCollection, WithStartRow
|
||||
{
|
||||
$this->batch = $batch;
|
||||
// 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
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
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\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -38,14 +37,4 @@ class SalesImportItem extends Model
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
15
app/Modules/Sales/SalesServiceProvider.php
Normal file
15
app/Modules/Sales/SalesServiceProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
63
app/Modules/Sales/Services/SalesService.php
Normal file
63
app/Modules/Sales/Services/SalesService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user