Compare commits
145 Commits
6631f64e4b
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0e3b4f6f | |||
| 0e51992cb4 | |||
| ac6a81b3d2 | |||
| 106de4e945 | |||
| b0848a6bb8 | |||
| db0c1ce3af | |||
| 1d134c9ad8 | |||
| 1ae21febb5 | |||
| fc20c6d813 | |||
| af5f2f55ab | |||
| eab9e2ce93 | |||
| 8215b42e43 | |||
| db49f417df | |||
| 9e574fea85 | |||
| 7eed761861 | |||
| b3299618ce | |||
| 9a50bbf887 | |||
| 89183ca124 | |||
| 74728c47b9 | |||
| daae429cd4 | |||
| b2a63bd1ed | |||
| 7bf892db19 | |||
| a41d3d8f55 | |||
| 239e547a5d | |||
| c1d302f03e | |||
| 32c2612a5f | |||
| 8928a84ff9 | |||
| 23682b3ffe | |||
| 7367577f6a | |||
| 5c4693577a | |||
| 632dee13a5 | |||
| cdcc0f4ce3 | |||
| f6167fdaec | |||
| b29278aa12 | |||
| ed6fb37ec3 | |||
| 6bd52fe3db | |||
| f83baffddb | |||
| a8091276b8 | |||
| 18edb3cb69 | |||
| 74417e2e31 | |||
| 0d7bb2758d | |||
| 19c2eeba7b | |||
| 55272d5d43 | |||
| a2c99e3a36 | |||
| 43d7cada34 | |||
| 5b15ca2cd6 | |||
| aa4143ccf1 | |||
| 8a9b8135bd | |||
| 736a01f198 | |||
| 32f993a6e1 | |||
| 231d1ad029 | |||
| c7e1154af8 | |||
| d28671b60c | |||
| 4b2ccd36b8 | |||
| b685c818a4 | |||
| bf48fe0c35 | |||
| 2b752b51ff | |||
| 9bc7c8514b | |||
| 287ac6faa3 | |||
| 9ce8ff4e06 | |||
| a6b5496529 | |||
| 79e5916d19 | |||
| a6ed2720d5 | |||
| 190d6c2bd9 | |||
| a64a4682f3 | |||
| 4f745c1021 | |||
| 3e3d8ffb6c | |||
| 74a084d938 | |||
| f7238c2860 | |||
| 7dfe46ff9a | |||
| 8e364bc2f7 | |||
| 2e166d44d2 | |||
| 78a7ca4261 | |||
| e3afc0b64a | |||
| 4d6d37743e | |||
| a0a61ba683 | |||
| 2e7aeef367 | |||
| 566dfa31ae | |||
| f18fb169f3 | |||
| 6600cde3bc | |||
| f0e6c6e4d1 | |||
| ecfcbb93ed | |||
| 6770a4ec2f | |||
| b17e305374 | |||
| 7ffbc2b1ea | |||
| 4f85f80f8e | |||
| 4e24b70af3 | |||
| ff66b295e1 | |||
| d736bf9802 | |||
| 7c1ee40882 | |||
| 9793ab774b | |||
| 5da14da58e | |||
| 6e174a09a5 | |||
| 0f45a539de | |||
| 268fc10ded | |||
| 822a83f700 | |||
| c84d6f7600 | |||
| 1dd50473ce | |||
| 24ae6f3eee | |||
| d60367ac57 | |||
| 3088959c7c | |||
| cbd8d11848 | |||
| 0b60dab208 | |||
| 7848976a06 | |||
| 48115082e5 | |||
| eca2f38395 | |||
| 807790a7fc | |||
| f9f0d09195 | |||
| ef1fc47cff | |||
| 19c60a6126 | |||
| 8ea1ce1515 | |||
| d852d7b2ec | |||
| 720c9a176a | |||
| 1bd89dcf2e | |||
| 2c7d249014 | |||
| bd51911561 | |||
| bd29410191 | |||
| be315a76cc | |||
| fad74df6ac | |||
| 7160a7e780 | |||
| 3e28067c97 | |||
| fd3ddd0bac | |||
| 5797ff118d | |||
| 41d5e8e7fc | |||
| f4ca6b09e8 | |||
| 1c8c3009ec | |||
| 315cce467e | |||
| 001ba33335 | |||
| 6209b28345 | |||
| 1759fceaed | |||
| 54d36f51e7 | |||
| 981d887ae8 | |||
| 8e91f28ef4 | |||
| fbcdcd05b0 | |||
| 8d838ee6f6 | |||
| fd86ae0153 | |||
| cdf434d63c | |||
| ccdbe48b88 | |||
| 564c6588c1 | |||
| 21d0ea4cc2 | |||
| 6b0f3c9bcd | |||
| 0aaa761a47 | |||
| d683861233 | |||
| b240877d40 | |||
| e31715becb |
@@ -5,70 +5,64 @@ trigger: always_on
|
|||||||
---
|
---
|
||||||
trigger: always_on
|
trigger: always_on
|
||||||
---
|
---
|
||||||
預設專案運行於 WSL2 的 Laravel Sail (Docker) 環境。
|
|
||||||
開發框架規範說明書:ERP 系統 (koori-erp)
|
|
||||||
1. 專案概述
|
|
||||||
目標: 打造一個強大且穩定的 ERP 後台管理系統。
|
|
||||||
|
|
||||||
核心架構: 採用 單體式架構配現代化前端 (Monolith with a Modern Frontend)。使用 Laravel、Inertia.js 及 React。
|
# 開發框架規範說明書:ERP 系統 (star-erp)
|
||||||
|
|
||||||
工作流程: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。
|
## 1. 專案概述
|
||||||
|
* **目標**: 打造一個強大且穩定的 ERP 後台管理系統。
|
||||||
|
* **核心架構**: 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。
|
||||||
|
* **工作流程**: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。
|
||||||
|
|
||||||
2. 技術棧 (Tech Stack)
|
## 2. 技術棧 (Tech Stack)
|
||||||
後端: PHP 8.5 / Laravel 12
|
* **後端**: PHP 8.5 / Laravel 12
|
||||||
|
* **前端橋樑**: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
|
||||||
|
* **前端庫**: React (以 Functional Components 與 Hooks 為主)
|
||||||
|
* **樣式處理**: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
|
||||||
|
* **資料庫**: MySQL 8.0
|
||||||
|
* **開發環境**: Laravel Sail (Docker / WSL2)
|
||||||
|
* **未來擴充**: 針對高併發或跨平台模組,預留 Golang 微服務接口。
|
||||||
|
|
||||||
前端橋樑: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
|
## 3. 目錄結構與慣例
|
||||||
|
|
||||||
前端庫: React (以 Functional Components 與 Hooks 為主)
|
### 3.1 後端 (Laravel - Modular Monolith)
|
||||||
|
系統採用模組化架構,核心邏輯位於 `app/Modules/` 下:
|
||||||
|
|
||||||
樣式處理: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
|
* **Modules**: 位於 `app/Modules/{ModuleName}/`。
|
||||||
|
* **Controllers**: `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`。
|
||||||
|
* **Models**: `app/Modules/{ModuleName}/Models/`。
|
||||||
|
* **Routes**: `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。
|
||||||
|
* **Global Routes**: `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。
|
||||||
|
|
||||||
資料庫: MySQL 8.0
|
### 3.2 前端 (React)
|
||||||
|
* **Pages (頁面)**: 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。
|
||||||
|
* **Components (組件)**: 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
|
||||||
|
* **Layouts (版面)**: 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。
|
||||||
|
|
||||||
開發環境: Laravel Sail (Docker / WSL2)
|
## 4. 整合指南 (UI/UX 轉換至 Laravel)
|
||||||
|
* **組件遷移**: 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。
|
||||||
|
* **資料傳遞**: 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。
|
||||||
|
* **狀態管理**: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
|
||||||
|
|
||||||
未來擴充: 針對高併發或跨平台模組,預留 Golang 微服務接口。
|
## 5. 開發標準 (Coding Standards)
|
||||||
|
* **命名規範**:
|
||||||
|
* Controllers: `PascalCaseController.php`
|
||||||
|
* React Components: `PascalCase.jsx`
|
||||||
|
* Routes: `kebab-case` (小寫橫線分隔)
|
||||||
|
* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
||||||
|
|
||||||
3. 目錄結構與慣例
|
## 6. AI 協作規則 (給 Antigravity AI)
|
||||||
3.1 後端 (Laravel)
|
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||||
Controllers: 必須回傳 Inertia::render() 來渲染頁面。
|
* **代碼生成指令**:
|
||||||
|
* 所有的解釋說明請使用 **繁體中文**。
|
||||||
|
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||||
|
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||||
|
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||||
|
|
||||||
Models: 嚴格執行型別標註,使用 Eloquent 進行資料庫操作。
|
## 7. 運行機制 (Docker / Sail)
|
||||||
|
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||||
|
|
||||||
Routes: 統一在 routes/web.php 定義 Inertia 路由。
|
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||||
|
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
||||||
3.2 前端 (React)
|
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
||||||
Pages (頁面): 位於 resources/js/Pages/。每個檔案代表一個完整的路由視圖。
|
* **執行 Composer**: `./vendor/bin/sail composer install`
|
||||||
|
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||||
Components (組件): 位於 resources/js/Components/。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
|
|
||||||
|
|
||||||
Layouts (版面): 位於 resources/js/Layouts/。定義 ERP 的通用版面(例如:包含側邊欄 Sidebar 與導覽列 Navbar 的後台主框架)。
|
|
||||||
|
|
||||||
4. 整合指南 (UI/UX 轉換至 Laravel)
|
|
||||||
組件遷移: 將 UI/UX 的 React 原始碼移入 resources/js/ 時,應進行「原子化」拆解,提高元件複用率。
|
|
||||||
|
|
||||||
資料傳遞: 透過 Laravel Controller 的 props 傳送動態資料給 React。除非是後續的異步請求,否則避免在 React 初次渲染時使用 axios 抓取資料,應優先使用 Inertia 的資料流。
|
|
||||||
|
|
||||||
狀態管理: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
|
|
||||||
|
|
||||||
5. 開發標準 (Coding Standards)
|
|
||||||
命名規範:
|
|
||||||
|
|
||||||
Controllers: PascalCaseController.php
|
|
||||||
|
|
||||||
React Components: PascalCase.jsx
|
|
||||||
|
|
||||||
Routes: kebab-case (小寫橫線分隔)
|
|
||||||
|
|
||||||
回傳格式: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
|
|
||||||
|
|
||||||
6. AI 協作規則 (給 Antigravity AI)
|
|
||||||
角色設定: 你是一位專業的全端開發工程師助手。
|
|
||||||
|
|
||||||
代碼生成指令:
|
|
||||||
|
|
||||||
所有的解釋說明請使用 繁體中文。
|
|
||||||
|
|
||||||
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
|
||||||
|
|
||||||
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
|
||||||
158
.agent/skills/activity-logging/SKILL.md
Normal file
158
.agent/skills/activity-logging/SKILL.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
---
|
||||||
|
name: 操作紀錄實作規範
|
||||||
|
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 操作紀錄實作規範
|
||||||
|
|
||||||
|
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
|
||||||
|
|
||||||
|
## 1. 後端實作標準 (Backend)
|
||||||
|
|
||||||
|
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
|
||||||
|
|
||||||
|
### 1.1 啟用 Activity Log
|
||||||
|
|
||||||
|
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class Product extends Model
|
||||||
|
{
|
||||||
|
use LogsActivity;
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
|
||||||
|
->dontSubmitEmptyLogs(); // 若無變動則不記錄
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 手動記錄 (Manual Logging)
|
||||||
|
|
||||||
|
若需在 Controller 手動記錄(例如需客製化邏輯),**必須**自行實作變動過濾,不可直接儲存所有屬性。
|
||||||
|
|
||||||
|
**錯誤範例 (Do NOT do this):**
|
||||||
|
```php
|
||||||
|
// ❌ 錯誤:這會導致每次更新都記錄所有欄位,即使它們沒變
|
||||||
|
activity()
|
||||||
|
->withProperties(['attributes' => $newAttributes, 'old' => $oldAttributes])
|
||||||
|
->log('updated');
|
||||||
|
```
|
||||||
|
|
||||||
|
**正確範例 (Do this):**
|
||||||
|
```php
|
||||||
|
// ✅ 正確:自行比對差異,只存變動值
|
||||||
|
$changedAttributes = [];
|
||||||
|
$changedOldAttributes = [];
|
||||||
|
|
||||||
|
foreach ($newAttributes as $key => $value) {
|
||||||
|
if ($value != ($oldAttributes[$key] ?? null)) {
|
||||||
|
$changedAttributes[$key] = $value;
|
||||||
|
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($changedAttributes)) {
|
||||||
|
activity()
|
||||||
|
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
|
||||||
|
->log('updated');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 快照策略 (Snapshot Strategy)
|
||||||
|
|
||||||
|
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊(如名稱、代號、類別名稱)。
|
||||||
|
|
||||||
|
**主要方式:使用 `tapActivity` (推薦)**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$properties = $activity->properties;
|
||||||
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
|
||||||
|
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
|
||||||
|
$snapshot['category_name'] = $this->category ? $this->category->name : null;
|
||||||
|
$snapshot['po_number'] = $this->code; // 儲存單號
|
||||||
|
|
||||||
|
// 保存自身名稱 (Context)
|
||||||
|
$snapshot['name'] = $this->name;
|
||||||
|
|
||||||
|
$properties['snapshot'] = $snapshot;
|
||||||
|
$activity->properties = $properties;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 顯示名稱映射 (UI Mapping)
|
||||||
|
|
||||||
|
### 2.1 對象名稱映射 (Mapping)
|
||||||
|
|
||||||
|
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
|
||||||
|
|
||||||
|
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected function getSubjectMap()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'App\Modules\Inventory\Models\Product' => '商品',
|
||||||
|
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 欄位名稱中文化 (Field Translation)
|
||||||
|
|
||||||
|
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
|
||||||
|
|
||||||
|
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
// ... 既有欄位
|
||||||
|
'transaction_date': '費用日期',
|
||||||
|
'category': '費用類別',
|
||||||
|
'amount': '金額',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 前端顯示邏輯 (Frontend)
|
||||||
|
|
||||||
|
### 3.1 列表描述生成 (Description Generation)
|
||||||
|
|
||||||
|
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述(例如:「Admin 新增 電話費 公共事業費」)。
|
||||||
|
|
||||||
|
若您的 Model 使用了特殊的識別欄位(例如 `category`),**必須**將其加入 `nameParams` 陣列中。
|
||||||
|
|
||||||
|
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const nameParams = [
|
||||||
|
'po_number', 'name', 'code',
|
||||||
|
'category_name',
|
||||||
|
'category' // ✅ 確保加入此欄位,前端才能抓到 $snapshot['category']
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 詳情過濾邏輯
|
||||||
|
|
||||||
|
前端 `ActivityDetailDialog` 已內建智慧過濾邏輯:
|
||||||
|
- **Created**: 顯示初始化欄位。
|
||||||
|
- **Updated**: **僅顯示有變動的欄位** (由 `isChanged` 判斷)。
|
||||||
|
- **Deleted**: 顯示刪除前的完整資料。
|
||||||
|
|
||||||
|
開發者僅需確保傳入的 `attributes` 與 `old` 資料結構正確,過濾邏輯會自動運作。
|
||||||
|
|
||||||
|
## 檢核清單
|
||||||
|
|
||||||
|
- [ ] **Backend**: Model 是否已設定 `logOnlyDirty` 或手動實作過濾?
|
||||||
|
- [ ] **Backend**: 是否已透過 `tapActivity` 或手動方式記錄 Snapshot(關鍵名稱)?
|
||||||
|
- [ ] **Backend**: 是否已在 `ActivityLogController` 加入 Model 中文名稱映射?
|
||||||
|
- [ ] **Frontend**: 是否已在 `ActivityDetailDialog` 加入欄位中文翻譯?
|
||||||
|
- [ ] **Frontend**: 若使用特殊識別欄位,是否已加入 `LogTable` 的 `nameParams`?
|
||||||
140
.agent/skills/permission-management/SKILL.md
Normal file
140
.agent/skills/permission-management/SKILL.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
name: 權限管理與實作規範
|
||||||
|
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 權限管理與實作規範
|
||||||
|
|
||||||
|
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
|
||||||
|
|
||||||
|
## 1. 定義權限 (Backend)
|
||||||
|
|
||||||
|
所有權限皆定義於 `database/seeders/PermissionSeeder.php`。
|
||||||
|
|
||||||
|
### 步驟:
|
||||||
|
|
||||||
|
1. 開啟 `database/seeders/PermissionSeeder.php`。
|
||||||
|
2. 在 `$permissions` 陣列中新增功能對應的權限字串。
|
||||||
|
* **命名慣例**:`{resource}.{action}` (例如:`system.view_logs`, `products.create`)
|
||||||
|
* 常用動作:`view`, `create`, `edit`, `delete`, `publish`, `export`
|
||||||
|
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
|
||||||
|
* `super-admin`:通常擁有所有權限(程式碼中 `Permission::all()` 自動涵蓋,無需手動新增)。
|
||||||
|
* `admin`:通常擁有大部分權限。
|
||||||
|
* 其他角色 (`warehouse-manager`, `purchaser`, `viewer`):依業務邏輯分配。
|
||||||
|
|
||||||
|
### 範例:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. 新增權限字串
|
||||||
|
$permissions = [
|
||||||
|
// ... 現有權限
|
||||||
|
'system.view_logs', // 新增:檢視系統日誌
|
||||||
|
];
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// 2. 分配給角色
|
||||||
|
$admin->givePermissionTo([
|
||||||
|
// ... 現有權限
|
||||||
|
'system.view_logs',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 套用資料庫變更
|
||||||
|
|
||||||
|
修改 Seeder 後,必須重新執行 Seeder 以將權限寫入資料庫。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 對於所有租戶執行 Seeder (開發環境)
|
||||||
|
php artisan tenants:seed --class=PermissionSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 路由保護 (Backend Middleware)
|
||||||
|
|
||||||
|
在 `routes/web.php` 中,使用 `permission:{name}` middleware 保護路由。
|
||||||
|
|
||||||
|
### 範例:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 單一權限保護
|
||||||
|
Route::get('/logs', [LogController::class, 'index'])
|
||||||
|
->middleware('permission:system.view_logs')
|
||||||
|
->name('logs.index');
|
||||||
|
|
||||||
|
// 路由群組保護
|
||||||
|
Route::middleware('permission:products.view')->group(function () {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// 多重權限 (OR 邏輯:有其一即可)
|
||||||
|
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 前端權限判斷 (React Component)
|
||||||
|
|
||||||
|
使用自訂 Hook `usePermission` 來控制 UI 元素的顯示(例如:隱藏沒有權限的按鈕)。
|
||||||
|
|
||||||
|
### 引入 Hook:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePermission } from "@/hooks/usePermission";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用方式:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function ProductIndex() {
|
||||||
|
const { can } = usePermission();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>商品列表</h1>
|
||||||
|
|
||||||
|
{/* 只有擁有 create 權限才顯示按鈕 */}
|
||||||
|
{can('products.create') && (
|
||||||
|
<Button>新增商品</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 組合判斷 */}
|
||||||
|
{can('products.edit') && <EditButton />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 權限 Hook 介面說明:
|
||||||
|
|
||||||
|
- `can(permission: string)`: 檢查當前使用者是否擁有指定權限。
|
||||||
|
- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
|
||||||
|
- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
|
||||||
|
|
||||||
|
## 5. 配置權限群組名稱 (Backend UI Config)
|
||||||
|
|
||||||
|
為了讓新權限在「角色與權限」管理介面中顯示正確的中文分組標題,需修改 Controller 設定。
|
||||||
|
|
||||||
|
### 步驟:
|
||||||
|
|
||||||
|
1. 開啟 `app/Http/Controllers/Admin/RoleController.php`。
|
||||||
|
2. 找到 `getGroupedPermissions` 方法。
|
||||||
|
3. 在 `$groupDefinitions` 陣列中,新增 `{resource}` 對應的中文名稱。
|
||||||
|
|
||||||
|
### 範例:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$groupDefinitions = [
|
||||||
|
'products' => '商品資料管理',
|
||||||
|
// ...
|
||||||
|
'utility_fees' => '公共事業費管理', // 新增此行
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 檢核清單
|
||||||
|
|
||||||
|
- [ ] `PermissionSeeder.php` 已新增權限字串。
|
||||||
|
- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
|
||||||
|
- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
|
||||||
|
- [ ] `RoleController.php` 已新增權限群組的中文名稱映射。
|
||||||
|
- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
|
||||||
|
- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。
|
||||||
949
.agent/skills/ui-consistency/SKILL.md
Normal file
949
.agent/skills/ui-consistency/SKILL.md
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
---
|
||||||
|
name: 客戶端後台 UI 統一規範
|
||||||
|
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
|
||||||
|
---
|
||||||
|
|
||||||
|
# 客戶端後台 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.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>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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().current()!),
|
||||||
|
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 分頁規範
|
||||||
|
|
||||||
|
### 6.1 統一分頁元件
|
||||||
|
|
||||||
|
使用 `@/Components/shared/Pagination` 元件:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Pagination from "@/Components/shared/Pagination";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
|
||||||
|
// 在表格下方
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>每頁顯示</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={perPage}
|
||||||
|
onValueChange={handlePerPageChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: "10" },
|
||||||
|
{ label: "20", value: "20" },
|
||||||
|
{ label: "50", value: "50" },
|
||||||
|
{ label: "100", value: "100" }
|
||||||
|
]}
|
||||||
|
className="w-[80px] h-8"
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<span>筆</span>
|
||||||
|
</div>
|
||||||
|
<Pagination links={data.links} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 每頁筆數狀態管理
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||||
|
|
||||||
|
const handlePerPageChange = (value: string) => {
|
||||||
|
setPerPage(value);
|
||||||
|
router.get(
|
||||||
|
route('resource.index'),
|
||||||
|
{ per_page: value },
|
||||||
|
{ preserveState: false, replace: true, preserveScroll: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Badge 與狀態顯示
|
||||||
|
|
||||||
|
### 7.1 基本 Badge
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
|
||||||
|
// Outline 樣式(最常用)
|
||||||
|
<Badge variant="outline">{item.category?.name || '-'}</Badge>
|
||||||
|
|
||||||
|
// 預設樣式(主題色背景)
|
||||||
|
<Badge variant="default">啟用中</Badge>
|
||||||
|
|
||||||
|
// 錯誤樣式
|
||||||
|
<Badge variant="destructive">停用</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 角色顯示(特殊樣式)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{user.roles.map(role => (
|
||||||
|
<div
|
||||||
|
key={role.id}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-2.5 py-1 rounded-md border",
|
||||||
|
role.name === 'super-admin'
|
||||||
|
? "bg-purple-50 border-purple-200"
|
||||||
|
: "bg-gray-50 border-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{role.name === 'super-admin' && <Shield className="h-3.5 w-3.5 text-purple-600" />}
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
role.name === 'super-admin' ? "text-purple-700" : "text-gray-900"
|
||||||
|
)}>
|
||||||
|
{role.display_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 頁面佈局規範
|
||||||
|
|
||||||
|
### 8.1 頁面結構
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function ResourceIndex() {
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '分類名稱', href: '#' },
|
||||||
|
{ label: '頁面名稱', href: route('resource.index'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="頁面標題" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
{/* 頁面頭部 */}
|
||||||
|
{/* 主要內容 */}
|
||||||
|
{/* 分頁元件 */}
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 標準頁面頭部
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<IconComponent className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
頁面標題
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
頁面說明文字
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Can permission="resource.create">
|
||||||
|
<Link href={route('resource.create')}>
|
||||||
|
<Button className="button-filled-primary">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新增項目
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 權限控制規範
|
||||||
|
|
||||||
|
### 9.1 使用 Can 元件
|
||||||
|
|
||||||
|
**所有**涉及權限的 UI 元素都必須使用 `<Can>` 元件包裹:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Can } from "@/Components/Permission/Can";
|
||||||
|
|
||||||
|
<Can permission="resource.create">
|
||||||
|
{/* 新增按鈕 */}
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
<Can permission="resource.edit">
|
||||||
|
{/* 編輯按鈕 */}
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
<Can permission="resource.delete">
|
||||||
|
{/* 刪除按鈕 */}
|
||||||
|
</Can>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 權限命名規範
|
||||||
|
|
||||||
|
遵循 `resource.action` 格式:
|
||||||
|
|
||||||
|
- `resource.view`:查看列表/詳情
|
||||||
|
- `resource.create`:新增
|
||||||
|
- `resource.edit`:編輯
|
||||||
|
- `resource.delete`:刪除
|
||||||
|
|
||||||
|
### 9.3 多權限判斷
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 滿足任一權限即可
|
||||||
|
<Can permission={['products.edit', 'products.delete']}>
|
||||||
|
<div>管理操作</div>
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
// 必須滿足所有權限
|
||||||
|
import { CanAll } from "@/Components/Permission/Can";
|
||||||
|
|
||||||
|
<CanAll permissions={['products.edit', 'products.delete']}>
|
||||||
|
<button>完整管理</button>
|
||||||
|
</CanAll>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 通知訊息規範
|
||||||
|
|
||||||
|
### 10.1 使用 Toast 通知
|
||||||
|
|
||||||
|
使用 `sonner` 的 `toast` 進行通知:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// 成功訊息
|
||||||
|
toast.success('操作成功');
|
||||||
|
|
||||||
|
// 錯誤訊息
|
||||||
|
toast.error('操作失敗');
|
||||||
|
|
||||||
|
// 資訊訊息
|
||||||
|
toast.info('提示訊息');
|
||||||
|
|
||||||
|
// 警告訊息
|
||||||
|
toast.warning('警告訊息');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 常見操作的 Toast 訊息
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 新增成功
|
||||||
|
router.post(route('resource.store'), data, {
|
||||||
|
onSuccess: () => toast.success('新增成功'),
|
||||||
|
onError: () => toast.error('新增失敗,請檢查輸入內容'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新成功
|
||||||
|
router.put(route('resource.update', id), data, {
|
||||||
|
onSuccess: () => toast.success('更新成功'),
|
||||||
|
onError: () => toast.error('更新失敗'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刪除成功
|
||||||
|
router.delete(route('resource.destroy', id), {
|
||||||
|
onSuccess: () => toast.success('已刪除'),
|
||||||
|
onError: () => toast.error('刪除失敗,請檢查權限'),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 表單規範
|
||||||
|
|
||||||
|
### 11.1 表單容器
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* 表單欄位 */}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 表單欄位
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
欄位名稱 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.field}
|
||||||
|
onChange={(e) => setData("field", e.target.value)}
|
||||||
|
placeholder="請輸入..."
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
{errors.field && <p className="mt-1 text-sm text-red-500">{errors.field}</p>}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 下拉選單
|
||||||
|
|
||||||
|
使用 `SearchableSelect` 元件:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
|
||||||
|
<SearchableSelect
|
||||||
|
value={data.category_id}
|
||||||
|
onValueChange={(value) => setData("category_id", value)}
|
||||||
|
options={categories.map(cat => ({ label: cat.name, value: String(cat.id) }))}
|
||||||
|
placeholder="請選擇分類"
|
||||||
|
searchThreshold={10} // 超過 10 個選項才顯示搜尋框
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.4 對話框 (Dialog) 滾動與佈局
|
||||||
|
|
||||||
|
當對話框內容可能超出螢幕高度時(如長表單或詳細資料),**請勿使用 `ScrollArea`**,應直接在 `DialogContent` 使用原生的 `overflow-y-auto`。
|
||||||
|
|
||||||
|
**原因**:`ScrollArea` 在 Flex 佈局計算高度時容易失效或導致雙重滾動條。以及與原生捲動行為不一致。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 錯誤:使用 ScrollArea 或固定高度計算
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<ScrollArea className="h-[500px]">
|
||||||
|
{/* 內容 */}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
// ✅ 正確:直接使用 overflow-y-auto 與 max-h
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>...</DialogHeader>
|
||||||
|
<form className="p-6">
|
||||||
|
{/* 內容會自動滾動 */}
|
||||||
|
</form>
|
||||||
|
<DialogFooter>...</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.5 輸入框尺寸 (Input Sizes)
|
||||||
|
|
||||||
|
為確保介面整齊與統一,所有表單輸入元件標準高度應為 **`h-9`** (36px),與標準按鈕尺寸對齊。
|
||||||
|
|
||||||
|
- **Input**: 預設即為 `h-9` (由 `py-1` 與 `text-sm` 組合而成)
|
||||||
|
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
||||||
|
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
||||||
|
|
||||||
|
## 11.6 日期輸入框樣式 (Date Input Style)
|
||||||
|
|
||||||
|
日期輸入框應採用「**左側裝飾圖示 + 右側原生操作**」的配置,以保持視覺一致性並保留瀏覽器原生便利性。
|
||||||
|
|
||||||
|
**樣式規格**:
|
||||||
|
1. **容器**: 使用 `relative` 定位。
|
||||||
|
2. **圖標**: 使用 `Calendar` 圖標,放置於絕對位置 `absolute left-2.5 top-2.5`,顏色 `text-gray-400`,並設定 `pointer-events-none` 避免干擾點擊。
|
||||||
|
3. **輸入框**: 設定 `pl-9` (左內距) 以避開圖示,並使用原生 `type="date"` 或 `type="datetime-local"`。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="pl-9 block w-full"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11.7 搜尋選單樣式 (SearchableSelect Style)
|
||||||
|
|
||||||
|
`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SearchableSelect
|
||||||
|
className="h-9" // 確保高度一致
|
||||||
|
// ...other props
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11.8 篩選列規範 (Filter Bar Norms)
|
||||||
|
|
||||||
|
列表頁面的篩選區域(Filter Bar)應遵循以下規範以節省空間並保持層級清晰:
|
||||||
|
|
||||||
|
1. **標籤文字 (Labels)**: 使用 **`text-xs`** (`12px`) 大小,顏色建議使用 `text-gray-500` 或 `text-grey-2`。這與一般表單 (`text-sm`) 不同,目的是降低篩選列的視覺權重。
|
||||||
|
2. **輸入元件高度**: 統一使用 **`h-9`** (`36px`)。
|
||||||
|
3. **佈局**:
|
||||||
|
- **容器內距**: 統一使用 **`p-5`** (`20px`)。
|
||||||
|
- **Grid 間距**: 建議使用 **`gap-4`** (`16px`) 或 `gap-6` (`24px`),但同一專案內需統一。本專案推薦 **`gap-4`**。
|
||||||
|
- **垂直間距**: Label 與 Input 之間使用 **`space-y-1`** (`4px`)。
|
||||||
|
- **排版**: 建議使用 Grid 系統 (`grid-cols-12`) 進行排版。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
|
||||||
|
<Input className="h-9" placeholder="..." />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **操作按鈕區 (Action Bar)**:
|
||||||
|
- **位置**: 位於篩選列最下方。
|
||||||
|
- **樣式**: 統一使用 `flex items-center justify-end border-t border-grey-4 pt-5 gap-3`。
|
||||||
|
- **說明**: `border-grey-4` 為標準通用邊框色,`pt-5` 與容器 padding (`p-5`) 呼應,維持視覺平衡。
|
||||||
|
|
||||||
|
5. **收合模式 (Collapsible Mode)**:
|
||||||
|
- **目的**: 節省垂直空間,預設隱藏較佔空間與低頻使用的篩選器(如日期區間)。
|
||||||
|
- **實作**:
|
||||||
|
- 預設狀態:若無相關篩選值,則預設為 **收合 (Collapsed)**。
|
||||||
|
- 切換按鈕:位於 Action Bar 左側 (`mr-auto`)。
|
||||||
|
- 樣式:Ghost Button + `ChevronDown`/`ChevronUp` Icon + 提示圓點 (Indicator)。
|
||||||
|
- **邏輯**: 若載入頁面時已有被隱藏的篩選值 (e.g. `date_start`),則強制 **展開 (Expanded)** 或顯示提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 檢查清單
|
||||||
|
|
||||||
|
在開發或審查頁面時,請確認以下項目:
|
||||||
|
|
||||||
|
### ✅ 按鈕
|
||||||
|
- [ ] 使用 `button-filled-*` 或 `button-outlined-*` 類別
|
||||||
|
- [ ] 主要操作使用 `button-filled-primary`
|
||||||
|
- [ ] 編輯操作使用 `button-outlined-primary`
|
||||||
|
- [ ] 刪除操作使用 `button-outlined-error`
|
||||||
|
- [ ] 按鈕尺寸正確(sm/default/lg)
|
||||||
|
- [ ] 包含適當的圖標
|
||||||
|
|
||||||
|
### ✅ 圖標
|
||||||
|
- [ ] 全部使用 `lucide-react`
|
||||||
|
- [ ] 尺寸正確(h-3/h-4/h-5/h-6)
|
||||||
|
- [ ] 顏色與上下文一致
|
||||||
|
|
||||||
|
### ✅ 表格
|
||||||
|
- [ ] 使用 `@/Components/ui/table` 元件
|
||||||
|
- [ ] 有 `bg-white rounded-xl border` 容器
|
||||||
|
- [ ] 標題列有 `bg-gray-50` 背景
|
||||||
|
- [ ] 序號欄固定寬度並置中
|
||||||
|
- [ ] 操作欄使用 `flex justify-center gap-2`
|
||||||
|
- [ ] 空狀態訊息置中顯示
|
||||||
|
|
||||||
|
### ✅ 分頁
|
||||||
|
- [ ] 使用 `@/Components/shared/Pagination`
|
||||||
|
- [ ] 有每頁筆數選擇器(10/20/50/100)
|
||||||
|
|
||||||
|
### ✅ 權限
|
||||||
|
- [ ] 所有操作按鈕都用 `<Can>` 包裹
|
||||||
|
- [ ] 權限命名符合 `resource.action` 格式
|
||||||
|
|
||||||
|
### ✅ 通知
|
||||||
|
- [ ] 使用 `toast` 提供操作反饋
|
||||||
|
- [ ] 成功/錯誤訊息明確
|
||||||
|
|
||||||
|
### ✅ 整體
|
||||||
|
- [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕)
|
||||||
|
- [ ] 容器寬度使用 `max-w-7xl`
|
||||||
|
- [ ] 使用正確的佈局(`AuthenticatedLayout`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 常見錯誤與修正
|
||||||
|
|
||||||
|
### ❌ 錯誤:自定義按鈕樣式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 錯誤
|
||||||
|
<Button className="bg-green-500 text-white hover:bg-green-600">
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// ✅ 正確
|
||||||
|
<Button className="button-filled-primary">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 錯誤:混用圖標庫
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 錯誤
|
||||||
|
import { FaEdit } from 'react-icons/fa';
|
||||||
|
<FaEdit />
|
||||||
|
|
||||||
|
// ✅ 正確
|
||||||
|
import { Pencil } from 'lucide-react';
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 錯誤:操作欄未置中
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 錯誤
|
||||||
|
<TableCell>
|
||||||
|
<Button>編輯</Button>
|
||||||
|
<Button>刪除</Button>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
// ✅ 正確
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 錯誤:缺少權限控制
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 錯誤
|
||||||
|
<Button onClick={handleDelete}>刪除</Button>
|
||||||
|
|
||||||
|
// ✅ 正確
|
||||||
|
<Can permission="resource.delete">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-error"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 參考範例
|
||||||
|
|
||||||
|
以下頁面展示了完整的 UI 統一性實踐:
|
||||||
|
|
||||||
|
- **使用者管理**:`resources/js/Pages/Admin/User/Index.tsx`
|
||||||
|
- **角色管理**:`resources/js/Pages/Admin/Role/Index.tsx`
|
||||||
|
- **產品管理**:`resources/js/Pages/Product/Index.tsx`
|
||||||
|
- **倉庫管理**:`resources/js/Pages/Warehouse/Index.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 總結
|
||||||
|
|
||||||
|
遵循本規範可確保:
|
||||||
|
|
||||||
|
1. ✅ **視覺一致性**:所有頁面看起來像同一個系統
|
||||||
|
2. ✅ **維護效率**:使用統一組件,修改一處即可影響全局
|
||||||
|
3. ✅ **開發速度**:有明確的模式可循,減少決策時間
|
||||||
|
4. ✅ **使用者體驗**:一致的互動模式降低學習成本
|
||||||
|
5. ✅ **安全性**:統一的權限控制確保資料安全
|
||||||
|
|
||||||
|
當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範!
|
||||||
10
.env.example
10
.env.example
@@ -1,10 +1,14 @@
|
|||||||
APP_NAME=KooriERP
|
APP_NAME=StarERP
|
||||||
COMPOSE_PROJECT_NAME=koori-erp
|
COMPOSE_PROJECT_NAME=star-erp
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
|
||||||
|
CENTRAL_DOMAINS=localhost,127.0.0.1
|
||||||
|
TENANT_DEFAULT_DOMAIN=star-erp.test
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
@@ -24,7 +28,7 @@ LOG_LEVEL=debug
|
|||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mysql
|
DB_HOST=mysql
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=koori_erp
|
DB_DATABASE=star_erp
|
||||||
DB_USERNAME=sail
|
DB_USERNAME=sail
|
||||||
DB_PASSWORD=password
|
DB_PASSWORD=password
|
||||||
FORWARD_DB_PORT=3307
|
FORWARD_DB_PORT=3307
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# name: Koori-ERP-Demo-Deploy
|
|
||||||
# on:
|
|
||||||
# push:
|
|
||||||
# branches:
|
|
||||||
# - demo
|
|
||||||
|
|
||||||
# jobs:
|
|
||||||
# sync-update:
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# - name: 1. Checkout New Code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
# with:
|
|
||||||
# github-server-url: http://192.168.0.103:3000
|
|
||||||
# repository: ${{ gitea.repository }}
|
|
||||||
# # - name: 1. Checkout New Code
|
|
||||||
# # run: |
|
|
||||||
# # # 進入工作目錄並直接用 git 抓 code,完全不需要 Node
|
|
||||||
# # rm -rf ./*
|
|
||||||
# # git clone -b main http://server:3000/${{ gitea.repository }}.git .
|
|
||||||
|
|
||||||
# - name: 2. Sync Files to Running Container
|
|
||||||
# run: |
|
|
||||||
# # A. 執行複製
|
|
||||||
# cp .env.example .env
|
|
||||||
# sed -i "s|APP_KEY=.*|APP_KEY=${{ secrets.APP_KEY }}|g" .env
|
|
||||||
# # B. 確保容器環境是最新的
|
|
||||||
# # --wait 會確保容器真的跑起來了才執行下一步
|
|
||||||
# docker compose up -d --build --force-recreate --wait
|
|
||||||
|
|
||||||
# # C. 執行精簡化複製 (關鍵優化!)
|
|
||||||
# # 排除 .git, node_modules, vendor 這三大黑洞
|
|
||||||
# tar --exclude='.git' \
|
|
||||||
# --exclude='node_modules' \
|
|
||||||
# --exclude='vendor' \
|
|
||||||
# -cf - . | docker exec -i koori-erp-laravel tar -xf - -C /var/www/html
|
|
||||||
|
|
||||||
# docker exec koori-erp-laravel chown -R 1000:1000 /var/www/html
|
|
||||||
|
|
||||||
|
|
||||||
# - name: 3. Backend & Frontend Build
|
|
||||||
# run: |
|
|
||||||
# docker exec -u 1000:1000 -w /var/www/html koori-erp-laravel sh -c "
|
|
||||||
# composer install --optimize-autoloader &&
|
|
||||||
# npm install &&
|
|
||||||
# npm run build &&
|
|
||||||
# php artisan migrate --force &&
|
|
||||||
# php artisan optimize:clear
|
|
||||||
# "
|
|
||||||
|
|
||||||
# - name: 4. Final Permission Fix
|
|
||||||
# run: |
|
|
||||||
# # 統一修正權限
|
|
||||||
# docker exec koori-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
|
||||||
@@ -11,6 +11,107 @@ jobs:
|
|||||||
deploy-demo:
|
deploy-demo:
|
||||||
if: github.ref == 'refs/heads/demo'
|
if: github.ref == 'refs/heads/demo'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
github-server-url: http://192.168.0.103:3000
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Step 1 - Push Code to Demo
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y rsync openssh-client
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||||
|
chmod 600 ~/.ssh/id_rsa_demo
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='vendor' \
|
||||||
|
--exclude='storage' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='public/build' \
|
||||||
|
-e "ssh -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||||
|
./ amba@192.168.0.103:/home/amba/star-erp/
|
||||||
|
rm ~/.ssh/id_rsa_demo
|
||||||
|
|
||||||
|
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
|
||||||
|
- name: Step 2 - Check if Rebuild Needed
|
||||||
|
id: check_rebuild
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 192.168.0.103
|
||||||
|
port: 22
|
||||||
|
username: amba
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /home/amba/star-erp
|
||||||
|
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "REBUILD_NEEDED=true"
|
||||||
|
else
|
||||||
|
echo "REBUILD_NEEDED=false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 啟動或重建容器(根據檢查結果決定是否加 --build)
|
||||||
|
- name: Step 3 - Container Up & Health Check
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 192.168.0.103
|
||||||
|
port: 22
|
||||||
|
username: amba
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /home/amba/koori-erp
|
||||||
|
chown -R 1000:1000 .
|
||||||
|
|
||||||
|
# 檢查是否需要重建
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --build --wait
|
||||||
|
else
|
||||||
|
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||||
|
# 確保容器正在運行(若未運行則啟動)
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q 'koori-erp-laravel'; then
|
||||||
|
echo "容器未運行,正在啟動..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
|
||||||
|
else
|
||||||
|
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "容器狀態:" && docker ps --filter "name=koori-erp-laravel"
|
||||||
|
|
||||||
|
|
||||||
|
- name: Step 4 - Composer & NPM Build
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 192.168.0.103
|
||||||
|
port: 22
|
||||||
|
username: amba
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||||
|
# 1. 後端依賴 (Demo 環境建議加上 --no-interaction 避免卡住)
|
||||||
|
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||||
|
|
||||||
|
# 2. 前端編譯
|
||||||
|
npm install &&
|
||||||
|
npm run build &&
|
||||||
|
|
||||||
|
# 3. Laravel 初始化與優化
|
||||||
|
php artisan migrate --force &&
|
||||||
|
php artisan db:seed --force &&
|
||||||
|
php artisan optimize:clear &&
|
||||||
|
php artisan optimize &&
|
||||||
|
php artisan view:cache
|
||||||
|
"
|
||||||
|
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
|
# --- 2. 正式環境部署 (erp.koori.tw:2224) ---
|
||||||
|
deploy-production:
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -18,61 +119,82 @@ jobs:
|
|||||||
github-server-url: http://192.168.0.103:3000
|
github-server-url: http://192.168.0.103:3000
|
||||||
repository: ${{ github.repository }}
|
repository: ${{ github.repository }}
|
||||||
|
|
||||||
- name: Deploy to 103 Demo
|
- name: Step 1 - Push Code to Production
|
||||||
run: |
|
run: |
|
||||||
cp .env.example .env
|
apt-get update && apt-get install -y rsync openssh-client
|
||||||
# 設定 Demo 專用的 Key
|
mkdir -p ~/.ssh
|
||||||
sed -i "s|APP_KEY=.*|APP_KEY=${{ secrets.APP_KEY }}|g" .env
|
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_rsa_prod
|
||||||
docker compose up -d --build --wait
|
chmod 600 ~/.ssh/id_rsa_prod
|
||||||
# 同步檔案到容器內
|
rsync -avz --delete \
|
||||||
tar --exclude='.git' --exclude='node_modules' --exclude='vendor' -cf - . | docker exec -i koori-erp-laravel tar -xf - -C /var/www/html
|
--exclude='.git' \
|
||||||
docker exec koori-erp-laravel chown -R 1000:1000 /var/www/html
|
--exclude='.env' \
|
||||||
docker exec -u 1000:1000 -w /var/www/html koori-erp-laravel sh -c "composer install && npm install && npm run build && php artisan migrate --force && php artisan optimize:clear"
|
--exclude='node_modules' \
|
||||||
docker exec koori-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
--exclude='vendor' \
|
||||||
|
--exclude='public/build' \
|
||||||
|
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
||||||
|
./ root@erp.koori.tw:/var/www/star-erp/
|
||||||
|
rm ~/.ssh/id_rsa_prod
|
||||||
|
|
||||||
# --- 2. 正式環境部署 (erp.koori.tw:2224) ---
|
|
||||||
# deploy-production:
|
|
||||||
# if: github.ref == 'refs/heads/main'
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# - name: Checkout Code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
|
|
||||||
# # 使用 rsync 透過 2224 Port 推送代碼
|
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
|
||||||
# - name: Push Code to Production
|
- name: Step 2 - Check if Rebuild Needed
|
||||||
# run: |
|
id: check_rebuild_prod
|
||||||
# # 注意:這裡的 -e 指定了 ssh port 2224
|
uses: appleboy/ssh-action@master
|
||||||
# rsync -avz --delete \
|
with:
|
||||||
# --exclude='.git' \
|
host: erp.koori.tw
|
||||||
# --exclude='node_modules' \
|
port: 2224
|
||||||
# --exclude='vendor' \
|
username: root
|
||||||
# -e "ssh -p 2224 -o StrictHostKeyChecking=no" \
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
# ./ root@erp.koori.tw:/var/www/koori-erp-prod/
|
script: |
|
||||||
|
cd /var/www/star-erp
|
||||||
|
# [Patch] 修正正式機 Nginx Proxy 配置 (對應外部 SSL/OpenResty)
|
||||||
|
sed -i "s/- '8080:8080'/- '80:80'\n - '8080:8080'/" compose.yaml
|
||||||
|
sed -i "s/demo-proxy.conf/prod-proxy.conf/" compose.yaml
|
||||||
|
|
||||||
# # 遠端執行 Docker 指令
|
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
|
||||||
# - name: Remote Docker Commands
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||||
# uses: appleboy/ssh-action@master
|
echo "REBUILD_NEEDED=true"
|
||||||
# with:
|
else
|
||||||
# host: erp.koori.tw
|
echo "REBUILD_NEEDED=false"
|
||||||
# port: 2224 # <--- 這裡指定了 2224 Port
|
fi
|
||||||
# username: root
|
|
||||||
# key: ${{ secrets.PROD_SSH_KEY }}
|
# 3. 啟動或重建容器(根據檢查結果決定是否加 --build)
|
||||||
# script: |
|
- name: Step 3 - Container Up & Health Check
|
||||||
# cd /var/www/koori-erp-prod
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: erp.koori.tw
|
||||||
|
port: 2224
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /var/www/star-erp
|
||||||
|
chown -R 1000:1000 .
|
||||||
|
|
||||||
# # 1. 確保 .env 存在 (建議正式機手動維護 .env,不隨 git 連動)
|
# 檢查是否需要重建
|
||||||
# if [ ! -f .env ]; then cp .env.example .env; fi
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --build --wait
|
||||||
|
else
|
||||||
|
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||||
|
# 確保容器正在運行(若未運行則啟動)
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||||
|
echo "容器未運行,正在啟動..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
|
||||||
|
else
|
||||||
|
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# # 2. 啟動容器
|
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||||
# docker compose up -d --build
|
|
||||||
|
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||||
# # 3. 執行 Laravel 正式環境優化流程
|
composer install --no-dev --optimize-autoloader &&
|
||||||
# docker exec -u 1000:1000 koori-erp-laravel-prod sh -c "
|
npm install &&
|
||||||
# composer install --no-dev --optimize-autoloader &&
|
npm run build
|
||||||
# npm install &&
|
|
||||||
# npm run build &&
|
php artisan migrate --force &&
|
||||||
# php artisan migrate --force &&
|
php artisan optimize:clear &&
|
||||||
# php artisan config:cache &&
|
php artisan optimize &&
|
||||||
# php artisan route:cache &&
|
php artisan view:cache
|
||||||
# php artisan view:cache
|
"
|
||||||
# "
|
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
酒水客戶導入規劃.md
|
||||||
|
智慧補貨系統分析報告.md
|
||||||
|
|
||||||
|
/docs/pptx_build
|
||||||
|
|||||||
209
README.md
209
README.md
@@ -1,81 +1,182 @@
|
|||||||
# Koori ERP
|
# Star ERP (Koori ERP)
|
||||||
|
|
||||||
本專案是一個基於 Laravel 12, Inertia.js (React) 與 Tailwind CSS 開發的 ERP 系統。
|
Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind CSS** 構建的現代化多租戶 ERP 系統。
|
||||||
|
本專案專為高效能、SaaS 架構設計,並預設配置了完整的 Docker 開發環境。
|
||||||
|
|
||||||
## 開發環境需求
|
## 🌟 專案架構
|
||||||
|
|
||||||
- **WSL2** (Windows 建議環境)
|
- **核心框架**: Laravel 12 (PHP 8.5)
|
||||||
- **Docker Desktop** 或 **Docker Engine**
|
- **多租戶引擎**: stancl/tenancy (Single Database per Tenant)
|
||||||
- **PHP 8.5+** (本地端若需執行基礎 composer 指令,或直接使用 Sail 容器)
|
- **前端架構**: React 18, Inertia.js (單體式/Monolith)
|
||||||
- **Node.js 20+**
|
- **UI 框架**: Tailwind CSS
|
||||||
|
- **基礎設施**: Docker (Laravel Sail), Nginx Reverse Proxy, MySQL 8.0, Redis
|
||||||
|
|
||||||
## 啟動步驟
|
## 📂 系統功能詳細說明
|
||||||
|
|
||||||
本專案使用 [Laravel Sail](https://laravel.com/docs/12.x/sail) 作為 Docker 開發環境。
|
### 🌳 系統功能架構樹 (含 2.0 升級規劃)
|
||||||
|
```text
|
||||||
|
Star ERP
|
||||||
|
├── 🏠 儀表板 (Dashboard)
|
||||||
|
│ ├── 📊 數據看板 (原有)
|
||||||
|
│ ├── 🔔 營運警示 (原有)
|
||||||
|
│ ├── ✨ 銷售熱力圖 (新)
|
||||||
|
│ ├── ✨ 庫存效期預警 (新)
|
||||||
|
│ └── ✨ 待出貨監控 (新)
|
||||||
|
├── ✨ 🤝 銷售與全通路 (Sales & CRM) 【New】
|
||||||
|
│ ├── ✨ 全通路訂單整合
|
||||||
|
│ ├── ✨ 客戶管理 (CRM)
|
||||||
|
│ └── ✨ 促銷活動
|
||||||
|
├── 📦 商品與庫存管理
|
||||||
|
│ ├── 📄 商品資料 (原有)
|
||||||
|
│ ├── 🏢 倉庫管理 (原有)
|
||||||
|
│ ├── 🚚 內調撥 (原有)
|
||||||
|
│ ├── ✨ 屬性管理 (過敏原/成分)
|
||||||
|
│ ├── ✨ 效期監控 (FEFO)
|
||||||
|
│ └── ✨ 智慧補貨建議 (AI)
|
||||||
|
├── ✨ 🚚 智慧物流 (Logistics) 【New】
|
||||||
|
│ ├── ✨ 路徑規劃
|
||||||
|
│ └── ✨ 裝車單管理
|
||||||
|
├── 🏭 生產與品質管理
|
||||||
|
│ ├── 📝 生產工單 (原有)
|
||||||
|
│ ├── 🧪 原料耗用 (原有)
|
||||||
|
│ ├── ✨ 配方管理 (Recipe)
|
||||||
|
│ ├── ✨ 品質檢驗 (QC)
|
||||||
|
│ └── ✨ 雙向溯源 (原料<->成品)
|
||||||
|
├── 🛒 採購與廠商
|
||||||
|
│ ├── 👥 廠商資料 (原有)
|
||||||
|
│ ├── 📝 採購單 (原有)
|
||||||
|
│ └── ✨ 供應商評鑑 (新)
|
||||||
|
├── 💰 財務管理
|
||||||
|
│ ├── 🧾 公共事業費 (原有)
|
||||||
|
│ ├── ✨ 應收/應付帳款 (AR/AP)
|
||||||
|
│ └── ✨ 成本精算 (料工費)
|
||||||
|
├── 📊 報表管理
|
||||||
|
│ └── 📑 會計報表 (原有)
|
||||||
|
└── ⚙️ 系統管理 (原有)
|
||||||
|
├── 👤 使用者管理
|
||||||
|
├── 🛡️ 角色與權限
|
||||||
|
└── 📜 操作紀錄
|
||||||
|
```
|
||||||
|
|
||||||
### 1. 安裝依賴 (初次啟動)
|
---
|
||||||
|
|
||||||
建立目錄:mkdir 檔案名稱 && cd 檔案名稱
|
#### 1. 🏠 儀表板 (Dashboard)
|
||||||
|
- **數據看板**:顯示商品總數、供應商數、活躍倉庫數等。
|
||||||
|
- **營運警示**:低庫存商品與待辦事項警示。
|
||||||
|
- **✨ 強化功能**:銷售熱力圖、庫存效期預警、待出貨監控。
|
||||||
|
|
||||||
抓取代碼:git clone http://git網址/帳號/專案.git .
|
#### 2. ✨ 🤝 銷售與全通路 (Sales & CRM)
|
||||||
|
- **全通路訂單**:整合 POS、品牌電商、智慧販賣機訂單。
|
||||||
|
- **客戶管理 (CRM)**:會員資料庫、消費歷史與等級。
|
||||||
|
- **促銷活動**:滿額折、買一送一、組合價等折扣引擎。
|
||||||
|
|
||||||
如果您是第一次 clone 專案,請先安裝 PHP 與 JS 依賴:
|
#### 3. 📦 商品與庫存管理
|
||||||
|
- **商品資料**:品名、規格、多單位換算。
|
||||||
|
- **倉庫管理**:多站點庫存監控、銷售設定。
|
||||||
|
- **內調撥**:倉庫間庫存轉移。
|
||||||
|
- **✨ 強化功能**:過敏原/成分管理、**FEFO (先到期先出)** 效期監控、AI 智慧補貨建議。
|
||||||
|
|
||||||
|
#### 4. 🏭 生產與品質管理
|
||||||
|
- **生產工單**:排程管理、生產入庫。
|
||||||
|
- **✨ 強化功能**:配方管理 (Recipe V.C.)、QC 檢驗流程 (IQC/IPQC/FQC)、**雙向溯源** (原料 <-> 成品)。
|
||||||
|
|
||||||
|
#### 5. 🛒 採購與廠商
|
||||||
|
- **採購單**:詢價、下單、收貨與驗收流程。
|
||||||
|
- **✨ 強化功能**:供應商評鑑系統。
|
||||||
|
|
||||||
|
#### 6. 💰 財務管理
|
||||||
|
- **公共事業費**:水電氣網等固定支出。
|
||||||
|
- **✨ 強化功能**:應收/應付帳款 (AR/AP) 管理、**成本精算** (料工費分攤)。
|
||||||
|
|
||||||
|
#### 7. ⚙️ 系統管理
|
||||||
|
- **使用者與權限**:RBAC 細緻權限控管。
|
||||||
|
- **操作紀錄**:全系統關鍵行為軌跡 (Audit Log)。
|
||||||
|
|
||||||
|
## 🚀 快速開始
|
||||||
|
|
||||||
|
### 1. 環境準備
|
||||||
|
請確保您的開發環境已安裝:
|
||||||
|
- Docker Desktop 或 Docker Engine
|
||||||
|
- Git
|
||||||
|
- WSL2 (Windows 用戶建議)
|
||||||
|
|
||||||
|
### 2. 初始化專案
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. 下載專案
|
||||||
|
git clone <repository_url> star-erp
|
||||||
|
cd star-erp
|
||||||
|
|
||||||
# 初始化 .env 檔案
|
# 2. 設定環境變數
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# 請檢查 .env 內容,本機開發預設配置:
|
||||||
|
# APP_PORT=8080 (總後台)
|
||||||
|
# DEMO_TENANT_PORT=8081 (租戶測試)
|
||||||
|
# VITE_PORT=5174
|
||||||
|
|
||||||
```
|
# 3. 啟動容器
|
||||||
|
|
||||||
### 2. 啟動 Docker 容器
|
|
||||||
|
|
||||||
在專案根目錄執行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 背景執行容器
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
docker exec -it koori-erp-laravel.test-1 composer install
|
|
||||||
|
|
||||||
# 生成 App Key
|
|
||||||
docker exec -it koori-erp-laravel.test-1 php artisan key:generate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 資料庫遷移與初始化
|
### 3. 安裝依賴與初始化
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# (選填) 如果有種子資料
|
# 安裝 PHP 依賴
|
||||||
docker exec -it koori-erp-laravel.test-1 php artisan migrate --seed
|
docker exec -it star-erp-laravel composer install
|
||||||
|
|
||||||
|
# 生成 Application Key
|
||||||
|
docker exec -it star-erp-laravel php artisan key:generate
|
||||||
|
|
||||||
|
# 執行資料庫遷移與種子資料 (建立基礎表格)
|
||||||
|
docker exec -it star-erp-laravel php artisan migrate --seed
|
||||||
|
|
||||||
|
# 安裝與編譯前端資源
|
||||||
|
docker exec -it star-erp-laravel npm install
|
||||||
|
docker exec -it star-erp-laravel npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 啟動前端開發伺服器 (Vite)
|
## 🌐 服務訪問 (開發與 Demo 模式)
|
||||||
|
|
||||||
|
本專案使用獨立的 Nginx 容器 (`star-erp-proxy`) 進行反向代理,以模擬多租戶環境的分流。
|
||||||
|
|
||||||
|
| 服務 | URL | 說明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **總後台 (Landlord)** | `http://localhost:8080` | 中央管理介面,用於新增與管理租戶 |
|
||||||
|
| **租戶演示 (Demo)** | `http://localhost:8081` | 模擬租戶環境,預設存取 `koori` 租戶 |
|
||||||
|
| **Vite HMR** | `http://localhost:5174` | 前端開發熱更新服務 |
|
||||||
|
|
||||||
|
> **開發小撇步**:為了方便測試,本機與 Demo 環境啟用了 `DEMO_TENANT_PORT=8081` 功能,允許透過端口直接識別租戶,無需修改 hosts 檔案。
|
||||||
|
|
||||||
|
## 🏢 正式環境運作流程
|
||||||
|
|
||||||
|
在正式環境 (Production) 下,系統採用標準的 **域名識別 (Domain Identification)** 模式:
|
||||||
|
|
||||||
|
1. **進入總後台**:透過中央域名登入 (如 `admin.star-erp.com`)。
|
||||||
|
2. **新增租戶**:在總後台建立新租戶 (例如 ID: `client-a`),並綁定專屬域名 (如 `erp.client-a.com`)。
|
||||||
|
3. **DNS 設定**:在 DNS 服務商將該租戶域名 (CNAME 或 A 紀錄) 指向伺服器 IP。
|
||||||
|
4. **訪問**:使用者直接瀏覽 `http://erp.client-a.com`,系統會自動切換至該租戶的專屬資料庫。
|
||||||
|
|
||||||
|
## 🛠 常用指令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it koori-erp-laravel.test-1 npm install
|
# 進入 Laravel 容器 Shell
|
||||||
docker exec -it koori-erp-laravel.test-1 npm run build
|
docker exec -it star-erp-laravel bash
|
||||||
|
|
||||||
|
# 清除快取 (Config/Route/View) - 修改 .env 後建議執行
|
||||||
|
docker exec -it star-erp-laravel php artisan optimize:clear
|
||||||
|
|
||||||
|
# 執行 Tinker (互動式 Shell)
|
||||||
|
docker exec -it star-erp-laravel php artisan tinker
|
||||||
|
|
||||||
|
# 停止容器
|
||||||
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
啟動後,您可以透過以下連結瀏覽專案:
|
## 🧪 開發規範
|
||||||
- **後台網址**: [http://localhost](http://localhost)
|
|
||||||
- **Vite 伺服器**: [http://localhost:5174](http://localhost:5174)
|
|
||||||
|
|
||||||
## 常用 Sail 指令
|
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
|
||||||
|
- **前端**: React Functional Components + Hooks。UI 元件位於 `resources/js/Components`。
|
||||||
- **停止服務**: `./vendor/bin/sail stop`
|
- **樣式**: 全面使用 Tailwind CSS,避免手寫 CSS。
|
||||||
- **執行 Artisan 指令**: `./vendor/bin/sail artisan ...`
|
- **多租戶**:
|
||||||
- **執行 Composer 指令**: `./vendor/bin/sail composer ...`
|
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
||||||
- **執行測試**: `./vendor/bin/sail test`
|
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
||||||
|
|
||||||
## 技術棧
|
|
||||||
|
|
||||||
- **Backend**: Laravel 12
|
|
||||||
- **Frontend**: React (Functional Components) via Inertia.js
|
|
||||||
- **Styling**: Tailwind CSS
|
|
||||||
- **Database**: MySQL 8.0
|
|
||||||
- **Cache/Session**: Redis
|
|
||||||
|
|
||||||
## 開發規範
|
|
||||||
|
|
||||||
請參考專案內的開發文件或 AI 指導規則,確保 UI/UX 元件與後端邏輯符合專案架構。
|
|
||||||
|
|||||||
131
app/Console/Commands/MigrateToTenant.php
Normal file
131
app/Console/Commands/MigrateToTenant.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Modules\Core\Models\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將現有資料遷移到租戶資料庫
|
||||||
|
*
|
||||||
|
* 此指令用於初次設定多租戶時,將現有的 ERP 資料遷移到第一個租戶
|
||||||
|
*/
|
||||||
|
class MigrateToTenant extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenancy:migrate-data {tenant_id} {--dry-run : 只顯示會遷移的表,不實際執行}';
|
||||||
|
protected $description = '將現有 central DB 資料遷移到指定租戶資料庫';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需要遷移的表 (依賴順序排列)
|
||||||
|
*/
|
||||||
|
protected array $tablesToMigrate = [
|
||||||
|
'users',
|
||||||
|
'password_reset_tokens',
|
||||||
|
'sessions',
|
||||||
|
'cache',
|
||||||
|
'cache_locks',
|
||||||
|
'jobs',
|
||||||
|
'job_batches',
|
||||||
|
'failed_jobs',
|
||||||
|
'categories',
|
||||||
|
'units',
|
||||||
|
'vendors',
|
||||||
|
'products',
|
||||||
|
'product_vendor',
|
||||||
|
'warehouses',
|
||||||
|
'inventories',
|
||||||
|
'inventory_transactions',
|
||||||
|
'purchase_orders',
|
||||||
|
'purchase_order_items',
|
||||||
|
'permissions',
|
||||||
|
'roles',
|
||||||
|
'model_has_permissions',
|
||||||
|
'model_has_roles',
|
||||||
|
'role_has_permissions',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tenantId = $this->argument('tenant_id');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
// 檢查租戶是否存在
|
||||||
|
$tenant = Tenant::find($tenantId);
|
||||||
|
if (!$tenant) {
|
||||||
|
$this->error("租戶 '{$tenantId}' 不存在!請先建立租戶。");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("開始遷移資料到租戶: {$tenantId}");
|
||||||
|
$this->info("租戶資料庫: tenant{$tenantId}");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('⚠️ Dry Run 模式 - 不會實際遷移資料');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得 central 資料庫連線
|
||||||
|
$centralConnection = config('database.default');
|
||||||
|
$tenantDbName = 'tenant' . $tenantId;
|
||||||
|
|
||||||
|
foreach ($this->tablesToMigrate as $table) {
|
||||||
|
// 檢查表是否存在於 central
|
||||||
|
if (!Schema::connection($centralConnection)->hasTable($table)) {
|
||||||
|
$this->line(" ⏭️ 跳過 {$table} (表不存在)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 計算資料筆數
|
||||||
|
$count = DB::connection($centralConnection)->table($table)->count();
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->line(" ⏭️ 跳過 {$table} (無資料)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info(" 📋 {$table}: {$count} 筆資料");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 實際遷移資料
|
||||||
|
$this->info(" 🔄 遷移 {$table}: {$count} 筆資料...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用租戶上下文執行
|
||||||
|
$tenant->run(function () use ($centralConnection, $table) {
|
||||||
|
// 取得 central 資料
|
||||||
|
$data = DB::connection($centralConnection)->table($table)->get();
|
||||||
|
|
||||||
|
if ($data->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 關閉外鍵檢查
|
||||||
|
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
|
||||||
|
// 清空目標表
|
||||||
|
DB::table($table)->truncate();
|
||||||
|
|
||||||
|
// 分批插入 (每批 100 筆)
|
||||||
|
foreach ($data->chunk(100) as $chunk) {
|
||||||
|
DB::table($table)->insert($chunk->map(fn($item) => (array) $item)->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢復外鍵檢查
|
||||||
|
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info(" ✅ {$table} 遷移完成");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error(" ❌ {$table} 遷移失敗: " . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('🎉 資料遷移完成!');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Enums/WarehouseType.php
Normal file
25
app/Enums/WarehouseType.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum WarehouseType: string
|
||||||
|
{
|
||||||
|
case STANDARD = 'standard'; // 標準倉/總倉
|
||||||
|
case PRODUCTION = 'production'; // 生產倉/廚房
|
||||||
|
case RETAIL = 'retail'; // 門市倉/前台
|
||||||
|
case VENDING = 'vending'; // 販賣機倉/IoT
|
||||||
|
case TRANSIT = 'transit'; // 在途倉/移動倉
|
||||||
|
case QUARANTINE = 'quarantine'; // 瑕疵倉/報廢倉
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::STANDARD => '標準倉 (總倉)',
|
||||||
|
self::PRODUCTION => '生產倉 (廚房/加工)',
|
||||||
|
self::RETAIL => '門市倉 (前台销售)',
|
||||||
|
self::VENDING => '販賣機 (IoT設備)',
|
||||||
|
self::TRANSIT => '在途倉 (物流車)',
|
||||||
|
self::QUARANTINE => '瑕疵倉 (報廢/檢驗)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class InventoryController extends Controller
|
|
||||||
{
|
|
||||||
public function index(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
|
|
||||||
{
|
|
||||||
$warehouse->load([
|
|
||||||
'inventories.product.category',
|
|
||||||
'inventories.lastIncomingTransaction',
|
|
||||||
'inventories.lastOutgoingTransaction'
|
|
||||||
]);
|
|
||||||
$allProducts = \App\Models\Product::with('category')->get();
|
|
||||||
|
|
||||||
// 1. 準備 availableProducts
|
|
||||||
$availableProducts = $allProducts->map(function ($product) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $product->id, // Frontend expects string
|
|
||||||
'name' => $product->name,
|
|
||||||
'type' => $product->category ? $product->category->name : '其他', // 暫時用 Category Name 當 Type
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 準備 inventories (模擬批號)
|
|
||||||
// 2. 準備 inventories
|
|
||||||
// 資料庫結構為 (warehouse_id, product_id) 唯一,故為扁平列表
|
|
||||||
$inventories = $warehouse->inventories->map(function ($inv) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $inv->id,
|
|
||||||
'warehouseId' => (string) $inv->warehouse_id,
|
|
||||||
'productId' => (string) $inv->product_id,
|
|
||||||
'productName' => $inv->product->name,
|
|
||||||
'productCode' => $inv->product->code ?? 'N/A',
|
|
||||||
'unit' => $inv->product->base_unit ?? '個',
|
|
||||||
'quantity' => (float) $inv->quantity,
|
|
||||||
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
|
|
||||||
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
|
|
||||||
'batchNumber' => 'BATCH-' . $inv->id, // DB 無批號,暫時模擬,某些 UI 可能還會用到
|
|
||||||
'expiryDate' => '2099-12-31', // DB 無效期,暫時模擬
|
|
||||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
|
||||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. 準備 safetyStockSettings
|
|
||||||
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
|
|
||||||
return !is_null($inv->safety_stock);
|
|
||||||
})->map(function ($inv) {
|
|
||||||
return [
|
|
||||||
'id' => 'ss-' . $inv->id,
|
|
||||||
'warehouseId' => (string) $inv->warehouse_id,
|
|
||||||
'productId' => (string) $inv->product_id,
|
|
||||||
'productName' => $inv->product->name,
|
|
||||||
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
|
|
||||||
'safetyStock' => (float) $inv->safety_stock,
|
|
||||||
'createdAt' => $inv->created_at->toIso8601String(),
|
|
||||||
'updatedAt' => $inv->updated_at->toIso8601String(),
|
|
||||||
];
|
|
||||||
})->values();
|
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/Inventory', [
|
|
||||||
'warehouse' => $warehouse,
|
|
||||||
'inventories' => $inventories,
|
|
||||||
'safetyStockSettings' => $safetyStockSettings,
|
|
||||||
'availableProducts' => $availableProducts,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(\App\Models\Warehouse $warehouse)
|
|
||||||
{
|
|
||||||
// 取得所有商品供前端選單使用
|
|
||||||
$products = \App\Models\Product::select('id', 'name', 'base_unit')->get()->map(function ($product) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $product->id,
|
|
||||||
'name' => $product->name,
|
|
||||||
'unit' => $product->base_unit,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/AddInventory', [
|
|
||||||
'warehouse' => $warehouse,
|
|
||||||
'products' => $products,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'inboundDate' => 'required|date',
|
|
||||||
'reason' => 'required|string',
|
|
||||||
'notes' => 'nullable|string',
|
|
||||||
'items' => 'required|array|min:1',
|
|
||||||
'items.*.productId' => 'required|exists:products,id',
|
|
||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
|
|
||||||
foreach ($validated['items'] as $item) {
|
|
||||||
// 取得或建立庫存紀錄
|
|
||||||
$inventory = $warehouse->inventories()->firstOrCreate(
|
|
||||||
['product_id' => $item['productId']],
|
|
||||||
['quantity' => 0, 'safety_stock' => null]
|
|
||||||
);
|
|
||||||
|
|
||||||
$currentQty = $inventory->quantity;
|
|
||||||
$newQty = $currentQty + $item['quantity'];
|
|
||||||
|
|
||||||
// 更新庫存
|
|
||||||
$inventory->update(['quantity' => $newQty]);
|
|
||||||
|
|
||||||
// 寫入異動紀錄
|
|
||||||
$inventory->transactions()->create([
|
|
||||||
'type' => '手動入庫',
|
|
||||||
'quantity' => $item['quantity'],
|
|
||||||
'balance_before' => $currentQty,
|
|
||||||
'balance_after' => $newQty,
|
|
||||||
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
|
|
||||||
'actual_time' => $validated['inboundDate'],
|
|
||||||
'user_id' => auth()->id(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
|
||||||
->with('success', '庫存記錄已儲存成功');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function edit(\App\Models\Warehouse $warehouse, $inventoryId)
|
|
||||||
{
|
|
||||||
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
|
|
||||||
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
|
|
||||||
if (str_starts_with($inventoryId, 'mock-inv-')) {
|
|
||||||
return redirect()->back()->with('error', '無法編輯範例資料');
|
|
||||||
}
|
|
||||||
|
|
||||||
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
|
|
||||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
|
||||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
|
||||||
|
|
||||||
// 轉換為前端需要的格式
|
|
||||||
$inventoryData = [
|
|
||||||
'id' => (string) $inventory->id,
|
|
||||||
'warehouseId' => (string) $inventory->warehouse_id,
|
|
||||||
'productId' => (string) $inventory->product_id,
|
|
||||||
'productName' => $inventory->product->name,
|
|
||||||
'quantity' => (float) $inventory->quantity,
|
|
||||||
'batchNumber' => 'BATCH-' . $inventory->id, // Mock
|
|
||||||
'expiryDate' => '2099-12-31', // Mock
|
|
||||||
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
|
|
||||||
'lastOutboundDate' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
// 整理異動紀錄
|
|
||||||
$transactions = $inventory->transactions->map(function ($tx) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $tx->id,
|
|
||||||
'type' => $tx->type,
|
|
||||||
'quantity' => (float) $tx->quantity,
|
|
||||||
'balanceAfter' => (float) $tx->balance_after,
|
|
||||||
'reason' => $tx->reason,
|
|
||||||
'userName' => $tx->user ? $tx->user->name : '系統',
|
|
||||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/EditInventory', [
|
|
||||||
'warehouse' => $warehouse,
|
|
||||||
'inventory' => $inventoryData,
|
|
||||||
'transactions' => $transactions,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId)
|
|
||||||
{
|
|
||||||
// 若是 product ID (舊邏輯),先轉為 inventory
|
|
||||||
// 但新路由我們傳的是 inventory ID
|
|
||||||
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
|
|
||||||
|
|
||||||
$inventory = \App\Models\Inventory::find($inventoryId);
|
|
||||||
|
|
||||||
// 如果找不到 (可能是舊路由傳 product ID)
|
|
||||||
if (!$inventory) {
|
|
||||||
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$inventory) {
|
|
||||||
return redirect()->back()->with('error', '找不到庫存紀錄');
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'quantity' => 'required|numeric|min:0',
|
|
||||||
// 以下欄位改為 nullable,支援新表單
|
|
||||||
'type' => 'nullable|string',
|
|
||||||
'operation' => 'nullable|in:add,subtract,set',
|
|
||||||
'reason' => 'nullable|string',
|
|
||||||
'notes' => 'nullable|string',
|
|
||||||
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
|
|
||||||
'batchNumber' => 'nullable|string',
|
|
||||||
'expiryDate' => 'nullable|date',
|
|
||||||
'lastInboundDate' => 'nullable|date',
|
|
||||||
'lastOutboundDate' => 'nullable|date',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
|
|
||||||
$currentQty = $inventory->quantity;
|
|
||||||
$newQty = $validated['quantity'];
|
|
||||||
|
|
||||||
// 判斷操作模式
|
|
||||||
if (isset($validated['operation'])) {
|
|
||||||
$changeQty = 0;
|
|
||||||
switch ($validated['operation']) {
|
|
||||||
case 'add':
|
|
||||||
$changeQty = $validated['quantity'];
|
|
||||||
$newQty = $currentQty + $changeQty;
|
|
||||||
break;
|
|
||||||
case 'subtract':
|
|
||||||
$changeQty = -$validated['quantity'];
|
|
||||||
$newQty = $currentQty + $changeQty;
|
|
||||||
break;
|
|
||||||
case 'set':
|
|
||||||
$changeQty = $newQty - $currentQty;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 來自編輯頁面,直接 Set
|
|
||||||
$changeQty = $newQty - $currentQty;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新庫存
|
|
||||||
$inventory->update(['quantity' => $newQty]);
|
|
||||||
|
|
||||||
// 異動類型映射
|
|
||||||
$type = $validated['type'] ?? 'adjustment';
|
|
||||||
$typeMapping = [
|
|
||||||
'adjustment' => '盤點調整',
|
|
||||||
'purchase_in' => '採購進貨',
|
|
||||||
'sales_out' => '銷售出庫',
|
|
||||||
'return_in' => '退貨入庫',
|
|
||||||
'return_out' => '退貨出庫',
|
|
||||||
'transfer_in' => '撥補入庫',
|
|
||||||
'transfer_out' => '撥補出庫',
|
|
||||||
];
|
|
||||||
$chineseType = $typeMapping[$type] ?? $type;
|
|
||||||
|
|
||||||
// 如果是編輯頁面來的,可能沒有 type,預設為 "盤點調整" 或 "手動編輯"
|
|
||||||
if (!isset($validated['type'])) {
|
|
||||||
$chineseType = '手動編輯';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 寫入異動紀錄
|
|
||||||
// 如果數量沒變,是否要寫紀錄?通常編輯頁面按儲存可能只改了其他欄位(如果有)
|
|
||||||
// 但因為我們目前只存 quantity,如果 quantity 沒變,可以不寫異動,或者寫一筆 0 的異動代表更新屬性
|
|
||||||
if (abs($changeQty) > 0.0001) {
|
|
||||||
$inventory->transactions()->create([
|
|
||||||
'type' => $chineseType,
|
|
||||||
'quantity' => $changeQty,
|
|
||||||
'balance_before' => $currentQty,
|
|
||||||
'balance_after' => $newQty,
|
|
||||||
'reason' => ($validated['reason'] ?? '編輯頁面更新') . ($validated['notes'] ?? ''),
|
|
||||||
'actual_time' => now(), // 手動調整設定為當下
|
|
||||||
'user_id' => auth()->id(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
|
|
||||||
->with('success', '庫存資料已更新');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(\App\Models\Warehouse $warehouse, $inventoryId)
|
|
||||||
{
|
|
||||||
$inventory = \App\Models\Inventory::findOrFail($inventoryId);
|
|
||||||
|
|
||||||
// 歸零異動
|
|
||||||
if ($inventory->quantity > 0) {
|
|
||||||
$inventory->transactions()->create([
|
|
||||||
'type' => '手動編輯',
|
|
||||||
'quantity' => -$inventory->quantity,
|
|
||||||
'balance_before' => $inventory->quantity,
|
|
||||||
'balance_after' => 0,
|
|
||||||
'reason' => '刪除庫存品項',
|
|
||||||
'actual_time' => now(),
|
|
||||||
'user_id' => auth()->id(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$inventory->delete();
|
|
||||||
|
|
||||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
|
||||||
->with('success', '庫存品項已刪除');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function history(\App\Models\Warehouse $warehouse, $inventoryId)
|
|
||||||
{
|
|
||||||
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
|
|
||||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
|
||||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
|
||||||
|
|
||||||
$transactions = $inventory->transactions->map(function ($tx) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $tx->id,
|
|
||||||
'type' => $tx->type,
|
|
||||||
'quantity' => (float) $tx->quantity,
|
|
||||||
'balanceAfter' => (float) $tx->balance_after,
|
|
||||||
'reason' => $tx->reason,
|
|
||||||
'userName' => $tx->user ? $tx->user->name : '系統',
|
|
||||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
|
||||||
'warehouse' => $warehouse,
|
|
||||||
'inventory' => [
|
|
||||||
'id' => (string) $inventory->id,
|
|
||||||
'productName' => $inventory->product->name,
|
|
||||||
'productCode' => $inventory->product->code,
|
|
||||||
'quantity' => (float) $inventory->quantity,
|
|
||||||
],
|
|
||||||
'transactions' => $transactions
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Landlord;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Core\Models\Tenant;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$stats = [
|
||||||
|
'totalTenants' => Tenant::count(),
|
||||||
|
'activeTenants' => Tenant::whereJsonContains('data->is_active', true)->count(),
|
||||||
|
'recentTenants' => Tenant::latest()->take(5)->get()->map(function ($tenant) {
|
||||||
|
return [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
|
'domains' => $tenant->domains->pluck('domain')->toArray(),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Dashboard', $stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Http/Controllers/Landlord/ProfileController.php
Normal file
55
app/Http/Controllers/Landlord/ProfileController.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Landlord;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示使用者設定頁面
|
||||||
|
*/
|
||||||
|
public function edit(Request $request)
|
||||||
|
{
|
||||||
|
return Inertia::render('Landlord/Profile/Edit', [
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新使用者基本資料
|
||||||
|
*/
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
|
||||||
|
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update($validated);
|
||||||
|
|
||||||
|
return back()->with('success', '個人資料已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新密碼
|
||||||
|
*/
|
||||||
|
public function updatePassword(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'current_password' => ['required', 'current_password'],
|
||||||
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', '密碼已更新');
|
||||||
|
}
|
||||||
|
}
|
||||||
234
app/Http/Controllers/Landlord/TenantController.php
Normal file
234
app/Http/Controllers/Landlord/TenantController.php
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Landlord;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Core\Models\Tenant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class TenantController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示租戶列表
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$tenants = Tenant::with('domains')->get()->map(function ($tenant) {
|
||||||
|
return [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'email' => $tenant->email ?? null,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
|
'domains' => $tenant->domains->pluck('domain')->toArray(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Tenant/Index', [
|
||||||
|
'tenants' => $tenants,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示新增租戶表單
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return Inertia::render('Landlord/Tenant/Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 儲存新租戶
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['nullable', 'email', 'max:100'],
|
||||||
|
'domain' => ['nullable', 'string', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'id' => $validated['id'],
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 綁定網域(如果沒有輸入,使用預設網域)
|
||||||
|
$defaultDomain = env('TENANT_DEFAULT_DOMAIN', 'star-erp.test');
|
||||||
|
$domain = !empty($validated['domain'])
|
||||||
|
? $validated['domain']
|
||||||
|
: $validated['id'] . '.' . $defaultDomain;
|
||||||
|
$tenant->domains()->create(['domain' => $domain]);
|
||||||
|
|
||||||
|
return redirect()->route('landlord.tenants.index')
|
||||||
|
->with('success', "租戶 {$validated['name']} 建立成功!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示單一租戶詳情
|
||||||
|
*/
|
||||||
|
public function show(string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::with('domains')->findOrFail($id);
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Tenant/Show', [
|
||||||
|
'tenant' => [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'email' => $tenant->email ?? null,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
|
'updated_at' => $tenant->updated_at->format('Y-m-d H:i'),
|
||||||
|
'domains' => $tenant->domains->map(fn($d) => [
|
||||||
|
'id' => $d->id,
|
||||||
|
'domain' => $d->domain,
|
||||||
|
])->toArray(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示租戶樣式管理頁面
|
||||||
|
*/
|
||||||
|
public function showBranding(Tenant $tenant)
|
||||||
|
{
|
||||||
|
$logoUrl = null;
|
||||||
|
if (isset($tenant->branding['logo_path'])) {
|
||||||
|
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Tenant/Branding', [
|
||||||
|
'tenant' => [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'branding' => $tenant->branding ?? [],
|
||||||
|
],
|
||||||
|
'logo_url' => $logoUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示編輯租戶表單
|
||||||
|
*/
|
||||||
|
public function edit(string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Tenant/Edit', [
|
||||||
|
'tenant' => [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'email' => $tenant->email ?? null,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新租戶
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['nullable', 'email', 'max:100'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('landlord.tenants.index')
|
||||||
|
->with('success', "租戶 {$validated['name']} 更新成功!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除租戶
|
||||||
|
*/
|
||||||
|
public function destroy(string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
$name = $tenant->name ?? $id;
|
||||||
|
|
||||||
|
$tenant->delete();
|
||||||
|
|
||||||
|
return redirect()->route('landlord.tenants.index')
|
||||||
|
->with('success', "租戶 {$name} 已刪除!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增域名到租戶
|
||||||
|
*/
|
||||||
|
public function addDomain(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'domain' => ['required', 'string', 'max:100', Rule::unique('domains', 'domain')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->domains()->create(['domain' => $validated['domain']]);
|
||||||
|
|
||||||
|
return back()->with('success', "域名 {$validated['domain']} 已綁定!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除租戶的域名
|
||||||
|
*/
|
||||||
|
public function removeDomain(string $id, int $domainId)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
$domain = $tenant->domains()->findOrFail($domainId);
|
||||||
|
$domainName = $domain->domain;
|
||||||
|
|
||||||
|
$domain->delete();
|
||||||
|
|
||||||
|
return back()->with('success', "域名 {$domainName} 已移除!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新租戶品牌樣式設定
|
||||||
|
*/
|
||||||
|
public function updateBranding(Request $request, Tenant $tenant)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'logo' => 'nullable|image|max:2048',
|
||||||
|
'primary_color' => 'required|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||||
|
'text_color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$branding = $tenant->branding ?? [];
|
||||||
|
|
||||||
|
// 處理 Logo 上傳
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
// 刪除舊 Logo
|
||||||
|
if (isset($branding['logo_path'])) {
|
||||||
|
\Storage::disk('public')->delete($branding['logo_path']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 儲存新 Logo
|
||||||
|
$path = $request->file('logo')->store('tenant-logos', 'public');
|
||||||
|
$branding['logo_path'] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新主色系
|
||||||
|
$branding['primary_color'] = $validated['primary_color'];
|
||||||
|
|
||||||
|
// 如果有傳入字體顏色則更新,否則保留原值(或預設值)
|
||||||
|
if (isset($validated['text_color'])) {
|
||||||
|
$branding['text_color'] = $validated['text_color'];
|
||||||
|
} elseif (!isset($branding['text_color'])) {
|
||||||
|
$branding['text_color'] = '#1a1a1a';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->update(['branding' => $branding]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '樣式設定已更新');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Product;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class ProductController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display a listing of the resource.
|
|
||||||
*/
|
|
||||||
public function index(Request $request): Response
|
|
||||||
{
|
|
||||||
$query = Product::with('category');
|
|
||||||
|
|
||||||
if ($request->filled('search')) {
|
|
||||||
$search = $request->search;
|
|
||||||
$query->where(function ($q) use ($search) {
|
|
||||||
$q->where('name', 'like', "%{$search}%")
|
|
||||||
->orWhere('code', 'like', "%{$search}%")
|
|
||||||
->orWhere('brand', 'like', "%{$search}%");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->filled('category_id') && $request->category_id !== 'all') {
|
|
||||||
$query->where('category_id', $request->category_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$perPage = $request->input('per_page', 10);
|
|
||||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
|
||||||
$perPage = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sortField = $request->input('sort_field', 'id');
|
|
||||||
$sortDirection = $request->input('sort_direction', 'desc');
|
|
||||||
|
|
||||||
// Define allowed sort fields to prevent SQL injection
|
|
||||||
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit', 'conversion_rate'];
|
|
||||||
if (!in_array($sortField, $allowedSorts)) {
|
|
||||||
$sortField = 'id';
|
|
||||||
}
|
|
||||||
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
|
||||||
$sortDirection = 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle relation sorting (category name) separately if needed, or simple join
|
|
||||||
if ($sortField === 'category_id') {
|
|
||||||
// Join categories for sorting by name? Or just by ID?
|
|
||||||
// Simple approach: sort by ID for now, or join if user wants name sort.
|
|
||||||
// Let's assume standard field sorting first.
|
|
||||||
$query->orderBy('category_id', $sortDirection);
|
|
||||||
} else {
|
|
||||||
$query->orderBy($sortField, $sortDirection);
|
|
||||||
}
|
|
||||||
|
|
||||||
$products = $query->paginate($perPage)->withQueryString();
|
|
||||||
|
|
||||||
$categories = \App\Models\Category::where('is_active', true)->get();
|
|
||||||
|
|
||||||
return Inertia::render('Product/Index', [
|
|
||||||
'products' => $products,
|
|
||||||
'categories' => $categories,
|
|
||||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a newly created resource in storage.
|
|
||||||
*/
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'category_id' => 'required|exists:categories,id',
|
|
||||||
'brand' => 'nullable|string|max:255',
|
|
||||||
'specification' => 'nullable|string',
|
|
||||||
'base_unit' => 'required|string|max:50',
|
|
||||||
'large_unit' => 'nullable|string|max:50',
|
|
||||||
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
|
|
||||||
'purchase_unit' => 'nullable|string|max:50',
|
|
||||||
], [
|
|
||||||
'name.required' => '商品名稱為必填',
|
|
||||||
'category_id.required' => '請選擇分類',
|
|
||||||
'category_id.exists' => '所選分類不存在',
|
|
||||||
'base_unit.required' => '基本庫存單位為必填',
|
|
||||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
|
||||||
'conversion_rate.numeric' => '換算率必須為數字',
|
|
||||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Auto-generate code
|
|
||||||
$prefix = 'P';
|
|
||||||
$lastProduct = Product::withTrashed()->latest('id')->first();
|
|
||||||
$nextId = $lastProduct ? $lastProduct->id + 1 : 1;
|
|
||||||
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
|
|
||||||
|
|
||||||
$validated['code'] = $code;
|
|
||||||
|
|
||||||
$product = Product::create($validated);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '商品已建立');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the specified resource in storage.
|
|
||||||
*/
|
|
||||||
public function update(Request $request, Product $product)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'category_id' => 'required|exists:categories,id',
|
|
||||||
'brand' => 'nullable|string|max:255',
|
|
||||||
'specification' => 'nullable|string',
|
|
||||||
'base_unit' => 'required|string|max:50',
|
|
||||||
'large_unit' => 'nullable|string|max:50',
|
|
||||||
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$product->update($validated);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '商品已更新');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the specified resource from storage.
|
|
||||||
*/
|
|
||||||
public function destroy(Product $product)
|
|
||||||
{
|
|
||||||
$product->delete();
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '商品已刪除');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\PurchaseOrder;
|
|
||||||
use App\Models\Vendor;
|
|
||||||
use App\Models\Warehouse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class PurchaseOrderController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
|
|
||||||
|
|
||||||
// Search
|
|
||||||
if ($request->search) {
|
|
||||||
$query->where(function($q) use ($request) {
|
|
||||||
$q->where('code', 'like', "%{$request->search}%")
|
|
||||||
->orWhereHas('vendor', function($vq) use ($request) {
|
|
||||||
$vq->where('name', 'like', "%{$request->search}%");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
if ($request->status && $request->status !== 'all') {
|
|
||||||
$query->where('status', $request->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
|
|
||||||
$query->where('warehouse_id', $request->warehouse_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
$sortField = $request->sort_field ?? 'id';
|
|
||||||
$sortDirection = $request->sort_direction ?? 'desc';
|
|
||||||
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
|
|
||||||
|
|
||||||
if (in_array($sortField, $allowedSortFields)) {
|
|
||||||
$query->orderBy($sortField, $sortDirection);
|
|
||||||
}
|
|
||||||
|
|
||||||
$orders = $query->paginate(15)->withQueryString();
|
|
||||||
|
|
||||||
return Inertia::render('PurchaseOrder/Index', [
|
|
||||||
'orders' => $orders,
|
|
||||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'sort_field', 'sort_direction']),
|
|
||||||
'warehouses' => Warehouse::all(['id', 'name']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $vendor->id,
|
|
||||||
'name' => $vendor->name,
|
|
||||||
'commonProducts' => $vendor->products->map(function ($product) {
|
|
||||||
return [
|
|
||||||
'productId' => (string) $product->id,
|
|
||||||
'productName' => $product->name,
|
|
||||||
'unit' => $product->base_unit,
|
|
||||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$warehouses = Warehouse::all()->map(function ($w) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $w->id,
|
|
||||||
'name' => $w->name,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return Inertia::render('PurchaseOrder/Create', [
|
|
||||||
'suppliers' => $vendors,
|
|
||||||
'warehouses' => $warehouses,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'vendor_id' => 'required|exists:vendors,id',
|
|
||||||
'warehouse_id' => 'required|exists:warehouses,id',
|
|
||||||
'expected_delivery_date' => 'nullable|date',
|
|
||||||
'remark' => 'nullable|string',
|
|
||||||
'items' => 'required|array|min:1',
|
|
||||||
'items.*.productId' => 'required|exists:products,id',
|
|
||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
|
||||||
'items.*.unitPrice' => 'required|numeric|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
DB::beginTransaction();
|
|
||||||
|
|
||||||
// 生成單號:YYYYMMDD001
|
|
||||||
$today = now()->format('Ymd');
|
|
||||||
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
|
|
||||||
->lockForUpdate() // 鎖定以避免並發衝突
|
|
||||||
->orderBy('code', 'desc')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($lastOrder) {
|
|
||||||
// 取得最後 3 碼序號並加 1
|
|
||||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
|
||||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
|
||||||
} else {
|
|
||||||
$sequence = '001';
|
|
||||||
}
|
|
||||||
$code = $today . $sequence;
|
|
||||||
|
|
||||||
$totalAmount = 0;
|
|
||||||
foreach ($validated['items'] as $item) {
|
|
||||||
$totalAmount += $item['quantity'] * $item['unitPrice'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple tax calculation (e.g., 5%)
|
|
||||||
$taxAmount = round($totalAmount * 0.05, 2);
|
|
||||||
$grandTotal = $totalAmount + $taxAmount;
|
|
||||||
|
|
||||||
// 確保有一個有效的使用者 ID
|
|
||||||
$userId = auth()->id();
|
|
||||||
if (!$userId) {
|
|
||||||
$user = \App\Models\User::first();
|
|
||||||
if (!$user) {
|
|
||||||
$user = \App\Models\User::create([
|
|
||||||
'name' => '系統管理員',
|
|
||||||
'email' => 'admin@example.com',
|
|
||||||
'password' => bcrypt('password'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
$userId = $user->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$order = PurchaseOrder::create([
|
|
||||||
'code' => $code,
|
|
||||||
'vendor_id' => $validated['vendor_id'],
|
|
||||||
'warehouse_id' => $validated['warehouse_id'],
|
|
||||||
'user_id' => $userId,
|
|
||||||
'status' => 'draft',
|
|
||||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
|
||||||
'total_amount' => $totalAmount,
|
|
||||||
'tax_amount' => $taxAmount,
|
|
||||||
'grand_total' => $grandTotal,
|
|
||||||
'remark' => $validated['remark'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($validated['items'] as $item) {
|
|
||||||
$order->items()->create([
|
|
||||||
'product_id' => $item['productId'],
|
|
||||||
'quantity' => $item['quantity'],
|
|
||||||
'unit_price' => $item['unitPrice'],
|
|
||||||
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::commit();
|
|
||||||
|
|
||||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show($id)
|
|
||||||
{
|
|
||||||
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product'])->findOrFail($id);
|
|
||||||
|
|
||||||
return Inertia::render('PurchaseOrder/Show', [
|
|
||||||
'order' => $order
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function edit($id)
|
|
||||||
{
|
|
||||||
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
|
|
||||||
|
|
||||||
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $vendor->id,
|
|
||||||
'name' => $vendor->name,
|
|
||||||
'commonProducts' => $vendor->products->map(function ($product) {
|
|
||||||
return [
|
|
||||||
'productId' => (string) $product->id,
|
|
||||||
'productName' => $product->name,
|
|
||||||
'unit' => $product->base_unit,
|
|
||||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$warehouses = Warehouse::all()->map(function ($w) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $w->id,
|
|
||||||
'name' => $w->name,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return Inertia::render('PurchaseOrder/Create', [
|
|
||||||
'order' => $order,
|
|
||||||
'suppliers' => $vendors,
|
|
||||||
'warehouses' => $warehouses,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, $id)
|
|
||||||
{
|
|
||||||
$order = PurchaseOrder::findOrFail($id);
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'vendor_id' => 'required|exists:vendors,id',
|
|
||||||
'warehouse_id' => 'required|exists:warehouses,id',
|
|
||||||
'expected_delivery_date' => 'nullable|date',
|
|
||||||
'remark' => 'nullable|string',
|
|
||||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
|
||||||
'items' => 'required|array|min:1',
|
|
||||||
'items.*.productId' => 'required|exists:products,id',
|
|
||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
|
||||||
'items.*.unitPrice' => 'required|numeric|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
DB::beginTransaction();
|
|
||||||
|
|
||||||
$totalAmount = 0;
|
|
||||||
foreach ($validated['items'] as $item) {
|
|
||||||
$totalAmount += $item['quantity'] * $item['unitPrice'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple tax calculation (e.g., 5%)
|
|
||||||
$taxAmount = round($totalAmount * 0.05, 2);
|
|
||||||
$grandTotal = $totalAmount + $taxAmount;
|
|
||||||
|
|
||||||
$order->update([
|
|
||||||
'vendor_id' => $validated['vendor_id'],
|
|
||||||
'warehouse_id' => $validated['warehouse_id'],
|
|
||||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
|
||||||
'total_amount' => $totalAmount,
|
|
||||||
'tax_amount' => $taxAmount,
|
|
||||||
'grand_total' => $grandTotal,
|
|
||||||
'remark' => $validated['remark'],
|
|
||||||
'status' => $validated['status'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Sync items
|
|
||||||
$order->items()->delete();
|
|
||||||
foreach ($validated['items'] as $item) {
|
|
||||||
$order->items()->create([
|
|
||||||
'product_id' => $item['productId'],
|
|
||||||
'quantity' => $item['quantity'],
|
|
||||||
'unit_price' => $item['unitPrice'],
|
|
||||||
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::commit();
|
|
||||||
|
|
||||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy($id)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
DB::beginTransaction();
|
|
||||||
|
|
||||||
$order = PurchaseOrder::findOrFail($id);
|
|
||||||
|
|
||||||
// Delete associated items first (due to FK constraints if not cascade)
|
|
||||||
$order->items()->delete();
|
|
||||||
$order->delete();
|
|
||||||
|
|
||||||
DB::commit();
|
|
||||||
|
|
||||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Vendor;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class VendorController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display a listing of the resource.
|
|
||||||
*/
|
|
||||||
public function index(\Illuminate\Http\Request $request): \Inertia\Response
|
|
||||||
{
|
|
||||||
$query = Vendor::query();
|
|
||||||
|
|
||||||
if ($request->filled('search')) {
|
|
||||||
$search = $request->search;
|
|
||||||
$query->where(function ($q) use ($search) {
|
|
||||||
$q->where('name', 'like', "%{$search}%")
|
|
||||||
->orWhere('code', 'like', "%{$search}%")
|
|
||||||
->orWhere('tax_id', 'like', "%{$search}%")
|
|
||||||
->orWhere('owner', 'like', "%{$search}%")
|
|
||||||
->orWhere('contact_name', 'like', "%{$search}%");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$sortField = $request->input('sort_field', 'id');
|
|
||||||
$sortDirection = $request->input('sort_direction', 'desc');
|
|
||||||
|
|
||||||
$allowedSorts = ['id', 'code', 'name', 'owner', 'contact_name', 'phone'];
|
|
||||||
if (!in_array($sortField, $allowedSorts)) {
|
|
||||||
$sortField = 'id';
|
|
||||||
}
|
|
||||||
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
|
||||||
$sortDirection = 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
$vendors = $query->orderBy($sortField, $sortDirection)
|
|
||||||
->paginate(10)
|
|
||||||
->withQueryString();
|
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Vendor/Index', [
|
|
||||||
'vendors' => $vendors,
|
|
||||||
'filters' => $request->only(['search', 'sort_field', 'sort_direction']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the specified resource.
|
|
||||||
*/
|
|
||||||
public function show(Vendor $vendor): \Inertia\Response
|
|
||||||
{
|
|
||||||
$vendor->load('products');
|
|
||||||
return \Inertia\Inertia::render('Vendor/Show', [
|
|
||||||
'vendor' => $vendor,
|
|
||||||
'products' => \App\Models\Product::all(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a newly created resource in storage.
|
|
||||||
*/
|
|
||||||
public function store(\Illuminate\Http\Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'short_name' => 'nullable|string|max:255',
|
|
||||||
'tax_id' => 'nullable|string|max:8',
|
|
||||||
'owner' => 'nullable|string|max:255',
|
|
||||||
'contact_name' => 'nullable|string|max:255',
|
|
||||||
'tel' => 'nullable|string|max:50',
|
|
||||||
'phone' => 'nullable|string|max:50',
|
|
||||||
'email' => 'nullable|email|max:255',
|
|
||||||
'address' => 'nullable|string',
|
|
||||||
'remark' => 'nullable|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Auto-generate code
|
|
||||||
$prefix = 'V';
|
|
||||||
$lastVendor = Vendor::latest('id')->first();
|
|
||||||
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
|
|
||||||
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
|
|
||||||
|
|
||||||
$validated['code'] = $code;
|
|
||||||
|
|
||||||
Vendor::create($validated);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '廠商已建立');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the specified resource in storage.
|
|
||||||
*/
|
|
||||||
public function update(\Illuminate\Http\Request $request, Vendor $vendor)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'short_name' => 'nullable|string|max:255',
|
|
||||||
'tax_id' => 'nullable|string|max:8',
|
|
||||||
'owner' => 'nullable|string|max:255',
|
|
||||||
'contact_name' => 'nullable|string|max:255',
|
|
||||||
'tel' => 'nullable|string|max:50',
|
|
||||||
'phone' => 'nullable|string|max:50',
|
|
||||||
'email' => 'nullable|email|max:255',
|
|
||||||
'address' => 'nullable|string',
|
|
||||||
'remark' => 'nullable|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$vendor->update($validated);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '廠商資料已更新');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the specified resource from storage.
|
|
||||||
*/
|
|
||||||
public function destroy(Vendor $vendor)
|
|
||||||
{
|
|
||||||
$vendor->delete();
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '廠商已刪除');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Vendor;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class VendorProductController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* 新增供貨商品 (Attach)
|
|
||||||
*/
|
|
||||||
public function store(Request $request, Vendor $vendor)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'product_id' => 'required|exists:products,id',
|
|
||||||
'last_price' => 'nullable|numeric|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 檢查是否已存在
|
|
||||||
if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) {
|
|
||||||
return redirect()->back()->with('error', '該商品已在供貨清單中');
|
|
||||||
}
|
|
||||||
|
|
||||||
$vendor->products()->attach($validated['product_id'], [
|
|
||||||
'last_price' => $validated['last_price'] ?? null
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '供貨商品已新增');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新供貨商品資訊 (Update Pivot)
|
|
||||||
*/
|
|
||||||
public function update(Request $request, Vendor $vendor, $productId)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'last_price' => 'nullable|numeric|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$vendor->products()->updateExistingPivot($productId, [
|
|
||||||
'last_price' => $validated['last_price'] ?? null
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '供貨資訊已更新');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除供貨商品 (Detach)
|
|
||||||
*/
|
|
||||||
public function destroy(Vendor $vendor, $productId)
|
|
||||||
{
|
|
||||||
$vendor->products()->detach($productId);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '供貨商品已移除');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
use App\Models\Warehouse;
|
|
||||||
|
|
||||||
use Inertia\Inertia;
|
|
||||||
|
|
||||||
class WarehouseController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$query = Warehouse::query();
|
|
||||||
|
|
||||||
if ($request->has('search')) {
|
|
||||||
$search = $request->input('search');
|
|
||||||
$query->where(function ($q) use ($search) {
|
|
||||||
$q->where('name', 'like', "%{$search}%")
|
|
||||||
->orWhere('code', 'like', "%{$search}%");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
|
|
||||||
->withCount(['inventories as low_stock_count' => function ($query) {
|
|
||||||
$query->whereColumn('quantity', '<', 'safety_stock');
|
|
||||||
}])
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->paginate(10)
|
|
||||||
->withQueryString();
|
|
||||||
|
|
||||||
return Inertia::render('Warehouse/Index', [
|
|
||||||
'warehouses' => $warehouses,
|
|
||||||
'filters' => $request->only(['search']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:50',
|
|
||||||
'address' => 'nullable|string|max:255',
|
|
||||||
'description' => 'nullable|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Auto-generate code
|
|
||||||
$prefix = 'WH';
|
|
||||||
$lastWarehouse = Warehouse::latest('id')->first();
|
|
||||||
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
|
||||||
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
|
|
||||||
|
|
||||||
$validated['code'] = $code;
|
|
||||||
|
|
||||||
Warehouse::create($validated);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '倉庫已建立');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, Warehouse $warehouse)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:50',
|
|
||||||
'address' => 'nullable|string|max:255',
|
|
||||||
'description' => 'nullable|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$warehouse->update($validated);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '倉庫資訊已更新');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Warehouse $warehouse)
|
|
||||||
{
|
|
||||||
// 真實刪除
|
|
||||||
$warehouse->delete();
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '倉庫已刪除');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,9 +35,43 @@ class HandleInertiaRequests extends Middleware
|
|||||||
*/
|
*/
|
||||||
public function share(Request $request): array
|
public function share(Request $request): array
|
||||||
{
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
//
|
'auth' => [
|
||||||
|
'user' => $user ? [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'username' => $user->username ?? null,
|
||||||
|
// 權限資料
|
||||||
|
'roles' => $user->getRoleNames(),
|
||||||
|
'role_labels' => $user->roles->pluck('display_name'),
|
||||||
|
'permissions' => $user->getAllPermissions()->pluck('name')->toArray(),
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
'flash' => [
|
||||||
|
'success' => $request->session()->get('success'),
|
||||||
|
'error' => $request->session()->get('error'),
|
||||||
|
],
|
||||||
|
'branding' => function () {
|
||||||
|
$tenant = tenancy()->tenant;
|
||||||
|
if (!$tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logoUrl = null;
|
||||||
|
if (isset($tenant->branding['logo_path'])) {
|
||||||
|
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'logo_url' => $logoUrl,
|
||||||
|
'primary_color' => $tenant->branding['primary_color'] ?? '#01ab83',
|
||||||
|
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
|
||||||
|
];
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/Http/Middleware/PreventAccessFromTenantDomains.php
Normal file
20
app/Http/Middleware/PreventAccessFromTenantDomains.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Stancl\Tenancy\Tenancy;
|
||||||
|
|
||||||
|
class PreventAccessFromTenantDomains
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
// 如果租戶已初始化 (代表是從租戶域名存取),則禁止訪問 Landlord 路由
|
||||||
|
if (tenancy()->initialized) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Middleware/UniversalTenancy.php
Normal file
38
app/Http/Middleware/UniversalTenancy.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class UniversalTenancy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// 判斷是否為中央域名
|
||||||
|
$centralDomains = config('tenancy.central_domains', []);
|
||||||
|
|
||||||
|
// [Hack] Demo 環境特殊規則:
|
||||||
|
// 如果設定了 demo_tenant_port (e.g. 8081),且請求端口相符,強制視為租戶請求
|
||||||
|
$demoPort = config('tenancy.demo_tenant_port');
|
||||||
|
if ($demoPort && $request->getPort() == $demoPort) {
|
||||||
|
return app(InitializeTenancyByDomain::class)->handle($request, $next);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($request->getHost(), $centralDomains)) {
|
||||||
|
// 如果是中央域名,不進行租戶初始化,直接繼續往下執行 (使用預設資料庫)
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是中央域名,嘗試透過域名初始化租戶
|
||||||
|
// 若找不到租戶,InitializeTenancyByDomain 會拋出異常
|
||||||
|
return app(InitializeTenancyByDomain::class)->handle($request, $next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class Category extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'is_active',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'is_active' => 'boolean',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the products for the category.
|
|
||||||
*/
|
|
||||||
public function products(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Product::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class Inventory extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\InventoryFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'warehouse_id',
|
|
||||||
'product_id',
|
|
||||||
'quantity',
|
|
||||||
'safety_stock',
|
|
||||||
'location',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Warehouse::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Product::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(InventoryTransaction::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastOutgoingTransaction()
|
|
||||||
{
|
|
||||||
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
|
||||||
'actual_time' => 'max',
|
|
||||||
'id' => 'max',
|
|
||||||
], function ($query) {
|
|
||||||
$query->where('quantity', '<', 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastIncomingTransaction()
|
|
||||||
{
|
|
||||||
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
|
||||||
'actual_time' => 'max',
|
|
||||||
'id' => 'max',
|
|
||||||
], function ($query) {
|
|
||||||
$query->where('quantity', '>', 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class Product extends Model
|
|
||||||
{
|
|
||||||
use HasFactory, SoftDeletes;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'code',
|
|
||||||
'name',
|
|
||||||
'category_id',
|
|
||||||
'brand',
|
|
||||||
'specification',
|
|
||||||
'base_unit',
|
|
||||||
'large_unit',
|
|
||||||
'conversion_rate',
|
|
||||||
'purchase_unit',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'conversion_rate' => 'decimal:4',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the category that owns the product.
|
|
||||||
*/
|
|
||||||
public function category(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Category::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Inventory::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Warehouse::class, 'inventories')
|
|
||||||
->withPivot(['quantity', 'safety_stock', 'location'])
|
|
||||||
->withTimestamps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class PurchaseOrder extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'code',
|
|
||||||
'vendor_id',
|
|
||||||
'warehouse_id',
|
|
||||||
'user_id',
|
|
||||||
'status',
|
|
||||||
'expected_delivery_date',
|
|
||||||
'total_amount',
|
|
||||||
'tax_amount',
|
|
||||||
'grand_total',
|
|
||||||
'remark',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'expected_delivery_date' => 'date',
|
|
||||||
'total_amount' => 'decimal:2',
|
|
||||||
'tax_amount' => 'decimal:2',
|
|
||||||
'grand_total' => 'decimal:2',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $appends = [
|
|
||||||
'poNumber',
|
|
||||||
'supplierId',
|
|
||||||
'supplierName',
|
|
||||||
'expectedDate',
|
|
||||||
'totalAmount',
|
|
||||||
'createdBy',
|
|
||||||
'warehouse_name',
|
|
||||||
'createdAt',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function getCreatedAtAttribute()
|
|
||||||
{
|
|
||||||
return $this->attributes['created_at'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPoNumberAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSupplierIdAttribute(): string
|
|
||||||
{
|
|
||||||
return (string) $this->vendor_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSupplierNameAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->vendor ? $this->vendor->name : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getExpectedDateAttribute(): ?string
|
|
||||||
{
|
|
||||||
return $this->expected_delivery_date ? $this->expected_delivery_date->format('Y-m-d') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTotalAmountAttribute(): float
|
|
||||||
{
|
|
||||||
return (float) ($this->attributes['total_amount'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedByAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->user ? $this->user->name : '系統';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWarehouseNameAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->warehouse ? $this->warehouse->name : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function vendor(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Vendor::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function warehouse(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Warehouse::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function items(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(PurchaseOrderItem::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class PurchaseOrderItem extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'purchase_order_id',
|
|
||||||
'product_id',
|
|
||||||
'quantity',
|
|
||||||
'unit_price',
|
|
||||||
'subtotal',
|
|
||||||
'received_quantity',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'quantity' => 'decimal:2',
|
|
||||||
'unit_price' => 'decimal:2',
|
|
||||||
'subtotal' => 'decimal:2',
|
|
||||||
'received_quantity' => 'decimal:2',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $appends = [
|
|
||||||
'productName',
|
|
||||||
'unit',
|
|
||||||
'productId',
|
|
||||||
'unitPrice',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function getProductIdAttribute(): string
|
|
||||||
{
|
|
||||||
return (string) $this->attributes['product_id'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUnitPriceAttribute(): float
|
|
||||||
{
|
|
||||||
return (float) $this->attributes['unit_price'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getProductNameAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->product ? $this->product->name : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUnitAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->product ? $this->product->base_unit : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function purchaseOrder(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(PurchaseOrder::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function product(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Product::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
|
|
||||||
class User extends Authenticatable
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
|
||||||
use HasFactory, Notifiable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that should be hidden for serialization.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
protected $hidden = [
|
|
||||||
'password',
|
|
||||||
'remember_token',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class Vendor extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'code',
|
|
||||||
'name',
|
|
||||||
'short_name',
|
|
||||||
'tax_id',
|
|
||||||
'owner',
|
|
||||||
'contact_name',
|
|
||||||
'tel',
|
|
||||||
'phone',
|
|
||||||
'email',
|
|
||||||
'address',
|
|
||||||
'remark'
|
|
||||||
];
|
|
||||||
public function products(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Product::class, 'product_vendor')
|
|
||||||
->withPivot('last_price')
|
|
||||||
->withTimestamps();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function purchaseOrders(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(PurchaseOrder::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class Warehouse extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'code',
|
|
||||||
'name',
|
|
||||||
'address',
|
|
||||||
'description',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Inventory::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(PurchaseOrder::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Product::class, 'inventories')
|
|
||||||
->withPivot(['quantity', 'safety_stock', 'location'])
|
|
||||||
->withTimestamps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
38
app/Modules/Core/Contracts/CoreServiceInterface.php
Normal file
38
app/Modules/Core/Contracts/CoreServiceInterface.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
interface CoreServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get multiple users by their IDs.
|
||||||
|
*
|
||||||
|
* @param array $ids
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function getUsersByIds(array $ids): Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific user by ID.
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
public function getUser(int $id): ?object;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users.
|
||||||
|
*
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function getAllUsers(): Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the system user or create one if not exists.
|
||||||
|
*
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
public function ensureSystemUserExists();
|
||||||
|
}
|
||||||
127
app/Modules/Core/Controllers/ActivityLogController.php
Normal file
127
app/Modules/Core/Controllers/ActivityLogController.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
|
class ActivityLogController extends Controller
|
||||||
|
{
|
||||||
|
private function getSubjectMap()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'App\Modules\Core\Models\User' => '使用者',
|
||||||
|
'App\Modules\Core\Models\Role' => '角色',
|
||||||
|
'App\Modules\Inventory\Models\Product' => '商品',
|
||||||
|
'App\Modules\Procurement\Models\Vendor' => '廠商',
|
||||||
|
'App\Modules\Inventory\Models\Category' => '商品分類',
|
||||||
|
'App\Modules\Inventory\Models\Unit' => '單位',
|
||||||
|
'App\Modules\Procurement\Models\PurchaseOrder' => '採購單',
|
||||||
|
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
|
||||||
|
'App\Modules\Inventory\Models\Inventory' => '庫存',
|
||||||
|
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
$sortBy = $request->input('sort_by', 'created_at');
|
||||||
|
$sortOrder = $request->input('sort_order', 'desc');
|
||||||
|
|
||||||
|
$search = $request->input('search');
|
||||||
|
$dateStart = $request->input('date_start');
|
||||||
|
$dateEnd = $request->input('date_end');
|
||||||
|
$event = $request->input('event');
|
||||||
|
$subjectType = $request->input('subject_type');
|
||||||
|
$causerId = $request->input('causer_id');
|
||||||
|
|
||||||
|
$query = Activity::with('causer');
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('description', 'like', "%{$search}%")
|
||||||
|
->orWhere('log_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('properties', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dateStart) {
|
||||||
|
$query->whereDate('created_at', '>=', $dateStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dateEnd) {
|
||||||
|
$query->whereDate('created_at', '<=', $dateEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event) {
|
||||||
|
$query->where('event', $event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subjectType) {
|
||||||
|
$query->where('subject_type', $subjectType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($causerId) {
|
||||||
|
$query->where('causer_id', $causerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sortBy === 'created_at') {
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
} else {
|
||||||
|
$query->latest();
|
||||||
|
}
|
||||||
|
|
||||||
|
$activities = $query->paginate($perPage)
|
||||||
|
->through(function ($activity) {
|
||||||
|
$subjectMap = $this->getSubjectMap();
|
||||||
|
|
||||||
|
$eventMap = [
|
||||||
|
'created' => '新增',
|
||||||
|
'updated' => '更新',
|
||||||
|
'deleted' => '刪除',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $activity->id,
|
||||||
|
'description' => $eventMap[$activity->event] ?? $activity->event,
|
||||||
|
'subject_type' => $subjectMap[$activity->subject_type] ?? class_basename($activity->subject_type),
|
||||||
|
'event' => $activity->event,
|
||||||
|
'causer' => $activity->causer ? $activity->causer->name : 'System',
|
||||||
|
'created_at' => $activity->created_at->format('Y-m-d H:i:s'),
|
||||||
|
'properties' => $activity->properties,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 準備用於前端篩選的主題類型
|
||||||
|
$subjectTypes = collect($this->getSubjectMap())->map(function ($label, $value) {
|
||||||
|
return ['label' => $label, 'value' => $value];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
// 取得用於操作者篩選的使用者
|
||||||
|
$users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
|
||||||
|
->map(function ($user) {
|
||||||
|
return ['label' => $user->name, 'value' => (string) $user->id];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Admin/ActivityLog/Index', [
|
||||||
|
'activities' => $activities,
|
||||||
|
'filters' => [
|
||||||
|
'per_page' => $request->input('per_page', '10'),
|
||||||
|
'sort_by' => $request->input('sort_by'),
|
||||||
|
'sort_order' => $request->input('sort_order'),
|
||||||
|
'search' => $request->input('search'),
|
||||||
|
'date_start' => $request->input('date_start'),
|
||||||
|
'date_end' => $request->input('date_end'),
|
||||||
|
'event' => $request->input('event'),
|
||||||
|
'subject_type' => $request->input('subject_type'),
|
||||||
|
'causer_id' => $request->input('causer_id'),
|
||||||
|
],
|
||||||
|
'subject_types' => $subjectTypes,
|
||||||
|
'users' => $users,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Modules/Core/Controllers/Auth/LoginController.php
Normal file
76
app/Modules/Core/Controllers/Auth/LoginController.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the login view.
|
||||||
|
*/
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
$centralDomains = config('tenancy.central_domains', []);
|
||||||
|
|
||||||
|
// [Hack] Demo 環境特殊規則
|
||||||
|
$demoPort = config('tenancy.demo_tenant_port');
|
||||||
|
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
|
||||||
|
return Inertia::render('Landlord/Auth/Login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Auth/Login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming authentication request.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'username' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
], [
|
||||||
|
'username.required' => '請輸入帳號',
|
||||||
|
'password.required' => '請輸入密碼',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credentials = $request->only('username', 'password');
|
||||||
|
|
||||||
|
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
$centralDomains = config('tenancy.central_domains', []);
|
||||||
|
$centralDomains = config('tenancy.central_domains', []);
|
||||||
|
// [Hack] Demo 環境特殊規則
|
||||||
|
$demoPort = config('tenancy.demo_tenant_port');
|
||||||
|
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
|
||||||
|
return redirect()->intended(route('landlord.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'username' => '帳號或密碼錯誤。',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy an authenticated session.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request)
|
||||||
|
{
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Modules/Core/Controllers/DashboardController.php
Normal file
51
app/Modules/Core/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
protected $inventoryService;
|
||||||
|
protected $procurementService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
InventoryServiceInterface $inventoryService,
|
||||||
|
ProcurementServiceInterface $procurementService
|
||||||
|
) {
|
||||||
|
$this->inventoryService = $inventoryService;
|
||||||
|
$this->procurementService = $procurementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$centralDomains = config('tenancy.central_domains', []);
|
||||||
|
|
||||||
|
$demoPort = config('tenancy.demo_tenant_port');
|
||||||
|
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
|
||||||
|
return redirect()->route('landlord.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invStats = $this->inventoryService->getDashboardStats();
|
||||||
|
$procStats = $this->procurementService->getDashboardStats();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'productsCount' => $invStats['productsCount'],
|
||||||
|
'vendorsCount' => $procStats['vendorsCount'],
|
||||||
|
'purchaseOrdersCount' => $procStats['purchaseOrdersCount'],
|
||||||
|
'warehousesCount' => $invStats['warehousesCount'],
|
||||||
|
'totalInventoryValue' => $invStats['totalInventoryQuantity'], // 原本前端命名是 totalInventoryValue 但實作是 Quantity,暫且保留欄位名以不破壞前端
|
||||||
|
'pendingOrdersCount' => $procStats['pendingOrdersCount'],
|
||||||
|
'lowStockCount' => $invStats['lowStockCount'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Dashboard', [
|
||||||
|
'stats' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Modules/Core/Controllers/ProfileController.php
Normal file
55
app/Modules/Core/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示使用者設定頁面
|
||||||
|
*/
|
||||||
|
public function edit(Request $request)
|
||||||
|
{
|
||||||
|
return Inertia::render('Profile/Edit', [
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新使用者基本資料
|
||||||
|
*/
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
|
||||||
|
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update($validated);
|
||||||
|
|
||||||
|
return back()->with('success', '個人資料已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新密碼
|
||||||
|
*/
|
||||||
|
public function updatePassword(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'current_password' => ['required', 'current_password'],
|
||||||
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', '密碼已更新');
|
||||||
|
}
|
||||||
|
}
|
||||||
210
app/Modules/Core/Controllers/RoleController.php
Normal file
210
app/Modules/Core/Controllers/RoleController.php
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class RoleController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示資源列表。
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$sortBy = $request->input('sort_by', 'id');
|
||||||
|
$sortOrder = $request->input('sort_order', 'asc');
|
||||||
|
|
||||||
|
$query = Role::withCount('users', 'permissions')
|
||||||
|
->with('users:id,name,username');
|
||||||
|
|
||||||
|
// 處理排序
|
||||||
|
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
} else {
|
||||||
|
$query->orderBy('id', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $query->get();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Role/Index', [
|
||||||
|
'roles' => $roles,
|
||||||
|
'filters' => $request->only(['sort_by', 'sort_order']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示建立新資源的表單。
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$permissions = $this->getGroupedPermissions();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Role/Create', [
|
||||||
|
'groupedPermissions' => $permissions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將新建立的資源儲存到儲存體中。
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
|
||||||
|
'display_name' => ['required', 'string', 'max:255'],
|
||||||
|
'permissions' => ['array'],
|
||||||
|
'permissions.*' => ['exists:permissions,name']
|
||||||
|
]);
|
||||||
|
|
||||||
|
$role = Role::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'display_name' => $validated['display_name']
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($validated['permissions'])) {
|
||||||
|
$role->syncPermissions($validated['permissions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('roles.index')->with('success', '角色建立成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示編輯指定資源的表單。
|
||||||
|
*/
|
||||||
|
public function edit(string $id)
|
||||||
|
{
|
||||||
|
$role = Role::with('permissions')->findOrFail($id);
|
||||||
|
|
||||||
|
// 禁止編輯超級管理員角色
|
||||||
|
if ($role->name === 'super-admin') {
|
||||||
|
return redirect()->route('roles.index')->with('error', '超級管理員角色不可編輯');
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupedPermissions = $this->getGroupedPermissions();
|
||||||
|
$currentPermissions = $role->permissions->pluck('name')->toArray();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Role/Edit', [
|
||||||
|
'role' => $role,
|
||||||
|
'groupedPermissions' => $groupedPermissions,
|
||||||
|
'currentPermissions' => $currentPermissions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新儲存體中的指定資源。
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$role = Role::findOrFail($id);
|
||||||
|
|
||||||
|
if ($role->name === 'super-admin') {
|
||||||
|
return redirect()->route('roles.index')->with('error', '超級管理員角色不可變更');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($role->id)],
|
||||||
|
'display_name' => ['required', 'string', 'max:255'],
|
||||||
|
'permissions' => ['array'],
|
||||||
|
'permissions.*' => ['exists:permissions,name']
|
||||||
|
]);
|
||||||
|
|
||||||
|
$role->update([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'display_name' => $validated['display_name']
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($validated['permissions'])) {
|
||||||
|
$role->syncPermissions($validated['permissions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('roles.index')->with('success', '角色更新成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從儲存體中移除指定資源。
|
||||||
|
*/
|
||||||
|
public function destroy(string $id)
|
||||||
|
{
|
||||||
|
$role = Role::withCount('users')->findOrFail($id);
|
||||||
|
|
||||||
|
if ($role->name === 'super-admin') {
|
||||||
|
return back()->with('error', '超級管理員角色不可刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($role->users_count > 0) {
|
||||||
|
return back()->with('error', "尚有 {$role->users_count} 位使用者屬於此角色,無法刪除");
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->delete();
|
||||||
|
|
||||||
|
return redirect()->route('roles.index')->with('success', '角色已刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得並分組權限
|
||||||
|
*/
|
||||||
|
private function getGroupedPermissions()
|
||||||
|
{
|
||||||
|
$allPermissions = Permission::orderBy('name')->get();
|
||||||
|
$grouped = [];
|
||||||
|
|
||||||
|
foreach ($allPermissions as $permission) {
|
||||||
|
$parts = explode('.', $permission->name);
|
||||||
|
$group = $parts[0];
|
||||||
|
$action = $parts[1] ?? '';
|
||||||
|
|
||||||
|
// 特定權限遷移邏輯
|
||||||
|
if ($permission->name === 'inventory.transfer') {
|
||||||
|
$group = 'warehouses'; // 調撥功能移至倉庫管理下
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($grouped[$group])) {
|
||||||
|
$grouped[$group] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$grouped[$group][] = $permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 依照側邊欄順序定義
|
||||||
|
$groupDefinitions = [
|
||||||
|
'products' => '商品資料管理',
|
||||||
|
'warehouses' => '倉庫管理',
|
||||||
|
'inventory' => '庫存管理',
|
||||||
|
'vendors' => '廠商資料管理',
|
||||||
|
'purchase_orders' => '採購單管理',
|
||||||
|
'users' => '使用者管理',
|
||||||
|
'roles' => '角色與權限',
|
||||||
|
'utility_fees' => '公共事業費管理',
|
||||||
|
'accounting' => '會計報表',
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($groupDefinitions as $key => $displayName) {
|
||||||
|
if (isset($grouped[$key])) {
|
||||||
|
$result[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'name' => $displayName,
|
||||||
|
'permissions' => $grouped[$key]
|
||||||
|
];
|
||||||
|
unset($grouped[$key]); // 從待處理中移除
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理剩餘未定義在 groupDefinitions 中的群組 (安全機制)
|
||||||
|
foreach ($grouped as $key => $permissions) {
|
||||||
|
$result[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'name' => ucfirst($key),
|
||||||
|
'permissions' => $permissions
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
app/Modules/Core/Controllers/UserController.php
Normal file
245
app/Modules/Core/Controllers/UserController.php
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Modules\Core\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示資源列表。
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
$sortBy = $request->input('sort_by', 'id');
|
||||||
|
$sortOrder = $request->input('sort_order', 'asc');
|
||||||
|
$search = $request->input('search');
|
||||||
|
$roleId = $request->input('role');
|
||||||
|
|
||||||
|
$query = User::with(['roles:id,name,display_name']);
|
||||||
|
|
||||||
|
// 處理搜尋
|
||||||
|
if ($search) {
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%")
|
||||||
|
->orWhere('username', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理角色篩選
|
||||||
|
if ($roleId && $roleId !== 'all') {
|
||||||
|
$query->whereHas('roles', function ($q) use ($roleId) {
|
||||||
|
$q->where('id', $roleId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理排序
|
||||||
|
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
} else {
|
||||||
|
$query->orderBy('id', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $query->paginate($perPage)->withQueryString();
|
||||||
|
$roles = Role::select('id', 'name', 'display_name')->get();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/User/Index', [
|
||||||
|
'users' => $users,
|
||||||
|
'roles' => $roles,
|
||||||
|
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示建立新資源的表單。
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$roles = Role::pluck('display_name', 'name');
|
||||||
|
|
||||||
|
return Inertia::render('Admin/User/Create', [
|
||||||
|
'roles' => $roles
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將新建立的資源儲存到儲存體中。
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'],
|
||||||
|
'username' => ['required', 'string', 'max:255', 'unique:users'],
|
||||||
|
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||||
|
'roles' => ['array'],
|
||||||
|
], [
|
||||||
|
'password.required' => '請輸入密碼',
|
||||||
|
'password.min' => '密碼長度至少需 :min 個字元',
|
||||||
|
'password.confirmed' => '密碼確認不符',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'username' => $validated['username'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($validated['roles'])) {
|
||||||
|
$user->syncRoles($validated['roles']);
|
||||||
|
|
||||||
|
// 更新 'created' 紀錄以包含角色資訊
|
||||||
|
$activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user))
|
||||||
|
->where('subject_id', $user->id)
|
||||||
|
->where('event', 'created')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($activity) {
|
||||||
|
$roleNames = $user->roles()->pluck('display_name')->join(', ');
|
||||||
|
$properties = $activity->properties->toArray();
|
||||||
|
$properties['attributes']['role_id'] = $roleNames;
|
||||||
|
$activity->properties = $properties;
|
||||||
|
$activity->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', '使用者建立成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示編輯指定資源的表單。
|
||||||
|
*/
|
||||||
|
public function edit(string $id)
|
||||||
|
{
|
||||||
|
$user = User::with('roles')->findOrFail($id);
|
||||||
|
$roles = Role::get(['id', 'name', 'display_name']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/User/Edit', [
|
||||||
|
'user' => $user,
|
||||||
|
'roles' => $roles,
|
||||||
|
'currentRoles' => $user->getRoleNames()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新儲存體中的指定資源。
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||||
|
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||||
|
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||||
|
'roles' => ['array'],
|
||||||
|
], [
|
||||||
|
'password.min' => '密碼長度至少需 :min 個字元',
|
||||||
|
'password.confirmed' => '密碼確認不符',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1. 準備資料並偵測變更
|
||||||
|
$userData = [
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'username' => $validated['username'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($validated['password'])) {
|
||||||
|
$userData['password'] = Hash::make($validated['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->fill($userData);
|
||||||
|
|
||||||
|
// 捕捉變更屬性以進行手動記錄
|
||||||
|
$dirty = $user->getDirty();
|
||||||
|
$oldAttributes = [];
|
||||||
|
$newAttributes = [];
|
||||||
|
|
||||||
|
foreach ($dirty as $key => $value) {
|
||||||
|
$oldAttributes[$key] = $user->getOriginal($key);
|
||||||
|
$newAttributes[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 儲存但不觸發事件(防止重複記錄)
|
||||||
|
$user->saveQuietly();
|
||||||
|
|
||||||
|
// 2. 處理角色
|
||||||
|
$roleChanges = null;
|
||||||
|
if (isset($validated['roles'])) {
|
||||||
|
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||||
|
$user->syncRoles($validated['roles']);
|
||||||
|
$newRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||||
|
|
||||||
|
if ($oldRoles !== $newRoles) {
|
||||||
|
$roleChanges = [
|
||||||
|
'old' => $oldRoles,
|
||||||
|
'new' => $newRoles
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 手動記錄活動(單一整合記錄)
|
||||||
|
if (!empty($newAttributes) || $roleChanges) {
|
||||||
|
$properties = [
|
||||||
|
'attributes' => $newAttributes,
|
||||||
|
'old' => $oldAttributes,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($roleChanges) {
|
||||||
|
$properties['attributes']['role_id'] = $roleChanges['new'];
|
||||||
|
$properties['old']['role_id'] = $roleChanges['old'];
|
||||||
|
}
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($user)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->event('updated')
|
||||||
|
->withProperties($properties)
|
||||||
|
->tap(function (\Spatie\Activitylog\Contracts\Activity $activity) use ($user) {
|
||||||
|
// 手動加入快照,因為使用 saveQuietly 所以不使用模型的 LogOptions
|
||||||
|
$activity->properties = $activity->properties->merge([
|
||||||
|
'snapshot' => [
|
||||||
|
'name' => $user->name,
|
||||||
|
'username' => $user->username,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->log('updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', '使用者更新成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從儲存體中移除指定資源。
|
||||||
|
*/
|
||||||
|
public function destroy(string $id)
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
|
if ($user->hasRole('super-admin')) {
|
||||||
|
return back()->with('error', '無法刪除超級管理員帳號');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->id === auth()->id()) {
|
||||||
|
return back()->with('error', '無法刪除自己');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', '使用者已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Modules/Core/CoreServiceProvider.php
Normal file
20
app/Modules/Core/CoreServiceProvider.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||||
|
use App\Modules\Core\Services\CoreService;
|
||||||
|
|
||||||
|
class CoreServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->bind(CoreServiceInterface::class, CoreService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Modules/Core/Models/Role.php
Normal file
20
app/Modules/Core/Models/Role.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Models;
|
||||||
|
|
||||||
|
use Spatie\Permission\Models\Role as SpatieRole;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class Role extends SpatieRole
|
||||||
|
{
|
||||||
|
use LogsActivity;
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Modules/Core/Models/Tenant.php
Normal file
34
app/Modules/Core/Models/Tenant.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Models;
|
||||||
|
|
||||||
|
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
|
||||||
|
use Stancl\Tenancy\Contracts\TenantWithDatabase;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\HasDatabase;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\HasDomains;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租戶 Model
|
||||||
|
*
|
||||||
|
* 代表 ERP 系統中的每一個客戶公司 (如:小小冰室、酒水客戶等)
|
||||||
|
*
|
||||||
|
* 自訂屬性 (存在 data JSON 欄位中,可透過 $tenant->name 存取):
|
||||||
|
* - name: 租戶名稱 (如: 小小冰室)
|
||||||
|
* - email: 聯絡信箱
|
||||||
|
* - is_active: 是否啟用
|
||||||
|
*/
|
||||||
|
class Tenant extends BaseTenant implements TenantWithDatabase
|
||||||
|
{
|
||||||
|
use HasDatabase, HasDomains;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定義獨立欄位 (非 data JSON)
|
||||||
|
* 只有 id 是獨立欄位,其他自訂屬性都存在 data JSON 中
|
||||||
|
*/
|
||||||
|
public static function getCustomColumns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Modules/Core/Models/User.php
Normal file
80
app/Modules/Core/Models/User.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Models;
|
||||||
|
|
||||||
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
|
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可批量賦值的屬性。
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 建立模型的新工廠實例。
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Factories\Factory
|
||||||
|
*/
|
||||||
|
protected static function newFactory()
|
||||||
|
{
|
||||||
|
return \Database\Factories\UserFactory::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化時應隱藏的屬性。
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得應進行轉換的屬性。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$activity->properties = $activity->properties->merge([
|
||||||
|
'snapshot' => [
|
||||||
|
'name' => $this->name,
|
||||||
|
'username' => $this->username,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Modules/Core/Routes/web.php
Normal file
54
app/Modules/Core/Routes/web.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Modules\Core\Controllers\Auth\LoginController;
|
||||||
|
use App\Modules\Core\Controllers\DashboardController;
|
||||||
|
use App\Modules\Core\Controllers\ProfileController;
|
||||||
|
use App\Modules\Core\Controllers\RoleController;
|
||||||
|
use App\Modules\Core\Controllers\UserController;
|
||||||
|
use App\Modules\Core\Controllers\ActivityLogController;
|
||||||
|
|
||||||
|
// 登入/登出路由
|
||||||
|
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
||||||
|
Route::post('/login', [LoginController::class, 'store']);
|
||||||
|
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
||||||
|
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
// 儀表板 - 所有登入使用者皆可存取
|
||||||
|
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
|
// 使用者帳號設定
|
||||||
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
|
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
|
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
|
||||||
|
|
||||||
|
// 系統管理
|
||||||
|
Route::prefix('admin')->group(function () {
|
||||||
|
Route::middleware('permission:roles.view')->group(function () {
|
||||||
|
Route::get('/roles', [RoleController::class, 'index'])->name('roles.index');
|
||||||
|
Route::middleware('permission:roles.create')->group(function () {
|
||||||
|
Route::get('/roles/create', [RoleController::class, 'create'])->name('roles.create');
|
||||||
|
Route::post('/roles', [RoleController::class, 'store'])->name('roles.store');
|
||||||
|
});
|
||||||
|
Route::get('/roles/{role}/edit', [RoleController::class, 'edit'])->middleware('permission:roles.edit')->name('roles.edit');
|
||||||
|
Route::put('/roles/{role}', [RoleController::class, 'update'])->middleware('permission:roles.edit')->name('roles.update');
|
||||||
|
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('permission:roles.delete')->name('roles.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware('permission:users.view')->group(function () {
|
||||||
|
Route::get('/users', [UserController::class, 'index'])->name('users.index');
|
||||||
|
Route::middleware('permission:users.create')->group(function () {
|
||||||
|
Route::get('/users/create', [UserController::class, 'create'])->name('users.create');
|
||||||
|
Route::post('/users', [UserController::class, 'store'])->name('users.store');
|
||||||
|
});
|
||||||
|
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
|
||||||
|
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
||||||
|
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware('permission:system.view_logs')->group(function () {
|
||||||
|
Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
55
app/Modules/Core/Services/CoreService.php
Normal file
55
app/Modules/Core/Services/CoreService.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Core\Services;
|
||||||
|
|
||||||
|
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||||
|
use App\Modules\Core\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class CoreService implements CoreServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get multiple users by their IDs.
|
||||||
|
*
|
||||||
|
* @param array $ids
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function getUsersByIds(array $ids): Collection
|
||||||
|
{
|
||||||
|
return User::whereIn('id', $ids)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific user by ID.
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
public function getUser(int $id): ?object
|
||||||
|
{
|
||||||
|
return User::find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users.
|
||||||
|
*
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function getAllUsers(): Collection
|
||||||
|
{
|
||||||
|
return User::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureSystemUserExists()
|
||||||
|
{
|
||||||
|
$user = User::first();
|
||||||
|
if (!$user) {
|
||||||
|
$user = User::create([
|
||||||
|
'name' => '系統管理員',
|
||||||
|
'email' => 'admin@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Modules/Finance/Contracts/FinanceServiceInterface.php
Normal file
32
app/Modules/Finance/Contracts/FinanceServiceInterface.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Finance\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
interface FinanceServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get accounting report data.
|
||||||
|
*
|
||||||
|
* @param string $start
|
||||||
|
* @param string $end
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getAccountingReportData(string $start, string $end): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all utility fees with filters.
|
||||||
|
*
|
||||||
|
* @param array $filters
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function getUtilityFees(array $filters);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique categories of utility fees.
|
||||||
|
*
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function getUniqueCategories(): Collection;
|
||||||
|
}
|
||||||
100
app/Modules/Finance/Controllers/AccountingReportController.php
Normal file
100
app/Modules/Finance/Controllers/AccountingReportController.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Finance\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class AccountingReportController extends Controller
|
||||||
|
{
|
||||||
|
protected $financeService;
|
||||||
|
|
||||||
|
public function __construct(FinanceServiceInterface $financeService)
|
||||||
|
{
|
||||||
|
$this->financeService = $financeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
|
||||||
|
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
||||||
|
|
||||||
|
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
|
||||||
|
$allRecords = $reportData['records'];
|
||||||
|
|
||||||
|
// 3. Manual Pagination
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
$page = $request->input('page', 1);
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$paginatedRecords = new LengthAwarePaginator(
|
||||||
|
$allRecords->slice($offset, $perPage)->values(),
|
||||||
|
$allRecords->count(),
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
['path' => $request->url(), 'query' => $request->query()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return Inertia::render('Accounting/Report', [
|
||||||
|
'records' => $paginatedRecords,
|
||||||
|
'summary' => $reportData['summary'],
|
||||||
|
'filters' => [
|
||||||
|
'date_start' => $dateStart,
|
||||||
|
'date_end' => $dateEnd,
|
||||||
|
'per_page' => (int)$perPage,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(Request $request)
|
||||||
|
{
|
||||||
|
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
|
||||||
|
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
||||||
|
$selectedIdsParam = $request->input('selected_ids');
|
||||||
|
|
||||||
|
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
|
||||||
|
$allRecords = $reportData['records'];
|
||||||
|
|
||||||
|
if ($selectedIdsParam) {
|
||||||
|
$ids = explode(',', $selectedIdsParam);
|
||||||
|
$allRecords = $allRecords->whereIn('id', $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
$exportData = $allRecords->map(function ($record) {
|
||||||
|
return [
|
||||||
|
$record['date'],
|
||||||
|
$record['source'],
|
||||||
|
$record['category'],
|
||||||
|
$record['item'],
|
||||||
|
$record['reference'],
|
||||||
|
$record['invoice_number'],
|
||||||
|
$record['amount'],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||||
|
];
|
||||||
|
|
||||||
|
$callback = function () use ($exportData) {
|
||||||
|
$file = fopen('php://output', 'w');
|
||||||
|
// BOM for Excel compatibility with UTF-8
|
||||||
|
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||||
|
|
||||||
|
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
|
||||||
|
|
||||||
|
foreach ($exportData as $row) {
|
||||||
|
fputcsv($file, $row);
|
||||||
|
}
|
||||||
|
fclose($file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return response()->stream($callback, 200, $headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Modules/Finance/Controllers/UtilityFeeController.php
Normal file
88
app/Modules/Finance/Controllers/UtilityFeeController.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Finance\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Finance\Models\UtilityFee;
|
||||||
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class UtilityFeeController extends Controller
|
||||||
|
{
|
||||||
|
protected $financeService;
|
||||||
|
|
||||||
|
public function __construct(FinanceServiceInterface $financeService)
|
||||||
|
{
|
||||||
|
$this->financeService = $financeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$filters = $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']);
|
||||||
|
|
||||||
|
$fees = $this->financeService->getUtilityFees($filters)->withQueryString();
|
||||||
|
$availableCategories = $this->financeService->getUniqueCategories();
|
||||||
|
|
||||||
|
return Inertia::render('UtilityFee/Index', [
|
||||||
|
'fees' => $fees,
|
||||||
|
'availableCategories' => $availableCategories,
|
||||||
|
'filters' => $filters,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'transaction_date' => 'required|date',
|
||||||
|
'category' => 'required|string|max:255',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'invoice_number' => 'nullable|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fee = UtilityFee::create($validated);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($fee)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->event('created')
|
||||||
|
->log('created');
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, UtilityFee $utility_fee)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'transaction_date' => 'required|date',
|
||||||
|
'category' => 'required|string|max:255',
|
||||||
|
'amount' => 'required|numeric|min:0',
|
||||||
|
'invoice_number' => 'nullable|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utility_fee->update($validated);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($utility_fee)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->event('updated')
|
||||||
|
->log('updated');
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(UtilityFee $utility_fee)
|
||||||
|
{
|
||||||
|
activity()
|
||||||
|
->performedOn($utility_fee)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->event('deleted')
|
||||||
|
->log('deleted');
|
||||||
|
|
||||||
|
$utility_fee->delete();
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Modules/Finance/FinanceServiceProvider.php
Normal file
20
app/Modules/Finance/FinanceServiceProvider.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Finance;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
|
use App\Modules\Finance\Services\FinanceService;
|
||||||
|
|
||||||
|
class FinanceServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->bind(FinanceServiceInterface::class, FinanceService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Modules/Finance/Models/UtilityFee.php
Normal file
35
app/Modules/Finance/Models/UtilityFee.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Finance\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class UtilityFee extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'transaction_date',
|
||||||
|
'category',
|
||||||
|
'amount',
|
||||||
|
'invoice_number',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'transaction_date' => 'date',
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$activity->properties = $activity->properties->put('snapshot', [
|
||||||
|
'transaction_date' => $this->transaction_date->format('Y-m-d'),
|
||||||
|
'category' => $this->category,
|
||||||
|
'amount' => $this->amount,
|
||||||
|
'invoice_number' => $this->invoice_number,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Modules/Finance/Routes/web.php
Normal file
29
app/Modules/Finance/Routes/web.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Modules\Finance\Controllers\UtilityFeeController;
|
||||||
|
use App\Modules\Finance\Controllers\AccountingReportController;
|
||||||
|
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
// 公共事業費管理
|
||||||
|
Route::middleware('permission:utility_fees.view')->group(function () {
|
||||||
|
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
|
||||||
|
});
|
||||||
|
Route::middleware('permission:utility_fees.create')->group(function () {
|
||||||
|
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
|
||||||
|
});
|
||||||
|
Route::middleware('permission:utility_fees.edit')->group(function () {
|
||||||
|
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update');
|
||||||
|
});
|
||||||
|
Route::middleware('permission:utility_fees.delete')->group(function () {
|
||||||
|
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 會計報表
|
||||||
|
Route::middleware('permission:accounting.view')->prefix('accounting-report')->group(function () {
|
||||||
|
Route::get('/', [AccountingReportController::class, 'index'])->name('accounting.report');
|
||||||
|
Route::get('/export', [AccountingReportController::class, 'export'])
|
||||||
|
->middleware('permission:accounting.export')
|
||||||
|
->name('accounting.export');
|
||||||
|
});
|
||||||
|
});
|
||||||
104
app/Modules/Finance/Services/FinanceService.php
Normal file
104
app/Modules/Finance/Services/FinanceService.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Finance\Services;
|
||||||
|
|
||||||
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
|
use App\Modules\Finance\Models\UtilityFee;
|
||||||
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class FinanceService implements FinanceServiceInterface
|
||||||
|
{
|
||||||
|
protected $procurementService;
|
||||||
|
|
||||||
|
public function __construct(ProcurementServiceInterface $procurementService)
|
||||||
|
{
|
||||||
|
$this->procurementService = $procurementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAccountingReportData(string $start, string $end): array
|
||||||
|
{
|
||||||
|
// 1. 獲取採購單資料
|
||||||
|
$purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end)
|
||||||
|
->map(function ($po) {
|
||||||
|
return [
|
||||||
|
'id' => 'PO-' . $po->id,
|
||||||
|
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
|
||||||
|
'source' => '採購單',
|
||||||
|
'category' => '進貨支出',
|
||||||
|
'item' => $po->vendor->name ?? '未知廠商',
|
||||||
|
'reference' => $po->code,
|
||||||
|
'invoice_number' => $po->invoice_number,
|
||||||
|
'amount' => (float)$po->grand_total,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date)
|
||||||
|
$utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end])
|
||||||
|
->get()
|
||||||
|
->map(function ($fee) {
|
||||||
|
return [
|
||||||
|
'id' => 'UF-' . $fee->id,
|
||||||
|
'date' => $fee->transaction_date->format('Y-m-d'),
|
||||||
|
'source' => '公共事業費',
|
||||||
|
'category' => $fee->category,
|
||||||
|
'item' => $fee->description ?: $fee->category,
|
||||||
|
'reference' => '-',
|
||||||
|
'invoice_number' => $fee->invoice_number,
|
||||||
|
'amount' => (float)$fee->amount,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$allRecords = $purchaseOrders->concat($utilityFees)
|
||||||
|
->sortByDesc('date')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'records' => $allRecords,
|
||||||
|
'summary' => [
|
||||||
|
'total_amount' => $allRecords->sum('amount'),
|
||||||
|
'purchase_total' => $purchaseOrders->sum('amount'),
|
||||||
|
'utility_total' => $utilityFees->sum('amount'),
|
||||||
|
'record_count' => $allRecords->count(),
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUtilityFees(array $filters)
|
||||||
|
{
|
||||||
|
$query = UtilityFee::query();
|
||||||
|
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where(function($q) use ($search) {
|
||||||
|
$q->where('category', 'like', "%{$search}%")
|
||||||
|
->orWhere('invoice_number', 'like', "%{$search}%")
|
||||||
|
->orWhere('description', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['category']) && $filters['category'] !== 'all') {
|
||||||
|
$query->where('category', $filters['category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['date_start'])) {
|
||||||
|
$query->where('transaction_date', '>=', $filters['date_start']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['date_end'])) {
|
||||||
|
$query->where('transaction_date', '<=', $filters['date_end']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortField = $filters['sort_field'] ?? 'created_at';
|
||||||
|
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||||
|
$query->orderBy($sortField, $sortDirection);
|
||||||
|
|
||||||
|
return $query->paginate($filters['per_page'] ?? 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUniqueCategories(): Collection
|
||||||
|
{
|
||||||
|
return UtilityFee::distinct()->pluck('category');
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/Modules/Inventory/Contracts/InventoryServiceInterface.php
Normal file
115
app/Modules/Inventory/Contracts/InventoryServiceInterface.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Contracts;
|
||||||
|
|
||||||
|
interface InventoryServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if a product has sufficient stock in a specific warehouse.
|
||||||
|
*
|
||||||
|
* @param int $productId
|
||||||
|
* @param int $warehouseId
|
||||||
|
* @param float $quantity
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrease stock for a product (e.g., when an order is placed).
|
||||||
|
*
|
||||||
|
* @param int $productId
|
||||||
|
* @param int $warehouseId
|
||||||
|
* @param float $quantity
|
||||||
|
* @param string|null $reason
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active warehouses.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getAllWarehouses();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple products by their IDs.
|
||||||
|
*
|
||||||
|
* @param array $ids
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getProductsByIds(array $ids);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search products by name.
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getProductsByName(string $name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific product by ID.
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
public function getProduct(int $id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific warehouse by ID.
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
public function getWarehouse(int $id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available inventories in a specific warehouse.
|
||||||
|
*
|
||||||
|
* @param int $warehouseId
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getInventoriesByWarehouse(int $warehouseId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all products.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getAllProducts();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all units.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getUnits();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new inventory record (e.g., for finished goods).
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
public function createInventoryRecord(array $data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrease quantity of a specific inventory record.
|
||||||
|
*
|
||||||
|
* @param int $inventoryId
|
||||||
|
* @param float $quantity
|
||||||
|
* @param string|null $reason
|
||||||
|
* @param string|null $referenceType
|
||||||
|
* @param int|string|null $referenceId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for the dashboard.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getDashboardStats(): array;
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Models\Category;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class CategoryController extends Controller
|
class CategoryController extends Controller
|
||||||
530
app/Modules/Inventory/Controllers/InventoryController.php
Normal file
530
app/Modules/Inventory/Controllers/InventoryController.php
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
|
use App\Modules\Inventory\Models\Product;
|
||||||
|
use App\Modules\Inventory\Models\Inventory;
|
||||||
|
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||||
|
|
||||||
|
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||||
|
|
||||||
|
class InventoryController extends Controller
|
||||||
|
{
|
||||||
|
protected $coreService;
|
||||||
|
|
||||||
|
public function __construct(CoreServiceInterface $coreService)
|
||||||
|
{
|
||||||
|
$this->coreService = $coreService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request, Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
// ... (existing code for index) ...
|
||||||
|
$warehouse->load([
|
||||||
|
'inventories.product.category',
|
||||||
|
'inventories.product.baseUnit',
|
||||||
|
'inventories.lastIncomingTransaction',
|
||||||
|
'inventories.lastOutgoingTransaction'
|
||||||
|
]);
|
||||||
|
$allProducts = Product::with('category')->get();
|
||||||
|
|
||||||
|
// 1. 準備 availableProducts
|
||||||
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
|
return [
|
||||||
|
'id' => (string) $product->id,
|
||||||
|
'name' => $product->name,
|
||||||
|
'type' => $product->category?->name ?? '其他',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 從新表格讀取安全庫存設定 (商品-倉庫層級)
|
||||||
|
$safetyStockMap = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||||
|
->pluck('safety_stock', 'product_id')
|
||||||
|
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
||||||
|
|
||||||
|
// 3. 準備 inventories (批號分組)
|
||||||
|
$items = $warehouse->inventories()
|
||||||
|
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$inventories = $items->groupBy('product_id')->map(function ($batchItems) use ($safetyStockMap) {
|
||||||
|
$firstItem = $batchItems->first();
|
||||||
|
$product = $firstItem->product;
|
||||||
|
$totalQuantity = $batchItems->sum('quantity');
|
||||||
|
$totalValue = $batchItems->sum('total_value'); // 計算總價值
|
||||||
|
|
||||||
|
// 從獨立表格讀取安全庫存
|
||||||
|
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
|
||||||
|
|
||||||
|
// 計算狀態
|
||||||
|
$status = '正常';
|
||||||
|
if (!is_null($safetyStock)) {
|
||||||
|
if ($totalQuantity < $safetyStock) {
|
||||||
|
$status = '低於';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'productId' => (string) $firstItem->product_id,
|
||||||
|
'productName' => $product?->name ?? '未知商品',
|
||||||
|
'productCode' => $product?->code ?? 'N/A',
|
||||||
|
'baseUnit' => $product?->baseUnit?->name ?? '個',
|
||||||
|
'totalQuantity' => (float) $totalQuantity,
|
||||||
|
'totalValue' => (float) $totalValue,
|
||||||
|
'safetyStock' => $safetyStock,
|
||||||
|
'status' => $status,
|
||||||
|
'batches' => $batchItems->map(function ($inv) {
|
||||||
|
return [
|
||||||
|
'id' => (string) $inv->id,
|
||||||
|
'warehouseId' => (string) $inv->warehouse_id,
|
||||||
|
'productId' => (string) $inv->product_id,
|
||||||
|
'productName' => $inv->product?->name ?? '未知商品',
|
||||||
|
'productCode' => $inv->product?->code ?? 'N/A',
|
||||||
|
'unit' => $inv->product?->baseUnit?->name ?? '個',
|
||||||
|
'quantity' => (float) $inv->quantity,
|
||||||
|
'unit_cost' => (float) $inv->unit_cost,
|
||||||
|
'total_value' => (float) $inv->total_value,
|
||||||
|
'safetyStock' => null, // 批號層級不再有安全庫存
|
||||||
|
'status' => '正常',
|
||||||
|
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
||||||
|
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
|
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||||
|
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||||
|
];
|
||||||
|
})->values(),
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
// 4. 準備 safetyStockSettings (從新表格讀取)
|
||||||
|
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||||
|
->with(['product.category'])
|
||||||
|
->get()
|
||||||
|
->map(function ($setting) {
|
||||||
|
return [
|
||||||
|
'id' => (string) $setting->id,
|
||||||
|
'warehouseId' => (string) $setting->warehouse_id,
|
||||||
|
'productId' => (string) $setting->product_id,
|
||||||
|
'productName' => $setting->product?->name ?? '未知商品',
|
||||||
|
'productType' => $setting->product?->category?->name ?? '其他',
|
||||||
|
'safetyStock' => (float) $setting->safety_stock,
|
||||||
|
'createdAt' => $setting->created_at->toIso8601String(),
|
||||||
|
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Warehouse/Inventory', [
|
||||||
|
'warehouse' => $warehouse,
|
||||||
|
'inventories' => $inventories,
|
||||||
|
'safetyStockSettings' => $safetyStockSettings,
|
||||||
|
'availableProducts' => $availableProducts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
// ... (unchanged) ...
|
||||||
|
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||||
|
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
||||||
|
->get()
|
||||||
|
->map(function ($product) {
|
||||||
|
return [
|
||||||
|
'id' => (string) $product->id,
|
||||||
|
'name' => $product->name,
|
||||||
|
'code' => $product->code,
|
||||||
|
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||||
|
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||||
|
'conversionRate' => (float) $product->conversion_rate,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Warehouse/AddInventory', [
|
||||||
|
'warehouse' => $warehouse,
|
||||||
|
'products' => $products,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
// ... (unchanged) ...
|
||||||
|
$validated = $request->validate([
|
||||||
|
'inboundDate' => 'required|date',
|
||||||
|
'reason' => 'required|string',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.productId' => 'required|exists:products,id',
|
||||||
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
|
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
|
||||||
|
'items.*.batchMode' => 'required|in:existing,new',
|
||||||
|
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
|
||||||
|
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
|
||||||
|
'items.*.expiryDate' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($validated, $warehouse) {
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
// ... (略,傳遞 unit_cost 交給 Service 處理) ...
|
||||||
|
// 這裡需要修改呼叫 Service 的地方或直接更新邏輯
|
||||||
|
// 為求快速,我將在此更新邏輯
|
||||||
|
|
||||||
|
$inventory = null;
|
||||||
|
|
||||||
|
if ($item['batchMode'] === 'existing') {
|
||||||
|
// 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加)
|
||||||
|
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
||||||
|
if ($inventory->trashed()) {
|
||||||
|
$inventory->restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成本 (若有傳入)
|
||||||
|
if (isset($item['unit_cost'])) {
|
||||||
|
$inventory->unit_cost = $item['unit_cost'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 模式 B:建立新批號
|
||||||
|
$originCountry = $item['originCountry'] ?? 'TW';
|
||||||
|
$product = Product::find($item['productId']);
|
||||||
|
|
||||||
|
$batchNumber = Inventory::generateBatchNumber(
|
||||||
|
$product->code ?? 'UNK',
|
||||||
|
$originCountry,
|
||||||
|
$validated['inboundDate']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 檢查是否存在
|
||||||
|
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
|
||||||
|
[
|
||||||
|
'product_id' => $item['productId'],
|
||||||
|
'batch_number' => $batchNumber
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quantity' => 0,
|
||||||
|
'unit_cost' => $item['unit_cost'] ?? 0, // 新增
|
||||||
|
'total_value' => 0, // 稍後計算
|
||||||
|
'arrival_date' => $validated['inboundDate'],
|
||||||
|
'expiry_date' => $item['expiryDate'] ?? null,
|
||||||
|
'origin_country' => $originCountry,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($inventory->trashed()) {
|
||||||
|
$inventory->restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentQty = $inventory->quantity;
|
||||||
|
$newQty = $currentQty + $item['quantity'];
|
||||||
|
|
||||||
|
$inventory->quantity = $newQty;
|
||||||
|
// 更新總價值
|
||||||
|
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||||
|
$inventory->save();
|
||||||
|
|
||||||
|
// 寫入異動紀錄
|
||||||
|
$inventory->transactions()->create([
|
||||||
|
'type' => '手動入庫',
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_cost' => $inventory->unit_cost, // 記錄成本
|
||||||
|
'balance_before' => $currentQty,
|
||||||
|
'balance_after' => $newQty,
|
||||||
|
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
|
||||||
|
'actual_time' => $validated['inboundDate'],
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||||
|
->with('success', '庫存記錄已儲存成功');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (getBatches unchanged) ...
|
||||||
|
public function getBatches(Warehouse $warehouse, $productId, Request $request)
|
||||||
|
{
|
||||||
|
$originCountry = $request->query('originCountry', 'TW');
|
||||||
|
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
|
||||||
|
|
||||||
|
$batches = Inventory::where('warehouse_id', $warehouse->id)
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->get()
|
||||||
|
->map(function ($inventory) {
|
||||||
|
return [
|
||||||
|
'inventoryId' => (string) $inventory->id,
|
||||||
|
'batchNumber' => $inventory->batch_number,
|
||||||
|
'originCountry' => $inventory->origin_country,
|
||||||
|
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
|
||||||
|
'quantity' => (float) $inventory->quantity,
|
||||||
|
'unitCost' => (float) $inventory->unit_cost, // 新增
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 計算下一個流水號
|
||||||
|
$product = Product::find($productId);
|
||||||
|
$nextSequence = '01';
|
||||||
|
if ($product) {
|
||||||
|
$batchNumber = Inventory::generateBatchNumber(
|
||||||
|
$product->code ?? 'UNK',
|
||||||
|
$originCountry,
|
||||||
|
$arrivalDate
|
||||||
|
);
|
||||||
|
$nextSequence = substr($batchNumber, -2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'batches' => $batches,
|
||||||
|
'nextSequence' => $nextSequence
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function edit(Request $request, Warehouse $warehouse, $inventoryId)
|
||||||
|
{
|
||||||
|
if (str_starts_with($inventoryId, 'mock-inv-')) {
|
||||||
|
return redirect()->back()->with('error', '無法編輯範例資料');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 'transactions.user' 預載入
|
||||||
|
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||||
|
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||||
|
}])->findOrFail($inventoryId);
|
||||||
|
|
||||||
|
// 手動 Hydrate 使用者資料
|
||||||
|
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
|
||||||
|
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||||
|
|
||||||
|
// 轉換為前端需要的格式
|
||||||
|
$inventoryData = [
|
||||||
|
'id' => (string) $inventory->id,
|
||||||
|
'warehouseId' => (string) $inventory->warehouse_id,
|
||||||
|
'productId' => (string) $inventory->product_id,
|
||||||
|
'productName' => $inventory->product?->name ?? '未知商品',
|
||||||
|
'quantity' => (float) $inventory->quantity,
|
||||||
|
'unit_cost' => (float) $inventory->unit_cost,
|
||||||
|
'total_value' => (float) $inventory->total_value,
|
||||||
|
'batchNumber' => $inventory->batch_number ?? '-',
|
||||||
|
'expiryDate' => $inventory->expiry_date ?? null,
|
||||||
|
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
|
||||||
|
'lastOutboundDate' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 整理異動紀錄
|
||||||
|
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
|
||||||
|
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||||
|
return [
|
||||||
|
'id' => (string) $tx->id,
|
||||||
|
'type' => $tx->type,
|
||||||
|
'quantity' => (float) $tx->quantity,
|
||||||
|
'unit_cost' => (float) $tx->unit_cost,
|
||||||
|
'balanceAfter' => (float) $tx->balance_after,
|
||||||
|
'reason' => $tx->reason,
|
||||||
|
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||||
|
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Warehouse/EditInventory', [
|
||||||
|
'warehouse' => $warehouse,
|
||||||
|
'inventory' => $inventoryData,
|
||||||
|
'transactions' => $transactions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Warehouse $warehouse, $inventoryId)
|
||||||
|
{
|
||||||
|
// ... (unchanged) ...
|
||||||
|
$inventory = Inventory::find($inventoryId);
|
||||||
|
|
||||||
|
// 如果找不到 (可能是舊路由傳 product ID)
|
||||||
|
if (!$inventory) {
|
||||||
|
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$inventory) {
|
||||||
|
return redirect()->back()->with('error', '找不到庫存紀錄');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'quantity' => 'required|numeric|min:0',
|
||||||
|
// 以下欄位改為 nullable,支援新表單
|
||||||
|
'type' => 'nullable|string',
|
||||||
|
'operation' => 'nullable|in:add,subtract,set',
|
||||||
|
'reason' => 'nullable|string',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'unit_cost' => 'nullable|numeric|min:0', // 新增成本
|
||||||
|
// ...
|
||||||
|
'batchNumber' => 'nullable|string',
|
||||||
|
'expiryDate' => 'nullable|date',
|
||||||
|
'lastInboundDate' => 'nullable|date',
|
||||||
|
'lastOutboundDate' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($validated, $inventory) {
|
||||||
|
$currentQty = (float) $inventory->quantity;
|
||||||
|
$newQty = (float) $validated['quantity'];
|
||||||
|
|
||||||
|
// 判斷是否來自調整彈窗 (包含 operation 參數)
|
||||||
|
$isAdjustment = isset($validated['operation']);
|
||||||
|
$changeQty = 0;
|
||||||
|
|
||||||
|
if ($isAdjustment) {
|
||||||
|
switch ($validated['operation']) {
|
||||||
|
case 'add':
|
||||||
|
$changeQty = (float) $validated['quantity'];
|
||||||
|
$newQty = $currentQty + $changeQty;
|
||||||
|
break;
|
||||||
|
case 'subtract':
|
||||||
|
$changeQty = -(float) $validated['quantity'];
|
||||||
|
$newQty = $currentQty + $changeQty;
|
||||||
|
break;
|
||||||
|
case 'set':
|
||||||
|
$changeQty = $newQty - $currentQty;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 來自編輯頁面,直接 Set
|
||||||
|
$changeQty = $newQty - $currentQty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成本 (若有傳)
|
||||||
|
if (isset($validated['unit_cost'])) {
|
||||||
|
$inventory->unit_cost = $validated['unit_cost'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新庫存
|
||||||
|
$inventory->quantity = $newQty;
|
||||||
|
// 更新總值
|
||||||
|
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||||
|
$inventory->save();
|
||||||
|
|
||||||
|
// 異動類型映射
|
||||||
|
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
|
||||||
|
$typeMapping = [
|
||||||
|
'manual_adjustment' => '手動調整庫存',
|
||||||
|
'adjustment' => '盤點調整',
|
||||||
|
'purchase_in' => '採購進貨',
|
||||||
|
'sales_out' => '銷售出庫',
|
||||||
|
'return_in' => '退貨入庫',
|
||||||
|
'return_out' => '退貨出庫',
|
||||||
|
'transfer_in' => '撥補入庫',
|
||||||
|
'transfer_out' => '撥補出庫',
|
||||||
|
];
|
||||||
|
$chineseType = $typeMapping[$type] ?? $type;
|
||||||
|
|
||||||
|
// 如果是編輯頁面來的,且沒傳 type,設為手動編輯
|
||||||
|
if (!$isAdjustment && !isset($validated['type'])) {
|
||||||
|
$chineseType = '手動編輯';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整理原因
|
||||||
|
$reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
|
||||||
|
if (isset($validated['notes'])) {
|
||||||
|
$reason .= ' - ' . $validated['notes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 寫入異動紀錄
|
||||||
|
if (abs($changeQty) > 0.0001) {
|
||||||
|
$inventory->transactions()->create([
|
||||||
|
'type' => $chineseType,
|
||||||
|
'quantity' => $changeQty,
|
||||||
|
'unit_cost' => $inventory->unit_cost, // 記錄
|
||||||
|
'balance_before' => $currentQty,
|
||||||
|
'balance_after' => $newQty,
|
||||||
|
'reason' => $reason,
|
||||||
|
'actual_time' => now(),
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
|
||||||
|
->with('success', '庫存資料已更新');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Warehouse $warehouse, $inventoryId)
|
||||||
|
{
|
||||||
|
// ... (unchanged) ...
|
||||||
|
$inventory = Inventory::findOrFail($inventoryId);
|
||||||
|
|
||||||
|
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
||||||
|
if ($inventory->quantity > 0) {
|
||||||
|
return redirect()->back()->with('error', '庫存數量大於 0,無法刪除。請先進行出庫或調整。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 歸零異動 (因為已經限制為 0 才能刪,這段邏輯可以簡化,但為了保險起見,若有微小殘值仍可記錄歸零)
|
||||||
|
if (abs($inventory->quantity) > 0.0001) {
|
||||||
|
$inventory->transactions()->create([
|
||||||
|
'type' => '手動編輯',
|
||||||
|
'quantity' => -$inventory->quantity,
|
||||||
|
'unit_cost' => $inventory->unit_cost,
|
||||||
|
'balance_before' => $inventory->quantity,
|
||||||
|
'balance_after' => 0,
|
||||||
|
'reason' => '刪除庫存品項',
|
||||||
|
'actual_time' => now(),
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory->delete();
|
||||||
|
|
||||||
|
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||||
|
->with('success', '庫存品項已刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
// ... (前端 history 頁面可能也需要 unit_cost,這裡可補上) ...
|
||||||
|
$inventoryId = $request->query('inventoryId');
|
||||||
|
$productId = $request->query('productId');
|
||||||
|
|
||||||
|
if ($productId) {
|
||||||
|
// ... (略) ...
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inventoryId) {
|
||||||
|
// 單一批號查詢
|
||||||
|
// 移除 'transactions.user'
|
||||||
|
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||||
|
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||||
|
}])->findOrFail($inventoryId);
|
||||||
|
|
||||||
|
// 手動 Hydrate 使用者資料
|
||||||
|
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
|
||||||
|
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||||
|
|
||||||
|
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
|
||||||
|
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||||
|
return [
|
||||||
|
'id' => (string) $tx->id,
|
||||||
|
'type' => $tx->type,
|
||||||
|
'quantity' => (float) $tx->quantity,
|
||||||
|
'unit_cost' => (float) $tx->unit_cost,
|
||||||
|
'balanceAfter' => (float) $tx->balance_after,
|
||||||
|
'reason' => $tx->reason,
|
||||||
|
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||||
|
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Warehouse/InventoryHistory', [
|
||||||
|
'warehouse' => $warehouse,
|
||||||
|
'inventory' => [
|
||||||
|
'id' => (string) $inventory->id,
|
||||||
|
'productName' => $inventory->product?->name ?? '未知商品',
|
||||||
|
'productCode' => $inventory->product?->code ?? 'N/A',
|
||||||
|
'batchNumber' => $inventory->batch_number ?? '-',
|
||||||
|
'quantity' => (float) $inventory->quantity,
|
||||||
|
'unit_cost' => (float) $inventory->unit_cost,
|
||||||
|
'total_value' => (float) $inventory->total_value,
|
||||||
|
],
|
||||||
|
'transactions' => $transactions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()->with('error', '未提供查詢參數');
|
||||||
|
}
|
||||||
|
}
|
||||||
184
app/Modules/Inventory/Controllers/ProductController.php
Normal file
184
app/Modules/Inventory/Controllers/ProductController.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Models\Product;
|
||||||
|
use App\Modules\Inventory\Models\Unit;
|
||||||
|
use App\Modules\Inventory\Models\Category;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProductController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示資源列表。
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']);
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('brand', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('category_id') && $request->category_id !== 'all') {
|
||||||
|
$query->where('category_id', $request->category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||||
|
$perPage = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortField = $request->input('sort_field', 'id');
|
||||||
|
$sortDirection = $request->input('sort_direction', 'desc');
|
||||||
|
|
||||||
|
// 定義允許的排序欄位以防止 SQL 注入
|
||||||
|
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
|
||||||
|
if (!in_array($sortField, $allowedSorts)) {
|
||||||
|
$sortField = 'id';
|
||||||
|
}
|
||||||
|
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
||||||
|
$sortDirection = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果需要,分別處理關聯排序(分類名稱),或簡單的 join
|
||||||
|
if ($sortField === 'category_id') {
|
||||||
|
// 加入分類以便按名稱排序?還是僅按 ID?
|
||||||
|
// 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。
|
||||||
|
// 先假設標準欄位排序。
|
||||||
|
$query->orderBy('category_id', $sortDirection);
|
||||||
|
} else {
|
||||||
|
$query->orderBy($sortField, $sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = $query->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
|
$products->getCollection()->transform(function ($product) {
|
||||||
|
return (object) [
|
||||||
|
'id' => (string) $product->id,
|
||||||
|
'code' => $product->code,
|
||||||
|
'name' => $product->name,
|
||||||
|
'categoryId' => $product->category_id,
|
||||||
|
'category' => $product->category ? (object) [
|
||||||
|
'id' => $product->category->id,
|
||||||
|
'name' => $product->category->name,
|
||||||
|
] : null,
|
||||||
|
'brand' => $product->brand,
|
||||||
|
'specification' => $product->specification,
|
||||||
|
'baseUnitId' => $product->base_unit_id,
|
||||||
|
'baseUnit' => $product->baseUnit ? (object) [
|
||||||
|
'id' => $product->baseUnit->id,
|
||||||
|
'name' => $product->baseUnit->name,
|
||||||
|
] : null,
|
||||||
|
'largeUnitId' => $product->large_unit_id,
|
||||||
|
'largeUnit' => $product->largeUnit ? (object) [
|
||||||
|
'id' => $product->largeUnit->id,
|
||||||
|
'name' => $product->largeUnit->name,
|
||||||
|
] : null,
|
||||||
|
'purchaseUnitId' => $product->purchase_unit_id,
|
||||||
|
'purchaseUnit' => $product->purchaseUnit ? (object) [
|
||||||
|
'id' => $product->purchaseUnit->id,
|
||||||
|
'name' => $product->purchaseUnit->name,
|
||||||
|
] : null,
|
||||||
|
'conversionRate' => (float) $product->conversion_rate,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$categories = Category::where('is_active', true)->get();
|
||||||
|
|
||||||
|
return Inertia::render('Product/Index', [
|
||||||
|
'products' => $products,
|
||||||
|
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||||
|
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||||
|
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將新建立的資源儲存到儲存體中。
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => 'required|string|max:2|unique:products,code',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'category_id' => 'required|exists:categories,id',
|
||||||
|
'brand' => 'nullable|string|max:255',
|
||||||
|
'specification' => 'nullable|string',
|
||||||
|
|
||||||
|
'base_unit_id' => 'required|exists:units,id',
|
||||||
|
'large_unit_id' => 'nullable|exists:units,id',
|
||||||
|
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||||
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
|
], [
|
||||||
|
'code.required' => '商品代號為必填',
|
||||||
|
'code.max' => '商品代號最多 2 碼',
|
||||||
|
'code.unique' => '商品代號已存在',
|
||||||
|
'name.required' => '商品名稱為必填',
|
||||||
|
'category_id.required' => '請選擇分類',
|
||||||
|
'category_id.exists' => '所選分類不存在',
|
||||||
|
'base_unit_id.required' => '基本庫存單位為必填',
|
||||||
|
'base_unit_id.exists' => '所選基本單位不存在',
|
||||||
|
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||||
|
'conversion_rate.numeric' => '換算率必須為數字',
|
||||||
|
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product = Product::create($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '商品已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新儲存體中的指定資源。
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Product $product)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'category_id' => 'required|exists:categories,id',
|
||||||
|
'brand' => 'nullable|string|max:255',
|
||||||
|
'specification' => 'nullable|string',
|
||||||
|
'base_unit_id' => 'required|exists:units,id',
|
||||||
|
'large_unit_id' => 'nullable|exists:units,id',
|
||||||
|
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||||
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
|
], [
|
||||||
|
'code.required' => '商品代號為必填',
|
||||||
|
'code.max' => '商品代號最多 2 碼',
|
||||||
|
'code.unique' => '商品代號已存在',
|
||||||
|
'name.required' => '商品名稱為必填',
|
||||||
|
'category_id.required' => '請選擇分類',
|
||||||
|
'category_id.exists' => '所選分類不存在',
|
||||||
|
'base_unit_id.required' => '基本庫存單位為必填',
|
||||||
|
'base_unit_id.exists' => '所選基本單位不存在',
|
||||||
|
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||||
|
'conversion_rate.numeric' => '換算率必須為數字',
|
||||||
|
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product->update($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '商品已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從儲存體中移除指定資源。
|
||||||
|
*/
|
||||||
|
public function destroy(Product $product)
|
||||||
|
{
|
||||||
|
$product->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '商品已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
use App\Models\Warehouse;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Inventory;
|
|
||||||
use App\Models\Product;
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
|
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||||
|
use App\Modules\Inventory\Models\Product;
|
||||||
|
use App\Modules\Inventory\Models\Inventory;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -16,9 +19,7 @@ class SafetyStockController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Warehouse $warehouse)
|
public function index(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
$warehouse->load(['inventories.product.category']);
|
$allProducts = Product::with(['category', 'baseUnit'])->get();
|
||||||
|
|
||||||
$allProducts = Product::with('category')->get();
|
|
||||||
|
|
||||||
// 準備可選商品列表
|
// 準備可選商品列表
|
||||||
$availableProducts = $allProducts->map(function ($product) {
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
@@ -26,36 +27,38 @@ class SafetyStockController extends Controller
|
|||||||
'id' => (string) $product->id,
|
'id' => (string) $product->id,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'type' => $product->category ? $product->category->name : '其他',
|
'type' => $product->category ? $product->category->name : '其他',
|
||||||
'unit' => $product->base_unit,
|
'unit' => $product->baseUnit?->name ?? '個',
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 準備現有庫存列表 (用於狀態計算)
|
// 準備現有庫存列表 (用於庫存量對比)
|
||||||
$inventories = $warehouse->inventories->map(function ($inv) {
|
$inventories = Inventory::where('warehouse_id', $warehouse->id)
|
||||||
return [
|
->select('product_id', DB::raw('SUM(quantity) as total_quantity'))
|
||||||
'id' => (string) $inv->id,
|
->groupBy('product_id')
|
||||||
'productId' => (string) $inv->product_id,
|
->get()
|
||||||
'quantity' => (float) $inv->quantity,
|
->map(function ($inv) {
|
||||||
'safetyStock' => (float) $inv->safety_stock,
|
return [
|
||||||
];
|
'productId' => (string) $inv->product_id,
|
||||||
});
|
'quantity' => (float) $inv->total_quantity,
|
||||||
|
];
|
||||||
// 準備安全庫存設定列表
|
});
|
||||||
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
|
|
||||||
return !is_null($inv->safety_stock);
|
|
||||||
})->map(function ($inv) {
|
|
||||||
return [
|
|
||||||
'id' => (string) $inv->id,
|
|
||||||
'warehouseId' => (string) $inv->warehouse_id,
|
|
||||||
'productId' => (string) $inv->product_id,
|
|
||||||
'productName' => $inv->product->name,
|
|
||||||
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
|
|
||||||
'safetyStock' => (float) $inv->safety_stock,
|
|
||||||
'unit' => $inv->product->base_unit,
|
|
||||||
'updatedAt' => $inv->updated_at->toIso8601String(),
|
|
||||||
];
|
|
||||||
})->values();
|
|
||||||
|
|
||||||
|
// 準備安全庫存設定列表 (從新表格讀取)
|
||||||
|
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||||
|
->with(['product.category', 'product.baseUnit'])
|
||||||
|
->get()
|
||||||
|
->map(function ($setting) {
|
||||||
|
return [
|
||||||
|
'id' => (string) $setting->id,
|
||||||
|
'warehouseId' => (string) $setting->warehouse_id,
|
||||||
|
'productId' => (string) $setting->product_id,
|
||||||
|
'productName' => $setting->product->name,
|
||||||
|
'productType' => $setting->product->category ? $setting->product->category->name : '其他',
|
||||||
|
'safetyStock' => (float) $setting->safety_stock,
|
||||||
|
'unit' => $setting->product->baseUnit?->name ?? '個',
|
||||||
|
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return Inertia::render('Warehouse/SafetyStockSettings', [
|
return Inertia::render('Warehouse/SafetyStockSettings', [
|
||||||
'warehouse' => $warehouse,
|
'warehouse' => $warehouse,
|
||||||
@@ -78,7 +81,7 @@ class SafetyStockController extends Controller
|
|||||||
|
|
||||||
DB::transaction(function () use ($validated, $warehouse) {
|
DB::transaction(function () use ($validated, $warehouse) {
|
||||||
foreach ($validated['settings'] as $item) {
|
foreach ($validated['settings'] as $item) {
|
||||||
Inventory::updateOrCreate(
|
WarehouseProductSafetyStock::updateOrCreate(
|
||||||
[
|
[
|
||||||
'warehouse_id' => $warehouse->id,
|
'warehouse_id' => $warehouse->id,
|
||||||
'product_id' => $item['productId'],
|
'product_id' => $item['productId'],
|
||||||
@@ -96,13 +99,13 @@ class SafetyStockController extends Controller
|
|||||||
/**
|
/**
|
||||||
* 更新單筆安全庫存設定
|
* 更新單筆安全庫存設定
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, Warehouse $warehouse, Inventory $inventory)
|
public function update(Request $request, Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'safetyStock' => 'required|numeric|min:0',
|
'safetyStock' => 'required|numeric|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$inventory->update([
|
$safetyStock->update([
|
||||||
'safety_stock' => $validated['safetyStock'],
|
'safety_stock' => $validated['safetyStock'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -110,13 +113,11 @@ class SafetyStockController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刪除 (歸零) 安全庫存設定
|
* 刪除安全庫存設定
|
||||||
*/
|
*/
|
||||||
public function destroy(Warehouse $warehouse, Inventory $inventory)
|
public function destroy(Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
|
||||||
{
|
{
|
||||||
$inventory->update([
|
$safetyStock->delete();
|
||||||
'safety_stock' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', '安全庫存設定已移除');
|
return redirect()->back()->with('success', '安全庫存設定已移除');
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
use App\Models\Inventory;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Warehouse;
|
|
||||||
|
use App\Modules\Inventory\Models\Inventory;
|
||||||
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -27,26 +29,32 @@ class TransferOrderController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return DB::transaction(function () use ($validated) {
|
return DB::transaction(function () use ($validated) {
|
||||||
// 1. 檢查來源倉庫庫存
|
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
|
||||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||||
->where('product_id', $validated['productId'])
|
->where('product_id', $validated['productId'])
|
||||||
|
->where('batch_number', $validated['batchNumber'])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'quantity' => ['來源倉庫庫存不足'],
|
'quantity' => ['來源倉庫指定批號庫存不足'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 獲取或建立目標倉庫庫存
|
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
|
||||||
$targetInventory = Inventory::firstOrCreate(
|
$targetInventory = Inventory::firstOrCreate(
|
||||||
[
|
[
|
||||||
'warehouse_id' => $validated['targetWarehouseId'],
|
'warehouse_id' => $validated['targetWarehouseId'],
|
||||||
'product_id' => $validated['productId'],
|
'product_id' => $validated['productId'],
|
||||||
|
'batch_number' => $validated['batchNumber'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'quantity' => 0,
|
'quantity' => 0,
|
||||||
'safety_stock' => null, // 預設為 null (未設定),而非 0
|
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||||
|
'total_value' => 0,
|
||||||
|
'expiry_date' => $sourceInventory->expiry_date,
|
||||||
|
'quality_status' => $sourceInventory->quality_status,
|
||||||
|
'origin_country' => $sourceInventory->origin_country,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,12 +64,18 @@ class TransferOrderController extends Controller
|
|||||||
// 3. 執行庫存轉移 (扣除來源)
|
// 3. 執行庫存轉移 (扣除來源)
|
||||||
$oldSourceQty = $sourceInventory->quantity;
|
$oldSourceQty = $sourceInventory->quantity;
|
||||||
$newSourceQty = $oldSourceQty - $validated['quantity'];
|
$newSourceQty = $oldSourceQty - $validated['quantity'];
|
||||||
$sourceInventory->update(['quantity' => $newSourceQty]);
|
|
||||||
|
// 設定活動紀錄原因
|
||||||
|
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
|
||||||
|
$sourceInventory->quantity = $newSourceQty;
|
||||||
|
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
|
||||||
|
$sourceInventory->save();
|
||||||
|
|
||||||
// 記錄來源異動
|
// 記錄來源異動
|
||||||
$sourceInventory->transactions()->create([
|
$sourceInventory->transactions()->create([
|
||||||
'type' => '撥補出庫',
|
'type' => '撥補出庫',
|
||||||
'quantity' => -$validated['quantity'],
|
'quantity' => -$validated['quantity'],
|
||||||
|
'unit_cost' => $sourceInventory->unit_cost, // 記錄
|
||||||
'balance_before' => $oldSourceQty,
|
'balance_before' => $oldSourceQty,
|
||||||
'balance_after' => $newSourceQty,
|
'balance_after' => $newSourceQty,
|
||||||
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||||
@@ -72,12 +86,22 @@ class TransferOrderController extends Controller
|
|||||||
// 4. 執行庫存轉移 (增加目標)
|
// 4. 執行庫存轉移 (增加目標)
|
||||||
$oldTargetQty = $targetInventory->quantity;
|
$oldTargetQty = $targetInventory->quantity;
|
||||||
$newTargetQty = $oldTargetQty + $validated['quantity'];
|
$newTargetQty = $oldTargetQty + $validated['quantity'];
|
||||||
$targetInventory->update(['quantity' => $newTargetQty]);
|
|
||||||
|
// 設定活動紀錄原因
|
||||||
|
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
|
||||||
|
// 確保目標庫存也有成本 (如果是繼承來的)
|
||||||
|
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
|
||||||
|
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||||
|
}
|
||||||
|
$targetInventory->quantity = $newTargetQty;
|
||||||
|
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
|
||||||
|
$targetInventory->save();
|
||||||
|
|
||||||
// 記錄目標異動
|
// 記錄目標異動
|
||||||
$targetInventory->transactions()->create([
|
$targetInventory->transactions()->create([
|
||||||
'type' => '撥補入庫',
|
'type' => '撥補入庫',
|
||||||
'quantity' => $validated['quantity'],
|
'quantity' => $validated['quantity'],
|
||||||
|
'unit_cost' => $targetInventory->unit_cost, // 記錄
|
||||||
'balance_before' => $oldTargetQty,
|
'balance_before' => $oldTargetQty,
|
||||||
'balance_after' => $newTargetQty,
|
'balance_after' => $newTargetQty,
|
||||||
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||||
@@ -97,16 +121,19 @@ class TransferOrderController extends Controller
|
|||||||
public function getWarehouseInventories(Warehouse $warehouse)
|
public function getWarehouseInventories(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
$inventories = $warehouse->inventories()
|
$inventories = $warehouse->inventories()
|
||||||
->with(['product:id,name,base_unit,category_id', 'product.category'])
|
->with(['product.baseUnit', 'product.category'])
|
||||||
->where('quantity', '>', 0) // 只回傳有庫存的
|
->where('quantity', '>', 0) // 只回傳有庫存的
|
||||||
->get()
|
->get()
|
||||||
->map(function ($inv) {
|
->map(function ($inv) {
|
||||||
return [
|
return [
|
||||||
'productId' => (string) $inv->product_id,
|
'product_id' => (string) $inv->product_id,
|
||||||
'productName' => $inv->product->name,
|
'product_name' => $inv->product->name,
|
||||||
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
'batch_number' => $inv->batch_number,
|
||||||
'availableQty' => (float) $inv->quantity,
|
'quantity' => (float) $inv->quantity,
|
||||||
'unit' => $inv->product->base_unit,
|
'unit_cost' => (float) $inv->unit_cost, // 新增
|
||||||
|
'total_value' => (float) $inv->total_value, // 新增
|
||||||
|
'unit_name' => $inv->product->baseUnit?->name ?? '個',
|
||||||
|
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
72
app/Modules/Inventory/Controllers/UnitController.php
Normal file
72
app/Modules/Inventory/Controllers/UnitController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Models\Unit;
|
||||||
|
use App\Modules\Inventory\Models\Product; // Import Product to check for usage
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UnitController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 將新建立的資源儲存到儲存體中。
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255|unique:units,name',
|
||||||
|
'code' => 'nullable|string|max:50',
|
||||||
|
], [
|
||||||
|
'name.required' => '單位名稱為必填項目',
|
||||||
|
'name.unique' => '該單位名稱已存在',
|
||||||
|
'name.max' => '單位名稱不能超過 255 個字元',
|
||||||
|
'code.max' => '單位代碼不能超過 50 個字元',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Unit::create($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '單位已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新儲存體中的指定資源。
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Unit $unit)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255|unique:units,name,' . $unit->id,
|
||||||
|
'code' => 'nullable|string|max:50',
|
||||||
|
], [
|
||||||
|
'name.required' => '單位名稱為必填項目',
|
||||||
|
'name.unique' => '該單位名稱已存在',
|
||||||
|
'name.max' => '單位名稱不能超過 255 個字元',
|
||||||
|
'code.max' => '單位代碼不能超過 50 個字元',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$unit->update($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '單位已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從儲存體中移除指定資源。
|
||||||
|
*/
|
||||||
|
public function destroy(Unit $unit)
|
||||||
|
{
|
||||||
|
// 檢查單位是否已被任何商品使用
|
||||||
|
$isUsed = Product::where('base_unit_id', $unit->id)
|
||||||
|
->orWhere('large_unit_id', $unit->id)
|
||||||
|
->orWhere('purchase_unit_id', $unit->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($isUsed) {
|
||||||
|
return redirect()->back()->with('error', '該單位已被商品使用,無法刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
$unit->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '單位已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
135
app/Modules/Inventory/Controllers/WarehouseController.php
Normal file
135
app/Modules/Inventory/Controllers/WarehouseController.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
|
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class WarehouseController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Warehouse::query();
|
||||||
|
|
||||||
|
if ($request->has('search')) {
|
||||||
|
$search = $request->input('search');
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('code', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
|
||||||
|
->withSum(['inventories as available_stock' => function ($query) {
|
||||||
|
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期)
|
||||||
|
$query->where('quantity', '>', 0)
|
||||||
|
->where('quality_status', 'normal')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('expiry_date')
|
||||||
|
->orWhere('expiry_date', '>=', now());
|
||||||
|
});
|
||||||
|
}], 'quantity')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(10)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
// 修正各倉庫列表中的可用庫存計算:若倉庫不可銷售,則可用庫存為 0
|
||||||
|
$warehouses->getCollection()->transform(function ($w) {
|
||||||
|
if (!$w->is_sellable) {
|
||||||
|
$w->available_stock = 0;
|
||||||
|
}
|
||||||
|
return $w;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 計算全域總計 (不分頁)
|
||||||
|
$totals = [
|
||||||
|
'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
|
||||||
|
->where('quality_status', 'normal')
|
||||||
|
->whereHas('warehouse', function ($q) {
|
||||||
|
$q->where('is_sellable', true);
|
||||||
|
})
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('expiry_date')
|
||||||
|
->orWhere('expiry_date', '>=', now());
|
||||||
|
})->sum('quantity'),
|
||||||
|
'book_stock' => \App\Modules\Inventory\Models\Inventory::sum('quantity'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Warehouse/Index', [
|
||||||
|
'warehouses' => $warehouses,
|
||||||
|
'totals' => $totals,
|
||||||
|
'filters' => $request->only(['search']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:50',
|
||||||
|
'address' => 'nullable|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_sellable' => 'nullable|boolean',
|
||||||
|
'type' => 'required|string',
|
||||||
|
'license_plate' => 'nullable|string|max:20',
|
||||||
|
'driver_name' => 'nullable|string|max:50',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 自動產生代碼
|
||||||
|
$prefix = 'WH';
|
||||||
|
$lastWarehouse = Warehouse::latest('id')->first();
|
||||||
|
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
||||||
|
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$validated['code'] = $code;
|
||||||
|
|
||||||
|
Warehouse::create($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '倉庫已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:50',
|
||||||
|
'address' => 'nullable|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'is_sellable' => 'nullable|boolean',
|
||||||
|
'type' => 'required|string',
|
||||||
|
'license_plate' => 'nullable|string|max:20',
|
||||||
|
'driver_name' => 'nullable|string|max:50',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$warehouse->update($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '倉庫資訊已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Warehouse $warehouse)
|
||||||
|
{
|
||||||
|
// 檢查是否有相關聯的採購單
|
||||||
|
if ($warehouse->purchaseOrders()->exists()) {
|
||||||
|
return redirect()->back()->with('error', '無法刪除:該倉庫有相關聯的採購單,請先處理採購單。');
|
||||||
|
}
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\DB::transaction(function () use ($warehouse) {
|
||||||
|
// 刪除庫存異動紀錄 (透過庫存關聯)
|
||||||
|
foreach ($warehouse->inventories as $inventory) {
|
||||||
|
// 刪除該庫存的所有異動紀錄
|
||||||
|
$inventory->transactions()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刪除庫存項目
|
||||||
|
$warehouse->inventories()->delete();
|
||||||
|
|
||||||
|
// 刪除倉庫
|
||||||
|
$warehouse->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '倉庫及其庫存與紀錄已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Modules/Inventory/InventoryServiceProvider.php
Normal file
20
app/Modules/Inventory/InventoryServiceProvider.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use App\Modules\Inventory\Services\InventoryService;
|
||||||
|
|
||||||
|
class InventoryServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Modules/Inventory/Models/Category.php
Normal file
40
app/Modules/Inventory/Models/Category.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class Category extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'description'];
|
||||||
|
|
||||||
|
public function products(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$properties = $activity->properties;
|
||||||
|
|
||||||
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
$snapshot['name'] = $this->name;
|
||||||
|
$properties['snapshot'] = $snapshot;
|
||||||
|
|
||||||
|
$activity->properties = $properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/Modules/Inventory/Models/Inventory.php
Normal file
138
app/Modules/Inventory/Models/Inventory.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
|
||||||
|
class Inventory extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\InventoryFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
use \Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'warehouse_id',
|
||||||
|
'product_id',
|
||||||
|
'quantity',
|
||||||
|
'location',
|
||||||
|
'unit_cost',
|
||||||
|
'total_value',
|
||||||
|
// 批號追溯欄位
|
||||||
|
'batch_number',
|
||||||
|
'box_number',
|
||||||
|
'origin_country',
|
||||||
|
'arrival_date',
|
||||||
|
'expiry_date',
|
||||||
|
'source_purchase_order_id',
|
||||||
|
'quality_status',
|
||||||
|
'quality_remark',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'arrival_date' => 'date:Y-m-d',
|
||||||
|
'expiry_date' => 'date:Y-m-d',
|
||||||
|
'unit_cost' => 'decimal:4',
|
||||||
|
'total_value' => 'decimal:4',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用於活動記錄的暫時屬性(例如 "補貨 #123")。
|
||||||
|
* 此屬性不存儲在資料庫欄位中,但用於記錄上下文。
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
public $activityLogReason;
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
|
{
|
||||||
|
return \Spatie\Activitylog\LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$properties = $activity->properties;
|
||||||
|
$attributes = $properties['attributes'] ?? [];
|
||||||
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
|
||||||
|
// 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改
|
||||||
|
// $this 指的是 Inventory 模型實例
|
||||||
|
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null);
|
||||||
|
$snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null);
|
||||||
|
|
||||||
|
// 如果已設定原因,則進行捕捉
|
||||||
|
if ($this->activityLogReason) {
|
||||||
|
$attributes['_reason'] = $this->activityLogReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
$properties['attributes'] = $attributes;
|
||||||
|
$properties['snapshot'] = $snapshot;
|
||||||
|
$activity->properties = $properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Warehouse::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InventoryTransaction::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastOutgoingTransaction()
|
||||||
|
{
|
||||||
|
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
||||||
|
'actual_time' => 'max',
|
||||||
|
'id' => 'max',
|
||||||
|
], function ($query) {
|
||||||
|
$query->where('quantity', '<', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastIncomingTransaction()
|
||||||
|
{
|
||||||
|
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
||||||
|
'actual_time' => 'max',
|
||||||
|
'id' => 'max',
|
||||||
|
], function ($query) {
|
||||||
|
$query->where('quantity', '>', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生批號
|
||||||
|
* 格式:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
|
||||||
|
*/
|
||||||
|
public static function generateBatchNumber(string $productCode, string $originCountry, string $arrivalDate): string
|
||||||
|
{
|
||||||
|
$dateFormatted = date('Ymd', strtotime($arrivalDate));
|
||||||
|
$prefix = "{$productCode}-{$originCountry}-{$dateFormatted}-";
|
||||||
|
|
||||||
|
// 加入 withTrashed() 確保流水號不會撞到已刪除的紀錄
|
||||||
|
$lastBatch = static::withTrashed()
|
||||||
|
->where('batch_number', 'like', "{$prefix}%")
|
||||||
|
->orderByDesc('batch_number')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($lastBatch) {
|
||||||
|
$lastNumber = (int) substr($lastBatch->batch_number, -2);
|
||||||
|
$nextNumber = str_pad($lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||||
|
} else {
|
||||||
|
$nextNumber = '01';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prefix . $nextNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Modules\Inventory\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Models\Inventory;
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
class InventoryTransaction extends Model
|
class InventoryTransaction extends Model
|
||||||
{
|
{
|
||||||
@@ -16,6 +15,7 @@ class InventoryTransaction extends Model
|
|||||||
'inventory_id',
|
'inventory_id',
|
||||||
'type',
|
'type',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'unit_cost',
|
||||||
'balance_before',
|
'balance_before',
|
||||||
'balance_after',
|
'balance_after',
|
||||||
'reason',
|
'reason',
|
||||||
@@ -27,6 +27,7 @@ class InventoryTransaction extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'actual_time' => 'datetime',
|
'actual_time' => 'datetime',
|
||||||
|
'unit_cost' => 'decimal:4',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
@@ -34,11 +35,6 @@ class InventoryTransaction extends Model
|
|||||||
return $this->belongsTo(Inventory::class);
|
return $this->belongsTo(Inventory::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
|
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
113
app/Modules/Inventory/Models/Product.php
Normal file
113
app/Modules/Inventory/Models/Product.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
|
||||||
|
class Product extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, LogsActivity, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'category_id',
|
||||||
|
'brand',
|
||||||
|
'specification',
|
||||||
|
'base_unit_id',
|
||||||
|
'large_unit_id',
|
||||||
|
'conversion_rate',
|
||||||
|
'purchase_unit_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'conversion_rate' => 'decimal:4',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得該商品所屬的分類。
|
||||||
|
*/
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function baseUnit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Unit::class, 'base_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function largeUnit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Unit::class, 'large_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purchaseUnit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Unit::class, 'purchase_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Inventory::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InventoryTransaction::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$properties = $activity->properties;
|
||||||
|
$attributes = $properties['attributes'] ?? [];
|
||||||
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
|
||||||
|
// 處理分類名稱快照
|
||||||
|
if (isset($attributes['category_id'])) {
|
||||||
|
$category = Category::find($attributes['category_id']);
|
||||||
|
$snapshot['category_name'] = $category ? $category->name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理單位名稱快照
|
||||||
|
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
|
||||||
|
foreach ($unitFields as $field) {
|
||||||
|
if (isset($attributes[$field])) {
|
||||||
|
$unit = Unit::find($attributes[$field]);
|
||||||
|
$nameKey = str_replace('_id', '_name', $field);
|
||||||
|
$snapshot[$nameKey] = $unit ? $unit->name : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂")
|
||||||
|
$snapshot['name'] = $this->name;
|
||||||
|
|
||||||
|
$properties['attributes'] = $attributes;
|
||||||
|
$properties['snapshot'] = $snapshot;
|
||||||
|
$activity->properties = $properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Warehouse::class, 'inventories')
|
||||||
|
->withPivot(['quantity', 'safety_stock', 'location'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Modules/Inventory/Models/Unit.php
Normal file
45
app/Modules/Inventory/Models/Unit.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class Unit extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'abbreviation'];
|
||||||
|
|
||||||
|
public function productsAsBase(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Product::class, 'base_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function productsAsLarge(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Product::class, 'large_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$properties = $activity->properties;
|
||||||
|
|
||||||
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
$snapshot['name'] = $this->name;
|
||||||
|
$properties['snapshot'] = $snapshot;
|
||||||
|
|
||||||
|
$activity->properties = $properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Modules/Inventory/Models/Warehouse.php
Normal file
63
app/Modules/Inventory/Models/Warehouse.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
|
||||||
|
class Warehouse extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'type',
|
||||||
|
'address',
|
||||||
|
'description',
|
||||||
|
'is_sellable',
|
||||||
|
'license_plate',
|
||||||
|
'driver_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_sellable' => 'boolean',
|
||||||
|
'type' => \App\Enums\WarehouseType::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
|
{
|
||||||
|
return \Spatie\Activitylog\LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$properties = $activity->properties;
|
||||||
|
|
||||||
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
$snapshot['name'] = $this->name;
|
||||||
|
$properties['snapshot'] = $snapshot;
|
||||||
|
|
||||||
|
$activity->properties = $properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Inventory::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Product::class, 'inventories')
|
||||||
|
->withPivot(['quantity', 'safety_stock', 'location'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Modules/Inventory/Models/WarehouseProductSafetyStock.php
Normal file
41
app/Modules/Inventory/Models/WarehouseProductSafetyStock.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 倉庫-商品安全庫存設定
|
||||||
|
* 每個倉庫-商品組合只有一筆安全庫存設定
|
||||||
|
*/
|
||||||
|
class WarehouseProductSafetyStock extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'warehouse_product_safety_stocks';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'warehouse_id',
|
||||||
|
'product_id',
|
||||||
|
'safety_stock',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'safety_stock' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬倉庫
|
||||||
|
*/
|
||||||
|
public function warehouse(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Warehouse::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所屬商品
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Modules/Inventory/Routes/web.php
Normal file
80
app/Modules/Inventory/Routes/web.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Modules\Inventory\Controllers\CategoryController;
|
||||||
|
use App\Modules\Inventory\Controllers\UnitController;
|
||||||
|
use App\Modules\Inventory\Controllers\ProductController;
|
||||||
|
use App\Modules\Inventory\Controllers\WarehouseController;
|
||||||
|
use App\Modules\Inventory\Controllers\InventoryController;
|
||||||
|
use App\Modules\Inventory\Controllers\SafetyStockController;
|
||||||
|
use App\Modules\Inventory\Controllers\TransferOrderController;
|
||||||
|
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
|
||||||
|
// 類別管理 (用於商品對話框) - 需要商品權限
|
||||||
|
Route::middleware('permission:products.view')->group(function () {
|
||||||
|
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||||
|
Route::post('/categories', [CategoryController::class, 'store'])->middleware('permission:products.create')->name('categories.store');
|
||||||
|
Route::put('/categories/{category}', [CategoryController::class, 'update'])->middleware('permission:products.edit')->name('categories.update');
|
||||||
|
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->middleware('permission:products.delete')->name('categories.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 單位管理 - 需要商品權限
|
||||||
|
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||||
|
Route::post('/units', [UnitController::class, 'store'])->name('units.store');
|
||||||
|
Route::put('/units/{unit}', [UnitController::class, 'update'])->name('units.update');
|
||||||
|
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->name('units.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 商品管理
|
||||||
|
Route::middleware('permission:products.view')->group(function () {
|
||||||
|
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
||||||
|
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
|
||||||
|
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');
|
||||||
|
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete')->name('products.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 倉庫管理
|
||||||
|
Route::middleware('permission:warehouses.view')->group(function () {
|
||||||
|
Route::get('/warehouses', [WarehouseController::class, 'index'])->name('warehouses.index');
|
||||||
|
Route::post('/warehouses', [WarehouseController::class, 'store'])->middleware('permission:warehouses.create')->name('warehouses.store');
|
||||||
|
Route::put('/warehouses/{warehouse}', [WarehouseController::class, 'update'])->middleware('permission:warehouses.edit')->name('warehouses.update');
|
||||||
|
Route::delete('/warehouses/{warehouse}', [WarehouseController::class, 'destroy'])->middleware('permission:warehouses.delete')->name('warehouses.destroy');
|
||||||
|
|
||||||
|
// 倉庫庫存管理 - 需要庫存權限
|
||||||
|
Route::middleware('permission:inventory.view')->group(function () {
|
||||||
|
Route::get('/warehouses/{warehouse}/inventory', [InventoryController::class, 'index'])->name('warehouses.inventory.index');
|
||||||
|
Route::get('/warehouses/{warehouse}/inventory-history', [InventoryController::class, 'history'])->name('warehouses.inventory.history');
|
||||||
|
|
||||||
|
Route::middleware('permission:inventory.adjust')->group(function () {
|
||||||
|
Route::get('/warehouses/{warehouse}/inventory/create', [InventoryController::class, 'create'])->name('warehouses.inventory.create');
|
||||||
|
Route::post('/warehouses/{warehouse}/inventory', [InventoryController::class, 'store'])->name('warehouses.inventory.store');
|
||||||
|
Route::get('/warehouses/{warehouse}/inventory/{inventoryId}/edit', [InventoryController::class, 'edit'])->name('warehouses.inventory.edit');
|
||||||
|
Route::put('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'update'])->name('warehouses.inventory.update');
|
||||||
|
Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: 取得商品在特定倉庫的所有批號
|
||||||
|
Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches'])
|
||||||
|
->name('api.warehouses.inventory.batches');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 安全庫存設定
|
||||||
|
Route::middleware('permission:inventory.view')->group(function () {
|
||||||
|
Route::get('/warehouses/{warehouse}/safety-stock', [SafetyStockController::class, 'index'])->name('warehouses.safety-stock.index');
|
||||||
|
Route::middleware('permission:inventory.safety_stock')->group(function () {
|
||||||
|
Route::post('/warehouses/{warehouse}/safety-stock', [SafetyStockController::class, 'store'])->name('warehouses.safety-stock.store');
|
||||||
|
Route::put('/warehouses/{warehouse}/safety-stock/{safetyStock}', [SafetyStockController::class, 'update'])->name('warehouses.safety-stock.update');
|
||||||
|
Route::delete('/warehouses/{warehouse}/safety-stock/{safetyStock}', [SafetyStockController::class, 'destroy'])->name('warehouses.safety-stock.destroy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 撥補單 (在庫存調撥時使用)
|
||||||
|
Route::middleware('permission:inventory.transfer')->group(function () {
|
||||||
|
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
||||||
|
});
|
||||||
|
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
|
||||||
|
->middleware('permission:inventory.view')
|
||||||
|
->name('api.warehouses.inventories');
|
||||||
|
});
|
||||||
210
app/Modules/Inventory/Services/InventoryService.php
Normal file
210
app/Modules/Inventory/Services/InventoryService.php
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Services;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use App\Modules\Inventory\Models\Inventory;
|
||||||
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
|
use App\Modules\Inventory\Models\Product;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class InventoryService implements InventoryServiceInterface
|
||||||
|
{
|
||||||
|
public function getAllWarehouses()
|
||||||
|
{
|
||||||
|
return Warehouse::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllProducts()
|
||||||
|
{
|
||||||
|
return Product::with(['baseUnit'])->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUnits()
|
||||||
|
{
|
||||||
|
return \App\Modules\Inventory\Models\Unit::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInventoriesByIds(array $ids, array $with = [])
|
||||||
|
{
|
||||||
|
return Inventory::whereIn('id', $ids)->with($with)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProduct(int $id)
|
||||||
|
{
|
||||||
|
return Product::find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductsByIds(array $ids)
|
||||||
|
{
|
||||||
|
return Product::whereIn('id', $ids)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductsByName(string $name)
|
||||||
|
{
|
||||||
|
return Product::where('name', 'like', "%{$name}%")->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWarehouse(int $id)
|
||||||
|
{
|
||||||
|
return Warehouse::find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkStock(int $productId, int $warehouseId, float $quantity): bool
|
||||||
|
{
|
||||||
|
$stock = Inventory::where('product_id', $productId)
|
||||||
|
->where('warehouse_id', $warehouseId)
|
||||||
|
->sum('quantity');
|
||||||
|
|
||||||
|
return $stock >= $quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason) {
|
||||||
|
$inventories = Inventory::where('product_id', $productId)
|
||||||
|
->where('warehouse_id', $warehouseId)
|
||||||
|
->where('quantity', '>', 0)
|
||||||
|
->orderBy('arrival_date', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$remainingToDecrease = $quantity;
|
||||||
|
|
||||||
|
foreach ($inventories as $inventory) {
|
||||||
|
if ($remainingToDecrease <= 0) break;
|
||||||
|
|
||||||
|
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
||||||
|
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason);
|
||||||
|
$remainingToDecrease -= $decreaseAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remainingToDecrease > 0) {
|
||||||
|
// 這裡可以選擇報錯或允許負庫存,目前為了嚴謹拋出異常
|
||||||
|
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInventoriesByWarehouse(int $warehouseId)
|
||||||
|
{
|
||||||
|
return Inventory::with(['product.baseUnit', 'product.largeUnit'])
|
||||||
|
->where('warehouse_id', $warehouseId)
|
||||||
|
->where('quantity', '>', 0)
|
||||||
|
->orderBy('arrival_date', 'asc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createInventoryRecord(array $data)
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($data) {
|
||||||
|
// 嘗試查找是否已有相同批號的庫存
|
||||||
|
$inventory = Inventory::where('warehouse_id', $data['warehouse_id'])
|
||||||
|
->where('product_id', $data['product_id'])
|
||||||
|
->where('batch_number', $data['batch_number'] ?? null)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$balanceBefore = 0;
|
||||||
|
|
||||||
|
if ($inventory) {
|
||||||
|
// 若存在,則更新數量與相關資訊 (鎖定行以避免併發問題)
|
||||||
|
$inventory = Inventory::lockForUpdate()->find($inventory->id);
|
||||||
|
$balanceBefore = $inventory->quantity;
|
||||||
|
|
||||||
|
// 加權平均成本計算 (可選,這裡先採簡單邏輯:若有新成本則更新,否則沿用)
|
||||||
|
// 若本次入庫有指定成本,則更新該批次單價 (假設同批號成本相同)
|
||||||
|
if (isset($data['unit_cost'])) {
|
||||||
|
$inventory->unit_cost = $data['unit_cost'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory->quantity += $data['quantity'];
|
||||||
|
// 更新總價值
|
||||||
|
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||||
|
|
||||||
|
// 更新其他可能變更的欄位 (如最後入庫日)
|
||||||
|
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
|
||||||
|
$inventory->save();
|
||||||
|
} else {
|
||||||
|
// 若不存在,則建立新紀錄
|
||||||
|
$unitCost = $data['unit_cost'] ?? 0;
|
||||||
|
$inventory = Inventory::create([
|
||||||
|
'warehouse_id' => $data['warehouse_id'],
|
||||||
|
'product_id' => $data['product_id'],
|
||||||
|
'quantity' => $data['quantity'],
|
||||||
|
'unit_cost' => $unitCost,
|
||||||
|
'total_value' => $data['quantity'] * $unitCost,
|
||||||
|
'batch_number' => $data['batch_number'] ?? null,
|
||||||
|
'box_number' => $data['box_number'] ?? null,
|
||||||
|
'origin_country' => $data['origin_country'] ?? 'TW',
|
||||||
|
'arrival_date' => $data['arrival_date'] ?? now(),
|
||||||
|
'expiry_date' => $data['expiry_date'] ?? null,
|
||||||
|
'quality_status' => $data['quality_status'] ?? 'normal',
|
||||||
|
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
\App\Modules\Inventory\Models\InventoryTransaction::create([
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'type' => '入庫',
|
||||||
|
'quantity' => $data['quantity'],
|
||||||
|
'unit_cost' => $inventory->unit_cost, // 記錄當下成本
|
||||||
|
'balance_before' => $balanceBefore,
|
||||||
|
'balance_after' => $inventory->quantity,
|
||||||
|
'reason' => $data['reason'] ?? '手動入庫',
|
||||||
|
'reference_type' => $data['reference_type'] ?? null,
|
||||||
|
'reference_id' => $data['reference_id'] ?? null,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'actual_time' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $inventory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($inventoryId, $quantity, $reason, $referenceType, $referenceId) {
|
||||||
|
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
|
||||||
|
$balanceBefore = $inventory->quantity;
|
||||||
|
|
||||||
|
$inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
|
||||||
|
// 需要手動更新總價值
|
||||||
|
$inventory->refresh();
|
||||||
|
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||||
|
$inventory->save();
|
||||||
|
|
||||||
|
\App\Modules\Inventory\Models\InventoryTransaction::create([
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'type' => '出庫',
|
||||||
|
'quantity' => -$quantity,
|
||||||
|
'unit_cost' => $inventory->unit_cost, // 記錄出庫時的成本
|
||||||
|
'balance_before' => $balanceBefore,
|
||||||
|
'balance_after' => $inventory->quantity,
|
||||||
|
'reason' => $reason ?? '庫存扣減',
|
||||||
|
'reference_type' => $referenceType,
|
||||||
|
'reference_id' => $referenceId,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'actual_time' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDashboardStats(): array
|
||||||
|
{
|
||||||
|
// 庫存總表 join 安全庫存表,計算低庫存
|
||||||
|
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
|
||||||
|
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
|
||||||
|
function ($join) {
|
||||||
|
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
|
||||||
|
->on('ss.product_id', '=', 'inv.product_id');
|
||||||
|
})
|
||||||
|
->whereRaw('inv.total_qty <= ss.safety_stock')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'productsCount' => Product::count(),
|
||||||
|
'warehousesCount' => Warehouse::count(),
|
||||||
|
'lowStockCount' => $lowStockCount,
|
||||||
|
'totalInventoryQuantity' => Inventory::sum('quantity'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
interface ProcurementServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get purchase orders within a date range.
|
||||||
|
*
|
||||||
|
* @param string $start
|
||||||
|
* @param string $end
|
||||||
|
* @param array $statuses
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function getPurchaseOrdersByDate(string $start, string $end, array $statuses = ['received', 'completed']): Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get purchase orders by multiple IDs.
|
||||||
|
*
|
||||||
|
* @param array $ids
|
||||||
|
* @param array $with
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function getPurchaseOrdersByIds(array $ids, array $with = []): Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for the dashboard.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getDashboardStats(): array;
|
||||||
|
}
|
||||||
653
app/Modules/Procurement/Controllers/PurchaseOrderController.php
Normal file
653
app/Modules/Procurement/Controllers/PurchaseOrderController.php
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
|
use App\Modules\Procurement\Models\Vendor;
|
||||||
|
// use App\Modules\Inventory\Models\Warehouse; // REFACTORED: 移除直接依賴
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface; // NEW: 使用契約
|
||||||
|
use App\Modules\Core\Contracts\CoreServiceInterface; // NEW: 使用核心服務契約
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class PurchaseOrderController extends Controller
|
||||||
|
{
|
||||||
|
protected $inventoryService;
|
||||||
|
protected $coreService;
|
||||||
|
|
||||||
|
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
|
||||||
|
{
|
||||||
|
$this->inventoryService = $inventoryService;
|
||||||
|
$this->coreService = $coreService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
// 1. 從關聯中移除 'warehouse' 與 'user'
|
||||||
|
$query = PurchaseOrder::with(['vendor']);
|
||||||
|
|
||||||
|
// 搜尋
|
||||||
|
if ($request->search) {
|
||||||
|
$query->where(function($q) use ($request) {
|
||||||
|
$q->where('code', 'like', "%{$request->search}%")
|
||||||
|
->orWhereHas('vendor', function($vq) use ($request) {
|
||||||
|
$vq->where('name', 'like', "%{$request->search}%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 篩選
|
||||||
|
if ($request->status && $request->status !== 'all') {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
|
||||||
|
$query->where('warehouse_id', $request->warehouse_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期範圍
|
||||||
|
if ($request->date_start) {
|
||||||
|
$query->whereDate('created_at', '>=', $request->date_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->date_end) {
|
||||||
|
$query->whereDate('created_at', '<=', $request->date_end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
$sortField = $request->sort_field ?? 'id';
|
||||||
|
$sortDirection = $request->sort_direction ?? 'desc';
|
||||||
|
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
|
||||||
|
|
||||||
|
if (in_array($sortField, $allowedSortFields)) {
|
||||||
|
$query->orderBy($sortField, $sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
$orders = $query->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
|
// 2. 手動注入倉庫與使用者資料
|
||||||
|
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||||
|
$userIds = $orders->getCollection()->pluck('user_id')->unique()->toArray();
|
||||||
|
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||||
|
|
||||||
|
$orders->getCollection()->transform(function ($order) use ($warehouses, $users) {
|
||||||
|
// 水和倉庫
|
||||||
|
$warehouse = $warehouses->firstWhere('id', $order->warehouse_id);
|
||||||
|
$order->setRelation('warehouse', $warehouse);
|
||||||
|
|
||||||
|
// 水和使用者
|
||||||
|
$user = $users->get($order->user_id);
|
||||||
|
$order->setRelation('user', $user);
|
||||||
|
|
||||||
|
// 轉換為前端期望的格式 (camelCase)
|
||||||
|
return (object) [
|
||||||
|
'id' => (string) $order->id,
|
||||||
|
'poNumber' => $order->code,
|
||||||
|
'supplierId' => (string) $order->vendor_id,
|
||||||
|
'supplierName' => $order->vendor?->name ?? 'Unknown',
|
||||||
|
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
|
||||||
|
'expectedDate' => $order->expected_delivery_date?->toISOString(),
|
||||||
|
'status' => $order->status,
|
||||||
|
'totalAmount' => (float) $order->total_amount,
|
||||||
|
'taxAmount' => (float) $order->tax_amount,
|
||||||
|
'grandTotal' => (float) $order->grand_total,
|
||||||
|
'createdAt' => $order->created_at->toISOString(),
|
||||||
|
'createdBy' => $user?->name ?? 'System',
|
||||||
|
'warehouse_id' => (int) $order->warehouse_id,
|
||||||
|
'warehouse_name' => $warehouse?->name ?? 'Unknown',
|
||||||
|
'remark' => $order->remark,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return Inertia::render('PurchaseOrder/Index', [
|
||||||
|
'orders' => $orders,
|
||||||
|
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
|
||||||
|
'warehouses' => $warehouses->map(fn($w)=>(object)['id'=>$w->id, 'name'=>$w->name]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
// 1. 獲取廠商(無關聯)
|
||||||
|
$vendors = Vendor::all();
|
||||||
|
|
||||||
|
// 2. 手動注入:獲取 Pivot 資料
|
||||||
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
|
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
||||||
|
$productIds = $pivots->pluck('product_id')->unique()->toArray();
|
||||||
|
|
||||||
|
// 3. 從服務獲取商品
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
// 4. 重建前端結構
|
||||||
|
$vendors = $vendors->map(function ($vendor) use ($pivots, $products) {
|
||||||
|
$vendorProductPivots = $pivots->where('vendor_id', $vendor->id);
|
||||||
|
|
||||||
|
$commonProducts = $vendorProductPivots->map(function($pivot) use ($products) {
|
||||||
|
$product = $products[$pivot->product_id] ?? null;
|
||||||
|
if (!$product) return null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'productId' => (string) $product->id,
|
||||||
|
'productName' => $product->name,
|
||||||
|
'base_unit_id' => $product->base_unit_id,
|
||||||
|
'base_unit_name' => $product->baseUnit?->name,
|
||||||
|
'large_unit_id' => $product->large_unit_id,
|
||||||
|
'large_unit_name' => $product->largeUnit?->name,
|
||||||
|
'purchase_unit_id' => $product->purchase_unit_id,
|
||||||
|
'conversion_rate' => (float) $product->conversion_rate,
|
||||||
|
'lastPrice' => (float) $pivot->last_price,
|
||||||
|
];
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (string) $vendor->id,
|
||||||
|
'name' => $vendor->name,
|
||||||
|
'commonProducts' => $commonProducts
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) {
|
||||||
|
return [
|
||||||
|
'id' => (string) $w->id,
|
||||||
|
'name' => $w->name,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('PurchaseOrder/Create', [
|
||||||
|
'suppliers' => $vendors,
|
||||||
|
'warehouses' => $warehouses,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'vendor_id' => 'required|exists:vendors,id',
|
||||||
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
'order_date' => 'required|date', // 新增驗證
|
||||||
|
'expected_delivery_date' => 'nullable|date',
|
||||||
|
'remark' => 'nullable|string',
|
||||||
|
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||||
|
'invoice_date' => 'nullable|date',
|
||||||
|
'invoice_amount' => 'nullable|numeric|min:0',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.productId' => 'required|exists:products,id',
|
||||||
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
|
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
|
||||||
|
'items.*.unitId' => 'nullable|exists:units,id',
|
||||||
|
'tax_amount' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
// 生成單號:YYYYMMDD001
|
||||||
|
$today = now()->format('Ymd');
|
||||||
|
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
|
||||||
|
->lockForUpdate() // 鎖定以避免並發衝突
|
||||||
|
->orderBy('code', 'desc')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($lastOrder) {
|
||||||
|
// 取得最後 3 碼序號並加 1
|
||||||
|
$lastSequence = intval(substr($lastOrder->code, -3));
|
||||||
|
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
||||||
|
} else {
|
||||||
|
$sequence = '001';
|
||||||
|
}
|
||||||
|
$code = $today . $sequence;
|
||||||
|
|
||||||
|
$totalAmount = 0;
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
$totalAmount += $item['subtotal'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稅額計算
|
||||||
|
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
|
||||||
|
$grandTotal = $totalAmount + $taxAmount;
|
||||||
|
|
||||||
|
// 確保有一個有效的使用者 ID
|
||||||
|
$userId = auth()->id();
|
||||||
|
if (!$userId) {
|
||||||
|
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = PurchaseOrder::create([
|
||||||
|
'code' => $code,
|
||||||
|
'vendor_id' => $validated['vendor_id'],
|
||||||
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
|
'user_id' => $userId,
|
||||||
|
'status' => 'draft',
|
||||||
|
'order_date' => $validated['order_date'], // 新增
|
||||||
|
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||||
|
'total_amount' => $totalAmount,
|
||||||
|
'tax_amount' => $taxAmount,
|
||||||
|
'grand_total' => $grandTotal,
|
||||||
|
'remark' => $validated['remark'],
|
||||||
|
'invoice_number' => $validated['invoice_number'] ?? null,
|
||||||
|
'invoice_date' => $validated['invoice_date'] ?? null,
|
||||||
|
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
// 反算單價
|
||||||
|
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||||
|
|
||||||
|
$order->items()->create([
|
||||||
|
'product_id' => $item['productId'],
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_id' => $item['unitId'] ?? null,
|
||||||
|
'unit_price' => $unitPrice,
|
||||||
|
'subtotal' => $item['subtotal'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show($id)
|
||||||
|
{
|
||||||
|
$order = PurchaseOrder::with(['vendor', 'items'])->findOrFail($id);
|
||||||
|
|
||||||
|
// 手動注入
|
||||||
|
$order->setRelation('warehouse', $this->inventoryService->getWarehouse($order->warehouse_id));
|
||||||
|
$order->setRelation('user', $this->coreService->getUser($order->user_id));
|
||||||
|
|
||||||
|
$productIds = $order->items->pluck('product_id')->unique()->toArray();
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$formattedItems = $order->items->map(function ($item) use ($order, $products) {
|
||||||
|
$product = $products[$item->product_id] ?? null;
|
||||||
|
return (object) [
|
||||||
|
'productId' => (string) $item->product_id,
|
||||||
|
'productName' => $product?->name ?? 'Unknown',
|
||||||
|
'quantity' => (float) $item->quantity,
|
||||||
|
'unitId' => $item->unit_id,
|
||||||
|
'base_unit_id' => $product?->base_unit_id,
|
||||||
|
'base_unit_name' => $product?->baseUnit?->name,
|
||||||
|
'large_unit_id' => $product?->large_unit_id,
|
||||||
|
'large_unit_name' => $product?->largeUnit?->name,
|
||||||
|
'purchase_unit_id' => $product?->purchase_unit_id,
|
||||||
|
'conversion_rate' => (float) ($product?->conversion_rate ?? 1),
|
||||||
|
'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base',
|
||||||
|
'unitPrice' => (float) $item->unit_price,
|
||||||
|
'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $order->vendor_id)->where('product_id', $item->product_id)->value('last_price') ?? 0),
|
||||||
|
'subtotal' => (float) $item->subtotal,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$formattedOrder = (object) [
|
||||||
|
'id' => (string) $order->id,
|
||||||
|
'poNumber' => $order->code,
|
||||||
|
'supplierId' => (string) $order->vendor_id,
|
||||||
|
'supplierName' => $order->vendor?->name ?? 'Unknown',
|
||||||
|
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
|
||||||
|
'expectedDate' => $order->expected_delivery_date?->toISOString(),
|
||||||
|
'status' => $order->status,
|
||||||
|
'items' => $formattedItems,
|
||||||
|
'totalAmount' => (float) $order->total_amount,
|
||||||
|
'taxAmount' => (float) $order->tax_amount,
|
||||||
|
'grandTotal' => (float) $order->grand_total,
|
||||||
|
'createdAt' => $order->created_at->toISOString(),
|
||||||
|
'createdBy' => $order->user?->name ?? 'System',
|
||||||
|
'warehouse_id' => (int) $order->warehouse_id,
|
||||||
|
'warehouse_name' => $order->warehouse?->name ?? 'Unknown',
|
||||||
|
'remark' => $order->remark,
|
||||||
|
'invoiceNumber' => $order->invoice_number,
|
||||||
|
'invoiceDate' => $order->invoice_date,
|
||||||
|
'invoiceAmount' => (float) $order->invoice_amount,
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('PurchaseOrder/Show', [
|
||||||
|
'order' => $formattedOrder
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id)
|
||||||
|
{
|
||||||
|
// 1. 獲取訂單
|
||||||
|
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
||||||
|
|
||||||
|
// 2. 獲取廠商與商品(與 create 邏輯一致)
|
||||||
|
$vendors = Vendor::all();
|
||||||
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
|
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
||||||
|
$productIds = $pivots->pluck('product_id')->unique()->toArray();
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$vendors = $vendors->map(function ($vendor) use ($pivots, $products) {
|
||||||
|
$vendorProductPivots = $pivots->where('vendor_id', $vendor->id);
|
||||||
|
$commonProducts = $vendorProductPivots->map(function($pivot) use ($products) {
|
||||||
|
$product = $products[$pivot->product_id] ?? null;
|
||||||
|
if (!$product) return null;
|
||||||
|
return [
|
||||||
|
'productId' => (string) $product->id,
|
||||||
|
'productName' => $product->name,
|
||||||
|
'base_unit_id' => $product->base_unit_id,
|
||||||
|
'base_unit_name' => $product->baseUnit?->name,
|
||||||
|
'large_unit_id' => $product->large_unit_id,
|
||||||
|
'large_unit_name' => $product->largeUnit?->name,
|
||||||
|
'purchase_unit_id' => $product->purchase_unit_id,
|
||||||
|
'conversion_rate' => (float) $product->conversion_rate,
|
||||||
|
'lastPrice' => (float) $pivot->last_price,
|
||||||
|
];
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (string) $vendor->id,
|
||||||
|
'name' => $vendor->name,
|
||||||
|
'commonProducts' => $commonProducts
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 獲取倉庫
|
||||||
|
$warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) {
|
||||||
|
return [
|
||||||
|
'id' => (string) $w->id,
|
||||||
|
'name' => $w->name,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 注入訂單項目特定資料
|
||||||
|
// 2. 注入訂單項目
|
||||||
|
$itemProductIds = $order->items->pluck('product_id')->toArray();
|
||||||
|
$itemProducts = $this->inventoryService->getProductsByIds($itemProductIds)->keyBy('id');
|
||||||
|
|
||||||
|
$vendorId = $order->vendor_id;
|
||||||
|
$formattedItems = $order->items->map(function ($item) use ($vendorId, $itemProducts) {
|
||||||
|
$product = $itemProducts[$item->product_id] ?? null;
|
||||||
|
return (object) [
|
||||||
|
'productId' => (string) $item->product_id,
|
||||||
|
'productName' => $product?->name ?? 'Unknown',
|
||||||
|
'quantity' => (float) $item->quantity,
|
||||||
|
'unitId' => $item->unit_id,
|
||||||
|
'base_unit_id' => $product?->base_unit_id,
|
||||||
|
'base_unit_name' => $product?->baseUnit?->name,
|
||||||
|
'large_unit_id' => $product?->large_unit_id,
|
||||||
|
'large_unit_name' => $product?->largeUnit?->name,
|
||||||
|
'conversion_rate' => (float) ($product?->conversion_rate ?? 1),
|
||||||
|
'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base',
|
||||||
|
'unitPrice' => (float) $item->unit_price,
|
||||||
|
'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $vendorId)->where('product_id', $item->product_id)->value('last_price') ?? 0),
|
||||||
|
'subtotal' => (float) $item->subtotal,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$formattedOrder = (object) [
|
||||||
|
'id' => (string) $order->id,
|
||||||
|
'poNumber' => $order->code,
|
||||||
|
'supplierId' => (string) $order->vendor_id,
|
||||||
|
'warehouse_id' => (int) $order->warehouse_id,
|
||||||
|
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
|
||||||
|
'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'),
|
||||||
|
'status' => $order->status,
|
||||||
|
'items' => $formattedItems,
|
||||||
|
'remark' => $order->remark,
|
||||||
|
'invoiceNumber' => $order->invoice_number,
|
||||||
|
'invoiceDate' => $order->invoice_date,
|
||||||
|
'invoiceAmount' => (float) $order->invoice_amount,
|
||||||
|
'taxAmount' => (float) $order->tax_amount,
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('PurchaseOrder/Create', [
|
||||||
|
'order' => $formattedOrder,
|
||||||
|
'suppliers' => $vendors,
|
||||||
|
'warehouses' => $warehouses,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, $id)
|
||||||
|
{
|
||||||
|
$order = PurchaseOrder::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'vendor_id' => 'required|exists:vendors,id',
|
||||||
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
'order_date' => 'required|date', // 新增驗證
|
||||||
|
'expected_delivery_date' => 'nullable|date',
|
||||||
|
'remark' => 'nullable|string',
|
||||||
|
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
||||||
|
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||||
|
'invoice_date' => 'nullable|date',
|
||||||
|
'invoice_amount' => 'nullable|numeric|min:0',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.productId' => 'required|exists:products,id',
|
||||||
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
|
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
|
||||||
|
'items.*.unitId' => 'nullable|exists:units,id',
|
||||||
|
// 允許 tax_amount 和 taxAmount 以保持相容性
|
||||||
|
'tax_amount' => 'nullable|numeric|min:0',
|
||||||
|
'taxAmount' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
$totalAmount = 0;
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
$totalAmount += $item['subtotal'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稅額計算(處理兩個鍵)
|
||||||
|
$inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null;
|
||||||
|
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
||||||
|
$grandTotal = $totalAmount + $taxAmount;
|
||||||
|
|
||||||
|
// 1. 填充屬性但暫不儲存以捕捉變更
|
||||||
|
$order->fill([
|
||||||
|
'vendor_id' => $validated['vendor_id'],
|
||||||
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
|
'order_date' => $validated['order_date'], // 新增
|
||||||
|
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||||
|
'total_amount' => $totalAmount,
|
||||||
|
'tax_amount' => $taxAmount,
|
||||||
|
'grand_total' => $grandTotal,
|
||||||
|
'remark' => $validated['remark'],
|
||||||
|
'status' => $validated['status'],
|
||||||
|
'invoice_number' => $validated['invoice_number'] ?? null,
|
||||||
|
'invoice_date' => $validated['invoice_date'] ?? null,
|
||||||
|
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 捕捉變更屬性以進行手動記錄
|
||||||
|
$dirty = $order->getDirty();
|
||||||
|
$oldAttributes = [];
|
||||||
|
$newAttributes = [];
|
||||||
|
|
||||||
|
foreach ($dirty as $key => $value) {
|
||||||
|
$oldAttributes[$key] = $order->getOriginal($key);
|
||||||
|
$newAttributes[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 儲存但不觸發事件(防止重複記錄)
|
||||||
|
$order->saveQuietly();
|
||||||
|
|
||||||
|
// 2. 捕捉包含商品名稱的舊項目以進行比對
|
||||||
|
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||||
|
return [
|
||||||
|
'id' => $item->id,
|
||||||
|
'product_id' => $item->product_id,
|
||||||
|
'product_name' => $item->product?->name,
|
||||||
|
'quantity' => (float) $item->quantity,
|
||||||
|
'unit_id' => $item->unit_id,
|
||||||
|
'unit_name' => $item->unit?->name,
|
||||||
|
'subtotal' => (float) $item->subtotal,
|
||||||
|
];
|
||||||
|
})->keyBy('product_id');
|
||||||
|
|
||||||
|
// 同步項目(原始邏輯)
|
||||||
|
$order->items()->delete();
|
||||||
|
|
||||||
|
$newItemsData = [];
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
// 反算單價
|
||||||
|
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||||
|
|
||||||
|
$newItem = $order->items()->create([
|
||||||
|
'product_id' => $item['productId'],
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_id' => $item['unitId'] ?? null,
|
||||||
|
'unit_price' => $unitPrice,
|
||||||
|
'subtotal' => $item['subtotal'],
|
||||||
|
]);
|
||||||
|
$newItemsData[] = $newItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 計算項目差異
|
||||||
|
$itemDiffs = [
|
||||||
|
'added' => [],
|
||||||
|
'removed' => [],
|
||||||
|
'updated' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 重新獲取新項目以確保擁有最新的關聯
|
||||||
|
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||||
|
return [
|
||||||
|
'product_id' => $item->product_id,
|
||||||
|
'product_name' => $item->product?->name,
|
||||||
|
'quantity' => (float) $item->quantity,
|
||||||
|
'unit_id' => $item->unit_id,
|
||||||
|
'unit_name' => $item->unit?->name,
|
||||||
|
'subtotal' => (float) $item->subtotal,
|
||||||
|
];
|
||||||
|
})->keyBy('product_id');
|
||||||
|
|
||||||
|
// 找出已移除的項目
|
||||||
|
foreach ($oldItems as $productId => $oldItem) {
|
||||||
|
if (!$newItemsFormatted->has($productId)) {
|
||||||
|
$itemDiffs['removed'][] = $oldItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找出新增和更新的項目
|
||||||
|
foreach ($newItemsFormatted as $productId => $newItem) {
|
||||||
|
if (!$oldItems->has($productId)) {
|
||||||
|
$itemDiffs['added'][] = $newItem;
|
||||||
|
} else {
|
||||||
|
$oldItem = $oldItems[$productId];
|
||||||
|
// 比對欄位
|
||||||
|
if (
|
||||||
|
$oldItem['quantity'] != $newItem['quantity'] ||
|
||||||
|
$oldItem['unit_id'] != $newItem['unit_id'] ||
|
||||||
|
$oldItem['subtotal'] != $newItem['subtotal']
|
||||||
|
) {
|
||||||
|
$itemDiffs['updated'][] = [
|
||||||
|
'product_name' => $newItem['product_name'],
|
||||||
|
'old' => [
|
||||||
|
'quantity' => $oldItem['quantity'],
|
||||||
|
'unit_name' => $oldItem['unit_name'],
|
||||||
|
'subtotal' => $oldItem['subtotal'],
|
||||||
|
],
|
||||||
|
'new' => [
|
||||||
|
'quantity' => $newItem['quantity'],
|
||||||
|
'unit_name' => $newItem['unit_name'],
|
||||||
|
'subtotal' => $newItem['subtotal'],
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 手動記錄活動(單一整合記錄)
|
||||||
|
// 如果有屬性變更或項目變更則記錄
|
||||||
|
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
|
||||||
|
activity()
|
||||||
|
->performedOn($order)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->event('updated')
|
||||||
|
->withProperties([
|
||||||
|
'attributes' => $newAttributes,
|
||||||
|
'old' => $oldAttributes,
|
||||||
|
'items_diff' => $itemDiffs,
|
||||||
|
'snapshot' => [
|
||||||
|
'po_number' => $order->code,
|
||||||
|
'vendor_name' => $order->vendor?->name,
|
||||||
|
'warehouse_name' => $order->warehouse?->name,
|
||||||
|
'user_name' => $order->user?->name,
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->log('updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy($id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
||||||
|
|
||||||
|
// 為記錄注入資料
|
||||||
|
$productIds = $order->items->pluck('product_id')->unique()->toArray();
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
// 捕捉項目以進行記錄
|
||||||
|
$items = $order->items->map(function ($item) use ($products) {
|
||||||
|
$product = $products[$item->product_id] ?? null;
|
||||||
|
return [
|
||||||
|
'product_name' => $product?->name ?? 'Unknown',
|
||||||
|
'quantity' => floatval($item->quantity),
|
||||||
|
'unit_name' => 'N/A',
|
||||||
|
'subtotal' => floatval($item->subtotal),
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
// 手動記錄包含項目的刪除操作
|
||||||
|
activity()
|
||||||
|
->performedOn($order)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->event('deleted')
|
||||||
|
->withProperties([
|
||||||
|
'attributes' => $order->getAttributes(),
|
||||||
|
'items_diff' => [
|
||||||
|
'added' => [],
|
||||||
|
'removed' => $items,
|
||||||
|
'updated' => [],
|
||||||
|
],
|
||||||
|
'snapshot' => [
|
||||||
|
'po_number' => $order->code,
|
||||||
|
'vendor_name' => $order->vendor?->name,
|
||||||
|
'warehouse_name' => $order->warehouse?->name,
|
||||||
|
'user_name' => $order->user?->name,
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->log('deleted');
|
||||||
|
|
||||||
|
// 對此操作停用自動記錄
|
||||||
|
$order->disableLogging();
|
||||||
|
|
||||||
|
// 先刪除關聯項目
|
||||||
|
$order->items()->delete();
|
||||||
|
$order->delete();
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
app/Modules/Procurement/Controllers/VendorController.php
Normal file
194
app/Modules/Procurement/Controllers/VendorController.php
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Procurement\Models\Vendor;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class VendorController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected InventoryServiceInterface $inventoryService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示資源列表。
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Vendor::query();
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('tax_id', 'like', "%{$search}%")
|
||||||
|
->orWhere('owner', 'like', "%{$search}%")
|
||||||
|
->orWhere('contact_name', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortField = $request->input('sort_field', 'id');
|
||||||
|
$sortDirection = $request->input('sort_direction', 'desc');
|
||||||
|
|
||||||
|
$allowedSorts = ['id', 'code', 'name', 'owner', 'contact_name', 'phone'];
|
||||||
|
if (!in_array($sortField, $allowedSorts)) {
|
||||||
|
$sortField = 'id';
|
||||||
|
}
|
||||||
|
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
||||||
|
$sortDirection = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
|
||||||
|
$vendors = $query->orderBy($sortField, $sortDirection)
|
||||||
|
->paginate($perPage)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$vendors->getCollection()->transform(function ($vendor) {
|
||||||
|
return (object) [
|
||||||
|
'id' => (string) $vendor->id,
|
||||||
|
'code' => $vendor->code,
|
||||||
|
'name' => $vendor->name,
|
||||||
|
'shortName' => $vendor->short_name,
|
||||||
|
'taxId' => $vendor->tax_id,
|
||||||
|
'owner' => $vendor->owner,
|
||||||
|
'contactName' => $vendor->contact_name,
|
||||||
|
'phone' => $vendor->phone,
|
||||||
|
'tel' => $vendor->tel,
|
||||||
|
'email' => $vendor->email,
|
||||||
|
'address' => $vendor->address,
|
||||||
|
'remark' => $vendor->remark,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Vendor/Index', [
|
||||||
|
'vendors' => $vendors,
|
||||||
|
'filters' => $request->only(['search', 'sort_field', 'sort_direction', 'per_page']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示指定資源。
|
||||||
|
*/
|
||||||
|
public function show(Vendor $vendor): Response
|
||||||
|
{
|
||||||
|
// $vendor->load(['products.baseUnit', 'products.largeUnit']); // REMOVED: Cross-module relation
|
||||||
|
|
||||||
|
// 1. 獲取關聯的 Product IDs 與 Pivot Data
|
||||||
|
$pivots = \Illuminate\Support\Facades\DB::table('product_vendor')
|
||||||
|
->where('vendor_id', $vendor->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$productIds = $pivots->pluck('product_id')->toArray();
|
||||||
|
|
||||||
|
// 2. 透過 Service 獲取 Products
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$supplyProducts = $pivots->map(function ($pivot) use ($products) {
|
||||||
|
$product = $products->get($pivot->product_id);
|
||||||
|
if (!$product) return null;
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'id' => (string) $pivot->id,
|
||||||
|
'productId' => (string) $product->id,
|
||||||
|
'productName' => $product->name,
|
||||||
|
'unit' => $product->baseUnit?->name ?? 'N/A',
|
||||||
|
'baseUnit' => $product->baseUnit?->name,
|
||||||
|
'largeUnit' => $product->largeUnit?->name,
|
||||||
|
'conversionRate' => (float) $product->conversion_rate,
|
||||||
|
'lastPrice' => (float) $pivot->last_price,
|
||||||
|
];
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
$formattedVendor = (object) [
|
||||||
|
'id' => (string) $vendor->id,
|
||||||
|
'code' => $vendor->code,
|
||||||
|
'name' => $vendor->name,
|
||||||
|
'shortName' => $vendor->short_name,
|
||||||
|
'taxId' => $vendor->tax_id,
|
||||||
|
'owner' => $vendor->owner,
|
||||||
|
'contactName' => $vendor->contact_name,
|
||||||
|
'phone' => $vendor->phone,
|
||||||
|
'tel' => $vendor->tel,
|
||||||
|
'email' => $vendor->email,
|
||||||
|
'address' => $vendor->address,
|
||||||
|
'remark' => $vendor->remark,
|
||||||
|
'supplyProducts' => $supplyProducts,
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Vendor/Show', [
|
||||||
|
'vendor' => $formattedVendor,
|
||||||
|
'products' => $this->inventoryService->getAllProducts(), // 使用已有的服務獲取所有商品供選取
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將新建立的資源儲存到儲存體中。
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'short_name' => 'nullable|string|max:255',
|
||||||
|
'tax_id' => 'nullable|string|max:8',
|
||||||
|
'owner' => 'nullable|string|max:255',
|
||||||
|
'contact_name' => 'nullable|string|max:255',
|
||||||
|
'tel' => 'nullable|string|max:50',
|
||||||
|
'phone' => 'nullable|string|max:50',
|
||||||
|
'email' => 'nullable|email|max:255',
|
||||||
|
'address' => 'nullable|string',
|
||||||
|
'remark' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 自動產生代碼
|
||||||
|
$prefix = 'V';
|
||||||
|
$lastVendor = Vendor::latest('id')->first();
|
||||||
|
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
|
||||||
|
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$validated['code'] = $code;
|
||||||
|
|
||||||
|
Vendor::create($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '廠商已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新儲存體中的指定資源。
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Vendor $vendor)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'short_name' => 'nullable|string|max:255',
|
||||||
|
'tax_id' => 'nullable|string|max:8',
|
||||||
|
'owner' => 'nullable|string|max:255',
|
||||||
|
'contact_name' => 'nullable|string|max:255',
|
||||||
|
'tel' => 'nullable|string|max:50',
|
||||||
|
'phone' => 'nullable|string|max:50',
|
||||||
|
'email' => 'nullable|email|max:255',
|
||||||
|
'address' => 'nullable|string',
|
||||||
|
'remark' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$vendor->update($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '廠商資料已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從儲存體中移除指定資源。
|
||||||
|
*/
|
||||||
|
public function destroy(Vendor $vendor)
|
||||||
|
{
|
||||||
|
$vendor->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '廠商已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Modules/Procurement/Controllers/VendorProductController.php
Normal file
130
app/Modules/Procurement/Controllers/VendorProductController.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Procurement\Models\Vendor;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class VendorProductController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected InventoryServiceInterface $inventoryService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增供貨商品 (Attach)
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Vendor $vendor)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'product_id' => 'required|exists:products,id',
|
||||||
|
'last_price' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 檢查是否已存在
|
||||||
|
if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) {
|
||||||
|
return redirect()->back()->with('error', '該商品已在供貨清單中');
|
||||||
|
}
|
||||||
|
|
||||||
|
$vendor->products()->attach($validated['product_id'], [
|
||||||
|
'last_price' => $validated['last_price'] ?? null
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 記錄操作
|
||||||
|
$product = $this->inventoryService->getProduct($validated['product_id']);
|
||||||
|
activity()
|
||||||
|
->performedOn($vendor)
|
||||||
|
->withProperties([
|
||||||
|
'attributes' => [
|
||||||
|
'product_name' => $product->name,
|
||||||
|
'last_price' => $validated['last_price'] ?? null,
|
||||||
|
],
|
||||||
|
'sub_subject' => '供貨商品',
|
||||||
|
'snapshot' => [
|
||||||
|
'name' => "{$vendor->name}-{$product->name}", // 顯示例如:台積電-紅糖
|
||||||
|
'vendor_name' => $vendor->name,
|
||||||
|
'product_name' => $product->name,
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->event('created')
|
||||||
|
->log('新增供貨商品');
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '供貨商品已新增');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新供貨商品資訊 (Update Pivot)
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Vendor $vendor, $productId)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'last_price' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 獲取舊價格
|
||||||
|
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
|
||||||
|
|
||||||
|
$vendor->products()->updateExistingPivot($productId, [
|
||||||
|
'last_price' => $validated['last_price'] ?? null
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 記錄操作
|
||||||
|
$product = $this->inventoryService->getProduct($productId);
|
||||||
|
activity()
|
||||||
|
->performedOn($vendor)
|
||||||
|
->withProperties([
|
||||||
|
'old' => [
|
||||||
|
'last_price' => $old_price,
|
||||||
|
],
|
||||||
|
'attributes' => [
|
||||||
|
'last_price' => $validated['last_price'] ?? null,
|
||||||
|
],
|
||||||
|
'sub_subject' => '供貨商品',
|
||||||
|
'snapshot' => [
|
||||||
|
'name' => "{$vendor->name}-{$product->name}",
|
||||||
|
'vendor_name' => $vendor->name,
|
||||||
|
'product_name' => $product->name,
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->event('updated')
|
||||||
|
->log('更新供貨商品價格');
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '供貨資訊已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除供貨商品 (Detach)
|
||||||
|
*/
|
||||||
|
public function destroy(Vendor $vendor, $productId)
|
||||||
|
{
|
||||||
|
// 記錄操作 (需在 detach 前獲取資訊)
|
||||||
|
$product = $this->inventoryService->getProduct($productId);
|
||||||
|
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
|
||||||
|
|
||||||
|
$vendor->products()->detach($productId);
|
||||||
|
|
||||||
|
if ($product) {
|
||||||
|
activity()
|
||||||
|
->performedOn($vendor)
|
||||||
|
->withProperties([
|
||||||
|
'old' => [
|
||||||
|
'product_name' => $product->name,
|
||||||
|
'last_price' => $old_price,
|
||||||
|
],
|
||||||
|
'sub_subject' => '供貨商品',
|
||||||
|
'snapshot' => [
|
||||||
|
'name' => "{$vendor->name}-{$product->name}",
|
||||||
|
'vendor_name' => $vendor->name,
|
||||||
|
'product_name' => $product->name,
|
||||||
|
]
|
||||||
|
])
|
||||||
|
->event('deleted')
|
||||||
|
->log('移除供貨商品');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '供貨商品已移除');
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Modules/Procurement/Models/PurchaseOrder.php
Normal file
73
app/Modules/Procurement/Models/PurchaseOrder.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrder extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PurchaseOrderFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'vendor_id',
|
||||||
|
'warehouse_id',
|
||||||
|
'user_id',
|
||||||
|
'order_date',
|
||||||
|
'expected_delivery_date',
|
||||||
|
'status',
|
||||||
|
'total_amount',
|
||||||
|
'tax_amount',
|
||||||
|
'grand_total',
|
||||||
|
'remark',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'order_date' => 'date',
|
||||||
|
'expected_delivery_date' => 'date',
|
||||||
|
'total_amount' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
|
{
|
||||||
|
return \Spatie\Activitylog\LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$snapshot = $activity->properties['snapshot'] ?? [];
|
||||||
|
|
||||||
|
$snapshot['po_number'] = $this->code;
|
||||||
|
|
||||||
|
if ($this->vendor) {
|
||||||
|
$snapshot['vendor_name'] = $this->vendor->name;
|
||||||
|
}
|
||||||
|
// Warehouse relation removed in Strict Mode. Snapshot should be set via manual hydration if needed,
|
||||||
|
// or during the procurement process where warehouse_id is known.
|
||||||
|
|
||||||
|
$activity->properties = $activity->properties->merge([
|
||||||
|
'snapshot' => $snapshot
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vendor(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Vendor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PurchaseOrderItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Modules/Procurement/Models/PurchaseOrderItem.php
Normal file
41
app/Modules/Procurement/Models/PurchaseOrderItem.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderItem extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PurchaseOrderItemFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'purchase_order_id',
|
||||||
|
'product_id',
|
||||||
|
'quantity',
|
||||||
|
'unit_price',
|
||||||
|
'subtotal',
|
||||||
|
// 驗收欄位
|
||||||
|
'received_quantity',
|
||||||
|
// 批號與效期 (驗收時填寫)
|
||||||
|
'batch_number',
|
||||||
|
'expiry_date',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'quantity' => 'decimal:2',
|
||||||
|
'unit_price' => 'decimal:4',
|
||||||
|
'subtotal' => 'decimal:2',
|
||||||
|
'received_quantity' => 'decimal:2',
|
||||||
|
'expiry_date' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function purchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PurchaseOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
55
app/Modules/Procurement/Models/Vendor.php
Normal file
55
app/Modules/Procurement/Models/Vendor.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
|
||||||
|
class Vendor extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\VendorFactory> */
|
||||||
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'short_name',
|
||||||
|
'tax_id',
|
||||||
|
'owner',
|
||||||
|
'contact_name',
|
||||||
|
'tel',
|
||||||
|
'phone',
|
||||||
|
'email',
|
||||||
|
'address',
|
||||||
|
'remark',
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PurchaseOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||||
|
{
|
||||||
|
$properties = $activity->properties;
|
||||||
|
|
||||||
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
$snapshot['name'] = $this->name;
|
||||||
|
$properties['snapshot'] = $snapshot;
|
||||||
|
|
||||||
|
$activity->properties = $properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Modules/Procurement/ProcurementServiceProvider.php
Normal file
20
app/Modules/Procurement/ProcurementServiceProvider.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
|
use App\Modules\Procurement\Services\ProcurementService;
|
||||||
|
|
||||||
|
class ProcurementServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->bind(ProcurementServiceInterface::class, ProcurementService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Modules/Procurement/Routes/web.php
Normal file
38
app/Modules/Procurement/Routes/web.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Modules\Procurement\Controllers\VendorController;
|
||||||
|
use App\Modules\Procurement\Controllers\VendorProductController;
|
||||||
|
use App\Modules\Procurement\Controllers\PurchaseOrderController;
|
||||||
|
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
// 廠商管理
|
||||||
|
Route::middleware('permission:vendors.view')->group(function () {
|
||||||
|
Route::get('/vendors', [VendorController::class, 'index'])->name('vendors.index');
|
||||||
|
Route::get('/vendors/{vendor}', [VendorController::class, 'show'])->name('vendors.show');
|
||||||
|
Route::post('/vendors', [VendorController::class, 'store'])->middleware('permission:vendors.create')->name('vendors.store');
|
||||||
|
Route::put('/vendors/{vendor}', [VendorController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.update');
|
||||||
|
Route::delete('/vendors/{vendor}', [VendorController::class, 'destroy'])->middleware('permission:vendors.delete')->name('vendors.destroy');
|
||||||
|
|
||||||
|
// 供貨商品相關路由
|
||||||
|
Route::post('/vendors/{vendor}/products', [VendorProductController::class, 'store'])->middleware('permission:vendors.edit')->name('vendors.products.store');
|
||||||
|
Route::put('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.products.update');
|
||||||
|
Route::delete('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'destroy'])->middleware('permission:vendors.edit')->name('vendors.products.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 採購單管理
|
||||||
|
Route::middleware('permission:purchase_orders.view')->group(function () {
|
||||||
|
Route::get('/purchase-orders', [PurchaseOrderController::class, 'index'])->name('purchase-orders.index');
|
||||||
|
|
||||||
|
Route::middleware('permission:purchase_orders.create')->group(function () {
|
||||||
|
Route::get('/purchase-orders/create', [PurchaseOrderController::class, 'create'])->name('purchase-orders.create');
|
||||||
|
Route::post('/purchase-orders', [PurchaseOrderController::class, 'store'])->name('purchase-orders.store');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/purchase-orders/{id}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show');
|
||||||
|
|
||||||
|
Route::get('/purchase-orders/{id}/edit', [PurchaseOrderController::class, 'edit'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.edit');
|
||||||
|
Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update');
|
||||||
|
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
app/Modules/Procurement/Services/ProcurementService.php
Normal file
32
app/Modules/Procurement/Services/ProcurementService.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Procurement\Services;
|
||||||
|
|
||||||
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ProcurementService implements ProcurementServiceInterface
|
||||||
|
{
|
||||||
|
public function getPurchaseOrdersByDate(string $start, string $end, array $statuses = ['received', 'completed']): Collection
|
||||||
|
{
|
||||||
|
return PurchaseOrder::with(['vendor'])
|
||||||
|
->whereIn('status', $statuses)
|
||||||
|
->whereBetween('created_at', [$start . ' 00:00:00', $end . ' 23:59:59'])
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPurchaseOrdersByIds(array $ids, array $with = []): Collection
|
||||||
|
{
|
||||||
|
return PurchaseOrder::whereIn('id', $ids)->with($with)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDashboardStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'vendorsCount' => \App\Modules\Procurement\Models\Vendor::count(),
|
||||||
|
'purchaseOrdersCount' => PurchaseOrder::count(),
|
||||||
|
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
415
app/Modules/Production/Controllers/ProductionOrderController.php
Normal file
415
app/Modules/Production/Controllers/ProductionOrderController.php
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Modules\Production\Models\ProductionOrder;
|
||||||
|
use App\Modules\Production\Models\ProductionOrderItem;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProductionOrderController extends Controller
|
||||||
|
{
|
||||||
|
protected $inventoryService;
|
||||||
|
protected $coreService;
|
||||||
|
|
||||||
|
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
|
||||||
|
{
|
||||||
|
$this->inventoryService = $inventoryService;
|
||||||
|
$this->coreService = $coreService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生產工單列表
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
// 不再使用 with(),避免跨模組 Eager Loading
|
||||||
|
$query = ProductionOrder::query();
|
||||||
|
|
||||||
|
// 搜尋 (此處 orWhereHas 暫時保留,因 Laravel query builder 仍可作用於資料表層級,
|
||||||
|
// 但實務上若模組完全隔離,應考慮搜尋引擎或 ID 預選)
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('output_batch_number', 'like', "%{$search}%");
|
||||||
|
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
|
||||||
|
$q->where('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('output_batch_number', 'like', "%{$search}%");
|
||||||
|
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
|
||||||
|
$productIds = $this->inventoryService->getProductsByName($search)->pluck('id');
|
||||||
|
$q->orWhereIn('product_id', $productIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狀態篩選
|
||||||
|
if ($request->filled('status') && $request->status !== 'all') {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排除軟刪除
|
||||||
|
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
|
||||||
|
|
||||||
|
// 分頁
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
$productionOrders = $query->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
|
// --- 手動資料水和 (Manual Hydration) ---
|
||||||
|
$productIds = $productionOrders->pluck('product_id')->unique()->filter()->toArray();
|
||||||
|
$warehouseIds = $productionOrders->pluck('warehouse_id')->unique()->filter()->toArray();
|
||||||
|
$userIds = $productionOrders->pluck('user_id')->unique()->filter()->toArray();
|
||||||
|
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
$warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id');
|
||||||
|
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||||
|
|
||||||
|
$productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users) {
|
||||||
|
$order->product = $products->get($order->product_id);
|
||||||
|
$order->warehouse = $warehouses->get($order->warehouse_id);
|
||||||
|
$order->user = $users->get($order->user_id);
|
||||||
|
return $order;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Production/Index', [
|
||||||
|
'productionOrders' => $productionOrders,
|
||||||
|
'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增生產單表單
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Production/Create', [
|
||||||
|
'products' => $this->inventoryService->getAllProducts(),
|
||||||
|
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||||
|
'units' => $this->inventoryService->getUnits(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 儲存生產單(含自動扣料與成品入庫)
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$status = $request->input('status', 'draft');
|
||||||
|
|
||||||
|
$baseRules = [
|
||||||
|
'product_id' => 'required',
|
||||||
|
'output_batch_number' => 'required|string|max:50',
|
||||||
|
'status' => 'nullable|in:draft,completed',
|
||||||
|
];
|
||||||
|
|
||||||
|
$completedRules = [
|
||||||
|
'warehouse_id' => 'required',
|
||||||
|
'output_quantity' => 'required|numeric|min:0.01',
|
||||||
|
'production_date' => 'required|date',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.inventory_id' => 'required',
|
||||||
|
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
||||||
|
];
|
||||||
|
|
||||||
|
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
|
||||||
|
|
||||||
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated, $request, $status) {
|
||||||
|
// 1. 建立生產工單
|
||||||
|
$productionOrder = ProductionOrder::create([
|
||||||
|
'code' => ProductionOrder::generateCode(),
|
||||||
|
'product_id' => $validated['product_id'],
|
||||||
|
'warehouse_id' => $validated['warehouse_id'] ?? null,
|
||||||
|
'output_quantity' => $validated['output_quantity'] ?? 0,
|
||||||
|
'output_batch_number' => $validated['output_batch_number'],
|
||||||
|
'output_box_count' => $request->output_box_count,
|
||||||
|
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
||||||
|
'expiry_date' => $request->expiry_date,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'status' => $status,
|
||||||
|
'remark' => $request->remark,
|
||||||
|
]);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('created');
|
||||||
|
|
||||||
|
// 2. 處理明細
|
||||||
|
if (!empty($request->items)) {
|
||||||
|
foreach ($request->items as $item) {
|
||||||
|
ProductionOrderItem::create([
|
||||||
|
'production_order_id' => $productionOrder->id,
|
||||||
|
'inventory_id' => $item['inventory_id'],
|
||||||
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === 'completed') {
|
||||||
|
$this->inventoryService->decreaseInventoryQuantity(
|
||||||
|
$item['inventory_id'],
|
||||||
|
$item['quantity_used'],
|
||||||
|
"生產單 #{$productionOrder->code} 耗料",
|
||||||
|
ProductionOrder::class,
|
||||||
|
$productionOrder->id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 成品入庫
|
||||||
|
if ($status === 'completed') {
|
||||||
|
$this->inventoryService->createInventoryRecord([
|
||||||
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
|
'product_id' => $validated['product_id'],
|
||||||
|
'quantity' => $validated['output_quantity'],
|
||||||
|
'batch_number' => $validated['output_batch_number'],
|
||||||
|
'box_number' => $request->output_box_count,
|
||||||
|
'arrival_date' => $validated['production_date'],
|
||||||
|
'expiry_date' => $request->expiry_date,
|
||||||
|
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
|
||||||
|
'reference_type' => ProductionOrder::class,
|
||||||
|
'reference_id' => $productionOrder->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('completed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('production-orders.index')
|
||||||
|
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢視生產單詳情
|
||||||
|
*/
|
||||||
|
public function show(ProductionOrder $productionOrder): Response
|
||||||
|
{
|
||||||
|
// 手動水和主表資料
|
||||||
|
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
|
||||||
|
if ($productionOrder->product) {
|
||||||
|
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
|
||||||
|
}
|
||||||
|
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
|
||||||
|
$productionOrder->user = $this->coreService->getUser($productionOrder->user_id);
|
||||||
|
|
||||||
|
// 手動水和明細資料
|
||||||
|
$items = $productionOrder->items;
|
||||||
|
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
|
||||||
|
$inventories = $this->inventoryService->getInventoriesByIds(
|
||||||
|
$inventoryIds,
|
||||||
|
['product.baseUnit', 'sourcePurchaseOrder.vendor']
|
||||||
|
)->keyBy('id');
|
||||||
|
|
||||||
|
$units = $this->inventoryService->getUnits()->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->inventory = $inventories->get($item->inventory_id);
|
||||||
|
$item->unit = $units->get($item->unit_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Production/Show', [
|
||||||
|
'productionOrder' => $productionOrder,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得倉庫內可用庫存
|
||||||
|
*/
|
||||||
|
public function getWarehouseInventories($warehouseId)
|
||||||
|
{
|
||||||
|
$inventories = $this->inventoryService->getInventoriesByWarehouse($warehouseId);
|
||||||
|
|
||||||
|
$data = $inventories->map(function ($inv) {
|
||||||
|
return [
|
||||||
|
'id' => $inv->id,
|
||||||
|
'product_id' => $inv->product_id,
|
||||||
|
'product_name' => $inv->product->name ?? '未知商品',
|
||||||
|
'product_code' => $inv->product->code ?? '',
|
||||||
|
'batch_number' => $inv->batch_number,
|
||||||
|
'box_number' => $inv->box_number,
|
||||||
|
'quantity' => $inv->quantity,
|
||||||
|
'arrival_date' => $inv->arrival_date ? $inv->arrival_date->format('Y-m-d') : null,
|
||||||
|
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
|
'unit_name' => $inv->product->baseUnit->name ?? '',
|
||||||
|
'base_unit_id' => $inv->product->base_unit_id ?? null,
|
||||||
|
'large_unit_id' => $inv->product->large_unit_id ?? null,
|
||||||
|
'conversion_rate' => $inv->product->conversion_rate ?? 1,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 編輯生產單
|
||||||
|
*/
|
||||||
|
public function edit(ProductionOrder $productionOrder): Response
|
||||||
|
{
|
||||||
|
if ($productionOrder->status !== 'draft') {
|
||||||
|
return redirect()->route('production-orders.show', $productionOrder->id)
|
||||||
|
->with('error', '只有草稿狀態的生產單可以編輯');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基本水和
|
||||||
|
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
|
||||||
|
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
|
||||||
|
|
||||||
|
// 手動水和明細資料
|
||||||
|
$items = $productionOrder->items;
|
||||||
|
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
|
||||||
|
$inventories = $this->inventoryService->getInventoriesByIds(
|
||||||
|
$inventoryIds,
|
||||||
|
['product.baseUnit']
|
||||||
|
)->keyBy('id');
|
||||||
|
|
||||||
|
$units = $this->inventoryService->getUnits()->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->inventory = $inventories->get($item->inventory_id);
|
||||||
|
$item->unit = $units->get($item->unit_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Production/Edit', [
|
||||||
|
'productionOrder' => $productionOrder,
|
||||||
|
'products' => $this->inventoryService->getAllProducts(),
|
||||||
|
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||||
|
'units' => $this->inventoryService->getUnits(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新生產單
|
||||||
|
*/
|
||||||
|
public function update(Request $request, ProductionOrder $productionOrder)
|
||||||
|
{
|
||||||
|
if ($productionOrder->status !== 'draft') {
|
||||||
|
return redirect()->route('production-orders.show', $productionOrder->id)
|
||||||
|
->with('error', '只有草稿可以修改');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $request->input('status', 'draft');
|
||||||
|
|
||||||
|
// 基礎驗證規則
|
||||||
|
$baseRules = [
|
||||||
|
'product_id' => 'required|exists:products,id',
|
||||||
|
'output_batch_number' => 'required|string|max:50',
|
||||||
|
'status' => 'required|in:draft,completed',
|
||||||
|
'remark' => 'nullable|string',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 完工時的嚴格驗證規則
|
||||||
|
$completedRules = [
|
||||||
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
|
'output_quantity' => 'required|numeric|min:0.01',
|
||||||
|
'production_date' => 'required|date',
|
||||||
|
'expiry_date' => 'nullable|date',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.inventory_id' => 'required|exists:inventories,id',
|
||||||
|
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 若狀態切換為 completed,需合併驗證規則
|
||||||
|
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
|
||||||
|
|
||||||
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated, $request, $status, $productionOrder) {
|
||||||
|
$productionOrder->update([
|
||||||
|
'product_id' => $validated['product_id'],
|
||||||
|
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
|
||||||
|
'output_quantity' => $validated['output_quantity'] ?? 0,
|
||||||
|
'output_batch_number' => $validated['output_batch_number'],
|
||||||
|
'output_box_count' => $request->output_box_count,
|
||||||
|
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
||||||
|
'expiry_date' => $request->expiry_date,
|
||||||
|
'status' => $status,
|
||||||
|
'remark' => $request->remark,
|
||||||
|
]);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('updated');
|
||||||
|
|
||||||
|
// 重新建立明細
|
||||||
|
$productionOrder->items()->delete();
|
||||||
|
|
||||||
|
if (!empty($request->items)) {
|
||||||
|
foreach ($request->items as $item) {
|
||||||
|
ProductionOrderItem::create([
|
||||||
|
'production_order_id' => $productionOrder->id,
|
||||||
|
'inventory_id' => $item['inventory_id'],
|
||||||
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === 'completed') {
|
||||||
|
$this->inventoryService->decreaseInventoryQuantity(
|
||||||
|
$item['inventory_id'],
|
||||||
|
$item['quantity_used'],
|
||||||
|
"生產單 #{$productionOrder->code} 耗料",
|
||||||
|
ProductionOrder::class,
|
||||||
|
$productionOrder->id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'completed') {
|
||||||
|
$this->inventoryService->createInventoryRecord([
|
||||||
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
|
'product_id' => $validated['product_id'],
|
||||||
|
'quantity' => $validated['output_quantity'],
|
||||||
|
'batch_number' => $validated['output_batch_number'],
|
||||||
|
'box_number' => $request->output_box_count,
|
||||||
|
'arrival_date' => $validated['production_date'],
|
||||||
|
'expiry_date' => $request->expiry_date,
|
||||||
|
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
|
||||||
|
'reference_type' => ProductionOrder::class,
|
||||||
|
'reference_id' => $productionOrder->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('completed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('production-orders.index')
|
||||||
|
->with('success', '生產單已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除生產單
|
||||||
|
*/
|
||||||
|
public function destroy(ProductionOrder $productionOrder)
|
||||||
|
{
|
||||||
|
if ($productionOrder->status === 'completed') {
|
||||||
|
return redirect()->back()->with('error', '已完工的生產單無法刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($productionOrder) {
|
||||||
|
// 紀錄刪除動作 (需在刪除前或使用軟刪除)
|
||||||
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('deleted');
|
||||||
|
|
||||||
|
$productionOrder->items()->delete();
|
||||||
|
$productionOrder->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('production-orders.index')->with('success', '生產單已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/Modules/Production/Controllers/RecipeController.php
Normal file
191
app/Modules/Production/Controllers/RecipeController.php
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Production\Models\Recipe;
|
||||||
|
use App\Modules\Production\Models\RecipeItem;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class RecipeController extends Controller
|
||||||
|
{
|
||||||
|
protected $inventoryService;
|
||||||
|
|
||||||
|
public function __construct(InventoryServiceInterface $inventoryService)
|
||||||
|
{
|
||||||
|
$this->inventoryService = $inventoryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配方列表
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Recipe::query();
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('name', 'like', "%{$search}%");
|
||||||
|
|
||||||
|
$productIds = $this->inventoryService->getProductsByName($search)->pluck('id');
|
||||||
|
$q->orWhereIn('product_id', $productIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
|
||||||
|
|
||||||
|
$recipes = $query->paginate($request->input('per_page', 10))->withQueryString();
|
||||||
|
|
||||||
|
// Manual Hydration
|
||||||
|
$productIds = $recipes->pluck('product_id')->unique()->filter()->toArray();
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
|
||||||
|
$recipes->getCollection()->transform(function ($recipe) use ($products) {
|
||||||
|
$recipe->product = $products->get($recipe->product_id);
|
||||||
|
return $recipe;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Production/Recipe/Index', [
|
||||||
|
'recipes' => $recipes,
|
||||||
|
'filters' => $request->only(['search', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增配方表單
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Production/Recipe/Create', [
|
||||||
|
'products' => $this->inventoryService->getAllProducts(),
|
||||||
|
'units' => $this->inventoryService->getUnits(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 儲存配方
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'product_id' => 'required|exists:products,id',
|
||||||
|
'code' => 'required|string|max:50|unique:recipes,code',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'yield_quantity' => 'required|numeric|min:0.01',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_id' => 'required|exists:products,id',
|
||||||
|
'items.*.quantity' => 'required|numeric|min:0.0001',
|
||||||
|
'items.*.unit_id' => 'nullable|exists:units,id',
|
||||||
|
'items.*.remark' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated) {
|
||||||
|
$recipe = Recipe::create([
|
||||||
|
'product_id' => $validated['product_id'],
|
||||||
|
'code' => $validated['code'],
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'],
|
||||||
|
'yield_quantity' => $validated['yield_quantity'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
RecipeItem::create([
|
||||||
|
'recipe_id' => $recipe->id,
|
||||||
|
'product_id' => $item['product_id'],
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_id' => $item['unit_id'],
|
||||||
|
'remark' => $item['remark'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('recipes.index')->with('success', '配方已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 編輯配方表單
|
||||||
|
*/
|
||||||
|
public function edit(Recipe $recipe): Response
|
||||||
|
{
|
||||||
|
// Hydrate Product
|
||||||
|
$recipe->product = $this->inventoryService->getProduct($recipe->product_id);
|
||||||
|
|
||||||
|
// Load items with details
|
||||||
|
$items = $recipe->items;
|
||||||
|
$productIds = $items->pluck('product_id')->unique()->toArray();
|
||||||
|
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||||
|
$units = $this->inventoryService->getUnits()->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->product = $products->get($item->product_id);
|
||||||
|
$item->unit = $units->get($item->unit_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Production/Recipe/Edit', [
|
||||||
|
'recipe' => $recipe,
|
||||||
|
'products' => $this->inventoryService->getAllProducts(),
|
||||||
|
'units' => $this->inventoryService->getUnits(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配方
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Recipe $recipe)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'product_id' => 'required|exists:products,id',
|
||||||
|
'code' => 'required|string|max:50|unique:recipes,code,' . $recipe->id,
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'yield_quantity' => 'required|numeric|min:0.01',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_id' => 'required|exists:products,id',
|
||||||
|
'items.*.quantity' => 'required|numeric|min:0.0001',
|
||||||
|
'items.*.unit_id' => 'nullable|exists:units,id',
|
||||||
|
'items.*.remark' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($validated, $recipe) {
|
||||||
|
$recipe->update([
|
||||||
|
'product_id' => $validated['product_id'],
|
||||||
|
'code' => $validated['code'],
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'],
|
||||||
|
'yield_quantity' => $validated['yield_quantity'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sync items (Delete all and recreate)
|
||||||
|
$recipe->items()->delete();
|
||||||
|
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
RecipeItem::create([
|
||||||
|
'recipe_id' => $recipe->id,
|
||||||
|
'product_id' => $item['product_id'],
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_id' => $item['unit_id'],
|
||||||
|
'remark' => $item['remark'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('recipes.index')->with('success', '配方已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除配方
|
||||||
|
*/
|
||||||
|
public function destroy(Recipe $recipe)
|
||||||
|
{
|
||||||
|
$recipe->delete();
|
||||||
|
return redirect()->back()->with('success', '配方已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Modules/Production/Models/ProductionOrder.php
Normal file
77
app/Modules/Production/Models/ProductionOrder.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class ProductionOrder extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'product_id',
|
||||||
|
'warehouse_id',
|
||||||
|
'output_quantity',
|
||||||
|
'output_batch_number',
|
||||||
|
'output_box_count',
|
||||||
|
'production_date',
|
||||||
|
'expiry_date',
|
||||||
|
'user_id',
|
||||||
|
'status',
|
||||||
|
'remark',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'production_date' => 'date',
|
||||||
|
'expiry_date' => 'date',
|
||||||
|
'output_quantity' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logOnly([
|
||||||
|
'code',
|
||||||
|
'status',
|
||||||
|
'output_quantity',
|
||||||
|
'output_batch_number',
|
||||||
|
'production_date',
|
||||||
|
'remark'
|
||||||
|
])
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs()
|
||||||
|
->setDescriptionForEvent(fn(string $eventName) => "生產工單已{$this->getEventDescription($eventName)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getEventDescription($eventName): string
|
||||||
|
{
|
||||||
|
return match ($eventName) {
|
||||||
|
'created' => '建立',
|
||||||
|
'updated' => '更新',
|
||||||
|
'deleted' => '刪除',
|
||||||
|
default => $eventName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateCode()
|
||||||
|
{
|
||||||
|
$prefix = 'PO' . now()->format('Ymd');
|
||||||
|
$lastOrder = self::where('code', 'like', $prefix . '%')->latest()->first();
|
||||||
|
if ($lastOrder) {
|
||||||
|
$lastSequence = intval(substr($lastOrder->code, -3));
|
||||||
|
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
||||||
|
} else {
|
||||||
|
$sequence = '001';
|
||||||
|
}
|
||||||
|
return $prefix . $sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductionOrderItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Modules/Production/Models/ProductionOrderItem.php
Normal file
29
app/Modules/Production/Models/ProductionOrderItem.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionOrderItem extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\ProductionOrderItemFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'production_order_id',
|
||||||
|
'inventory_id',
|
||||||
|
'quantity_used',
|
||||||
|
'unit_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'quantity_used' => 'decimal:4',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductionOrder::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Modules/Production/Models/Recipe.php
Normal file
34
app/Modules/Production/Models/Recipe.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'product_id',
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'yield_quantity',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'yield_quantity' => 'decimal:2',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public function items()
|
||||||
|
{
|
||||||
|
return $this->hasMany(RecipeItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Modules/Production/Models/RecipeItem.php
Normal file
31
app/Modules/Production/Models/RecipeItem.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Production\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeItem extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'recipe_id',
|
||||||
|
'product_id',
|
||||||
|
'quantity',
|
||||||
|
'unit_id',
|
||||||
|
'remark',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'quantity' => 'decimal:4',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function recipe()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Recipe::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
32
app/Modules/Production/Routes/web.php
Normal file
32
app/Modules/Production/Routes/web.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Modules\Production\Controllers\ProductionOrderController;
|
||||||
|
use App\Modules\Production\Controllers\RecipeController;
|
||||||
|
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
// 配方管理
|
||||||
|
Route::resource('recipes', RecipeController::class);
|
||||||
|
|
||||||
|
// 生產管理
|
||||||
|
Route::middleware('permission:production_orders.view')->group(function () {
|
||||||
|
Route::get('/production-orders', [ProductionOrderController::class, 'index'])->name('production-orders.index');
|
||||||
|
|
||||||
|
Route::middleware('permission:production_orders.create')->group(function () {
|
||||||
|
Route::get('/production-orders/create', [ProductionOrderController::class, 'create'])->name('production-orders.create');
|
||||||
|
Route::post('/production-orders', [ProductionOrderController::class, 'store'])->name('production-orders.store');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/production-orders/{productionOrder}', [ProductionOrderController::class, 'show'])->name('production-orders.show');
|
||||||
|
|
||||||
|
Route::middleware('permission:production_orders.edit')->group(function () {
|
||||||
|
Route::get('/production-orders/{productionOrder}/edit', [ProductionOrderController::class, 'edit'])->name('production-orders.edit');
|
||||||
|
Route::put('/production-orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('production-orders.update');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生產管理 API
|
||||||
|
Route::get('/api/production/warehouses/{warehouse}/inventories', [ProductionOrderController::class, 'getWarehouseInventories'])
|
||||||
|
->middleware('permission:production_orders.create')
|
||||||
|
->name('api.production.warehouses.inventories');
|
||||||
|
});
|
||||||
12
app/Modules/Shared/Contracts/ServiceInterface.php
Normal file
12
app/Modules/Shared/Contracts/ServiceInterface.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Shared\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Service Interface
|
||||||
|
* 所有模組的 Service 都應繼承此介面 (若有通用方法)
|
||||||
|
*/
|
||||||
|
interface ServiceInterface
|
||||||
|
{
|
||||||
|
// Future common methods
|
||||||
|
}
|
||||||
18
app/Modules/Shared/SharedServiceProvider.php
Normal file
18
app/Modules/Shared/SharedServiceProvider.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Shared;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class SharedServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
// Register shared services or repositories here
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -14,11 +16,24 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
// 如果是在正式環境,強制轉為 https
|
||||||
|
if (config('app.env') === 'production') {
|
||||||
|
URL::forceScheme('https');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隱含授權:讓 "super-admin" 角色擁有所有權限
|
||||||
|
\Illuminate\Support\Facades\Gate::before(function ($user, $ability) {
|
||||||
|
return $user->hasRole('super-admin') ? true : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 載入房東後台路由 (只在 central domain 可用)
|
||||||
|
$this->app->booted(function () {
|
||||||
|
if (file_exists(base_path('routes/landlord.php'))) {
|
||||||
|
Route::middleware('web')->group(base_path('routes/landlord.php'));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
48
app/Providers/ModuleServiceProvider.php
Normal file
48
app/Providers/ModuleServiceProvider.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class ModuleServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$modulesPath = app_path('Modules');
|
||||||
|
|
||||||
|
if (File::exists($modulesPath)) {
|
||||||
|
$modules = File::directories($modulesPath);
|
||||||
|
|
||||||
|
foreach ($modules as $module) {
|
||||||
|
// $moduleName = basename($module);
|
||||||
|
// Load Routes
|
||||||
|
$routesPath = $module . '/Routes/web.php';
|
||||||
|
if (File::exists($routesPath)) {
|
||||||
|
Route::middleware('web')
|
||||||
|
->group($routesPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Service Provider
|
||||||
|
$moduleName = basename($module);
|
||||||
|
$providerClass = "App\\Modules\\{$moduleName}\\{$moduleName}ServiceProvider";
|
||||||
|
|
||||||
|
if (class_exists($providerClass)) {
|
||||||
|
$this->app->register($providerClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Providers/TenancyServiceProvider.php
Normal file
148
app/Providers/TenancyServiceProvider.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Stancl\JobPipeline\JobPipeline;
|
||||||
|
use Stancl\Tenancy\Events;
|
||||||
|
use Stancl\Tenancy\Jobs;
|
||||||
|
use Stancl\Tenancy\Listeners;
|
||||||
|
use Stancl\Tenancy\Middleware;
|
||||||
|
|
||||||
|
class TenancyServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
// By default, no namespace is used to support the callable array syntax.
|
||||||
|
public static string $controllerNamespace = '';
|
||||||
|
|
||||||
|
public function events()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Tenant events
|
||||||
|
Events\CreatingTenant::class => [],
|
||||||
|
Events\TenantCreated::class => [
|
||||||
|
JobPipeline::make([
|
||||||
|
Jobs\CreateDatabase::class,
|
||||||
|
Jobs\MigrateDatabase::class,
|
||||||
|
Jobs\SeedDatabase::class,
|
||||||
|
|
||||||
|
// Your own jobs to prepare the tenant.
|
||||||
|
// Provision API keys, create S3 buckets, anything you want!
|
||||||
|
|
||||||
|
])->send(function (Events\TenantCreated $event) {
|
||||||
|
return $event->tenant;
|
||||||
|
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||||
|
],
|
||||||
|
Events\SavingTenant::class => [],
|
||||||
|
Events\TenantSaved::class => [],
|
||||||
|
Events\UpdatingTenant::class => [],
|
||||||
|
Events\TenantUpdated::class => [],
|
||||||
|
Events\DeletingTenant::class => [],
|
||||||
|
Events\TenantDeleted::class => [
|
||||||
|
JobPipeline::make([
|
||||||
|
Jobs\DeleteDatabase::class,
|
||||||
|
])->send(function (Events\TenantDeleted $event) {
|
||||||
|
return $event->tenant;
|
||||||
|
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||||
|
],
|
||||||
|
|
||||||
|
// Domain events
|
||||||
|
Events\CreatingDomain::class => [],
|
||||||
|
Events\DomainCreated::class => [],
|
||||||
|
Events\SavingDomain::class => [],
|
||||||
|
Events\DomainSaved::class => [],
|
||||||
|
Events\UpdatingDomain::class => [],
|
||||||
|
Events\DomainUpdated::class => [],
|
||||||
|
Events\DeletingDomain::class => [],
|
||||||
|
Events\DomainDeleted::class => [],
|
||||||
|
|
||||||
|
// Database events
|
||||||
|
Events\DatabaseCreated::class => [],
|
||||||
|
Events\DatabaseMigrated::class => [],
|
||||||
|
Events\DatabaseSeeded::class => [],
|
||||||
|
Events\DatabaseRolledBack::class => [],
|
||||||
|
Events\DatabaseDeleted::class => [],
|
||||||
|
|
||||||
|
// Tenancy events
|
||||||
|
Events\InitializingTenancy::class => [],
|
||||||
|
Events\TenancyInitialized::class => [
|
||||||
|
Listeners\BootstrapTenancy::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
Events\EndingTenancy::class => [],
|
||||||
|
Events\TenancyEnded::class => [
|
||||||
|
Listeners\RevertToCentralContext::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
Events\BootstrappingTenancy::class => [],
|
||||||
|
Events\TenancyBootstrapped::class => [],
|
||||||
|
Events\RevertingToCentralContext::class => [],
|
||||||
|
Events\RevertedToCentralContext::class => [],
|
||||||
|
|
||||||
|
// Resource syncing
|
||||||
|
Events\SyncedResourceSaved::class => [
|
||||||
|
Listeners\UpdateSyncedResource::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
|
||||||
|
Events\SyncedResourceChangedInForeignDatabase::class => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot()
|
||||||
|
{
|
||||||
|
$this->bootEvents();
|
||||||
|
$this->mapRoutes();
|
||||||
|
|
||||||
|
$this->makeTenancyMiddlewareHighestPriority();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function bootEvents()
|
||||||
|
{
|
||||||
|
foreach ($this->events() as $event => $listeners) {
|
||||||
|
foreach ($listeners as $listener) {
|
||||||
|
if ($listener instanceof JobPipeline) {
|
||||||
|
$listener = $listener->toListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::listen($event, $listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mapRoutes()
|
||||||
|
{
|
||||||
|
$this->app->booted(function () {
|
||||||
|
if (file_exists(base_path('routes/tenant.php'))) {
|
||||||
|
Route::namespace(static::$controllerNamespace)
|
||||||
|
->group(base_path('routes/tenant.php'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeTenancyMiddlewareHighestPriority()
|
||||||
|
{
|
||||||
|
$tenancyMiddleware = [
|
||||||
|
// Even higher priority than the initialization middleware
|
||||||
|
Middleware\PreventAccessFromCentralDomains::class,
|
||||||
|
|
||||||
|
Middleware\InitializeTenancyByDomain::class,
|
||||||
|
Middleware\InitializeTenancyBySubdomain::class,
|
||||||
|
Middleware\InitializeTenancyByDomainOrSubdomain::class,
|
||||||
|
Middleware\InitializeTenancyByPath::class,
|
||||||
|
Middleware\InitializeTenancyByRequestData::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (array_reverse($tenancyMiddleware) as $middleware) {
|
||||||
|
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Middleware\TrustProxies;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
use Spatie\Permission\Exceptions\UnauthorizedException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
// 信任所有代理(用於反向代理環境)
|
||||||
|
TrustProxies::at('*');
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -11,10 +18,32 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
// Tenancy 必須最先執行,確保資料庫連線在 Session 讀取之前建立
|
||||||
|
$middleware->web(prepend: [
|
||||||
|
\App\Http\Middleware\UniversalTenancy::class,
|
||||||
|
]);
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 註冊 Spatie Permission 中間件別名
|
||||||
|
$middleware->alias([
|
||||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
// 處理 Spatie Permission 的 UnauthorizedException
|
||||||
|
$exceptions->render(function (UnauthorizedException $e) {
|
||||||
|
return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 處理一般的 403 HttpException
|
||||||
|
$exceptions->render(function (HttpException $e) {
|
||||||
|
if ($e->getStatusCode() === 403) {
|
||||||
|
return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,6 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\TenancyServiceProvider::class,
|
||||||
|
App\Providers\ModuleServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
26
compose.yaml
26
compose.yaml
@@ -6,12 +6,12 @@ services:
|
|||||||
args:
|
args:
|
||||||
WWWGROUP: '${WWWGROUP}'
|
WWWGROUP: '${WWWGROUP}'
|
||||||
image: 'sail-8.5/app'
|
image: 'sail-8.5/app'
|
||||||
container_name: koori-erp-laravel
|
container_name: star-erp-laravel
|
||||||
hostname: koori-erp-laravel
|
hostname: star-erp-laravel
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- 'host.docker.internal:host-gateway'
|
- 'host.docker.internal:host-gateway'
|
||||||
ports:
|
ports:
|
||||||
- '${APP_PORT:-80}:80'
|
# - '${APP_PORT:-8080}:80' # 由 proxy 處理
|
||||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||||
environment:
|
environment:
|
||||||
WWWUSER: '${WWWUSER}'
|
WWWUSER: '${WWWUSER}'
|
||||||
@@ -29,8 +29,8 @@ services:
|
|||||||
# - mailpit
|
# - mailpit
|
||||||
mysql:
|
mysql:
|
||||||
image: 'mysql/mysql-server:8.0'
|
image: 'mysql/mysql-server:8.0'
|
||||||
container_name: koori-erp-mysql
|
container_name: star-erp-mysql
|
||||||
hostname: koori-erp-mysql
|
hostname: star-erp-mysql
|
||||||
ports:
|
ports:
|
||||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||||
environment:
|
environment:
|
||||||
@@ -56,8 +56,8 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
redis:
|
redis:
|
||||||
image: 'redis:alpine'
|
image: 'redis:alpine'
|
||||||
container_name: koori-erp-redis
|
container_name: star-erp-redis
|
||||||
hostname: koori-erp-redis
|
hostname: star-erp-redis
|
||||||
# ports:
|
# ports:
|
||||||
# - '${FORWARD_REDIS_PORT:-6379}:6379'
|
# - '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -71,6 +71,18 @@ services:
|
|||||||
- ping
|
- ping
|
||||||
retries: 3
|
retries: 3
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
proxy:
|
||||||
|
image: 'nginx:alpine'
|
||||||
|
container_name: star-erp-proxy
|
||||||
|
ports:
|
||||||
|
- '8080:8080'
|
||||||
|
- '8081:8081'
|
||||||
|
volumes:
|
||||||
|
- './nginx/demo-proxy.conf:/etc/nginx/conf.d/default.conf:ro'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
depends_on:
|
||||||
|
- laravel.test
|
||||||
# mailpit:
|
# mailpit:
|
||||||
# image: 'axllent/mailpit:latest'
|
# image: 'axllent/mailpit:latest'
|
||||||
# ports:
|
# ports:
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"spatie/laravel-activitylog": "^4.10",
|
||||||
|
"spatie/laravel-permission": "^6.24",
|
||||||
|
"stancl/jobpipeline": "^1.8",
|
||||||
|
"stancl/tenancy": "^3.9",
|
||||||
"tightenco/ziggy": "^2.6"
|
"tightenco/ziggy": "^2.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@@ -27,6 +31,7 @@
|
|||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
|
"App\\Modules\\": "app/Modules/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
}
|
}
|
||||||
@@ -88,4 +93,4 @@
|
|||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true
|
"prefer-stable": true
|
||||||
}
|
}
|
||||||
463
composer.lock
generated
463
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "56c0c203f0c7715d0a0f4d3d36b1932c",
|
"content-hash": "46092572c41c587bf3e7fc53465e5b56",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -508,6 +508,59 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "facade/ignition-contracts",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/facade/ignition-contracts.git",
|
||||||
|
"reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
|
||||||
|
"reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.3|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^v2.15.8",
|
||||||
|
"phpunit/phpunit": "^9.3.11",
|
||||||
|
"vimeo/psalm": "^3.17.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Facade\\IgnitionContracts\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://flareapp.io",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Solution contracts for Ignition",
|
||||||
|
"homepage": "https://github.com/facade/ignition-contracts",
|
||||||
|
"keywords": [
|
||||||
|
"contracts",
|
||||||
|
"flare",
|
||||||
|
"ignition"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/facade/ignition-contracts/issues",
|
||||||
|
"source": "https://github.com/facade/ignition-contracts/tree/1.0.2"
|
||||||
|
},
|
||||||
|
"time": "2020-10-16T08:27:54+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "fruitcake/php-cors",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@@ -3360,6 +3413,414 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"time": "2025-12-14T04:43:48+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-activitylog",
|
||||||
|
"version": "4.10.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-activitylog.git",
|
||||||
|
"reference": "bb879775d487438ed9a99e64f09086b608990c10"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bb879775d487438ed9a99e64f09086b608990c10",
|
||||||
|
"reference": "bb879775d487438ed9a99e64f09086b608990c10",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
|
||||||
|
"illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0",
|
||||||
|
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"spatie/laravel-package-tools": "^1.6.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.0 || ^10.0",
|
||||||
|
"pestphp/pest": "^1.20 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Spatie\\Activitylog\\ActivitylogServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\Activitylog\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sebastian De Deyne",
|
||||||
|
"email": "sebastian@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tom Witkowski",
|
||||||
|
"email": "dev.gummibeer@gmail.com",
|
||||||
|
"homepage": "https://gummibeer.de",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A very simple activity logger to monitor the users of your website or application",
|
||||||
|
"homepage": "https://github.com/spatie/activitylog",
|
||||||
|
"keywords": [
|
||||||
|
"activity",
|
||||||
|
"laravel",
|
||||||
|
"log",
|
||||||
|
"spatie",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-activitylog/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-activitylog/tree/4.10.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://spatie.be/open-source/support-us",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-06-15T06:59:49+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-package-tools",
|
||||||
|
"version": "1.92.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||||
|
"reference": "f09a799850b1ed765103a4f0b4355006360c49a5"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5",
|
||||||
|
"reference": "f09a799850b1ed765103a4f0b4355006360c49a5",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.5",
|
||||||
|
"orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
|
||||||
|
"pestphp/pest": "^1.23|^2.1|^3.1",
|
||||||
|
"phpunit/php-code-coverage": "^9.0|^10.0|^11.0",
|
||||||
|
"phpunit/phpunit": "^9.5.24|^10.5|^11.5",
|
||||||
|
"spatie/pest-plugin-test-time": "^1.1|^2.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\LaravelPackageTools\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Tools for creating Laravel packages",
|
||||||
|
"homepage": "https://github.com/spatie/laravel-package-tools",
|
||||||
|
"keywords": [
|
||||||
|
"laravel-package-tools",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-07-17T15:46:43+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-permission",
|
||||||
|
"version": "6.24.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-permission.git",
|
||||||
|
"reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/76adb1fc8d07c16a0721c35c4cc330b7a12598d7",
|
||||||
|
"reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/passport": "^11.0|^12.0",
|
||||||
|
"laravel/pint": "^1.0",
|
||||||
|
"orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0",
|
||||||
|
"phpunit/phpunit": "^9.4|^10.1|^11.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Spatie\\Permission\\PermissionServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "6.x-dev",
|
||||||
|
"dev-master": "6.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\Permission\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Permission handling for Laravel 8.0 and up",
|
||||||
|
"homepage": "https://github.com/spatie/laravel-permission",
|
||||||
|
"keywords": [
|
||||||
|
"acl",
|
||||||
|
"laravel",
|
||||||
|
"permission",
|
||||||
|
"permissions",
|
||||||
|
"rbac",
|
||||||
|
"roles",
|
||||||
|
"security",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-permission/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-permission/tree/6.24.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-12-13T21:45:21+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stancl/jobpipeline",
|
||||||
|
"version": "v1.8.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/archtechx/jobpipeline.git",
|
||||||
|
"reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/archtechx/jobpipeline/zipball/c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c",
|
||||||
|
"reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-redis": "*",
|
||||||
|
"orchestra/testbench": "^8.0|^9.0|^10.0",
|
||||||
|
"spatie/valuestore": "^1.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Stancl\\JobPipeline\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Samuel Štancl",
|
||||||
|
"email": "samuel.stancl@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Turn any series of jobs into Laravel listeners.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/archtechx/jobpipeline/issues",
|
||||||
|
"source": "https://github.com/archtechx/jobpipeline/tree/v1.8.1"
|
||||||
|
},
|
||||||
|
"time": "2025-07-29T20:21:17+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stancl/tenancy",
|
||||||
|
"version": "v3.9.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/archtechx/tenancy.git",
|
||||||
|
"reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/archtechx/tenancy/zipball/d98a170fbd2e114604bfec3bc6267a3d6e02dec1",
|
||||||
|
"reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"facade/ignition-contracts": "^1.0.2",
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0",
|
||||||
|
"ramsey/uuid": "^4.7.3",
|
||||||
|
"stancl/jobpipeline": "^1.8.0",
|
||||||
|
"stancl/virtualcolumn": "^1.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/dbal": "^3.6.0",
|
||||||
|
"laravel/framework": "^10.0|^11.0|^12.0",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.12.2",
|
||||||
|
"orchestra/testbench": "^8.0|^9.0|^10.0",
|
||||||
|
"spatie/valuestore": "^1.3.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Tenancy": "Stancl\\Tenancy\\Facades\\Tenancy",
|
||||||
|
"GlobalCache": "Stancl\\Tenancy\\Facades\\GlobalCache"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Stancl\\Tenancy\\TenancyServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Stancl\\Tenancy\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Samuel Štancl",
|
||||||
|
"email": "samuel.stancl@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Automatic multi-tenancy for your Laravel application.",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"multi-database",
|
||||||
|
"multi-tenancy",
|
||||||
|
"tenancy"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/archtechx/tenancy/issues",
|
||||||
|
"source": "https://github.com/archtechx/tenancy/tree/v3.9.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://tenancyforlaravel.com/donate",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/stancl",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-03-13T16:02:11+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stancl/virtualcolumn",
|
||||||
|
"version": "v1.5.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/archtechx/virtualcolumn.git",
|
||||||
|
"reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/archtechx/virtualcolumn/zipball/75718edcfeeb19abc1970f5395043f7d43cce5bc",
|
||||||
|
"reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/database": ">=10.0",
|
||||||
|
"illuminate/support": ">=10.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"orchestra/testbench": ">=8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Stancl\\VirtualColumn\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Samuel Štancl",
|
||||||
|
"email": "samuel.stancl@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Eloquent virtual column.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/archtechx/virtualcolumn/issues",
|
||||||
|
"source": "https://github.com/archtechx/virtualcolumn/tree/v1.5.0"
|
||||||
|
},
|
||||||
|
"time": "2025-02-25T13:12:44+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
"version": "v8.0.0",
|
"version": "v8.0.0",
|
||||||
|
|||||||
52
config/activitylog.php
Normal file
52
config/activitylog.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If set to false, no activities will be saved to the database.
|
||||||
|
*/
|
||||||
|
'enabled' => env('ACTIVITY_LOGGER_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When the clean-command is executed, all recording activities older than
|
||||||
|
* the number of days specified here will be deleted.
|
||||||
|
*/
|
||||||
|
'delete_records_older_than_days' => 365,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If no log name is passed to the activity() helper
|
||||||
|
* we use this default log name.
|
||||||
|
*/
|
||||||
|
'default_log_name' => 'default',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You can specify an auth driver here that gets user models.
|
||||||
|
* If this is null we'll use the current Laravel auth driver.
|
||||||
|
*/
|
||||||
|
'default_auth_driver' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If set to true, the subject returns soft deleted models.
|
||||||
|
*/
|
||||||
|
'subject_returns_soft_deleted_models' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model will be used to log activity.
|
||||||
|
* It should implement the Spatie\Activitylog\Contracts\Activity interface
|
||||||
|
* and extend Illuminate\Database\Eloquent\Model.
|
||||||
|
*/
|
||||||
|
'activity_model' => \Spatie\Activitylog\Models\Activity::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is the name of the table that will be created by the migration and
|
||||||
|
* used by the Activity model shipped with this package.
|
||||||
|
*/
|
||||||
|
'table_name' => env('ACTIVITY_LOGGER_TABLE_NAME', 'activity_log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is the database connection that will be used by the migration and
|
||||||
|
* the Activity model shipped with this package. In case it's not set
|
||||||
|
* Laravel's database.default will be used instead.
|
||||||
|
*/
|
||||||
|
'database_connection' => env('ACTIVITY_LOGGER_DB_CONNECTION'),
|
||||||
|
];
|
||||||
@@ -62,7 +62,7 @@ return [
|
|||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
'model' => env('AUTH_MODEL', App\Modules\Core\Models\User::class),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
// 'users' => [
|
||||||
|
|||||||
202
config/permission.php
Normal file
202
config/permission.php
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'models' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||||
|
* is often just the "Permission" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Permission model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Permission` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permission' => Spatie\Permission\Models\Permission::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||||
|
* is often just the "Role" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Role model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Role` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role' => App\Modules\Core\Models\Role::class,
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
'table_names' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'roles' => 'roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your permissions. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permissions' => 'permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_permissions' => 'model_has_permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models roles. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_roles' => 'model_has_roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role_has_permissions' => 'role_has_permissions',
|
||||||
|
],
|
||||||
|
|
||||||
|
'column_names' => [
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related pivots other than defaults
|
||||||
|
*/
|
||||||
|
'role_pivot_key' => null, // default 'role_id',
|
||||||
|
'permission_pivot_key' => null, // default 'permission_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related model primary key other than
|
||||||
|
* `model_id`.
|
||||||
|
*
|
||||||
|
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||||
|
* that case, name this `model_uuid`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_morph_key' => 'model_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to use the teams feature and your related model's
|
||||||
|
* foreign key is other than `team_id`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'team_foreign_key' => 'team_id',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the method for checking permissions will be registered on the gate.
|
||||||
|
* Set this to false if you want to implement custom logic for checking permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'register_permission_check_method' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||||
|
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||||
|
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||||
|
*/
|
||||||
|
'register_octane_reset_listener' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Events will fire when a role or permission is assigned/unassigned:
|
||||||
|
* \Spatie\Permission\Events\RoleAttached
|
||||||
|
* \Spatie\Permission\Events\RoleDetached
|
||||||
|
* \Spatie\Permission\Events\PermissionAttached
|
||||||
|
* \Spatie\Permission\Events\PermissionDetached
|
||||||
|
*
|
||||||
|
* To enable, set to true, and then create listeners to watch these events.
|
||||||
|
*/
|
||||||
|
'events_enabled' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Teams Feature.
|
||||||
|
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||||
|
* If you want the migrations to register the 'team_foreign_key', you must
|
||||||
|
* set this to true before doing the migration.
|
||||||
|
* If you already did the migration then you must make a new migration to also
|
||||||
|
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||||
|
* (view the latest version of this package's migration file)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'teams' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use to resolve the permissions team id
|
||||||
|
*/
|
||||||
|
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Passport Client Credentials Grant
|
||||||
|
* When set to true the package will use Passports Client to check permissions
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use_passport_client_credentials' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required permission names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_permission_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required role names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_role_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default wildcard permission lookups are disabled.
|
||||||
|
* See documentation to understand supported syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enable_wildcard_permission' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use for interpreting wildcard permissions.
|
||||||
|
* If you need to modify delimiters, override the class and specify its name here.
|
||||||
|
*/
|
||||||
|
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||||
|
|
||||||
|
/* Cache-specific settings */
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default all permissions are cached for 24 hours to speed up performance.
|
||||||
|
* When permissions or roles are updated the cache is flushed automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The cache key used to store all permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'key' => 'spatie.permission.cache',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You may optionally indicate a specific cache driver to use for permission and
|
||||||
|
* role caching using any of the `store` drivers listed in the cache.php config
|
||||||
|
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => 'default',
|
||||||
|
],
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user