Compare commits
13 Commits
9e574fea85
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0e3b4f6f | |||
| 0e51992cb4 | |||
| ac6a81b3d2 | |||
| 106de4e945 | |||
| b0848a6bb8 | |||
| db0c1ce3af | |||
| 1d134c9ad8 | |||
| 1ae21febb5 | |||
| fc20c6d813 | |||
| af5f2f55ab | |||
| eab9e2ce93 | |||
| 8215b42e43 | |||
| db49f417df |
@@ -5,73 +5,64 @@ 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)
|
||||
後端: PHP 8.5 / Laravel 12
|
||||
## 2. 技術棧 (Tech Stack)
|
||||
* **後端**: 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. 目錄結構與慣例
|
||||
3.1 後端 (Laravel)
|
||||
Controllers: 必須回傳 Inertia::render() 來渲染頁面。
|
||||
## 6. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**: 你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
Models: 嚴格執行型別標註,使用 Eloquent 進行資料庫操作。
|
||||
## 7. 運行機制 (Docker / Sail)
|
||||
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
|
||||
|
||||
Routes: 統一在 routes/web.php 定義 Inertia 路由。
|
||||
|
||||
3.2 前端 (React)
|
||||
Pages (頁面): 位於 resources/js/Pages/。每個檔案代表一個完整的路由視圖。
|
||||
|
||||
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 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
|
||||
7.運行機制
|
||||
因為是運行在docker上 所以要執行php的話 要執行docker exce
|
||||
* **啟動環境**: `./vendor/bin/sail up -d`
|
||||
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
||||
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
||||
* **執行 Composer**: `./vendor/bin/sail composer install`
|
||||
* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
|
||||
@@ -101,8 +101,8 @@ public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, st
|
||||
protected function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Models\Product' => '商品',
|
||||
'App\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||
'App\Modules\Inventory\Models\Product' => '商品',
|
||||
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
@@ -147,6 +147,10 @@ jobs:
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
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
|
||||
|
||||
# 檢查最近的 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"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ Homestead.yaml
|
||||
Thumbs.db
|
||||
酒水客戶導入規劃.md
|
||||
智慧補貨系統分析報告.md
|
||||
|
||||
/docs/pptx_build
|
||||
|
||||
94
README.md
94
README.md
@@ -11,24 +11,86 @@ Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind
|
||||
- **UI 框架**: Tailwind CSS
|
||||
- **基礎設施**: Docker (Laravel Sail), Nginx Reverse Proxy, MySQL 8.0, Redis
|
||||
|
||||
## 📂 系統選單結構 (Sidebar)
|
||||
## 📂 系統功能詳細說明
|
||||
|
||||
以下為 ERP 系統之側邊導覽結構及其對應之權限:
|
||||
### 🌳 系統功能架構樹 (含 2.0 升級規劃)
|
||||
```text
|
||||
Star ERP
|
||||
├── 🏠 儀表板 (Dashboard)
|
||||
│ ├── 📊 數據看板 (原有)
|
||||
│ ├── 🔔 營運警示 (原有)
|
||||
│ ├── ✨ 銷售熱力圖 (新)
|
||||
│ ├── ✨ 庫存效期預警 (新)
|
||||
│ └── ✨ 待出貨監控 (新)
|
||||
├── ✨ 🤝 銷售與全通路 (Sales & CRM) 【New】
|
||||
│ ├── ✨ 全通路訂單整合
|
||||
│ ├── ✨ 客戶管理 (CRM)
|
||||
│ └── ✨ 促銷活動
|
||||
├── 📦 商品與庫存管理
|
||||
│ ├── 📄 商品資料 (原有)
|
||||
│ ├── 🏢 倉庫管理 (原有)
|
||||
│ ├── 🚚 內調撥 (原有)
|
||||
│ ├── ✨ 屬性管理 (過敏原/成分)
|
||||
│ ├── ✨ 效期監控 (FEFO)
|
||||
│ └── ✨ 智慧補貨建議 (AI)
|
||||
├── ✨ 🚚 智慧物流 (Logistics) 【New】
|
||||
│ ├── ✨ 路徑規劃
|
||||
│ └── ✨ 裝車單管理
|
||||
├── 🏭 生產與品質管理
|
||||
│ ├── 📝 生產工單 (原有)
|
||||
│ ├── 🧪 原料耗用 (原有)
|
||||
│ ├── ✨ 配方管理 (Recipe)
|
||||
│ ├── ✨ 品質檢驗 (QC)
|
||||
│ └── ✨ 雙向溯源 (原料<->成品)
|
||||
├── 🛒 採購與廠商
|
||||
│ ├── 👥 廠商資料 (原有)
|
||||
│ ├── 📝 採購單 (原有)
|
||||
│ └── ✨ 供應商評鑑 (新)
|
||||
├── 💰 財務管理
|
||||
│ ├── 🧾 公共事業費 (原有)
|
||||
│ ├── ✨ 應收/應付帳款 (AR/AP)
|
||||
│ └── ✨ 成本精算 (料工費)
|
||||
├── 📊 報表管理
|
||||
│ └── 📑 會計報表 (原有)
|
||||
└── ⚙️ 系統管理 (原有)
|
||||
├── 👤 使用者管理
|
||||
├── 🛡️ 角色與權限
|
||||
└── 📜 操作紀錄
|
||||
```
|
||||
|
||||
- 🏠 **儀表板** (`/`)
|
||||
- 📦 **商品與庫存管理**
|
||||
- 📄 **商品資料管理** (`/products`) - `products.view`
|
||||
- 🏢 **倉庫管理** (`/warehouses`) - `warehouses.view`
|
||||
- 🚚 **廠商管理**
|
||||
- 👥 **廠商資料管理** (`/vendors`) - `vendors.view`
|
||||
- 🛒 **採購管理**
|
||||
- 📝 **採購單管理** (`/purchase-orders`) - `purchase_orders.view`
|
||||
- 💰 **財務管理**
|
||||
- 🧾 **公共事業費** (`/utility-fees`) - `utility_fees.view`
|
||||
- ⚙️ **系統管理**
|
||||
- 👤 **使用者管理** (`/admin/users`) - `users.view`
|
||||
- 🛡️ **角色與權限** (`/admin/roles`) - `roles.view`
|
||||
- 📜 **操作紀錄** (`/admin/activity-logs`) - `system.view_logs`
|
||||
---
|
||||
|
||||
#### 1. 🏠 儀表板 (Dashboard)
|
||||
- **數據看板**:顯示商品總數、供應商數、活躍倉庫數等。
|
||||
- **營運警示**:低庫存商品與待辦事項警示。
|
||||
- **✨ 強化功能**:銷售熱力圖、庫存效期預警、待出貨監控。
|
||||
|
||||
#### 2. ✨ 🤝 銷售與全通路 (Sales & CRM)
|
||||
- **全通路訂單**:整合 POS、品牌電商、智慧販賣機訂單。
|
||||
- **客戶管理 (CRM)**:會員資料庫、消費歷史與等級。
|
||||
- **促銷活動**:滿額折、買一送一、組合價等折扣引擎。
|
||||
|
||||
#### 3. 📦 商品與庫存管理
|
||||
- **商品資料**:品名、規格、多單位換算。
|
||||
- **倉庫管理**:多站點庫存監控、銷售設定。
|
||||
- **內調撥**:倉庫間庫存轉移。
|
||||
- **✨ 強化功能**:過敏原/成分管理、**FEFO (先到期先出)** 效期監控、AI 智慧補貨建議。
|
||||
|
||||
#### 4. 🏭 生產與品質管理
|
||||
- **生產工單**:排程管理、生產入庫。
|
||||
- **✨ 強化功能**:配方管理 (Recipe V.C.)、QC 檢驗流程 (IQC/IPQC/FQC)、**雙向溯源** (原料 <-> 成品)。
|
||||
|
||||
#### 5. 🛒 採購與廠商
|
||||
- **採購單**:詢價、下單、收貨與驗收流程。
|
||||
- **✨ 強化功能**:供應商評鑑系統。
|
||||
|
||||
#### 6. 💰 財務管理
|
||||
- **公共事業費**:水電氣網等固定支出。
|
||||
- **✨ 強化功能**:應收/應付帳款 (AR/AP) 管理、**成本精算** (料工費分攤)。
|
||||
|
||||
#### 7. ⚙️ 系統管理
|
||||
- **使用者與權限**:RBAC 細緻權限控管。
|
||||
- **操作紀錄**:全系統關鍵行為軌跡 (Audit Log)。
|
||||
|
||||
## 🚀 快速開始
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
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,150 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\UtilityFee;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class AccountingReportController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
|
||||
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
||||
|
||||
// 1. Get Purchase Orders (Completed or Received that are ready for accounting)
|
||||
$purchaseOrders = PurchaseOrder::with(['vendor'])
|
||||
->whereIn('status', ['received', 'completed'])
|
||||
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
|
||||
->get()
|
||||
->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' => $po->grand_total,
|
||||
];
|
||||
});
|
||||
|
||||
// 2. Get Utility Fees
|
||||
$utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd])
|
||||
->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' => $fee->amount,
|
||||
];
|
||||
});
|
||||
|
||||
// Combine and Sort
|
||||
$allRecords = $purchaseOrders->concat($utilityFees)
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
// 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()]
|
||||
);
|
||||
|
||||
$summary = [
|
||||
'total_amount' => $allRecords->sum('amount'),
|
||||
'purchase_total' => $purchaseOrders->sum('amount'),
|
||||
'utility_total' => $utilityFees->sum('amount'),
|
||||
'record_count' => $allRecords->count(),
|
||||
];
|
||||
|
||||
return Inertia::render('Accounting/Report', [
|
||||
'records' => $paginatedRecords,
|
||||
'summary' => $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());
|
||||
|
||||
$purchaseOrders = PurchaseOrder::with(['vendor'])
|
||||
->whereIn('status', ['received', 'completed'])
|
||||
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
|
||||
->get();
|
||||
|
||||
$utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd])->get();
|
||||
|
||||
$allRecords = collect();
|
||||
|
||||
foreach ($purchaseOrders as $po) {
|
||||
$allRecords->push([
|
||||
$po->created_at->toDateString(),
|
||||
'採購單',
|
||||
'進貨支出',
|
||||
$po->vendor->name ?? '',
|
||||
$po->code,
|
||||
$po->invoice_number,
|
||||
$po->grand_total,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($utilityFees as $fee) {
|
||||
$allRecords->push([
|
||||
$fee->transaction_date,
|
||||
'公共事業費',
|
||||
$fee->category,
|
||||
$fee->description,
|
||||
'-',
|
||||
$fee->invoice_number,
|
||||
$fee->amount,
|
||||
]);
|
||||
}
|
||||
|
||||
$allRecords = $allRecords->sortByDesc(0);
|
||||
|
||||
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$callback = function () use ($allRecords) {
|
||||
$file = fopen('php://output', 'w');
|
||||
// BOM for Excel compatibility with UTF-8
|
||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
|
||||
|
||||
foreach ($allRecords as $row) {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\Vendor;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\Inventory;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'productsCount' => Product::count(),
|
||||
'vendorsCount' => Vendor::count(),
|
||||
'purchaseOrdersCount' => PurchaseOrder::count(),
|
||||
'warehousesCount' => Warehouse::count(),
|
||||
'totalInventoryValue' => Inventory::join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->sum('inventories.quantity'), // Simplified, maybe just sum quantities for now
|
||||
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
|
||||
'lowStockCount' => Inventory::whereColumn('quantity', '<=', 'safety_stock')->count(),
|
||||
];
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,330 +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.product.baseUnit',
|
||||
'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?->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?->baseUnit?->name ?? '個',
|
||||
'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?->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::with(['baseUnit', 'largeUnit'])->select('id', 'name', 'base_unit_id', 'large_unit_id', 'conversion_rate')->get()->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
];
|
||||
});
|
||||
|
||||
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()->firstOrNew(
|
||||
['product_id' => $item['productId']],
|
||||
['quantity' => 0, 'safety_stock' => null]
|
||||
);
|
||||
|
||||
$currentQty = $inventory->quantity;
|
||||
$newQty = $currentQty + $item['quantity'];
|
||||
|
||||
// 更新庫存並儲存 (新紀錄: Created, 舊紀錄: Updated)
|
||||
$inventory->quantity = $newQty;
|
||||
$inventory->save();
|
||||
|
||||
// 寫入異動紀錄
|
||||
$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 ?? 'N/A',
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Landlord;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DashboardController extends Controller
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Landlord;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UtilityFee;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UtilityFeeController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = UtilityFee::query();
|
||||
|
||||
// Search
|
||||
if ($request->has('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('category', 'like', "%{$search}%")
|
||||
->orWhere('invoice_number', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Filtering
|
||||
if ($request->filled('category') && $request->input('category') !== 'all') {
|
||||
$query->where('category', $request->input('category'));
|
||||
}
|
||||
|
||||
if ($request->filled('date_start')) {
|
||||
$query->where('transaction_date', '>=', $request->input('date_start'));
|
||||
}
|
||||
|
||||
if ($request->filled('date_end')) {
|
||||
$query->where('transaction_date', '<=', $request->input('date_end'));
|
||||
}
|
||||
|
||||
// Sorting
|
||||
$sortField = $request->input('sort_field');
|
||||
$sortDirection = $request->input('sort_direction');
|
||||
|
||||
if ($sortField && $sortDirection) {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
} else {
|
||||
$query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
$fees = $query->paginate($request->input('per_page', 10))->withQueryString();
|
||||
|
||||
$availableCategories = UtilityFee::distinct()->pluck('category');
|
||||
|
||||
return Inertia::render('UtilityFee/Index', [
|
||||
'fees' => $fees,
|
||||
'availableCategories' => $availableCategories,
|
||||
'filters' => $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Log activity
|
||||
activity()
|
||||
->performedOn($fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('created')
|
||||
->withProperties([
|
||||
'attributes' => $fee->getAttributes(),
|
||||
'snapshot' => [
|
||||
'category' => $fee->category,
|
||||
'amount' => $fee->amount,
|
||||
'transaction_date' => $fee->transaction_date->format('Y-m-d'),
|
||||
]
|
||||
])
|
||||
->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',
|
||||
]);
|
||||
|
||||
// Capture old attributes before update
|
||||
$oldAttributes = $utility_fee->getAttributes();
|
||||
|
||||
$utility_fee->update($validated);
|
||||
|
||||
// Capture new attributes
|
||||
$newAttributes = $utility_fee->getAttributes();
|
||||
|
||||
// Manual logOnlyDirty: Filter attributes to only include changes
|
||||
$changedAttributes = [];
|
||||
$changedOldAttributes = [];
|
||||
|
||||
foreach ($newAttributes as $key => $value) {
|
||||
// Skip timestamps if they are the only change (optional, but good practice)
|
||||
if (in_array($key, ['updated_at'])) continue;
|
||||
|
||||
$oldValue = $oldAttributes[$key] ?? null;
|
||||
|
||||
// Simple comparison (casting to string to handle date objects vs strings if necessary,
|
||||
// but Eloquent attributes are usually consistent if casted.
|
||||
// Using loose comparison != handles most cases correctly)
|
||||
if ($value != $oldValue) {
|
||||
$changedAttributes[$key] = $value;
|
||||
$changedOldAttributes[$key] = $oldValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Only log if there are changes (excluding just updated_at)
|
||||
if (empty($changedAttributes)) {
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Log activity with before/after comparison
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => $changedAttributes,
|
||||
'old' => $changedOldAttributes,
|
||||
'snapshot' => [
|
||||
'category' => $utility_fee->category,
|
||||
'amount' => $utility_fee->amount,
|
||||
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function destroy(UtilityFee $utility_fee)
|
||||
{
|
||||
// Capture data snapshot before deletion
|
||||
$snapshot = [
|
||||
'category' => $utility_fee->category,
|
||||
'amount' => $utility_fee->amount,
|
||||
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
|
||||
'invoice_number' => $utility_fee->invoice_number,
|
||||
'description' => $utility_fee->description,
|
||||
];
|
||||
|
||||
// Log activity before deletion
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('deleted')
|
||||
->withProperties([
|
||||
'attributes' => $utility_fee->getAttributes(),
|
||||
'snapshot' => $snapshot
|
||||
])
|
||||
->log('deleted');
|
||||
|
||||
$utility_fee->delete();
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +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';
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
|
||||
$vendors = $query->orderBy($sortField, $sortDirection)
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return \Inertia\Inertia::render('Vendor/Index', [
|
||||
'vendors' => $vendors,
|
||||
'filters' => $request->only(['search', 'sort_field', 'sort_direction', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Vendor $vendor): \Inertia\Response
|
||||
{
|
||||
$vendor->load(['products.baseUnit', 'products.largeUnit']);
|
||||
return \Inertia\Inertia::render('Vendor/Show', [
|
||||
'vendor' => $vendor,
|
||||
'products' => \App\Models\Product::with('baseUnit')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,166 +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;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class PurchaseOrder extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'vendor_id',
|
||||
'warehouse_id',
|
||||
'user_id',
|
||||
'status',
|
||||
'expected_delivery_date',
|
||||
'total_amount',
|
||||
'tax_amount',
|
||||
'grand_total',
|
||||
'remark',
|
||||
'invoice_number',
|
||||
'invoice_date',
|
||||
'invoice_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expected_delivery_date' => 'date:Y-m-d',
|
||||
'invoice_date' => 'date:Y-m-d',
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'grand_total' => 'decimal:2',
|
||||
'invoice_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'poNumber',
|
||||
'supplierId',
|
||||
'supplierName',
|
||||
'expectedDate',
|
||||
'totalAmount',
|
||||
'taxAmount', // Add this
|
||||
'grandTotal', // Add this
|
||||
'createdBy',
|
||||
'warehouse_name',
|
||||
'createdAt',
|
||||
'invoiceNumber',
|
||||
'invoiceDate',
|
||||
'invoiceAmount',
|
||||
];
|
||||
|
||||
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->attributes['expected_delivery_date'] ?? null;
|
||||
}
|
||||
|
||||
public function getTotalAmountAttribute(): float
|
||||
{
|
||||
return (float) ($this->attributes['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
public function getTaxAmountAttribute(): float
|
||||
{
|
||||
return (float) ($this->attributes['tax_amount'] ?? 0);
|
||||
}
|
||||
|
||||
public function getGrandTotalAttribute(): float
|
||||
{
|
||||
return (float) ($this->attributes['grand_total'] ?? 0);
|
||||
}
|
||||
|
||||
public function getCreatedByAttribute(): string
|
||||
{
|
||||
return $this->user ? $this->user->name : '系統';
|
||||
}
|
||||
|
||||
public function getWarehouseNameAttribute(): string
|
||||
{
|
||||
return $this->warehouse ? $this->warehouse->name : '';
|
||||
}
|
||||
|
||||
public function getInvoiceNumberAttribute(): ?string
|
||||
{
|
||||
return $this->attributes['invoice_number'] ?? null;
|
||||
}
|
||||
|
||||
public function getInvoiceDateAttribute(): ?string
|
||||
{
|
||||
return $this->attributes['invoice_date'] ?? null;
|
||||
}
|
||||
|
||||
public function getInvoiceAmountAttribute(): ?float
|
||||
{
|
||||
return isset($this->attributes['invoice_amount']) ? (float) $this->attributes['invoice_amount'] : null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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'] ?? [];
|
||||
|
||||
// Snapshot key names
|
||||
$snapshot['po_number'] = $this->code;
|
||||
$snapshot['vendor_name'] = $this->vendor ? $this->vendor->name : null;
|
||||
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
|
||||
$snapshot['user_name'] = $this->user ? $this->user->name : null;
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +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_id', // 新增單位ID欄位
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
'received_quantity',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'received_quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function getProductNameAttribute(): string
|
||||
{
|
||||
return $this->product?->name ?? '';
|
||||
}
|
||||
|
||||
// 關聯單位
|
||||
public function unit(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class);
|
||||
}
|
||||
|
||||
public function getUnitNameAttribute(): string
|
||||
{
|
||||
// 優先使用關聯的 unit
|
||||
if ($this->unit) {
|
||||
return $this->unit->name;
|
||||
}
|
||||
|
||||
if (!$this->product) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Fallback: 嘗試從 Product 的關聯單位獲取
|
||||
return $this->product->purchaseUnit?->name
|
||||
?? $this->product->largeUnit?->name
|
||||
?? $this->product->baseUnit?->name
|
||||
?? '';
|
||||
}
|
||||
|
||||
public function purchaseOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UtilityFee extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'transaction_date',
|
||||
'category',
|
||||
'amount',
|
||||
'invoice_number',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'transaction_date' => 'date:Y-m-d',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
0
app/Modules/.gitkeep
Normal file
0
app/Modules/.gitkeep
Normal file
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();
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
@@ -12,16 +13,16 @@ class ActivityLogController extends Controller
|
||||
private function getSubjectMap()
|
||||
{
|
||||
return [
|
||||
'App\Models\User' => '使用者',
|
||||
'App\Models\Role' => '角色',
|
||||
'App\Models\Product' => '商品',
|
||||
'App\Models\Vendor' => '廠商',
|
||||
'App\Models\Category' => '商品分類',
|
||||
'App\Models\Unit' => '單位',
|
||||
'App\Models\PurchaseOrder' => '採購單',
|
||||
'App\Models\Warehouse' => '倉庫',
|
||||
'App\Models\Inventory' => '庫存',
|
||||
'App\Models\UtilityFee' => '公共事業費',
|
||||
'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' => '公共事業費',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,13 +96,13 @@ class ActivityLogController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
// Prepare subject types for frontend filter
|
||||
// 準備用於前端篩選的主題類型
|
||||
$subjectTypes = collect($this->getSubjectMap())->map(function ($label, $value) {
|
||||
return ['label' => $label, 'value' => $value];
|
||||
})->values();
|
||||
|
||||
// Get users for causer filter
|
||||
$users = \App\Models\User::select('id', 'name')->orderBy('name')->get()
|
||||
// 取得用於操作者篩選的使用者
|
||||
$users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
|
||||
->map(function ($user) {
|
||||
return ['label' => $user->name, 'value' => (string) $user->id];
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
namespace App\Modules\Core\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Validation\Rule;
|
||||
class RoleController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -22,7 +23,7 @@ class RoleController extends Controller
|
||||
$query = Role::withCount('users', 'permissions')
|
||||
->with('users:id,name,username');
|
||||
|
||||
// Handle sorting
|
||||
// 處理排序
|
||||
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
@@ -38,7 +39,7 @@ class RoleController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
* 顯示建立新資源的表單。
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
@@ -50,7 +51,7 @@ class RoleController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
@@ -74,7 +75,7 @@ class RoleController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
* 顯示編輯指定資源的表單。
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
@@ -96,7 +97,7 @@ class RoleController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
@@ -126,7 +127,7 @@ class RoleController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
|
||||
use App\Modules\Core\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Inertia\Inertia;
|
||||
@@ -13,7 +14,7 @@ use Illuminate\Support\Facades\Hash;
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
@@ -25,7 +26,7 @@ class UserController extends Controller
|
||||
|
||||
$query = User::with(['roles:id,name,display_name']);
|
||||
|
||||
// Handle Search
|
||||
// 處理搜尋
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
@@ -34,14 +35,14 @@ class UserController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Role Filter
|
||||
// 處理角色篩選
|
||||
if ($roleId && $roleId !== 'all') {
|
||||
$query->whereHas('roles', function ($q) use ($roleId) {
|
||||
$q->where('id', $roleId);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle sorting
|
||||
// 處理排序
|
||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
@@ -59,7 +60,7 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
* 顯示建立新資源的表單。
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
@@ -71,7 +72,7 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
@@ -97,7 +98,7 @@ class UserController extends Controller
|
||||
if (!empty($validated['roles'])) {
|
||||
$user->syncRoles($validated['roles']);
|
||||
|
||||
// Update the 'created' log to include roles
|
||||
// 更新 'created' 紀錄以包含角色資訊
|
||||
$activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user))
|
||||
->where('subject_id', $user->id)
|
||||
->where('event', 'created')
|
||||
@@ -117,7 +118,7 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
* 顯示編輯指定資源的表單。
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
@@ -132,7 +133,7 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
@@ -149,7 +150,7 @@ class UserController extends Controller
|
||||
'password.confirmed' => '密碼確認不符',
|
||||
]);
|
||||
|
||||
// 1. Prepare data and detect changes
|
||||
// 1. 準備資料並偵測變更
|
||||
$userData = [
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
@@ -162,7 +163,7 @@ class UserController extends Controller
|
||||
|
||||
$user->fill($userData);
|
||||
|
||||
// Capture dirty attributes for manual logging
|
||||
// 捕捉變更屬性以進行手動記錄
|
||||
$dirty = $user->getDirty();
|
||||
$oldAttributes = [];
|
||||
$newAttributes = [];
|
||||
@@ -172,10 +173,10 @@ class UserController extends Controller
|
||||
$newAttributes[$key] = $value;
|
||||
}
|
||||
|
||||
// Save without triggering events (prevents duplicate log)
|
||||
// 儲存但不觸發事件(防止重複記錄)
|
||||
$user->saveQuietly();
|
||||
|
||||
// 2. Handle Roles
|
||||
// 2. 處理角色
|
||||
$roleChanges = null;
|
||||
if (isset($validated['roles'])) {
|
||||
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||
@@ -190,7 +191,7 @@ class UserController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Manually Log activity (Single Consolidated Log)
|
||||
// 3. 手動記錄活動(單一整合記錄)
|
||||
if (!empty($newAttributes) || $roleChanges) {
|
||||
$properties = [
|
||||
'attributes' => $newAttributes,
|
||||
@@ -208,7 +209,7 @@ class UserController extends Controller
|
||||
->event('updated')
|
||||
->withProperties($properties)
|
||||
->tap(function (\Spatie\Activitylog\Contracts\Activity $activity) use ($user) {
|
||||
// Manually add snapshot since we aren't using the model's LogOptions due to saveQuietly
|
||||
// 手動加入快照,因為使用 saveQuietly 所以不使用模型的 LogOptions
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'snapshot' => [
|
||||
'name' => $user->name,
|
||||
@@ -223,7 +224,7 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
use Spatie\Permission\Models\Role as SpatieRole;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
|
||||
use Stancl\Tenancy\Contracts\TenantWithDatabase;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Modules\Core\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -16,10 +16,20 @@ class User extends Authenticatable
|
||||
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
* 可批量賦值的屬性。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
/**
|
||||
* 建立模型的新工廠實例。
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Factories\Factory
|
||||
*/
|
||||
protected static function newFactory()
|
||||
{
|
||||
return \Database\Factories\UserFactory::new();
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
@@ -28,7 +38,7 @@ class User extends Authenticatable
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
* 序列化時應隱藏的屬性。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
@@ -38,7 +48,7 @@ class User extends Authenticatable
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
* 取得應進行轉換的屬性。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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
|
||||
|
||||
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;
|
||||
|
||||
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', '未提供查詢參數');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\Unit;
|
||||
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;
|
||||
@@ -11,7 +14,7 @@ use Inertia\Response;
|
||||
class ProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
* 顯示資源列表。
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
@@ -38,7 +41,7 @@ class ProductController extends Controller
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
|
||||
// Define allowed sort fields to prevent SQL injection
|
||||
// 定義允許的排序欄位以防止 SQL 注入
|
||||
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
|
||||
if (!in_array($sortField, $allowedSorts)) {
|
||||
$sortField = 'id';
|
||||
@@ -47,11 +50,11 @@ class ProductController extends Controller
|
||||
$sortDirection = 'desc';
|
||||
}
|
||||
|
||||
// Handle relation sorting (category name) separately if needed, or simple join
|
||||
// 如果需要,分別處理關聯排序(分類名稱),或簡單的 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.
|
||||
// 加入分類以便按名稱排序?還是僅按 ID?
|
||||
// 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。
|
||||
// 先假設標準欄位排序。
|
||||
$query->orderBy('category_id', $sortDirection);
|
||||
} else {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
@@ -59,22 +62,54 @@ class ProductController extends Controller
|
||||
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
$categories = \App\Models\Category::where('is_active', true)->get();
|
||||
$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' => $categories,
|
||||
'units' => Unit::all(),
|
||||
'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']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
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',
|
||||
@@ -85,6 +120,9 @@ class ProductController extends Controller
|
||||
'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' => '所選分類不存在',
|
||||
@@ -95,25 +133,18 @@ class ProductController extends Controller
|
||||
'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([
|
||||
'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',
|
||||
@@ -123,6 +154,9 @@ class ProductController extends Controller
|
||||
'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' => '所選分類不存在',
|
||||
@@ -139,7 +173,7 @@ class ProductController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(Product $product)
|
||||
{
|
||||
@@ -1,10 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Product;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
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 Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -16,8 +19,6 @@ class SafetyStockController extends Controller
|
||||
*/
|
||||
public function index(Warehouse $warehouse)
|
||||
{
|
||||
$warehouse->load(['inventories.product.category']);
|
||||
|
||||
$allProducts = Product::with(['category', 'baseUnit'])->get();
|
||||
|
||||
// 準備可選商品列表
|
||||
@@ -30,32 +31,34 @@ class SafetyStockController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
// 準備現有庫存列表 (用於狀態計算)
|
||||
$inventories = $warehouse->inventories->map(function ($inv) {
|
||||
// 準備現有庫存列表 (用於庫存量對比)
|
||||
$inventories = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->select('product_id', DB::raw('SUM(quantity) as total_quantity'))
|
||||
->groupBy('product_id')
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'id' => (string) $inv->id,
|
||||
'productId' => (string) $inv->product_id,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'safetyStock' => (float) $inv->safety_stock,
|
||||
'quantity' => (float) $inv->total_quantity,
|
||||
];
|
||||
});
|
||||
|
||||
// 準備安全庫存設定列表
|
||||
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
|
||||
return !is_null($inv->safety_stock);
|
||||
})->map(function ($inv) {
|
||||
// 準備安全庫存設定列表 (從新表格讀取)
|
||||
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->with(['product.category', 'product.baseUnit'])
|
||||
->get()
|
||||
->map(function ($setting) {
|
||||
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->baseUnit?->name ?? '個',
|
||||
'updatedAt' => $inv->updated_at->toIso8601String(),
|
||||
'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(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/SafetyStockSettings', [
|
||||
'warehouse' => $warehouse,
|
||||
@@ -78,7 +81,7 @@ class SafetyStockController extends Controller
|
||||
|
||||
DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['settings'] as $item) {
|
||||
Inventory::updateOrCreate(
|
||||
WarehouseProductSafetyStock::updateOrCreate(
|
||||
[
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'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([
|
||||
'safetyStock' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$inventory->update([
|
||||
$safetyStock->update([
|
||||
'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([
|
||||
'safety_stock' => null,
|
||||
]);
|
||||
$safetyStock->delete();
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存設定已移除');
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Warehouse;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -27,26 +29,32 @@ class TransferOrderController extends Controller
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated) {
|
||||
// 1. 檢查來源倉庫庫存
|
||||
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
|
||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||
->where('product_id', $validated['productId'])
|
||||
->where('batch_number', $validated['batchNumber'])
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => ['來源倉庫庫存不足'],
|
||||
'quantity' => ['來源倉庫指定批號庫存不足'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 獲取或建立目標倉庫庫存
|
||||
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $validated['targetWarehouseId'],
|
||||
'product_id' => $validated['productId'],
|
||||
'batch_number' => $validated['batchNumber'],
|
||||
],
|
||||
[
|
||||
'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,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -59,12 +67,15 @@ class TransferOrderController extends Controller
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
|
||||
$sourceInventory->update(['quantity' => $newSourceQty]);
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源異動
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '撥補出庫',
|
||||
'quantity' => -$validated['quantity'],
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
@@ -78,12 +89,19 @@ class TransferOrderController extends Controller
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
|
||||
$targetInventory->update(['quantity' => $newTargetQty]);
|
||||
// 確保目標庫存也有成本 (如果是繼承來的)
|
||||
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([
|
||||
'type' => '撥補入庫',
|
||||
'quantity' => $validated['quantity'],
|
||||
'unit_cost' => $targetInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
@@ -108,11 +126,14 @@ class TransferOrderController extends Controller
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product->name,
|
||||
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
||||
'availableQty' => (float) $inv->quantity,
|
||||
'unit' => $inv->product->baseUnit?->name ?? '個',
|
||||
'product_id' => (string) $inv->product_id,
|
||||
'product_name' => $inv->product->name,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'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,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Models\Unit;
|
||||
use App\Models\Product; // Import Product to check for usage
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
* 將新建立的資源儲存到儲存體中。
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
@@ -29,7 +31,7 @@ class UnitController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* 更新儲存體中的指定資源。
|
||||
*/
|
||||
public function update(Request $request, Unit $unit)
|
||||
{
|
||||
@@ -49,11 +51,11 @@ class UnitController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 從儲存體中移除指定資源。
|
||||
*/
|
||||
public function destroy(Unit $unit)
|
||||
{
|
||||
// Check if unit is used in any product
|
||||
// 檢查單位是否已被任何商品使用
|
||||
$isUsed = Product::where('base_unit_id', $unit->id)
|
||||
->orWhere('large_unit_id', $unit->id)
|
||||
->orWhere('purchase_unit_id', $unit->id)
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
use App\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
|
||||
use Inertia\Inertia;
|
||||
|
||||
@@ -22,16 +24,45 @@ class WarehouseController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
|
||||
->withCount(['inventories as low_stock_count' => function ($query) {
|
||||
$query->whereColumn('quantity', '<', 'safety_stock');
|
||||
}])
|
||||
$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']),
|
||||
]);
|
||||
}
|
||||
@@ -42,9 +73,13 @@ class WarehouseController extends Controller
|
||||
'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',
|
||||
]);
|
||||
|
||||
// Auto-generate code
|
||||
// 自動產生代碼
|
||||
$prefix = 'WH';
|
||||
$lastWarehouse = Warehouse::latest('id')->first();
|
||||
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
||||
@@ -63,6 +98,10 @@ class WarehouseController extends Controller
|
||||
'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);
|
||||
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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
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',
|
||||
'is_active',
|
||||
];
|
||||
protected $fillable = ['name', 'description'];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the products for the category.
|
||||
*/
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
@@ -1,27 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
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',
|
||||
'safety_stock',
|
||||
'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',
|
||||
];
|
||||
|
||||
/**
|
||||
* Transient property to store the reason for the activity log (e.g., "Replenishment #123").
|
||||
* This is not stored in the database column but used for logging context.
|
||||
* 用於活動記錄的暫時屬性(例如 "補貨 #123")。
|
||||
* 此屬性不存儲在資料庫欄位中,但用於記錄上下文。
|
||||
* @var string|null
|
||||
*/
|
||||
public $activityLogReason;
|
||||
@@ -40,12 +59,12 @@ class Inventory extends Model
|
||||
$attributes = $properties['attributes'] ?? [];
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// Always snapshot names for context, even if IDs didn't change
|
||||
// $this refers to the Inventory model instance
|
||||
// 始終對名稱進行快照以便於上下文顯示,即使 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);
|
||||
|
||||
// Capture the reason if set
|
||||
// 如果已設定原因,則進行捕捉
|
||||
if ($this->activityLogReason) {
|
||||
$attributes['_reason'] = $this->activityLogReason;
|
||||
}
|
||||
@@ -89,4 +108,31 @@ class Inventory extends Model
|
||||
$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
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\User;
|
||||
|
||||
|
||||
class InventoryTransaction extends Model
|
||||
{
|
||||
@@ -16,6 +15,7 @@ class InventoryTransaction extends Model
|
||||
'inventory_id',
|
||||
'type',
|
||||
'quantity',
|
||||
'unit_cost',
|
||||
'balance_before',
|
||||
'balance_after',
|
||||
'reason',
|
||||
@@ -27,6 +27,7 @@ class InventoryTransaction extends Model
|
||||
|
||||
protected $casts = [
|
||||
'actual_time' => 'datetime',
|
||||
'unit_cost' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
@@ -34,11 +35,6 @@ class InventoryTransaction extends Model
|
||||
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
|
||||
{
|
||||
return $this->morphTo();
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity, SoftDeletes;
|
||||
@@ -31,7 +32,7 @@ class Product extends Model
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the category that owns the product.
|
||||
* 取得該商品所屬的分類。
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
@@ -53,10 +54,7 @@ class Product extends Model
|
||||
return $this->belongsTo(Unit::class, 'purchase_unit_id');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
@@ -82,13 +80,13 @@ class Product extends Model
|
||||
$attributes = $properties['attributes'] ?? [];
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// Handle Category Name Snapshot
|
||||
// 處理分類名稱快照
|
||||
if (isset($attributes['category_id'])) {
|
||||
$category = Category::find($attributes['category_id']);
|
||||
$snapshot['category_name'] = $category ? $category->name : null;
|
||||
}
|
||||
|
||||
// Handle Unit Name Snapshots
|
||||
// 處理單位名稱快照
|
||||
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
|
||||
foreach ($unitFields as $field) {
|
||||
if (isset($attributes[$field])) {
|
||||
@@ -98,7 +96,7 @@ class Product extends Model
|
||||
}
|
||||
}
|
||||
|
||||
// Always snapshot self name for context (so logs always show "Cola")
|
||||
// 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂")
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
@@ -1,24 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
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<\Database\Factories\UnitFactory> */
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
];
|
||||
protected $fillable = ['name', 'abbreviation'];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
public function productsAsBase(): HasMany
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
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();
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
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> */
|
||||
@@ -14,8 +15,17 @@ class Warehouse extends Model
|
||||
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
|
||||
@@ -42,10 +52,7 @@ class Warehouse extends Model
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Vendor;
|
||||
use App\Models\Warehouse;
|
||||
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)
|
||||
{
|
||||
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
|
||||
// 1. 從關聯中移除 'warehouse' 與 'user'
|
||||
$query = PurchaseOrder::with(['vendor']);
|
||||
|
||||
// Search
|
||||
// 搜尋
|
||||
if ($request->search) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('code', 'like', "%{$request->search}%")
|
||||
@@ -25,7 +39,7 @@ class PurchaseOrderController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Filters
|
||||
// 篩選
|
||||
if ($request->status && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
@@ -34,7 +48,7 @@ class PurchaseOrderController extends Controller
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
// Date Range
|
||||
// 日期範圍
|
||||
if ($request->date_start) {
|
||||
$query->whereDate('created_at', '>=', $request->date_start);
|
||||
}
|
||||
@@ -43,7 +57,7 @@ class PurchaseOrderController extends Controller
|
||||
$query->whereDate('created_at', '<=', $request->date_end);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
// 排序
|
||||
$sortField = $request->sort_field ?? 'id';
|
||||
$sortDirection = $request->sort_direction ?? 'desc';
|
||||
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
|
||||
@@ -55,20 +69,69 @@ class PurchaseOrderController extends Controller
|
||||
$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' => Warehouse::all(['id', 'name']),
|
||||
'warehouses' => $warehouses->map(fn($w)=>(object)['id'=>$w->id, 'name'=>$w->name]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $vendor->products->map(function ($product) {
|
||||
// 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,
|
||||
@@ -78,13 +141,18 @@ class PurchaseOrderController extends Controller
|
||||
'large_unit_name' => $product->largeUnit?->name,
|
||||
'purchase_unit_id' => $product->purchase_unit_id,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||
'lastPrice' => (float) $pivot->last_price,
|
||||
];
|
||||
})
|
||||
})->filter()->values();
|
||||
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $commonProducts
|
||||
];
|
||||
});
|
||||
|
||||
$warehouses = Warehouse::all()->map(function ($w) {
|
||||
$warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) {
|
||||
return [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
@@ -102,6 +170,7 @@ class PurchaseOrderController extends Controller
|
||||
$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}$/'],
|
||||
@@ -139,22 +208,14 @@ class PurchaseOrderController extends Controller
|
||||
$totalAmount += $item['subtotal'];
|
||||
}
|
||||
|
||||
// Tax calculation
|
||||
// 稅額計算
|
||||
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : 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;
|
||||
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
|
||||
}
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
@@ -163,6 +224,7 @@ class PurchaseOrderController extends Controller
|
||||
'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,
|
||||
@@ -198,58 +260,79 @@ class PurchaseOrderController extends Controller
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product.baseUnit', 'items.product.largeUnit'])->findOrFail($id);
|
||||
$order = PurchaseOrder::with(['vendor', 'items'])->findOrFail($id);
|
||||
|
||||
$order->items->transform(function ($item) use ($order) {
|
||||
$product = $item->product;
|
||||
if ($product) {
|
||||
// 手動附加所有必要的屬性
|
||||
$item->productId = (string) $product->id;
|
||||
$item->productName = $product->name;
|
||||
$item->base_unit_id = $product->base_unit_id;
|
||||
$item->base_unit_name = $product->baseUnit?->name;
|
||||
$item->large_unit_id = $product->large_unit_id;
|
||||
$item->large_unit_name = $product->largeUnit?->name;
|
||||
$item->purchase_unit_id = $product->purchase_unit_id;
|
||||
// 手動注入
|
||||
$order->setRelation('warehouse', $this->inventoryService->getWarehouse($order->warehouse_id));
|
||||
$order->setRelation('user', $this->coreService->getUser($order->user_id));
|
||||
|
||||
$item->conversion_rate = (float) $product->conversion_rate;
|
||||
$productIds = $order->items->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// Fetch last price
|
||||
$lastPrice = DB::table('product_vendor')
|
||||
->where('vendor_id', $order->vendor_id)
|
||||
->where('product_id', $product->id)
|
||||
->value('last_price');
|
||||
$item->previousPrice = (float) ($lastPrice ?? 0);
|
||||
|
||||
// 設定當前選中的單位 ID (from saved item)
|
||||
$item->unitId = $item->unit_id;
|
||||
|
||||
// 決定 selectedUnit (用於 UI 顯示)
|
||||
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
|
||||
$item->selectedUnit = 'large';
|
||||
} else {
|
||||
$item->selectedUnit = 'base';
|
||||
}
|
||||
|
||||
$item->unitPrice = (float) $item->unit_price;
|
||||
}
|
||||
return $item;
|
||||
$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' => $order
|
||||
'order' => $formattedOrder
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
|
||||
// 1. 獲取訂單
|
||||
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
||||
|
||||
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $vendor->products->map(function ($product) {
|
||||
// 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,
|
||||
@@ -259,59 +342,68 @@ class PurchaseOrderController extends Controller
|
||||
'large_unit_name' => $product->largeUnit?->name,
|
||||
'purchase_unit_id' => $product->purchase_unit_id,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||
'lastPrice' => (float) $pivot->last_price,
|
||||
];
|
||||
})
|
||||
})->filter()->values();
|
||||
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $commonProducts
|
||||
];
|
||||
});
|
||||
|
||||
$warehouses = Warehouse::all()->map(function ($w) {
|
||||
// 3. 獲取倉庫
|
||||
$warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) {
|
||||
return [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
];
|
||||
});
|
||||
|
||||
// Transform items for frontend form
|
||||
// Transform items for frontend form
|
||||
// 4. 注入訂單項目特定資料
|
||||
// 2. 注入訂單項目
|
||||
$itemProductIds = $order->items->pluck('product_id')->toArray();
|
||||
$itemProducts = $this->inventoryService->getProductsByIds($itemProductIds)->keyBy('id');
|
||||
|
||||
$vendorId = $order->vendor_id;
|
||||
$order->items->transform(function ($item) use ($vendorId) {
|
||||
$product = $item->product;
|
||||
if ($product) {
|
||||
// 手動附加所有必要的屬性
|
||||
$item->productId = (string) $product->id;
|
||||
$item->productName = $product->name;
|
||||
$item->base_unit_id = $product->base_unit_id;
|
||||
$item->base_unit_name = $product->baseUnit?->name;
|
||||
$item->large_unit_id = $product->large_unit_id;
|
||||
$item->large_unit_name = $product->largeUnit?->name;
|
||||
|
||||
$item->conversion_rate = (float) $product->conversion_rate;
|
||||
|
||||
// Fetch last price
|
||||
$lastPrice = DB::table('product_vendor')
|
||||
->where('vendor_id', $vendorId)
|
||||
->where('product_id', $product->id)
|
||||
->value('last_price');
|
||||
$item->previousPrice = (float) ($lastPrice ?? 0);
|
||||
|
||||
// 設定當前選中的單位 ID
|
||||
$item->unitId = $item->unit_id; // 資料庫中的 unit_id
|
||||
|
||||
// 決定 selectedUnit (用於 UI 狀態)
|
||||
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
|
||||
$item->selectedUnit = 'large';
|
||||
} else {
|
||||
$item->selectedUnit = 'base';
|
||||
}
|
||||
|
||||
$item->unitPrice = (float) $item->unit_price;
|
||||
}
|
||||
return $item;
|
||||
$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' => $order,
|
||||
'order' => $formattedOrder,
|
||||
'suppliers' => $vendors,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
@@ -324,6 +416,7 @@ class PurchaseOrderController extends Controller
|
||||
$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',
|
||||
@@ -335,7 +428,7 @@ class PurchaseOrderController extends Controller
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
|
||||
'items.*.unitId' => 'nullable|exists:units,id',
|
||||
// Allow both tax_amount and taxAmount for compatibility
|
||||
// 允許 tax_amount 和 taxAmount 以保持相容性
|
||||
'tax_amount' => 'nullable|numeric|min:0',
|
||||
'taxAmount' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
@@ -348,15 +441,16 @@ class PurchaseOrderController extends Controller
|
||||
$totalAmount += $item['subtotal'];
|
||||
}
|
||||
|
||||
// Tax calculation (handle both keys)
|
||||
// 稅額計算(處理兩個鍵)
|
||||
$inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null;
|
||||
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
||||
$grandTotal = $totalAmount + $taxAmount;
|
||||
|
||||
// 1. Fill attributes but don't save yet to capture changes
|
||||
// 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,
|
||||
@@ -368,7 +462,7 @@ class PurchaseOrderController extends Controller
|
||||
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||
]);
|
||||
|
||||
// Capture attribute changes for manual logging
|
||||
// 捕捉變更屬性以進行手動記錄
|
||||
$dirty = $order->getDirty();
|
||||
$oldAttributes = [];
|
||||
$newAttributes = [];
|
||||
@@ -378,10 +472,10 @@ class PurchaseOrderController extends Controller
|
||||
$newAttributes[$key] = $value;
|
||||
}
|
||||
|
||||
// Save without triggering events (prevents duplicate log)
|
||||
// 儲存但不觸發事件(防止重複記錄)
|
||||
$order->saveQuietly();
|
||||
|
||||
// 2. Capture old items with product names for diffing
|
||||
// 2. 捕捉包含商品名稱的舊項目以進行比對
|
||||
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
@@ -394,7 +488,7 @@ class PurchaseOrderController extends Controller
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
// Sync items (Original logic)
|
||||
// 同步項目(原始邏輯)
|
||||
$order->items()->delete();
|
||||
|
||||
$newItemsData = [];
|
||||
@@ -412,14 +506,14 @@ class PurchaseOrderController extends Controller
|
||||
$newItemsData[] = $newItem;
|
||||
}
|
||||
|
||||
// 3. Calculate Item Diffs
|
||||
// 3. 計算項目差異
|
||||
$itemDiffs = [
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// Re-fetch new items to ensure we have fresh relations
|
||||
// 重新獲取新項目以確保擁有最新的關聯
|
||||
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
@@ -431,20 +525,20 @@ class PurchaseOrderController extends Controller
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
// Find removed
|
||||
// 找出已移除的項目
|
||||
foreach ($oldItems as $productId => $oldItem) {
|
||||
if (!$newItemsFormatted->has($productId)) {
|
||||
$itemDiffs['removed'][] = $oldItem;
|
||||
}
|
||||
}
|
||||
|
||||
// Find added and updated
|
||||
// 找出新增和更新的項目
|
||||
foreach ($newItemsFormatted as $productId => $newItem) {
|
||||
if (!$oldItems->has($productId)) {
|
||||
$itemDiffs['added'][] = $newItem;
|
||||
} else {
|
||||
$oldItem = $oldItems[$productId];
|
||||
// Compare fields
|
||||
// 比對欄位
|
||||
if (
|
||||
$oldItem['quantity'] != $newItem['quantity'] ||
|
||||
$oldItem['unit_id'] != $newItem['unit_id'] ||
|
||||
@@ -467,8 +561,8 @@ class PurchaseOrderController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Manually Log activity (Single Consolidated Log)
|
||||
// Log if there are attribute changes OR item changes
|
||||
// 4. 手動記錄活動(單一整合記錄)
|
||||
// 如果有屬性變更或項目變更則記錄
|
||||
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
|
||||
activity()
|
||||
->performedOn($order)
|
||||
@@ -503,19 +597,24 @@ class PurchaseOrderController extends Controller
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$order = PurchaseOrder::with(['items.product', 'items.unit'])->findOrFail($id);
|
||||
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
||||
|
||||
// Capture items for logging
|
||||
$items = $order->items->map(function ($item) {
|
||||
// 為記錄注入資料
|
||||
$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' => $item->product_name,
|
||||
'product_name' => $product?->name ?? 'Unknown',
|
||||
'quantity' => floatval($item->quantity),
|
||||
'unit_name' => $item->unit_name,
|
||||
'unit_name' => 'N/A',
|
||||
'subtotal' => floatval($item->subtotal),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// Manually log the deletion with items
|
||||
// 手動記錄包含項目的刪除操作
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->user())
|
||||
@@ -536,10 +635,10 @@ class PurchaseOrderController extends Controller
|
||||
])
|
||||
->log('deleted');
|
||||
|
||||
// Disable automatic logging for this operation
|
||||
// 對此操作停用自動記錄
|
||||
$order->disableLogging();
|
||||
|
||||
// Delete associated items first
|
||||
// 先刪除關聯項目
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
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', '廠商已刪除');
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Models\Vendor;
|
||||
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)
|
||||
*/
|
||||
@@ -28,7 +34,7 @@ class VendorProductController extends Controller
|
||||
]);
|
||||
|
||||
// 記錄操作
|
||||
$product = \App\Models\Product::find($validated['product_id']);
|
||||
$product = $this->inventoryService->getProduct($validated['product_id']);
|
||||
activity()
|
||||
->performedOn($vendor)
|
||||
->withProperties([
|
||||
@@ -66,7 +72,7 @@ class VendorProductController extends Controller
|
||||
]);
|
||||
|
||||
// 記錄操作
|
||||
$product = \App\Models\Product::find($productId);
|
||||
$product = $this->inventoryService->getProduct($productId);
|
||||
activity()
|
||||
->performedOn($vendor)
|
||||
->withProperties([
|
||||
@@ -95,7 +101,7 @@ class VendorProductController extends Controller
|
||||
public function destroy(Vendor $vendor, $productId)
|
||||
{
|
||||
// 記錄操作 (需在 detach 前獲取資訊)
|
||||
$product = \App\Models\Product::find($productId);
|
||||
$product = $this->inventoryService->getProduct($productId);
|
||||
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
|
||||
|
||||
$vendor->products()->detach($productId);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
|
||||
class Vendor extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
/** @use HasFactory<\Database\Factories\VendorFactory> */
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
@@ -23,16 +24,12 @@ class Vendor extends Model
|
||||
'phone',
|
||||
'email',
|
||||
'address',
|
||||
'remark'
|
||||
'remark',
|
||||
];
|
||||
public function products(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'product_vendor')
|
||||
->withPivot('last_price')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function purchaseOrders(): HasMany
|
||||
|
||||
|
||||
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrder::class);
|
||||
}
|
||||
@@ -49,12 +46,8 @@ class Vendor extends Model
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
|
||||
// Store name in 'snapshot' for context, keeping 'attributes' clean
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
// Only set name if it's not already set (e.g. by controller for specific context like supply product)
|
||||
if (!isset($snapshot['name'])) {
|
||||
$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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\TenancyServiceProvider::class,
|
||||
App\Providers\ModuleServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -30,6 +31,7 @@
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"App\\Modules\\": "app/Modules/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
|
||||
2
composer.lock
generated
2
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "131ea6e8cc24a6a55229afded6bd9014",
|
||||
"content-hash": "46092572c41c587bf3e7fc53465e5b56",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
|
||||
@@ -62,7 +62,7 @@ return [
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
'model' => env('AUTH_MODEL', App\Modules\Core\Models\User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
|
||||
@@ -24,7 +24,7 @@ return [
|
||||
* `Spatie\Permission\Contracts\Role` contract.
|
||||
*/
|
||||
|
||||
'role' => App\Models\Role::class,
|
||||
'role' => App\Modules\Core\Models\Role::class,
|
||||
|
||||
],
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Stancl\Tenancy\Database\Models\Domain;
|
||||
use App\Models\Tenant;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
|
||||
return [
|
||||
'tenant_model' => Tenant::class,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Modules\Inventory\Models\Product>
|
||||
*/
|
||||
class ProductFactory extends Factory
|
||||
{
|
||||
|
||||
@@ -7,10 +7,17 @@ use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Modules\Core\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = \App\Modules\Core\Models\User::class;
|
||||
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
@@ -25,6 +32,7 @@ class UserFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'username' => fake()->unique()->userName(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 新增批號追溯相關欄位至 inventories 資料表。
|
||||
* 批號格式:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
|
||||
* 完整格式(含箱號):{商品代號}-{來源國家}-{入庫日期}-{批次流水號}-{箱號}
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Step 1: 新增批號相關欄位
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
// 批號組成:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
|
||||
$table->string('batch_number', 50)->nullable()->after('location')
|
||||
->comment('批號 (格式: AB-VN-20260119-01)');
|
||||
$table->string('box_number', 10)->nullable()->after('batch_number')
|
||||
->comment('箱號 (如: 01, 02)');
|
||||
|
||||
// 批號解析欄位(方便查詢與排序)
|
||||
$table->string('origin_country', 10)->nullable()->after('box_number')
|
||||
->comment('來源國家代碼 (如: VN, TW)');
|
||||
$table->date('arrival_date')->nullable()->after('origin_country')
|
||||
->comment('入庫日期');
|
||||
$table->date('expiry_date')->nullable()->after('arrival_date')
|
||||
->comment('效期');
|
||||
|
||||
// 來源追溯
|
||||
$table->foreignId('source_purchase_order_id')->nullable()->after('expiry_date')
|
||||
->constrained('purchase_orders')->nullOnDelete()
|
||||
->comment('來源採購單');
|
||||
|
||||
// 品質狀態
|
||||
$table->enum('quality_status', ['normal', 'frozen', 'rejected'])
|
||||
->default('normal')->after('source_purchase_order_id')
|
||||
->comment('品質狀態:正常/凍結/退貨');
|
||||
$table->text('quality_remark')->nullable()->after('quality_status')
|
||||
->comment('品質異常備註');
|
||||
});
|
||||
|
||||
// Step 2: 為現有資料設定預設批號 (LEGACY-{id})
|
||||
DB::statement("UPDATE inventories SET batch_number = CONCAT('LEGACY-', id) WHERE batch_number IS NULL");
|
||||
|
||||
// Step 3: 將 batch_number 改為必填
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->string('batch_number', 50)->nullable(false)->change();
|
||||
});
|
||||
|
||||
// Step 4: 新增批號相關索引 (不刪除舊索引,因為有外鍵依賴)
|
||||
// 舊的 warehouse_product_unique 保留,新增更精確的批號索引
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->index(['warehouse_id', 'product_id', 'batch_number'], 'inventories_batch_lookup');
|
||||
$table->index(['arrival_date'], 'inventories_arrival_date');
|
||||
$table->index(['quality_status'], 'inventories_quality_status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
// 移除索引
|
||||
$table->dropIndex('inventories_batch_lookup');
|
||||
$table->dropIndex('inventories_arrival_date');
|
||||
$table->dropIndex('inventories_quality_status');
|
||||
|
||||
// 移除新增欄位
|
||||
$table->dropForeign(['source_purchase_order_id']);
|
||||
$table->dropColumn([
|
||||
'batch_number',
|
||||
'box_number',
|
||||
'origin_country',
|
||||
'arrival_date',
|
||||
'expiry_date',
|
||||
'source_purchase_order_id',
|
||||
'quality_status',
|
||||
'quality_remark',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 生產工單主表,記錄每次生產的成品資訊。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('production_orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code', 50)->unique()->comment('生產單號 (如: PRO-20260121-001)');
|
||||
|
||||
// 成品資訊
|
||||
$table->foreignId('product_id')->constrained()->onDelete('restrict')
|
||||
->comment('成品商品');
|
||||
$table->string('output_batch_number', 50)->comment('成品批號');
|
||||
$table->string('output_box_count', 10)->nullable()->comment('成品箱數');
|
||||
$table->decimal('output_quantity', 10, 2)->comment('生產數量');
|
||||
|
||||
// 入庫倉庫
|
||||
$table->foreignId('warehouse_id')->constrained()->onDelete('restrict')
|
||||
->comment('入庫倉庫');
|
||||
|
||||
// 生產資訊
|
||||
$table->date('production_date')->comment('生產日期');
|
||||
$table->date('expiry_date')->nullable()->comment('成品效期');
|
||||
|
||||
// 操作人員
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete()
|
||||
->comment('操作人員');
|
||||
|
||||
// 狀態與備註
|
||||
$table->enum('status', ['draft', 'completed', 'cancelled'])
|
||||
->default('completed')->comment('狀態:草稿/完成/取消');
|
||||
$table->text('remark')->nullable()->comment('備註');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// 索引
|
||||
$table->index(['production_date', 'product_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('production_orders');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 生產工單明細表 (BOM),記錄使用的原物料。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('production_order_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// 所屬生產工單
|
||||
$table->foreignId('production_order_id')->constrained()->onDelete('cascade')
|
||||
->comment('所屬生產工單');
|
||||
|
||||
// 使用的庫存(含商品與批號)
|
||||
$table->foreignId('inventory_id')->constrained()->onDelete('restrict')
|
||||
->comment('使用的庫存紀錄 (含 product, batch)');
|
||||
|
||||
// 使用量
|
||||
$table->decimal('quantity_used', 10, 4)->comment('使用量');
|
||||
$table->foreignId('unit_id')->nullable()->constrained('units')->nullOnDelete()
|
||||
->comment('單位');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// 索引
|
||||
$table->index(['production_order_id', 'inventory_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('production_order_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 新增生產管理權限
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$guard = 'web';
|
||||
|
||||
// 建立生產管理權限
|
||||
$permissions = [
|
||||
'production_orders.view' => '檢視生產工單',
|
||||
'production_orders.create' => '建立生產工單',
|
||||
'production_orders.edit' => '編輯生產工單',
|
||||
'production_orders.delete' => '刪除生產工單',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name => $description) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => $guard],
|
||||
['name' => $name, 'guard_name' => $guard]
|
||||
);
|
||||
}
|
||||
|
||||
// 授予 super-admin 所有新權限
|
||||
$superAdmin = Role::where('name', 'super-admin')->first();
|
||||
if ($superAdmin) {
|
||||
$superAdmin->givePermissionTo(array_keys($permissions));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$permissions = [
|
||||
'production_orders.view',
|
||||
'production_orders.create',
|
||||
'production_orders.edit',
|
||||
'production_orders.delete',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::where('name', $name)->delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
// 先新增新的唯一約束 (倉庫 + 商品 + 批號)
|
||||
// 這樣可以確保 warehouse_id 始終有索引可用,避免外鍵約束錯誤
|
||||
$table->unique(['warehouse_id', 'product_id', 'batch_number'], 'warehouse_product_batch_unique');
|
||||
|
||||
// 然後移除舊的唯一約束 (倉庫 + 商品)
|
||||
$table->dropUnique('warehouse_product_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
// 恢復時也要注意順序,先加回舊的(如果資料允許),再刪除新的
|
||||
// 但如果資料已經有多批號,加回舊的會失敗。這裡只盡力而為。
|
||||
$table->unique(['warehouse_id', 'product_id'], 'warehouse_product_unique');
|
||||
$table->dropUnique('warehouse_product_batch_unique');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||
$table->string('type', 50)->comment('異動類型: 採購進貨, 銷售出庫, 盤點調整, 撥補入庫, 撥補出庫, 手動入庫, 手動調整庫存')->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||
$table->string('type', 20)->comment('異動類型: 採購進貨, 銷售出庫, 盤點調整, 撥補入庫, 撥補出庫, 手動入庫')->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('warehouse_product_safety_stocks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('warehouse_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('safety_stock', 10, 2)->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['warehouse_id', 'product_id'], 'wh_product_safety_stock_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('warehouse_product_safety_stocks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* 將 inventories.safety_stock 資料遷移至 warehouse_product_safety_stocks 表格
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 遷移現有資料:從 inventories 提取唯一的 warehouse_id + product_id 組合及其 safety_stock
|
||||
DB::statement("
|
||||
INSERT INTO warehouse_product_safety_stocks (warehouse_id, product_id, safety_stock, created_at, updated_at)
|
||||
SELECT
|
||||
warehouse_id,
|
||||
product_id,
|
||||
MAX(safety_stock) as safety_stock,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM inventories
|
||||
WHERE safety_stock IS NOT NULL AND safety_stock > 0
|
||||
GROUP BY warehouse_id, product_id
|
||||
ON DUPLICATE KEY UPDATE safety_stock = VALUES(safety_stock), updated_at = NOW()
|
||||
");
|
||||
|
||||
// 2. 移除 inventories 表的 safety_stock 欄位
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->dropColumn('safety_stock');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 1. 在 inventories 表重新加入 safety_stock 欄位
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->decimal('safety_stock', 10, 2)->nullable()->after('quantity');
|
||||
});
|
||||
|
||||
// 2. 將資料還原回 inventories (更新同商品所有批號)
|
||||
DB::statement("
|
||||
UPDATE inventories i
|
||||
INNER JOIN warehouse_product_safety_stocks ss
|
||||
ON i.warehouse_id = ss.warehouse_id AND i.product_id = ss.product_id
|
||||
SET i.safety_stock = ss.safety_stock
|
||||
");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 將 warehouse_id 改為 nullable,支援草稿模式。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('production_orders', function (Blueprint $table) {
|
||||
$table->foreignId('warehouse_id')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('production_orders', function (Blueprint $table) {
|
||||
$table->foreignId('warehouse_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('recipes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained('products')->onDelete('cascade')->comment('連結的成品商品 ID');
|
||||
$table->string('code')->unique()->comment('配方代號');
|
||||
$table->string('name')->comment('配方名稱');
|
||||
$table->text('description')->nullable()->comment('配方描述');
|
||||
$table->decimal('yield_quantity', 10, 2)->default(1.00)->comment('標準產出數量');
|
||||
$table->boolean('is_active')->default(true)->comment('是否啟用');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('recipe_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('recipe_id')->constrained('recipes')->onDelete('cascade');
|
||||
$table->foreignId('product_id')->constrained('products')->comment('原物料商品 ID');
|
||||
$table->decimal('quantity', 10, 4)->comment('標準用量');
|
||||
$table->foreignId('unit_id')->nullable()->constrained('units')->comment('單位 ID');
|
||||
$table->string('remark')->nullable()->comment('備註');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('recipe_items');
|
||||
Schema::dropIfExists('recipes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 新增配方管理權限
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$guard = 'web';
|
||||
|
||||
// 建立配方管理權限
|
||||
$permissions = [
|
||||
'recipes.view' => '檢視配方',
|
||||
'recipes.create' => '建立配方',
|
||||
'recipes.edit' => '編輯配方',
|
||||
'recipes.delete' => '刪除配方',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name => $description) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => $guard],
|
||||
['name' => $name, 'guard_name' => $guard]
|
||||
);
|
||||
}
|
||||
|
||||
// 授予 super-admin 所有新權限
|
||||
$superAdmin = Role::where('name', 'super-admin')->first();
|
||||
if ($superAdmin) {
|
||||
$superAdmin->givePermissionTo(array_keys($permissions));
|
||||
}
|
||||
|
||||
// 授予 admin 所有新權限
|
||||
$admin = Role::where('name', 'admin')->first();
|
||||
if ($admin) {
|
||||
$admin->givePermissionTo(array_keys($permissions));
|
||||
}
|
||||
|
||||
// 授予 warehouse-manager 檢視權限 (配方通常與庫存相關)
|
||||
$whManager = Role::where('name', 'warehouse-manager')->first();
|
||||
if ($whManager) {
|
||||
$whManager->givePermissionTo(['recipes.view']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$permissions = [
|
||||
'recipes.view',
|
||||
'recipes.create',
|
||||
'recipes.edit',
|
||||
'recipes.delete',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::where('name', $name)->delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->boolean('is_sellable')->default(true)->after('description')->comment('是否可銷售');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->dropColumn('is_sellable');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->string('type')->default('standard')->after('description')->comment('倉庫業務類型 (enum)');
|
||||
$table->string('license_plate')->nullable()->after('type')->comment('車牌號碼 (移動倉用)');
|
||||
$table->string('driver_name')->nullable()->after('license_plate')->comment('司機姓名 (移動倉用)');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->dropColumn(['type', 'license_plate', 'driver_name']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->decimal('unit_cost', 12, 4)->default(0)->after('quantity')->comment('單位成本');
|
||||
$table->decimal('total_value', 12, 4)->default(0)->after('unit_cost')->comment('庫存總價值');
|
||||
});
|
||||
|
||||
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||
$table->decimal('unit_cost', 12, 4)->nullable()->after('quantity')->comment('異動當下的單位成本');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||
$table->dropColumn('unit_cost');
|
||||
});
|
||||
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->dropColumn(['unit_cost', 'total_value']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||
$table->date('order_date')->nullable()->after('code');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||
$table->dropColumn('order_date');
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user