feat: 修正庫存與撥補單邏輯並整合文件
1. 修復倉庫統計數據加總與樣式。 2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。 3. 撥補單商品列表加入批號與效期顯示。 4. 修正撥補單儲存邏輯以支援精確批號轉移。 5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
158
FEATURES.md
158
FEATURES.md
@@ -1,158 +0,0 @@
|
|||||||
# Star ERP 功能選單詳細說明
|
|
||||||
|
|
||||||
## 🌳 系統功能架構樹 (含 2.0 升級規劃)
|
|
||||||
```text
|
|
||||||
Star ERP
|
|
||||||
├── 🏠 儀表板 (Dashboard)
|
|
||||||
│ ├── 📊 數據看板 (原有)
|
|
||||||
│ ├── 🔔 營運警示 (原有)
|
|
||||||
│ ├── ✨ 銷售熱力圖 (新)
|
|
||||||
│ ├── ✨ 庫存效期預警 (新)
|
|
||||||
│ └── ✨ 待出貨監控 (新)
|
|
||||||
├── ✨ 🤝 銷售與全通路 (Sales & CRM) 【New】
|
|
||||||
│ ├── ✨ 全通路訂單整合
|
|
||||||
│ ├── ✨ 客戶管理 (CRM)
|
|
||||||
│ └── ✨ 促銷活動
|
|
||||||
├── 📦 商品與庫存管理
|
|
||||||
│ ├── 📄 商品資料 (原有)
|
|
||||||
│ ├── 🏢 倉庫管理 (原有)
|
|
||||||
│ ├── 🚚 內調撥 (原有)
|
|
||||||
│ ├── ✨ 屬性管理 (過敏原/成分)
|
|
||||||
│ ├── ✨ 效期監控 (FEFO)
|
|
||||||
│ └── ✨ 智慧補貨建議 (AI)
|
|
||||||
├── ✨ 🚚 智慧物流 (Logistics) 【New】
|
|
||||||
│ ├── ✨ 路徑規劃
|
|
||||||
│ └── ✨ 裝車單管理
|
|
||||||
├── 🏭 生產與品質管理
|
|
||||||
│ ├── 📝 生產工單 (原有)
|
|
||||||
│ ├── 🧪 原料耗用 (原有)
|
|
||||||
│ ├── ✨ 配方管理 (Recipe)
|
|
||||||
│ ├── ✨ 品質檢驗 (QC)
|
|
||||||
│ └── ✨ 雙向溯源 (原料<->成品)
|
|
||||||
├── 🛒 採購與廠商
|
|
||||||
│ ├── 👥 廠商資料 (原有)
|
|
||||||
│ ├── 📝 採購單 (原有)
|
|
||||||
│ └── ✨ 供應商評鑑 (新)
|
|
||||||
├── 💰 財務管理
|
|
||||||
│ ├── 🧾 公共事業費 (原有)
|
|
||||||
│ ├── ✨ 應收/應付帳款 (AR/AP)
|
|
||||||
│ └── ✨ 成本精算 (料工費)
|
|
||||||
├── 📊 報表管理
|
|
||||||
│ └── 📑 會計報表 (原有)
|
|
||||||
└── ⚙️ 系統管理 (原有)
|
|
||||||
├── 👤 使用者管理
|
|
||||||
├── 🛡️ 角色與權限
|
|
||||||
└── 📜 操作紀錄
|
|
||||||
```
|
|
||||||
|
|
||||||
本文件詳細說明 Star ERP 各模組功能,並特別標註 **✨ 新增/強化** 之功能(針對 ERP 2.0 規劃)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 🏠 儀表板 (Dashboard)
|
|
||||||
系統的戰情中心,提供即時營運數據與警示。
|
|
||||||
|
|
||||||
### 🔹 原有功能
|
|
||||||
- **數據看板**:顯示商品總數、供應商數、活躍倉庫數等基礎營運指標。
|
|
||||||
- **營運警示**:顯示低庫存商品與待辦事項(如待審核單據)。
|
|
||||||
|
|
||||||
### ✨ 新增/強化功能
|
|
||||||
- **銷售熱力圖 (零售用)**:視覺化呈現熱銷時段與區域,輔助行銷決策。
|
|
||||||
- **庫存效期預警 (食品/化妝品用)**:針對即期品自動發出警示,協助優先促銷或處理,減少報廢。
|
|
||||||
- **待出貨監控**:即時追蹤已接單但尚未指派物流或出貨的訂單,避免訂單延遲。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. ✨ 🤝 銷售與全通路 (Sales & CRM) 【New】
|
|
||||||
針對 B2B 與 B2C 混合模式設計,整合多來源訂單與客戶關係。
|
|
||||||
|
|
||||||
### ✨ 核心功能
|
|
||||||
- **全通路訂單**:統一整合來自 POS、品牌電商官網、智慧販賣機的訂單,集中處理。
|
|
||||||
- **客戶管理 (CRM)**:建立完整的會員資料庫,記錄消費歷史與會員等級。
|
|
||||||
- **促銷活動**:內建價格策略引擎,支援滿額折、買一送一、組合價等靈活折扣管理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 📦 商品與庫存管理
|
|
||||||
支援食品業與零售業特性的高階庫存系統。
|
|
||||||
|
|
||||||
### 🔹 原有功能
|
|
||||||
- **商品資料**:管理品名、規格、多單位換算。
|
|
||||||
- **倉庫管理**:多站點(實體/虛擬)倉庫設定與庫存監控。
|
|
||||||
- **內調撥**:倉庫間的庫存轉移功能。
|
|
||||||
|
|
||||||
### ✨ 新增/強化功能
|
|
||||||
- **屬性管理**:
|
|
||||||
- **食品業**:標註過敏原資訊。
|
|
||||||
- **化妝品**:標註全成分表與保存條件。
|
|
||||||
- **效期監控 (FEFO)**:系統強制執行「先到期先出 (First Expired First Out)」邏輯,優於傳統 FIFO,確保出貨商品新鮮度。
|
|
||||||
- **智慧補貨建議**:AI 依據歷史銷量趨勢,自動計算建議補貨量,避免斷貨或過量庫存。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ✨ 🚚 智慧物流 (Logistics) 【New/Split】
|
|
||||||
針對冷鏈配送與多點補貨的最佳化工具。
|
|
||||||
|
|
||||||
### ✨ 核心功能
|
|
||||||
- **路徑規劃**:針對多點配送進行路線最佳化演算,節省油資與配送時間。
|
|
||||||
- **裝車單管理**:自動產出物流車領貨總表,協助倉管與司機快速核對上車貨品。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 🏭 生產與品質管理 (升級)
|
|
||||||
食品加工與製造的核心模組,重視配方精準度與食安溯源。
|
|
||||||
|
|
||||||
### 🔹 原有功能
|
|
||||||
- **生產工單**:排程管理、生產入庫。
|
|
||||||
- **原料耗用**:記錄生產過程消耗的原物料扣量。
|
|
||||||
|
|
||||||
### ✨ 新增/強化功能
|
|
||||||
- **配方管理 (Recipe)**:
|
|
||||||
- 支援百分比配方設定。
|
|
||||||
- 設定各製程階段的預期損耗率。
|
|
||||||
- 完整的配方版本控制 (Version Control)。
|
|
||||||
- **品質檢驗 (QC)**:
|
|
||||||
- 涵蓋進料檢驗 (IQC)、製程檢驗 (IPQC)、成品檢驗 (FQC)。
|
|
||||||
- 自動產出 COA (Certificate of Analysis) 品質分析報告。
|
|
||||||
- **雙向溯源**:
|
|
||||||
- **正向**:原料批號 -> 用於哪些成品。
|
|
||||||
- **逆向**:成品批號 -> 來自哪些原料 -> 供應商是誰。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 🛒 採購與廠商
|
|
||||||
掌握供應鏈源頭與進貨管理。
|
|
||||||
|
|
||||||
### 🔹 原有功能
|
|
||||||
- **廠商資料**:基本聯絡資訊與付款條件設定。
|
|
||||||
- **採購單**:完整的詢價、下單、收貨與驗收流程。
|
|
||||||
|
|
||||||
### ✨ 新增/強化功能
|
|
||||||
- **供應商評鑑**:系統自動分析廠商績效,包含「交期準時率」與「原料合格率」,作為管理依據。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 💰 財務管理 (升級)
|
|
||||||
從費用記錄升級為經營分析與成本精算中心。
|
|
||||||
|
|
||||||
### 🔹 原有功能
|
|
||||||
- **公共事業費**:記錄水電氣網等非商品類別之固定支出。
|
|
||||||
|
|
||||||
### ✨ 新增/強化功能
|
|
||||||
- **應收/應付帳款 (AR/AP)**:
|
|
||||||
- 管理客戶與廠商的帳期 (Credit Terms)。
|
|
||||||
- 自動化對帳單與未結帳款提醒。
|
|
||||||
- **成本精算**:
|
|
||||||
- 實作分攤邏輯,將「料(原料)、工(工時)、費(製造費用)」精確分攤至單一商品成本。
|
|
||||||
- 提供即時毛利分析報表 (Gross Margin Analysis)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 📊 報表管理
|
|
||||||
- **會計報表**:支出總表、採購分析。
|
|
||||||
- **資料匯出**:支援 CSV/Excel 格式匯出。
|
|
||||||
|
|
||||||
## 9. ⚙️ 系統管理
|
|
||||||
- **使用者管理**:帳號維護。
|
|
||||||
- **角色與權限 (RBAC)**:細緻的功能權限控管。
|
|
||||||
- **操作紀錄 (Audit Log)**:全系統關鍵行為軌跡留存。
|
|
||||||
98
README.md
98
README.md
@@ -11,28 +11,86 @@ Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind
|
|||||||
- **UI 框架**: Tailwind CSS
|
- **UI 框架**: Tailwind CSS
|
||||||
- **基礎設施**: Docker (Laravel Sail), Nginx Reverse Proxy, MySQL 8.0, Redis
|
- **基礎設施**: 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`
|
#### 1. 🏠 儀表板 (Dashboard)
|
||||||
- 🏢 **倉庫管理** (`/warehouses`) - `warehouses.view`
|
- **數據看板**:顯示商品總數、供應商數、活躍倉庫數等。
|
||||||
- 🚚 **廠商管理**
|
- **營運警示**:低庫存商品與待辦事項警示。
|
||||||
- 👥 **廠商資料管理** (`/vendors`) - `vendors.view`
|
- **✨ 強化功能**:銷售熱力圖、庫存效期預警、待出貨監控。
|
||||||
- 🛒 **採購管理**
|
|
||||||
- 📝 **採購單管理** (`/purchase-orders`) - `purchase_orders.view`
|
#### 2. ✨ 🤝 銷售與全通路 (Sales & CRM)
|
||||||
- 🏭 **生產管理**
|
- **全通路訂單**:整合 POS、品牌電商、智慧販賣機訂單。
|
||||||
- 📦 **生產工單** (`/production-orders`) - `production_orders.view`
|
- **客戶管理 (CRM)**:會員資料庫、消費歷史與等級。
|
||||||
- 💰 **財務管理**
|
- **促銷活動**:滿額折、買一送一、組合價等折扣引擎。
|
||||||
- 🧾 **公共事業費** (`/utility-fees`) - `utility_fees.view`
|
|
||||||
- 📊 **報表管理**
|
#### 3. 📦 商品與庫存管理
|
||||||
- 📑 **會計報表** (`/accounting-report`) - `accounting.view`
|
- **商品資料**:品名、規格、多單位換算。
|
||||||
- ⚙️ **系統管理**
|
- **倉庫管理**:多站點庫存監控、銷售設定。
|
||||||
- 👤 **使用者管理** (`/admin/users`) - `users.view`
|
- **內調撥**:倉庫間庫存轉移。
|
||||||
- 🛡️ **角色與權限** (`/admin/roles`) - `roles.view`
|
- **✨ 強化功能**:過敏原/成分管理、**FEFO (先到期先出)** 效期監控、AI 智慧補貨建議。
|
||||||
- 📜 **操作紀錄** (`/admin/activity-logs`) - `system.view_logs`
|
|
||||||
|
#### 4. 🏭 生產與品質管理
|
||||||
|
- **生產工單**:排程管理、生產入庫。
|
||||||
|
- **✨ 強化功能**:配方管理 (Recipe V.C.)、QC 檢驗流程 (IQC/IPQC/FQC)、**雙向溯源** (原料 <-> 成品)。
|
||||||
|
|
||||||
|
#### 5. 🛒 採購與廠商
|
||||||
|
- **採購單**:詢價、下單、收貨與驗收流程。
|
||||||
|
- **✨ 強化功能**:供應商評鑑系統。
|
||||||
|
|
||||||
|
#### 6. 💰 財務管理
|
||||||
|
- **公共事業費**:水電氣網等固定支出。
|
||||||
|
- **✨ 強化功能**:應收/應付帳款 (AR/AP) 管理、**成本精算** (料工費分攤)。
|
||||||
|
|
||||||
|
#### 7. ⚙️ 系統管理
|
||||||
|
- **使用者與權限**:RBAC 細緻權限控管。
|
||||||
|
- **操作紀錄**:全系統關鍵行為軌跡 (Audit Log)。
|
||||||
|
|
||||||
## 🚀 快速開始
|
## 🚀 快速開始
|
||||||
|
|
||||||
|
|||||||
31
app/Modules/Core/Contracts/CoreServiceInterface.php
Normal file
31
app/Modules/Core/Contracts/CoreServiceInterface.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
@@ -96,12 +96,12 @@ class ActivityLogController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prepare subject types for frontend filter
|
// 準備用於前端篩選的主題類型
|
||||||
$subjectTypes = collect($this->getSubjectMap())->map(function ($label, $value) {
|
$subjectTypes = collect($this->getSubjectMap())->map(function ($label, $value) {
|
||||||
return ['label' => $label, 'value' => $value];
|
return ['label' => $label, 'value' => $value];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
// Get users for causer filter
|
// 取得用於操作者篩選的使用者
|
||||||
$users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
|
$users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
|
||||||
->map(function ($user) {
|
->map(function ($user) {
|
||||||
return ['label' => $user->name, 'value' => (string) $user->id];
|
return ['label' => $user->name, 'value' => (string) $user->id];
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use Illuminate\Validation\Rule;
|
|||||||
class RoleController extends Controller
|
class RoleController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* 顯示資源列表。
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
@@ -23,7 +23,7 @@ class RoleController extends Controller
|
|||||||
$query = Role::withCount('users', 'permissions')
|
$query = Role::withCount('users', 'permissions')
|
||||||
->with('users:id,name,username');
|
->with('users:id,name,username');
|
||||||
|
|
||||||
// Handle sorting
|
// 處理排序
|
||||||
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
|
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
|
||||||
$query->orderBy($sortBy, $sortOrder);
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
} else {
|
} else {
|
||||||
@@ -39,7 +39,7 @@ class RoleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for creating a new resource.
|
* 顯示建立新資源的表單。
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
@@ -51,7 +51,7 @@ class RoleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* 將新建立的資源儲存到儲存體中。
|
||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
@@ -75,7 +75,7 @@ class RoleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for editing the specified resource.
|
* 顯示編輯指定資源的表單。
|
||||||
*/
|
*/
|
||||||
public function edit(string $id)
|
public function edit(string $id)
|
||||||
{
|
{
|
||||||
@@ -97,7 +97,7 @@ class RoleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* 更新儲存體中的指定資源。
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, string $id)
|
public function update(Request $request, string $id)
|
||||||
{
|
{
|
||||||
@@ -127,7 +127,7 @@ class RoleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* 從儲存體中移除指定資源。
|
||||||
*/
|
*/
|
||||||
public function destroy(string $id)
|
public function destroy(string $id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* 顯示資源列表。
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,7 @@ class UserController extends Controller
|
|||||||
|
|
||||||
$query = User::with(['roles:id,name,display_name']);
|
$query = User::with(['roles:id,name,display_name']);
|
||||||
|
|
||||||
// Handle Search
|
// 處理搜尋
|
||||||
if ($search) {
|
if ($search) {
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'like', "%{$search}%")
|
||||||
@@ -35,14 +35,14 @@ class UserController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Role Filter
|
// 處理角色篩選
|
||||||
if ($roleId && $roleId !== 'all') {
|
if ($roleId && $roleId !== 'all') {
|
||||||
$query->whereHas('roles', function ($q) use ($roleId) {
|
$query->whereHas('roles', function ($q) use ($roleId) {
|
||||||
$q->where('id', $roleId);
|
$q->where('id', $roleId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle sorting
|
// 處理排序
|
||||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||||
$query->orderBy($sortBy, $sortOrder);
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
} else {
|
} else {
|
||||||
@@ -60,7 +60,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for creating a new resource.
|
* 顯示建立新資源的表單。
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* 將新建立的資源儲存到儲存體中。
|
||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
@@ -98,7 +98,7 @@ class UserController extends Controller
|
|||||||
if (!empty($validated['roles'])) {
|
if (!empty($validated['roles'])) {
|
||||||
$user->syncRoles($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))
|
$activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user))
|
||||||
->where('subject_id', $user->id)
|
->where('subject_id', $user->id)
|
||||||
->where('event', 'created')
|
->where('event', 'created')
|
||||||
@@ -118,7 +118,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for editing the specified resource.
|
* 顯示編輯指定資源的表單。
|
||||||
*/
|
*/
|
||||||
public function edit(string $id)
|
public function edit(string $id)
|
||||||
{
|
{
|
||||||
@@ -133,7 +133,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* 更新儲存體中的指定資源。
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, string $id)
|
public function update(Request $request, string $id)
|
||||||
{
|
{
|
||||||
@@ -150,7 +150,7 @@ class UserController extends Controller
|
|||||||
'password.confirmed' => '密碼確認不符',
|
'password.confirmed' => '密碼確認不符',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 1. Prepare data and detect changes
|
// 1. 準備資料並偵測變更
|
||||||
$userData = [
|
$userData = [
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
@@ -163,7 +163,7 @@ class UserController extends Controller
|
|||||||
|
|
||||||
$user->fill($userData);
|
$user->fill($userData);
|
||||||
|
|
||||||
// Capture dirty attributes for manual logging
|
// 捕捉變更屬性以進行手動記錄
|
||||||
$dirty = $user->getDirty();
|
$dirty = $user->getDirty();
|
||||||
$oldAttributes = [];
|
$oldAttributes = [];
|
||||||
$newAttributes = [];
|
$newAttributes = [];
|
||||||
@@ -173,10 +173,10 @@ class UserController extends Controller
|
|||||||
$newAttributes[$key] = $value;
|
$newAttributes[$key] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save without triggering events (prevents duplicate log)
|
// 儲存但不觸發事件(防止重複記錄)
|
||||||
$user->saveQuietly();
|
$user->saveQuietly();
|
||||||
|
|
||||||
// 2. Handle Roles
|
// 2. 處理角色
|
||||||
$roleChanges = null;
|
$roleChanges = null;
|
||||||
if (isset($validated['roles'])) {
|
if (isset($validated['roles'])) {
|
||||||
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
|
||||||
@@ -191,7 +191,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Manually Log activity (Single Consolidated Log)
|
// 3. 手動記錄活動(單一整合記錄)
|
||||||
if (!empty($newAttributes) || $roleChanges) {
|
if (!empty($newAttributes) || $roleChanges) {
|
||||||
$properties = [
|
$properties = [
|
||||||
'attributes' => $newAttributes,
|
'attributes' => $newAttributes,
|
||||||
@@ -209,7 +209,7 @@ class UserController extends Controller
|
|||||||
->event('updated')
|
->event('updated')
|
||||||
->withProperties($properties)
|
->withProperties($properties)
|
||||||
->tap(function (\Spatie\Activitylog\Contracts\Activity $activity) use ($user) {
|
->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([
|
$activity->properties = $activity->properties->merge([
|
||||||
'snapshot' => [
|
'snapshot' => [
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
@@ -224,7 +224,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* 從儲存體中移除指定資源。
|
||||||
*/
|
*/
|
||||||
public function destroy(string $id)
|
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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,20 @@ class User extends Authenticatable
|
|||||||
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* 可批量賦值的屬性。
|
||||||
*
|
*
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 建立模型的新工廠實例。
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Factories\Factory
|
||||||
|
*/
|
||||||
|
protected static function newFactory()
|
||||||
|
{
|
||||||
|
return \Database\Factories\UserFactory::new();
|
||||||
|
}
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
@@ -28,7 +38,7 @@ class User extends Authenticatable
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that should be hidden for serialization.
|
* 序列化時應隱藏的屬性。
|
||||||
*
|
*
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
@@ -38,7 +48,7 @@ class User extends Authenticatable
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* 取得應進行轉換的屬性。
|
||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
42
app/Modules/Core/Services/CoreService.php
Normal file
42
app/Modules/Core/Services/CoreService.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
namespace App\Modules\Finance\Controllers;
|
namespace App\Modules\Finance\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
use App\Modules\Finance\Models\UtilityFee;
|
|
||||||
use App\Modules\Procurement\Models\PurchaseOrder;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -13,49 +11,20 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
|||||||
|
|
||||||
class AccountingReportController extends Controller
|
class AccountingReportController extends Controller
|
||||||
{
|
{
|
||||||
|
protected $financeService;
|
||||||
|
|
||||||
|
public function __construct(FinanceServiceInterface $financeService)
|
||||||
|
{
|
||||||
|
$this->financeService = $financeService;
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
|
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
|
||||||
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
||||||
|
|
||||||
// 1. Get Purchase Orders (Completed or Received that are ready for accounting)
|
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
|
||||||
$purchaseOrders = PurchaseOrder::with(['vendor'])
|
$allRecords = $reportData['records'];
|
||||||
->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
|
// 3. Manual Pagination
|
||||||
$perPage = $request->input('per_page', 10);
|
$perPage = $request->input('per_page', 10);
|
||||||
@@ -70,16 +39,9 @@ class AccountingReportController extends Controller
|
|||||||
['path' => $request->url(), 'query' => $request->query()]
|
['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', [
|
return Inertia::render('Accounting/Report', [
|
||||||
'records' => $paginatedRecords,
|
'records' => $paginatedRecords,
|
||||||
'summary' => $summary,
|
'summary' => $reportData['summary'],
|
||||||
'filters' => [
|
'filters' => [
|
||||||
'date_start' => $dateStart,
|
'date_start' => $dateStart,
|
||||||
'date_end' => $dateEnd,
|
'date_end' => $dateEnd,
|
||||||
@@ -94,60 +56,25 @@ class AccountingReportController extends Controller
|
|||||||
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
|
||||||
$selectedIdsParam = $request->input('selected_ids');
|
$selectedIdsParam = $request->input('selected_ids');
|
||||||
|
|
||||||
$purchaseOrdersQuery = PurchaseOrder::with(['vendor'])
|
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
|
||||||
->whereIn('status', ['received', 'completed']);
|
$allRecords = $reportData['records'];
|
||||||
|
|
||||||
$utilityFeesQuery = UtilityFee::query();
|
|
||||||
|
|
||||||
if ($selectedIdsParam) {
|
if ($selectedIdsParam) {
|
||||||
$ids = explode(',', $selectedIdsParam);
|
$ids = explode(',', $selectedIdsParam);
|
||||||
$poIds = [];
|
$allRecords = $allRecords->whereIn('id', $ids);
|
||||||
$ufIds = [];
|
|
||||||
foreach ($ids as $id) {
|
|
||||||
if (str_starts_with($id, 'PO-')) {
|
|
||||||
$poIds[] = substr($id, 3);
|
|
||||||
} elseif (str_starts_with($id, 'UF-')) {
|
|
||||||
$ufIds[] = substr($id, 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$purchaseOrders = $purchaseOrdersQuery->whereIn('id', $poIds)->get();
|
|
||||||
$utilityFees = $utilityFeesQuery->whereIn('id', $ufIds)->get();
|
|
||||||
} else {
|
|
||||||
$purchaseOrders = $purchaseOrdersQuery
|
|
||||||
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
|
|
||||||
->get();
|
|
||||||
$utilityFees = $utilityFeesQuery
|
|
||||||
->whereBetween('transaction_date', [$dateStart, $dateEnd])
|
|
||||||
->get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$allRecords = collect();
|
$exportData = $allRecords->map(function ($record) {
|
||||||
|
return [
|
||||||
foreach ($purchaseOrders as $po) {
|
$record['date'],
|
||||||
$allRecords->push([
|
$record['source'],
|
||||||
Carbon::parse($po->created_at)->toDateString(),
|
$record['category'],
|
||||||
'採購單',
|
$record['item'],
|
||||||
'進貨支出',
|
$record['reference'],
|
||||||
$po->vendor->name ?? '',
|
$record['invoice_number'],
|
||||||
$po->code,
|
$record['amount'],
|
||||||
$po->invoice_number,
|
];
|
||||||
(float)$po->grand_total,
|
});
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($utilityFees as $fee) {
|
|
||||||
$allRecords->push([
|
|
||||||
Carbon::parse($fee->transaction_date)->toDateString(),
|
|
||||||
'公共事業費',
|
|
||||||
$fee->category,
|
|
||||||
$fee->description,
|
|
||||||
'-',
|
|
||||||
$fee->invoice_number,
|
|
||||||
(float)$fee->amount,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$allRecords = $allRecords->sortByDesc(0);
|
|
||||||
|
|
||||||
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
|
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
|
||||||
$headers = [
|
$headers = [
|
||||||
@@ -155,14 +82,14 @@ class AccountingReportController extends Controller
|
|||||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||||
];
|
];
|
||||||
|
|
||||||
$callback = function () use ($allRecords) {
|
$callback = function () use ($exportData) {
|
||||||
$file = fopen('php://output', 'w');
|
$file = fopen('php://output', 'w');
|
||||||
// BOM for Excel compatibility with UTF-8
|
// BOM for Excel compatibility with UTF-8
|
||||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||||
|
|
||||||
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
|
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
|
||||||
|
|
||||||
foreach ($allRecords as $row) {
|
foreach ($exportData as $row) {
|
||||||
fputcsv($file, $row);
|
fputcsv($file, $row);
|
||||||
}
|
}
|
||||||
fclose($file);
|
fclose($file);
|
||||||
|
|||||||
@@ -4,57 +4,30 @@ namespace App\Modules\Finance\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Finance\Models\UtilityFee;
|
use App\Modules\Finance\Models\UtilityFee;
|
||||||
|
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class UtilityFeeController extends Controller
|
class UtilityFeeController extends Controller
|
||||||
{
|
{
|
||||||
|
protected $financeService;
|
||||||
|
|
||||||
|
public function __construct(FinanceServiceInterface $financeService)
|
||||||
|
{
|
||||||
|
$this->financeService = $financeService;
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$query = UtilityFee::query();
|
$filters = $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']);
|
||||||
|
|
||||||
// Search
|
$fees = $this->financeService->getUtilityFees($filters)->withQueryString();
|
||||||
if ($request->has('search')) {
|
$availableCategories = $this->financeService->getUniqueCategories();
|
||||||
$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', [
|
return Inertia::render('UtilityFee/Index', [
|
||||||
'fees' => $fees,
|
'fees' => $fees,
|
||||||
'availableCategories' => $availableCategories,
|
'availableCategories' => $availableCategories,
|
||||||
'filters' => $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
|
'filters' => $filters,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +43,10 @@ class UtilityFeeController extends Controller
|
|||||||
|
|
||||||
$fee = UtilityFee::create($validated);
|
$fee = UtilityFee::create($validated);
|
||||||
|
|
||||||
// Log activity
|
|
||||||
activity()
|
activity()
|
||||||
->performedOn($fee)
|
->performedOn($fee)
|
||||||
->causedBy(auth()->user())
|
->causedBy(auth()->user())
|
||||||
->event('created')
|
->event('created')
|
||||||
->withProperties([
|
|
||||||
'attributes' => $fee->getAttributes(),
|
|
||||||
'snapshot' => [
|
|
||||||
'category' => $fee->category,
|
|
||||||
'amount' => $fee->amount,
|
|
||||||
'transaction_date' => $fee->transaction_date->format('Y-m-d'),
|
|
||||||
]
|
|
||||||
])
|
|
||||||
->log('created');
|
->log('created');
|
||||||
|
|
||||||
return redirect()->back();
|
return redirect()->back();
|
||||||
@@ -98,52 +62,12 @@ class UtilityFeeController extends Controller
|
|||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Capture old attributes before update
|
|
||||||
$oldAttributes = $utility_fee->getAttributes();
|
|
||||||
|
|
||||||
$utility_fee->update($validated);
|
$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()
|
activity()
|
||||||
->performedOn($utility_fee)
|
->performedOn($utility_fee)
|
||||||
->causedBy(auth()->user())
|
->causedBy(auth()->user())
|
||||||
->event('updated')
|
->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');
|
->log('updated');
|
||||||
|
|
||||||
return redirect()->back();
|
return redirect()->back();
|
||||||
@@ -151,24 +75,10 @@ class UtilityFeeController extends Controller
|
|||||||
|
|
||||||
public function destroy(UtilityFee $utility_fee)
|
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()
|
activity()
|
||||||
->performedOn($utility_fee)
|
->performedOn($utility_fee)
|
||||||
->causedBy(auth()->user())
|
->causedBy(auth()->user())
|
||||||
->event('deleted')
|
->event('deleted')
|
||||||
->withProperties([
|
|
||||||
'attributes' => $utility_fee->getAttributes(),
|
|
||||||
'snapshot' => $snapshot
|
|
||||||
])
|
|
||||||
->log('deleted');
|
->log('deleted');
|
||||||
|
|
||||||
$utility_fee->delete();
|
$utility_fee->delete();
|
||||||
|
|||||||
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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,26 +11,25 @@ class UtilityFee extends Model
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'type', // 'electricity', 'water', 'gas', etc.
|
'transaction_date',
|
||||||
'billing_period_start',
|
'category',
|
||||||
'billing_period_end',
|
|
||||||
'due_date',
|
|
||||||
'amount',
|
'amount',
|
||||||
'usage_amount', // kWh, m3, etc.
|
'invoice_number',
|
||||||
'unit', // 度, 立方米
|
'description',
|
||||||
'status', // 'pending', 'paid', 'overdue'
|
|
||||||
'paid_at',
|
|
||||||
'payment_method',
|
|
||||||
'notes',
|
|
||||||
'receipt_image_path',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'billing_period_start' => 'date',
|
'transaction_date' => 'date',
|
||||||
'billing_period_end' => 'date',
|
|
||||||
'due_date' => 'date',
|
|
||||||
'paid_at' => 'datetime',
|
|
||||||
'amount' => 'decimal:2',
|
'amount' => 'decimal:2',
|
||||||
'usage_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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Modules/Inventory/Contracts/InventoryServiceInterface.php
Normal file
100
app/Modules/Inventory/Contracts/InventoryServiceInterface.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -5,11 +5,16 @@ namespace App\Modules\Inventory\Controllers;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
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\Inventory\Models\WarehouseProductSafetyStock;
|
||||||
|
|
||||||
class InventoryController extends Controller
|
class InventoryController extends Controller
|
||||||
{
|
{
|
||||||
public function index(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
public function index(Request $request, Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
$warehouse->load([
|
$warehouse->load([
|
||||||
'inventories.product.category',
|
'inventories.product.category',
|
||||||
@@ -17,7 +22,7 @@ class InventoryController extends Controller
|
|||||||
'inventories.lastIncomingTransaction',
|
'inventories.lastIncomingTransaction',
|
||||||
'inventories.lastOutgoingTransaction'
|
'inventories.lastOutgoingTransaction'
|
||||||
]);
|
]);
|
||||||
$allProducts = \App\Modules\Inventory\Models\Product::with('category')->get();
|
$allProducts = Product::with('category')->get();
|
||||||
|
|
||||||
// 1. 準備 availableProducts
|
// 1. 準備 availableProducts
|
||||||
$availableProducts = $allProducts->map(function ($product) {
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
@@ -98,7 +103,7 @@ class InventoryController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/Inventory', [
|
return Inertia::render('Warehouse/Inventory', [
|
||||||
'warehouse' => $warehouse,
|
'warehouse' => $warehouse,
|
||||||
'inventories' => $inventories,
|
'inventories' => $inventories,
|
||||||
'safetyStockSettings' => $safetyStockSettings,
|
'safetyStockSettings' => $safetyStockSettings,
|
||||||
@@ -106,10 +111,10 @@ class InventoryController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(\App\Modules\Inventory\Models\Warehouse $warehouse)
|
public function create(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
// 取得所有商品供前端選單使用
|
// 取得所有商品供前端選單使用
|
||||||
$products = \App\Modules\Inventory\Models\Product::with(['baseUnit', 'largeUnit'])
|
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||||
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($product) {
|
->map(function ($product) {
|
||||||
@@ -123,13 +128,13 @@ class InventoryController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/AddInventory', [
|
return Inertia::render('Warehouse/AddInventory', [
|
||||||
'warehouse' => $warehouse,
|
'warehouse' => $warehouse,
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
public function store(Request $request, Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'inboundDate' => 'required|date',
|
'inboundDate' => 'required|date',
|
||||||
@@ -144,22 +149,22 @@ class InventoryController extends Controller
|
|||||||
'items.*.expiryDate' => 'nullable|date',
|
'items.*.expiryDate' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
|
return DB::transaction(function () use ($validated, $warehouse) {
|
||||||
foreach ($validated['items'] as $item) {
|
foreach ($validated['items'] as $item) {
|
||||||
$inventory = null;
|
$inventory = null;
|
||||||
|
|
||||||
if ($item['batchMode'] === 'existing') {
|
if ($item['batchMode'] === 'existing') {
|
||||||
// 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加)
|
// 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加)
|
||||||
$inventory = \App\Modules\Inventory\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
||||||
if ($inventory->trashed()) {
|
if ($inventory->trashed()) {
|
||||||
$inventory->restore();
|
$inventory->restore();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 模式 B:建立新批號
|
// 模式 B:建立新批號
|
||||||
$originCountry = $item['originCountry'] ?? 'TW';
|
$originCountry = $item['originCountry'] ?? 'TW';
|
||||||
$product = \App\Modules\Inventory\Models\Product::find($item['productId']);
|
$product = Product::find($item['productId']);
|
||||||
|
|
||||||
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
|
$batchNumber = Inventory::generateBatchNumber(
|
||||||
$product->code ?? 'UNK',
|
$product->code ?? 'UNK',
|
||||||
$originCountry,
|
$originCountry,
|
||||||
$validated['inboundDate']
|
$validated['inboundDate']
|
||||||
@@ -210,12 +215,12 @@ class InventoryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
|
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
|
||||||
*/
|
*/
|
||||||
public function getBatches(\App\Modules\Inventory\Models\Warehouse $warehouse, $productId, \Illuminate\Http\Request $request)
|
public function getBatches(Warehouse $warehouse, $productId, Request $request)
|
||||||
{
|
{
|
||||||
$originCountry = $request->query('originCountry', 'TW');
|
$originCountry = $request->query('originCountry', 'TW');
|
||||||
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
|
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
|
||||||
|
|
||||||
$batches = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
|
$batches = Inventory::where('warehouse_id', $warehouse->id)
|
||||||
->where('product_id', $productId)
|
->where('product_id', $productId)
|
||||||
->get()
|
->get()
|
||||||
->map(function ($inventory) {
|
->map(function ($inventory) {
|
||||||
@@ -229,10 +234,10 @@ class InventoryController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 計算下一個流水號
|
// 計算下一個流水號
|
||||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
$product = Product::find($productId);
|
||||||
$nextSequence = '01';
|
$nextSequence = '01';
|
||||||
if ($product) {
|
if ($product) {
|
||||||
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
|
$batchNumber = Inventory::generateBatchNumber(
|
||||||
$product->code ?? 'UNK',
|
$product->code ?? 'UNK',
|
||||||
$originCountry,
|
$originCountry,
|
||||||
$arrivalDate
|
$arrivalDate
|
||||||
@@ -246,7 +251,7 @@ class InventoryController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
public function edit(Request $request, Warehouse $warehouse, $inventoryId)
|
||||||
{
|
{
|
||||||
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
|
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
|
||||||
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
|
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
|
||||||
@@ -254,7 +259,7 @@ class InventoryController extends Controller
|
|||||||
return redirect()->back()->with('error', '無法編輯範例資料');
|
return redirect()->back()->with('error', '無法編輯範例資料');
|
||||||
}
|
}
|
||||||
|
|
||||||
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
|
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||||
|
|
||||||
@@ -284,20 +289,20 @@ class InventoryController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/EditInventory', [
|
return Inertia::render('Warehouse/EditInventory', [
|
||||||
'warehouse' => $warehouse,
|
'warehouse' => $warehouse,
|
||||||
'inventory' => $inventoryData,
|
'inventory' => $inventoryData,
|
||||||
'transactions' => $transactions,
|
'transactions' => $transactions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
public function update(Request $request, Warehouse $warehouse, $inventoryId)
|
||||||
{
|
{
|
||||||
// 若是 product ID (舊邏輯),先轉為 inventory
|
// 若是 product ID (舊邏輯),先轉為 inventory
|
||||||
// 但新路由我們傳的是 inventory ID
|
// 但新路由我們傳的是 inventory ID
|
||||||
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
|
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
|
||||||
|
|
||||||
$inventory = \App\Modules\Inventory\Models\Inventory::find($inventoryId);
|
$inventory = Inventory::find($inventoryId);
|
||||||
|
|
||||||
// 如果找不到 (可能是舊路由傳 product ID)
|
// 如果找不到 (可能是舊路由傳 product ID)
|
||||||
if (!$inventory) {
|
if (!$inventory) {
|
||||||
@@ -322,7 +327,7 @@ class InventoryController extends Controller
|
|||||||
'lastOutboundDate' => 'nullable|date',
|
'lastOutboundDate' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
|
return DB::transaction(function () use ($validated, $inventory) {
|
||||||
$currentQty = (float) $inventory->quantity;
|
$currentQty = (float) $inventory->quantity;
|
||||||
$newQty = (float) $validated['quantity'];
|
$newQty = (float) $validated['quantity'];
|
||||||
|
|
||||||
@@ -395,9 +400,9 @@ class InventoryController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(\App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
public function destroy(Warehouse $warehouse, $inventoryId)
|
||||||
{
|
{
|
||||||
$inventory = \App\Modules\Inventory\Models\Inventory::findOrFail($inventoryId);
|
$inventory = Inventory::findOrFail($inventoryId);
|
||||||
|
|
||||||
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
||||||
if ($inventory->quantity > 0) {
|
if ($inventory->quantity > 0) {
|
||||||
@@ -430,7 +435,7 @@ class InventoryController extends Controller
|
|||||||
|
|
||||||
if ($productId) {
|
if ($productId) {
|
||||||
// 商品層級查詢
|
// 商品層級查詢
|
||||||
$inventories = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
|
$inventories = Inventory::where('warehouse_id', $warehouse->id)
|
||||||
->where('product_id', $productId)
|
->where('product_id', $productId)
|
||||||
->with(['product', 'transactions' => function($query) {
|
->with(['product', 'transactions' => function($query) {
|
||||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||||
@@ -491,7 +496,7 @@ class InventoryController extends Controller
|
|||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
return Inertia::render('Warehouse/InventoryHistory', [
|
||||||
'warehouse' => $warehouse,
|
'warehouse' => $warehouse,
|
||||||
'inventory' => [
|
'inventory' => [
|
||||||
'id' => 'product-' . $productId,
|
'id' => 'product-' . $productId,
|
||||||
@@ -505,7 +510,7 @@ class InventoryController extends Controller
|
|||||||
|
|
||||||
if ($inventoryId) {
|
if ($inventoryId) {
|
||||||
// 單一批號查詢
|
// 單一批號查詢
|
||||||
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
|
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||||
|
|
||||||
@@ -521,7 +526,7 @@ class InventoryController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
return Inertia::render('Warehouse/InventoryHistory', [
|
||||||
'warehouse' => $warehouse,
|
'warehouse' => $warehouse,
|
||||||
'inventory' => [
|
'inventory' => [
|
||||||
'id' => (string) $inventory->id,
|
'id' => (string) $inventory->id,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||||||
|
|
||||||
use App\Modules\Inventory\Models\Product;
|
use App\Modules\Inventory\Models\Product;
|
||||||
use App\Modules\Inventory\Models\Unit;
|
use App\Modules\Inventory\Models\Unit;
|
||||||
|
use App\Modules\Inventory\Models\Category;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -13,7 +14,7 @@ use Inertia\Response;
|
|||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* 顯示資源列表。
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
@@ -40,7 +41,7 @@ class ProductController extends Controller
|
|||||||
$sortField = $request->input('sort_field', 'id');
|
$sortField = $request->input('sort_field', 'id');
|
||||||
$sortDirection = $request->input('sort_direction', 'desc');
|
$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'];
|
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
|
||||||
if (!in_array($sortField, $allowedSorts)) {
|
if (!in_array($sortField, $allowedSorts)) {
|
||||||
$sortField = 'id';
|
$sortField = 'id';
|
||||||
@@ -49,11 +50,11 @@ class ProductController extends Controller
|
|||||||
$sortDirection = 'desc';
|
$sortDirection = 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle relation sorting (category name) separately if needed, or simple join
|
// 如果需要,分別處理關聯排序(分類名稱),或簡單的 join
|
||||||
if ($sortField === 'category_id') {
|
if ($sortField === 'category_id') {
|
||||||
// Join categories for sorting by name? Or just by ID?
|
// 加入分類以便按名稱排序?還是僅按 ID?
|
||||||
// Simple approach: sort by ID for now, or join if user wants name sort.
|
// 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。
|
||||||
// Let's assume standard field sorting first.
|
// 先假設標準欄位排序。
|
||||||
$query->orderBy('category_id', $sortDirection);
|
$query->orderBy('category_id', $sortDirection);
|
||||||
} else {
|
} else {
|
||||||
$query->orderBy($sortField, $sortDirection);
|
$query->orderBy($sortField, $sortDirection);
|
||||||
@@ -61,18 +62,49 @@ class ProductController extends Controller
|
|||||||
|
|
||||||
$products = $query->paginate($perPage)->withQueryString();
|
$products = $query->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
$categories = \App\Modules\Inventory\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', [
|
return Inertia::render('Product/Index', [
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
'categories' => $categories,
|
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||||
'units' => Unit::all(),
|
'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']),
|
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* 將新建立的資源儲存到儲存體中。
|
||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
@@ -107,7 +139,7 @@ class ProductController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* 更新儲存體中的指定資源。
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, Product $product)
|
public function update(Request $request, Product $product)
|
||||||
{
|
{
|
||||||
@@ -141,7 +173,7 @@ class ProductController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* 從儲存體中移除指定資源。
|
||||||
*/
|
*/
|
||||||
public function destroy(Product $product)
|
public function destroy(Product $product)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,25 +29,30 @@ class TransferOrderController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return DB::transaction(function () use ($validated) {
|
return DB::transaction(function () use ($validated) {
|
||||||
// 1. 檢查來源倉庫庫存
|
// 1. 檢查來源倉庫庫存 (精確匹配產品與批號)
|
||||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||||
->where('product_id', $validated['productId'])
|
->where('product_id', $validated['productId'])
|
||||||
|
->where('batch_number', $validated['batchNumber'])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'quantity' => ['來源倉庫庫存不足'],
|
'quantity' => ['來源倉庫指定批號庫存不足'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 獲取或建立目標倉庫庫存
|
// 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態)
|
||||||
$targetInventory = Inventory::firstOrCreate(
|
$targetInventory = Inventory::firstOrCreate(
|
||||||
[
|
[
|
||||||
'warehouse_id' => $validated['targetWarehouseId'],
|
'warehouse_id' => $validated['targetWarehouseId'],
|
||||||
'product_id' => $validated['productId'],
|
'product_id' => $validated['productId'],
|
||||||
|
'batch_number' => $validated['batchNumber'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'quantity' => 0,
|
'quantity' => 0,
|
||||||
|
'expiry_date' => $sourceInventory->expiry_date,
|
||||||
|
'quality_status' => $sourceInventory->quality_status,
|
||||||
|
'origin_country' => $sourceInventory->origin_country,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,11 +114,12 @@ class TransferOrderController extends Controller
|
|||||||
->get()
|
->get()
|
||||||
->map(function ($inv) {
|
->map(function ($inv) {
|
||||||
return [
|
return [
|
||||||
'productId' => (string) $inv->product_id,
|
'product_id' => (string) $inv->product_id,
|
||||||
'productName' => $inv->product->name,
|
'product_name' => $inv->product->name,
|
||||||
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
'batch_number' => $inv->batch_number,
|
||||||
'availableQty' => (float) $inv->quantity,
|
'quantity' => (float) $inv->quantity,
|
||||||
'unit' => $inv->product->baseUnit?->name ?? '個',
|
'unit_name' => $inv->product->baseUnit?->name ?? '個',
|
||||||
|
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Illuminate\Http\Request;
|
|||||||
class UnitController extends Controller
|
class UnitController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* 將新建立的資源儲存到儲存體中。
|
||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,7 @@ class UnitController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* 更新儲存體中的指定資源。
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, Unit $unit)
|
public function update(Request $request, Unit $unit)
|
||||||
{
|
{
|
||||||
@@ -51,11 +51,11 @@ class UnitController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* 從儲存體中移除指定資源。
|
||||||
*/
|
*/
|
||||||
public function destroy(Unit $unit)
|
public function destroy(Unit $unit)
|
||||||
{
|
{
|
||||||
// Check if unit is used in any product
|
// 檢查單位是否已被任何商品使用
|
||||||
$isUsed = Product::where('base_unit_id', $unit->id)
|
$isUsed = Product::where('base_unit_id', $unit->id)
|
||||||
->orWhere('large_unit_id', $unit->id)
|
->orWhere('large_unit_id', $unit->id)
|
||||||
->orWhere('purchase_unit_id', $unit->id)
|
->orWhere('purchase_unit_id', $unit->id)
|
||||||
|
|||||||
@@ -24,13 +24,45 @@ class WarehouseController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
|
$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')
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(10)
|
->paginate(10)
|
||||||
->withQueryString();
|
->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', [
|
return Inertia::render('Warehouse/Index', [
|
||||||
'warehouses' => $warehouses,
|
'warehouses' => $warehouses,
|
||||||
|
'totals' => $totals,
|
||||||
'filters' => $request->only(['search']),
|
'filters' => $request->only(['search']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -41,9 +73,10 @@ class WarehouseController extends Controller
|
|||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'address' => 'nullable|string|max:255',
|
'address' => 'nullable|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
|
'is_sellable' => 'nullable|boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-generate code
|
// 自動產生代碼
|
||||||
$prefix = 'WH';
|
$prefix = 'WH';
|
||||||
$lastWarehouse = Warehouse::latest('id')->first();
|
$lastWarehouse = Warehouse::latest('id')->first();
|
||||||
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
||||||
@@ -62,6 +95,7 @@ class WarehouseController extends Controller
|
|||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'address' => 'nullable|string|max:255',
|
'address' => 'nullable|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
|
'is_sellable' => 'nullable|boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$warehouse->update($validated);
|
$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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency
|
|
||||||
|
|
||||||
class Inventory extends Model
|
class Inventory extends Model
|
||||||
{
|
{
|
||||||
@@ -35,8 +35,8 @@ class Inventory extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transient property to store the reason for the activity log (e.g., "Replenishment #123").
|
* 用於活動記錄的暫時屬性(例如 "補貨 #123")。
|
||||||
* This is not stored in the database column but used for logging context.
|
* 此屬性不存儲在資料庫欄位中,但用於記錄上下文。
|
||||||
* @var string|null
|
* @var string|null
|
||||||
*/
|
*/
|
||||||
public $activityLogReason;
|
public $activityLogReason;
|
||||||
@@ -55,12 +55,12 @@ class Inventory extends Model
|
|||||||
$attributes = $properties['attributes'] ?? [];
|
$attributes = $properties['attributes'] ?? [];
|
||||||
$snapshot = $properties['snapshot'] ?? [];
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
|
||||||
// Always snapshot names for context, even if IDs didn't change
|
// 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改
|
||||||
// $this refers to the Inventory model instance
|
// $this 指的是 Inventory 模型實例
|
||||||
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null);
|
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null);
|
||||||
$snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null);
|
$snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null);
|
||||||
|
|
||||||
// Capture the reason if set
|
// 如果已設定原因,則進行捕捉
|
||||||
if ($this->activityLogReason) {
|
if ($this->activityLogReason) {
|
||||||
$attributes['_reason'] = $this->activityLogReason;
|
$attributes['_reason'] = $this->activityLogReason;
|
||||||
}
|
}
|
||||||
@@ -105,13 +105,7 @@ class Inventory extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 來源採購單
|
|
||||||
*/
|
|
||||||
public function sourcePurchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(PurchaseOrder::class, 'source_purchase_order_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 產生批號
|
* 產生批號
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Modules\Core\Models\User; // Cross-module Core dependency
|
use App\Modules\Core\Models\User; // 跨模組核心依賴
|
||||||
|
|
||||||
class InventoryTransaction extends Model
|
class InventoryTransaction extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use App\Modules\Procurement\Models\Vendor; // Cross-module dependency (Procurement)
|
|
||||||
|
|
||||||
class Product extends Model
|
class Product extends Model
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ class Product extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the category that owns the product.
|
* 取得該商品所屬的分類。
|
||||||
*/
|
*/
|
||||||
public function category(): BelongsTo
|
public function category(): BelongsTo
|
||||||
{
|
{
|
||||||
@@ -54,10 +54,7 @@ class Product extends Model
|
|||||||
return $this->belongsTo(Unit::class, 'purchase_unit_id');
|
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
|
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
{
|
{
|
||||||
@@ -83,13 +80,13 @@ class Product extends Model
|
|||||||
$attributes = $properties['attributes'] ?? [];
|
$attributes = $properties['attributes'] ?? [];
|
||||||
$snapshot = $properties['snapshot'] ?? [];
|
$snapshot = $properties['snapshot'] ?? [];
|
||||||
|
|
||||||
// Handle Category Name Snapshot
|
// 處理分類名稱快照
|
||||||
if (isset($attributes['category_id'])) {
|
if (isset($attributes['category_id'])) {
|
||||||
$category = Category::find($attributes['category_id']);
|
$category = Category::find($attributes['category_id']);
|
||||||
$snapshot['category_name'] = $category ? $category->name : null;
|
$snapshot['category_name'] = $category ? $category->name : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Unit Name Snapshots
|
// 處理單位名稱快照
|
||||||
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
|
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
|
||||||
foreach ($unitFields as $field) {
|
foreach ($unitFields as $field) {
|
||||||
if (isset($attributes[$field])) {
|
if (isset($attributes[$field])) {
|
||||||
@@ -99,7 +96,7 @@ class Product extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always snapshot self name for context (so logs always show "Cola")
|
// 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂")
|
||||||
$snapshot['name'] = $this->name;
|
$snapshot['name'] = $this->name;
|
||||||
|
|
||||||
$properties['attributes'] = $attributes;
|
$properties['attributes'] = $attributes;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency (Procurement)
|
|
||||||
|
|
||||||
class Warehouse extends Model
|
class Warehouse extends Model
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,11 @@ class Warehouse extends Model
|
|||||||
'name',
|
'name',
|
||||||
'address',
|
'address',
|
||||||
'description',
|
'description',
|
||||||
|
'is_sellable',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_sellable' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
@@ -43,10 +48,7 @@ class Warehouse extends Model
|
|||||||
return $this->hasMany(Inventory::class);
|
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
|
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
{
|
{
|
||||||
|
|||||||
168
app/Modules/Inventory/Services/InventoryService.php
Normal file
168
app/Modules/Inventory/Services/InventoryService.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?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 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;
|
||||||
|
|
||||||
|
$inventory->quantity += $data['quantity'];
|
||||||
|
// 更新其他可能變更的欄位 (如最後入庫日)
|
||||||
|
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
|
||||||
|
$inventory->save();
|
||||||
|
} else {
|
||||||
|
// 若不存在,則建立新紀錄
|
||||||
|
$inventory = Inventory::create([
|
||||||
|
'warehouse_id' => $data['warehouse_id'],
|
||||||
|
'product_id' => $data['product_id'],
|
||||||
|
'quantity' => $data['quantity'],
|
||||||
|
'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'],
|
||||||
|
'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);
|
||||||
|
$inventory->refresh();
|
||||||
|
|
||||||
|
\App\Modules\Inventory\Models\InventoryTransaction::create([
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'type' => '出庫',
|
||||||
|
'quantity' => -$quantity,
|
||||||
|
'balance_before' => $balanceBefore,
|
||||||
|
'balance_after' => $inventory->quantity,
|
||||||
|
'reason' => $reason ?? '庫存扣減',
|
||||||
|
'reference_type' => $referenceType,
|
||||||
|
'reference_id' => $referenceId,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'actual_time' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
@@ -6,18 +6,30 @@ use App\Http\Controllers\Controller;
|
|||||||
|
|
||||||
use App\Modules\Procurement\Models\PurchaseOrder;
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
use App\Modules\Procurement\Models\Vendor;
|
use App\Modules\Procurement\Models\Vendor;
|
||||||
use App\Modules\Inventory\Models\Warehouse;
|
// 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 Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class PurchaseOrderController extends Controller
|
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)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
|
// 1. 從關聯中移除 'warehouse' 與 'user'
|
||||||
|
$query = PurchaseOrder::with(['vendor']);
|
||||||
|
|
||||||
// Search
|
// 搜尋
|
||||||
if ($request->search) {
|
if ($request->search) {
|
||||||
$query->where(function($q) use ($request) {
|
$query->where(function($q) use ($request) {
|
||||||
$q->where('code', 'like', "%{$request->search}%")
|
$q->where('code', 'like', "%{$request->search}%")
|
||||||
@@ -27,7 +39,7 @@ class PurchaseOrderController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters
|
// 篩選
|
||||||
if ($request->status && $request->status !== 'all') {
|
if ($request->status && $request->status !== 'all') {
|
||||||
$query->where('status', $request->status);
|
$query->where('status', $request->status);
|
||||||
}
|
}
|
||||||
@@ -36,7 +48,7 @@ class PurchaseOrderController extends Controller
|
|||||||
$query->where('warehouse_id', $request->warehouse_id);
|
$query->where('warehouse_id', $request->warehouse_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date Range
|
// 日期範圍
|
||||||
if ($request->date_start) {
|
if ($request->date_start) {
|
||||||
$query->whereDate('created_at', '>=', $request->date_start);
|
$query->whereDate('created_at', '>=', $request->date_start);
|
||||||
}
|
}
|
||||||
@@ -45,7 +57,7 @@ class PurchaseOrderController extends Controller
|
|||||||
$query->whereDate('created_at', '<=', $request->date_end);
|
$query->whereDate('created_at', '<=', $request->date_end);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting
|
// 排序
|
||||||
$sortField = $request->sort_field ?? 'id';
|
$sortField = $request->sort_field ?? 'id';
|
||||||
$sortDirection = $request->sort_direction ?? 'desc';
|
$sortDirection = $request->sort_direction ?? 'desc';
|
||||||
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
|
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
|
||||||
@@ -57,20 +69,68 @@ class PurchaseOrderController extends Controller
|
|||||||
$perPage = $request->input('per_page', 10);
|
$perPage = $request->input('per_page', 10);
|
||||||
$orders = $query->paginate($perPage)->withQueryString();
|
$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',
|
||||||
|
'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', [
|
return Inertia::render('PurchaseOrder/Index', [
|
||||||
'orders' => $orders,
|
'orders' => $orders,
|
||||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
|
'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()
|
public function create()
|
||||||
{
|
{
|
||||||
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
|
// 1. 獲取廠商(無關聯)
|
||||||
return [
|
$vendors = Vendor::all();
|
||||||
'id' => (string) $vendor->id,
|
|
||||||
'name' => $vendor->name,
|
// 2. 手動注入:獲取 Pivot 資料
|
||||||
'commonProducts' => $vendor->products->map(function ($product) {
|
$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 [
|
return [
|
||||||
'productId' => (string) $product->id,
|
'productId' => (string) $product->id,
|
||||||
'productName' => $product->name,
|
'productName' => $product->name,
|
||||||
@@ -80,13 +140,18 @@ class PurchaseOrderController extends Controller
|
|||||||
'large_unit_name' => $product->largeUnit?->name,
|
'large_unit_name' => $product->largeUnit?->name,
|
||||||
'purchase_unit_id' => $product->purchase_unit_id,
|
'purchase_unit_id' => $product->purchase_unit_id,
|
||||||
'conversion_rate' => (float) $product->conversion_rate,
|
'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 [
|
return [
|
||||||
'id' => (string) $w->id,
|
'id' => (string) $w->id,
|
||||||
'name' => $w->name,
|
'name' => $w->name,
|
||||||
@@ -141,7 +206,7 @@ class PurchaseOrderController extends Controller
|
|||||||
$totalAmount += $item['subtotal'];
|
$totalAmount += $item['subtotal'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tax calculation
|
// 稅額計算
|
||||||
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
|
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
|
||||||
$grandTotal = $totalAmount + $taxAmount;
|
$grandTotal = $totalAmount + $taxAmount;
|
||||||
|
|
||||||
@@ -200,58 +265,78 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
public function show($id)
|
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;
|
$order->setRelation('warehouse', $this->inventoryService->getWarehouse($order->warehouse_id));
|
||||||
if ($product) {
|
$order->setRelation('user', $this->coreService->getUser($order->user_id));
|
||||||
// 手動附加所有必要的屬性
|
|
||||||
$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;
|
|
||||||
|
|
||||||
$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
|
$formattedItems = $order->items->map(function ($item) use ($order, $products) {
|
||||||
$lastPrice = DB::table('product_vendor')
|
$product = $products[$item->product_id] ?? null;
|
||||||
->where('vendor_id', $order->vendor_id)
|
return (object) [
|
||||||
->where('product_id', $product->id)
|
'productId' => (string) $item->product_id,
|
||||||
->value('last_price');
|
'productName' => $product?->name ?? 'Unknown',
|
||||||
$item->previousPrice = (float) ($lastPrice ?? 0);
|
'quantity' => (float) $item->quantity,
|
||||||
|
'unitId' => $item->unit_id,
|
||||||
// 設定當前選中的單位 ID (from saved item)
|
'base_unit_id' => $product?->base_unit_id,
|
||||||
$item->unitId = $item->unit_id;
|
'base_unit_name' => $product?->baseUnit?->name,
|
||||||
|
'large_unit_id' => $product?->large_unit_id,
|
||||||
// 決定 selectedUnit (用於 UI 顯示)
|
'large_unit_name' => $product?->largeUnit?->name,
|
||||||
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
|
'purchase_unit_id' => $product?->purchase_unit_id,
|
||||||
$item->selectedUnit = 'large';
|
'conversion_rate' => (float) ($product?->conversion_rate ?? 1),
|
||||||
} else {
|
'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base',
|
||||||
$item->selectedUnit = '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,
|
||||||
$item->unitPrice = (float) $item->unit_price;
|
];
|
||||||
}
|
|
||||||
return $item;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$formattedOrder = (object) [
|
||||||
|
'id' => (string) $order->id,
|
||||||
|
'poNumber' => $order->code,
|
||||||
|
'supplierId' => (string) $order->vendor_id,
|
||||||
|
'supplierName' => $order->vendor?->name ?? 'Unknown',
|
||||||
|
'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', [
|
return Inertia::render('PurchaseOrder/Show', [
|
||||||
'order' => $order
|
'order' => $formattedOrder
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit($id)
|
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) {
|
// 2. 獲取廠商與商品(與 create 邏輯一致)
|
||||||
return [
|
$vendors = Vendor::all();
|
||||||
'id' => (string) $vendor->id,
|
$vendorIds = $vendors->pluck('id')->toArray();
|
||||||
'name' => $vendor->name,
|
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
||||||
'commonProducts' => $vendor->products->map(function ($product) {
|
$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 [
|
return [
|
||||||
'productId' => (string) $product->id,
|
'productId' => (string) $product->id,
|
||||||
'productName' => $product->name,
|
'productName' => $product->name,
|
||||||
@@ -261,59 +346,67 @@ class PurchaseOrderController extends Controller
|
|||||||
'large_unit_name' => $product->largeUnit?->name,
|
'large_unit_name' => $product->largeUnit?->name,
|
||||||
'purchase_unit_id' => $product->purchase_unit_id,
|
'purchase_unit_id' => $product->purchase_unit_id,
|
||||||
'conversion_rate' => (float) $product->conversion_rate,
|
'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 [
|
return [
|
||||||
'id' => (string) $w->id,
|
'id' => (string) $w->id,
|
||||||
'name' => $w->name,
|
'name' => $w->name,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transform items for frontend form
|
// 4. 注入訂單項目特定資料
|
||||||
// Transform items for frontend form
|
// 2. 注入訂單項目
|
||||||
|
$itemProductIds = $order->items->pluck('product_id')->toArray();
|
||||||
|
$itemProducts = $this->inventoryService->getProductsByIds($itemProductIds)->keyBy('id');
|
||||||
|
|
||||||
$vendorId = $order->vendor_id;
|
$vendorId = $order->vendor_id;
|
||||||
$order->items->transform(function ($item) use ($vendorId) {
|
$formattedItems = $order->items->map(function ($item) use ($vendorId, $itemProducts) {
|
||||||
$product = $item->product;
|
$product = $itemProducts[$item->product_id] ?? null;
|
||||||
if ($product) {
|
return (object) [
|
||||||
// 手動附加所有必要的屬性
|
'productId' => (string) $item->product_id,
|
||||||
$item->productId = (string) $product->id;
|
'productName' => $product?->name ?? 'Unknown',
|
||||||
$item->productName = $product->name;
|
'quantity' => (float) $item->quantity,
|
||||||
$item->base_unit_id = $product->base_unit_id;
|
'unitId' => $item->unit_id,
|
||||||
$item->base_unit_name = $product->baseUnit?->name;
|
'base_unit_id' => $product?->base_unit_id,
|
||||||
$item->large_unit_id = $product->large_unit_id;
|
'base_unit_name' => $product?->baseUnit?->name,
|
||||||
$item->large_unit_name = $product->largeUnit?->name;
|
'large_unit_id' => $product?->large_unit_id,
|
||||||
|
'large_unit_name' => $product?->largeUnit?->name,
|
||||||
$item->conversion_rate = (float) $product->conversion_rate;
|
'conversion_rate' => (float) ($product?->conversion_rate ?? 1),
|
||||||
|
'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base',
|
||||||
// Fetch last price
|
'unitPrice' => (float) $item->unit_price,
|
||||||
$lastPrice = DB::table('product_vendor')
|
'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $vendorId)->where('product_id', $item->product_id)->value('last_price') ?? 0),
|
||||||
->where('vendor_id', $vendorId)
|
'subtotal' => (float) $item->subtotal,
|
||||||
->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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$formattedOrder = (object) [
|
||||||
|
'id' => (string) $order->id,
|
||||||
|
'poNumber' => $order->code,
|
||||||
|
'supplierId' => (string) $order->vendor_id,
|
||||||
|
'warehouse_id' => (int) $order->warehouse_id,
|
||||||
|
'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', [
|
return Inertia::render('PurchaseOrder/Create', [
|
||||||
'order' => $order,
|
'order' => $formattedOrder,
|
||||||
'suppliers' => $vendors,
|
'suppliers' => $vendors,
|
||||||
'warehouses' => $warehouses,
|
'warehouses' => $warehouses,
|
||||||
]);
|
]);
|
||||||
@@ -337,7 +430,7 @@ class PurchaseOrderController extends Controller
|
|||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
|
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
|
||||||
'items.*.unitId' => 'nullable|exists:units,id',
|
'items.*.unitId' => 'nullable|exists:units,id',
|
||||||
// Allow both tax_amount and taxAmount for compatibility
|
// 允許 tax_amount 和 taxAmount 以保持相容性
|
||||||
'tax_amount' => 'nullable|numeric|min:0',
|
'tax_amount' => 'nullable|numeric|min:0',
|
||||||
'taxAmount' => 'nullable|numeric|min:0',
|
'taxAmount' => 'nullable|numeric|min:0',
|
||||||
]);
|
]);
|
||||||
@@ -350,12 +443,12 @@ class PurchaseOrderController extends Controller
|
|||||||
$totalAmount += $item['subtotal'];
|
$totalAmount += $item['subtotal'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tax calculation (handle both keys)
|
// 稅額計算(處理兩個鍵)
|
||||||
$inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null;
|
$inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null;
|
||||||
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
||||||
$grandTotal = $totalAmount + $taxAmount;
|
$grandTotal = $totalAmount + $taxAmount;
|
||||||
|
|
||||||
// 1. Fill attributes but don't save yet to capture changes
|
// 1. 填充屬性但暫不儲存以捕捉變更
|
||||||
$order->fill([
|
$order->fill([
|
||||||
'vendor_id' => $validated['vendor_id'],
|
'vendor_id' => $validated['vendor_id'],
|
||||||
'warehouse_id' => $validated['warehouse_id'],
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
@@ -370,7 +463,7 @@ class PurchaseOrderController extends Controller
|
|||||||
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Capture attribute changes for manual logging
|
// 捕捉變更屬性以進行手動記錄
|
||||||
$dirty = $order->getDirty();
|
$dirty = $order->getDirty();
|
||||||
$oldAttributes = [];
|
$oldAttributes = [];
|
||||||
$newAttributes = [];
|
$newAttributes = [];
|
||||||
@@ -380,10 +473,10 @@ class PurchaseOrderController extends Controller
|
|||||||
$newAttributes[$key] = $value;
|
$newAttributes[$key] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save without triggering events (prevents duplicate log)
|
// 儲存但不觸發事件(防止重複記錄)
|
||||||
$order->saveQuietly();
|
$order->saveQuietly();
|
||||||
|
|
||||||
// 2. Capture old items with product names for diffing
|
// 2. 捕捉包含商品名稱的舊項目以進行比對
|
||||||
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||||
return [
|
return [
|
||||||
'id' => $item->id,
|
'id' => $item->id,
|
||||||
@@ -396,7 +489,7 @@ class PurchaseOrderController extends Controller
|
|||||||
];
|
];
|
||||||
})->keyBy('product_id');
|
})->keyBy('product_id');
|
||||||
|
|
||||||
// Sync items (Original logic)
|
// 同步項目(原始邏輯)
|
||||||
$order->items()->delete();
|
$order->items()->delete();
|
||||||
|
|
||||||
$newItemsData = [];
|
$newItemsData = [];
|
||||||
@@ -414,14 +507,14 @@ class PurchaseOrderController extends Controller
|
|||||||
$newItemsData[] = $newItem;
|
$newItemsData[] = $newItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Calculate Item Diffs
|
// 3. 計算項目差異
|
||||||
$itemDiffs = [
|
$itemDiffs = [
|
||||||
'added' => [],
|
'added' => [],
|
||||||
'removed' => [],
|
'removed' => [],
|
||||||
'updated' => [],
|
'updated' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Re-fetch new items to ensure we have fresh relations
|
// 重新獲取新項目以確保擁有最新的關聯
|
||||||
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||||
return [
|
return [
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
@@ -433,20 +526,20 @@ class PurchaseOrderController extends Controller
|
|||||||
];
|
];
|
||||||
})->keyBy('product_id');
|
})->keyBy('product_id');
|
||||||
|
|
||||||
// Find removed
|
// 找出已移除的項目
|
||||||
foreach ($oldItems as $productId => $oldItem) {
|
foreach ($oldItems as $productId => $oldItem) {
|
||||||
if (!$newItemsFormatted->has($productId)) {
|
if (!$newItemsFormatted->has($productId)) {
|
||||||
$itemDiffs['removed'][] = $oldItem;
|
$itemDiffs['removed'][] = $oldItem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find added and updated
|
// 找出新增和更新的項目
|
||||||
foreach ($newItemsFormatted as $productId => $newItem) {
|
foreach ($newItemsFormatted as $productId => $newItem) {
|
||||||
if (!$oldItems->has($productId)) {
|
if (!$oldItems->has($productId)) {
|
||||||
$itemDiffs['added'][] = $newItem;
|
$itemDiffs['added'][] = $newItem;
|
||||||
} else {
|
} else {
|
||||||
$oldItem = $oldItems[$productId];
|
$oldItem = $oldItems[$productId];
|
||||||
// Compare fields
|
// 比對欄位
|
||||||
if (
|
if (
|
||||||
$oldItem['quantity'] != $newItem['quantity'] ||
|
$oldItem['quantity'] != $newItem['quantity'] ||
|
||||||
$oldItem['unit_id'] != $newItem['unit_id'] ||
|
$oldItem['unit_id'] != $newItem['unit_id'] ||
|
||||||
@@ -469,8 +562,8 @@ class PurchaseOrderController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Manually Log activity (Single Consolidated Log)
|
// 4. 手動記錄活動(單一整合記錄)
|
||||||
// Log if there are attribute changes OR item changes
|
// 如果有屬性變更或項目變更則記錄
|
||||||
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
|
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
|
||||||
activity()
|
activity()
|
||||||
->performedOn($order)
|
->performedOn($order)
|
||||||
@@ -505,19 +598,24 @@ class PurchaseOrderController extends Controller
|
|||||||
try {
|
try {
|
||||||
DB::beginTransaction();
|
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 [
|
return [
|
||||||
'product_name' => $item->product_name,
|
'product_name' => $product?->name ?? 'Unknown',
|
||||||
'quantity' => floatval($item->quantity),
|
'quantity' => floatval($item->quantity),
|
||||||
'unit_name' => $item->unit_name,
|
'unit_name' => 'N/A',
|
||||||
'subtotal' => floatval($item->subtotal),
|
'subtotal' => floatval($item->subtotal),
|
||||||
];
|
];
|
||||||
})->toArray();
|
})->toArray();
|
||||||
|
|
||||||
// Manually log the deletion with items
|
// 手動記錄包含項目的刪除操作
|
||||||
activity()
|
activity()
|
||||||
->performedOn($order)
|
->performedOn($order)
|
||||||
->causedBy(auth()->user())
|
->causedBy(auth()->user())
|
||||||
@@ -538,10 +636,10 @@ class PurchaseOrderController extends Controller
|
|||||||
])
|
])
|
||||||
->log('deleted');
|
->log('deleted');
|
||||||
|
|
||||||
// Disable automatic logging for this operation
|
// 對此操作停用自動記錄
|
||||||
$order->disableLogging();
|
$order->disableLogging();
|
||||||
|
|
||||||
// Delete associated items first
|
// 先刪除關聯項目
|
||||||
$order->items()->delete();
|
$order->items()->delete();
|
||||||
$order->delete();
|
$order->delete();
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ namespace App\Modules\Procurement\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Procurement\Models\Vendor;
|
use App\Modules\Procurement\Models\Vendor;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
class VendorController extends Controller
|
class VendorController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected InventoryServiceInterface $inventoryService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* 顯示資源列表。
|
||||||
*/
|
*/
|
||||||
public function index(\Illuminate\Http\Request $request): \Inertia\Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$query = Vendor::query();
|
$query = Vendor::query();
|
||||||
|
|
||||||
@@ -44,28 +50,71 @@ class VendorController extends Controller
|
|||||||
->paginate($perPage)
|
->paginate($perPage)
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
|
|
||||||
return \Inertia\Inertia::render('Vendor/Index', [
|
$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,
|
'vendors' => $vendors,
|
||||||
'filters' => $request->only(['search', 'sort_field', 'sort_direction', 'per_page']),
|
'filters' => $request->only(['search', 'sort_field', 'sort_direction', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* 顯示指定資源。
|
||||||
*/
|
*/
|
||||||
public function show(Vendor $vendor): \Inertia\Response
|
public function show(Vendor $vendor): Response
|
||||||
{
|
{
|
||||||
$vendor->load(['products.baseUnit', 'products.largeUnit']);
|
$vendor->load(['products.baseUnit', 'products.largeUnit']);
|
||||||
return \Inertia\Inertia::render('Vendor/Show', [
|
|
||||||
'vendor' => $vendor,
|
$formattedVendor = (object) [
|
||||||
'products' => \App\Modules\Inventory\Models\Product::with('baseUnit')->get(),
|
'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' => $vendor->products->map(fn($p) => (object) [
|
||||||
|
'id' => (string) $p->pivot->id,
|
||||||
|
'productId' => (string) $p->id,
|
||||||
|
'productName' => $p->name,
|
||||||
|
'unit' => $p->baseUnit?->name ?? 'N/A',
|
||||||
|
'baseUnit' => $p->baseUnit?->name,
|
||||||
|
'largeUnit' => $p->largeUnit?->name,
|
||||||
|
'conversionRate' => (float) $p->conversion_rate,
|
||||||
|
'lastPrice' => (float) $p->pivot->last_price,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Vendor/Show', [
|
||||||
|
'vendor' => $formattedVendor,
|
||||||
|
'products' => $this->inventoryService->getAllProducts(), // 使用已有的服務獲取所有商品供選取
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* 將新建立的資源儲存到儲存體中。
|
||||||
*/
|
*/
|
||||||
public function store(\Illuminate\Http\Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
@@ -80,7 +129,7 @@ class VendorController extends Controller
|
|||||||
'remark' => 'nullable|string',
|
'remark' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-generate code
|
// 自動產生代碼
|
||||||
$prefix = 'V';
|
$prefix = 'V';
|
||||||
$lastVendor = Vendor::latest('id')->first();
|
$lastVendor = Vendor::latest('id')->first();
|
||||||
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
|
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
|
||||||
@@ -94,9 +143,9 @@ class VendorController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* 更新儲存體中的指定資源。
|
||||||
*/
|
*/
|
||||||
public function update(\Illuminate\Http\Request $request, Vendor $vendor)
|
public function update(Request $request, Vendor $vendor)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
@@ -117,7 +166,7 @@ class VendorController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* 從儲存體中移除指定資源。
|
||||||
*/
|
*/
|
||||||
public function destroy(Vendor $vendor)
|
public function destroy(Vendor $vendor)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ namespace App\Modules\Procurement\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Modules\Procurement\Models\Vendor;
|
use App\Modules\Procurement\Models\Vendor;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class VendorProductController extends Controller
|
class VendorProductController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected InventoryServiceInterface $inventoryService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新增供貨商品 (Attach)
|
* 新增供貨商品 (Attach)
|
||||||
*/
|
*/
|
||||||
@@ -30,7 +34,7 @@ class VendorProductController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// 記錄操作
|
// 記錄操作
|
||||||
$product = \App\Modules\Inventory\Models\Product::find($validated['product_id']);
|
$product = $this->inventoryService->getProduct($validated['product_id']);
|
||||||
activity()
|
activity()
|
||||||
->performedOn($vendor)
|
->performedOn($vendor)
|
||||||
->withProperties([
|
->withProperties([
|
||||||
@@ -68,7 +72,7 @@ class VendorProductController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// 記錄操作
|
// 記錄操作
|
||||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
$product = $this->inventoryService->getProduct($productId);
|
||||||
activity()
|
activity()
|
||||||
->performedOn($vendor)
|
->performedOn($vendor)
|
||||||
->withProperties([
|
->withProperties([
|
||||||
@@ -97,7 +101,7 @@ class VendorProductController extends Controller
|
|||||||
public function destroy(Vendor $vendor, $productId)
|
public function destroy(Vendor $vendor, $productId)
|
||||||
{
|
{
|
||||||
// 記錄操作 (需在 detach 前獲取資訊)
|
// 記錄操作 (需在 detach 前獲取資訊)
|
||||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
$product = $this->inventoryService->getProduct($productId);
|
||||||
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
|
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
|
||||||
|
|
||||||
$vendor->products()->detach($productId);
|
$vendor->products()->detach($productId);
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ namespace App\Modules\Procurement\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Modules\Inventory\Models\Warehouse;
|
|
||||||
use App\Modules\Core\Models\User;
|
|
||||||
|
|
||||||
class PurchaseOrder extends Model
|
class PurchaseOrder extends Model
|
||||||
{
|
{
|
||||||
@@ -14,19 +13,19 @@ class PurchaseOrder extends Model
|
|||||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'po_number',
|
'code',
|
||||||
'vendor_id',
|
'vendor_id',
|
||||||
'warehouse_id',
|
'warehouse_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'order_date',
|
|
||||||
'expected_delivery_date',
|
'expected_delivery_date',
|
||||||
'status',
|
'status',
|
||||||
'total_amount',
|
'total_amount',
|
||||||
'notes',
|
'tax_amount',
|
||||||
|
'grand_total',
|
||||||
|
'remark',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'order_date' => 'date',
|
|
||||||
'expected_delivery_date' => 'date',
|
'expected_delivery_date' => 'date',
|
||||||
'total_amount' => 'decimal:2',
|
'total_amount' => 'decimal:2',
|
||||||
];
|
];
|
||||||
@@ -43,14 +42,13 @@ class PurchaseOrder extends Model
|
|||||||
{
|
{
|
||||||
$snapshot = $activity->properties['snapshot'] ?? [];
|
$snapshot = $activity->properties['snapshot'] ?? [];
|
||||||
|
|
||||||
$snapshot['po_number'] = $this->po_number;
|
$snapshot['po_number'] = $this->code;
|
||||||
|
|
||||||
if ($this->vendor) {
|
if ($this->vendor) {
|
||||||
$snapshot['vendor_name'] = $this->vendor->name;
|
$snapshot['vendor_name'] = $this->vendor->name;
|
||||||
}
|
}
|
||||||
if ($this->warehouse) {
|
// Warehouse relation removed in Strict Mode. Snapshot should be set via manual hydration if needed,
|
||||||
$snapshot['warehouse_name'] = $this->warehouse->name;
|
// or during the procurement process where warehouse_id is known.
|
||||||
}
|
|
||||||
|
|
||||||
$activity->properties = $activity->properties->merge([
|
$activity->properties = $activity->properties->merge([
|
||||||
'snapshot' => $snapshot
|
'snapshot' => $snapshot
|
||||||
@@ -62,15 +60,9 @@ class PurchaseOrder extends Model
|
|||||||
return $this->belongsTo(Vendor::class);
|
return $this->belongsTo(Vendor::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Warehouse::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
|
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,8 +37,5 @@ class PurchaseOrderItem extends Model
|
|||||||
return $this->belongsTo(PurchaseOrder::class);
|
return $this->belongsTo(PurchaseOrder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Product::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ class Vendor extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
'name',
|
'name',
|
||||||
'contact_person',
|
'short_name',
|
||||||
'email',
|
|
||||||
'phone',
|
|
||||||
'address',
|
|
||||||
'tax_id',
|
'tax_id',
|
||||||
'payment_terms',
|
'owner',
|
||||||
|
'contact_name',
|
||||||
|
'tel',
|
||||||
|
'phone',
|
||||||
|
'email',
|
||||||
|
'address',
|
||||||
|
'remark',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Product::class)->withPivot('last_price')->withTimestamps();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
{
|
{
|
||||||
|
|||||||
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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Modules/Procurement/Services/ProcurementService.php
Normal file
23
app/Modules/Procurement/Services/ProcurementService.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,10 @@ namespace App\Modules\Production\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
use App\Modules\Inventory\Models\Product;
|
|
||||||
use App\Modules\Production\Models\ProductionOrder;
|
use App\Modules\Production\Models\ProductionOrder;
|
||||||
use App\Modules\Production\Models\ProductionOrderItem;
|
use App\Modules\Production\Models\ProductionOrderItem;
|
||||||
use App\Modules\Inventory\Models\Unit;
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
use App\Modules\Inventory\Models\Warehouse;
|
use App\Modules\Core\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -16,20 +15,31 @@ use Inertia\Response;
|
|||||||
|
|
||||||
class ProductionOrderController extends Controller
|
class ProductionOrderController extends Controller
|
||||||
{
|
{
|
||||||
|
protected $inventoryService;
|
||||||
|
|
||||||
|
public function __construct(InventoryServiceInterface $inventoryService)
|
||||||
|
{
|
||||||
|
$this->inventoryService = $inventoryService;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生產工單列表
|
* 生產工單列表
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$query = ProductionOrder::with(['product', 'warehouse', 'user']);
|
// 不再使用 with(),避免跨模組 Eager Loading
|
||||||
|
$query = ProductionOrder::query();
|
||||||
|
|
||||||
// 搜尋
|
// 搜尋 (此處 orWhereHas 暫時保留,因 Laravel query builder 仍可作用於資料表層級,
|
||||||
|
// 但實務上若模組完全隔離,應考慮搜尋引擎或 ID 預選)
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('code', 'like', "%{$search}%")
|
$q->where('code', 'like', "%{$search}%")
|
||||||
->orWhere('output_batch_number', 'like', "%{$search}%")
|
->orWhere('output_batch_number', 'like', "%{$search}%");
|
||||||
->orWhereHas('product', fn($pq) => $pq->where('name', 'like', "%{$search}%"));
|
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
|
||||||
|
$productIds = \App\Modules\Inventory\Models\Product::where('name', 'like', "%{$search}%")->pluck('id');
|
||||||
|
$q->orWhereIn('product_id', $productIds);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,19 +48,29 @@ class ProductionOrderController extends Controller
|
|||||||
$query->where('status', $request->status);
|
$query->where('status', $request->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序
|
// 排除軟刪除
|
||||||
$sortField = $request->input('sort_field', 'created_at');
|
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
|
||||||
$sortDirection = $request->input('sort_direction', 'desc');
|
|
||||||
$allowedSorts = ['id', 'code', 'production_date', 'output_quantity', 'created_at'];
|
|
||||||
if (!in_array($sortField, $allowedSorts)) {
|
|
||||||
$sortField = 'created_at';
|
|
||||||
}
|
|
||||||
$query->orderBy($sortField, $sortDirection);
|
|
||||||
|
|
||||||
// 分頁
|
// 分頁
|
||||||
$perPage = $request->input('per_page', 10);
|
$perPage = $request->input('per_page', 10);
|
||||||
$productionOrders = $query->paginate($perPage)->withQueryString();
|
$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 = User::whereIn('id', $userIds)->get()->keyBy('id'); // Core 模組暫由 Model 直接獲取
|
||||||
|
|
||||||
|
$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', [
|
return Inertia::render('Production/Index', [
|
||||||
'productionOrders' => $productionOrders,
|
'productionOrders' => $productionOrders,
|
||||||
'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']),
|
'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
@@ -63,9 +83,9 @@ class ProductionOrderController extends Controller
|
|||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Production/Create', [
|
return Inertia::render('Production/Create', [
|
||||||
'products' => Product::with(['baseUnit'])->get(),
|
'products' => $this->inventoryService->getAllProducts(),
|
||||||
'warehouses' => Warehouse::all(),
|
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||||
'units' => Unit::all(),
|
'units' => $this->inventoryService->getUnits(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,56 +94,26 @@ class ProductionOrderController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$status = $request->input('status', 'draft'); // 預設為草稿
|
$status = $request->input('status', 'draft');
|
||||||
|
|
||||||
// 共用驗證規則
|
|
||||||
$baseRules = [
|
$baseRules = [
|
||||||
'product_id' => 'required|exists:products,id',
|
'product_id' => 'required',
|
||||||
'output_batch_number' => 'required|string|max:50',
|
'output_batch_number' => 'required|string|max:50',
|
||||||
'status' => 'nullable|in:draft,completed',
|
'status' => 'nullable|in:draft,completed',
|
||||||
];
|
];
|
||||||
|
|
||||||
// 完成模式需要完整驗證
|
|
||||||
$completedRules = [
|
$completedRules = [
|
||||||
'warehouse_id' => 'required|exists:warehouses,id',
|
'warehouse_id' => 'required',
|
||||||
'output_quantity' => 'required|numeric|min:0.01',
|
'output_quantity' => 'required|numeric|min:0.01',
|
||||||
'output_box_count' => 'nullable|string|max:10',
|
|
||||||
'production_date' => 'required|date',
|
'production_date' => 'required|date',
|
||||||
'expiry_date' => 'nullable|date|after_or_equal:production_date',
|
|
||||||
'remark' => 'nullable|string',
|
|
||||||
'items' => 'required|array|min:1',
|
'items' => 'required|array|min:1',
|
||||||
'items.*.inventory_id' => 'required|exists:inventories,id',
|
'items.*.inventory_id' => 'required',
|
||||||
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
||||||
'items.*.unit_id' => 'nullable|exists:units,id',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 草稿模式的寬鬆規則
|
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
|
||||||
$draftRules = [
|
|
||||||
'warehouse_id' => 'nullable|exists:warehouses,id',
|
|
||||||
'output_quantity' => 'nullable|numeric|min:0',
|
|
||||||
'output_box_count' => 'nullable|string|max:10',
|
|
||||||
'production_date' => 'nullable|date',
|
|
||||||
'expiry_date' => 'nullable|date',
|
|
||||||
'remark' => 'nullable|string',
|
|
||||||
'items' => 'nullable|array',
|
|
||||||
'items.*.inventory_id' => 'nullable|exists:inventories,id',
|
|
||||||
'items.*.quantity_used' => 'nullable|numeric|min:0',
|
|
||||||
'items.*.unit_id' => 'nullable|exists:units,id',
|
|
||||||
];
|
|
||||||
|
|
||||||
$rules = $status === 'completed'
|
$validated = $request->validate($rules);
|
||||||
? array_merge($baseRules, $completedRules)
|
|
||||||
: array_merge($baseRules, $draftRules);
|
|
||||||
|
|
||||||
$validated = $request->validate($rules, [
|
|
||||||
'product_id.required' => '請選擇成品商品',
|
|
||||||
'output_batch_number.required' => '請輸入成品批號',
|
|
||||||
'warehouse_id.required' => '請選擇入庫倉庫',
|
|
||||||
'output_quantity.required' => '請輸入生產數量',
|
|
||||||
'production_date.required' => '請選擇生產日期',
|
|
||||||
'items.required' => '請至少新增一項原物料',
|
|
||||||
'items.min' => '請至少新增一項原物料',
|
|
||||||
]);
|
|
||||||
|
|
||||||
DB::transaction(function () use ($validated, $request, $status) {
|
DB::transaction(function () use ($validated, $request, $status) {
|
||||||
// 1. 建立生產工單
|
// 1. 建立生產工單
|
||||||
@@ -133,20 +123,22 @@ class ProductionOrderController extends Controller
|
|||||||
'warehouse_id' => $validated['warehouse_id'] ?? null,
|
'warehouse_id' => $validated['warehouse_id'] ?? null,
|
||||||
'output_quantity' => $validated['output_quantity'] ?? 0,
|
'output_quantity' => $validated['output_quantity'] ?? 0,
|
||||||
'output_batch_number' => $validated['output_batch_number'],
|
'output_batch_number' => $validated['output_batch_number'],
|
||||||
'output_box_count' => $validated['output_box_count'] ?? null,
|
'output_box_count' => $request->output_box_count,
|
||||||
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
||||||
'expiry_date' => $validated['expiry_date'] ?? null,
|
'expiry_date' => $request->expiry_date,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'remark' => $validated['remark'] ?? null,
|
'remark' => $request->remark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 建立明細 (草稿與完成模式皆需儲存)
|
activity()
|
||||||
if (!empty($validated['items'])) {
|
->performedOn($productionOrder)
|
||||||
foreach ($validated['items'] as $item) {
|
->causedBy(auth()->user())
|
||||||
if (empty($item['inventory_id'])) continue;
|
->log('created');
|
||||||
|
|
||||||
// 建立明細
|
// 2. 處理明細
|
||||||
|
if (!empty($request->items)) {
|
||||||
|
foreach ($request->items as $item) {
|
||||||
ProductionOrderItem::create([
|
ProductionOrderItem::create([
|
||||||
'production_order_id' => $productionOrder->id,
|
'production_order_id' => $productionOrder->id,
|
||||||
'inventory_id' => $item['inventory_id'],
|
'inventory_id' => $item['inventory_id'],
|
||||||
@@ -154,52 +146,71 @@ class ProductionOrderController extends Controller
|
|||||||
'unit_id' => $item['unit_id'] ?? null,
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 若為完成模式,則扣減原物料庫存
|
|
||||||
if ($status === 'completed') {
|
if ($status === 'completed') {
|
||||||
$inventory = Inventory::findOrFail($item['inventory_id']);
|
$this->inventoryService->decreaseInventoryQuantity(
|
||||||
$inventory->decrement('quantity', $item['quantity_used']);
|
$item['inventory_id'],
|
||||||
|
$item['quantity_used'],
|
||||||
|
"生產單 #{$productionOrder->code} 耗料",
|
||||||
|
ProductionOrder::class,
|
||||||
|
$productionOrder->id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 若為完成模式,執行成品入庫
|
// 3. 成品入庫
|
||||||
if ($status === 'completed') {
|
if ($status === 'completed') {
|
||||||
$product = Product::findOrFail($validated['product_id']);
|
$this->inventoryService->createInventoryRecord([
|
||||||
Inventory::create([
|
|
||||||
'warehouse_id' => $validated['warehouse_id'],
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
'product_id' => $validated['product_id'],
|
'product_id' => $validated['product_id'],
|
||||||
'quantity' => $validated['output_quantity'],
|
'quantity' => $validated['output_quantity'],
|
||||||
'batch_number' => $validated['output_batch_number'],
|
'batch_number' => $validated['output_batch_number'],
|
||||||
'box_number' => $validated['output_box_count'],
|
'box_number' => $request->output_box_count,
|
||||||
'origin_country' => 'TW', // 生產預設為本地
|
|
||||||
'arrival_date' => $validated['production_date'],
|
'arrival_date' => $validated['production_date'],
|
||||||
'expiry_date' => $validated['expiry_date'] ?? null,
|
'expiry_date' => $request->expiry_date,
|
||||||
'quality_status' => 'normal',
|
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
|
||||||
|
'reference_type' => ProductionOrder::class,
|
||||||
|
'reference_id' => $productionOrder->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('completed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$message = $status === 'completed'
|
|
||||||
? '生產單已建立,原物料已扣減,成品已入庫'
|
|
||||||
: '生產單草稿已儲存';
|
|
||||||
|
|
||||||
return redirect()->route('production-orders.index')
|
return redirect()->route('production-orders.index')
|
||||||
->with('success', $message);
|
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 檢視生產單詳情(含追溯資訊)
|
* 檢視生產單詳情
|
||||||
*/
|
*/
|
||||||
public function show(ProductionOrder $productionOrder): Response
|
public function show(ProductionOrder $productionOrder): Response
|
||||||
{
|
{
|
||||||
$productionOrder->load([
|
// 手動水和主表資料
|
||||||
'product.baseUnit',
|
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
|
||||||
'warehouse',
|
if ($productionOrder->product) {
|
||||||
'user',
|
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
|
||||||
'items.inventory.product',
|
}
|
||||||
'items.inventory.sourcePurchaseOrder.vendor',
|
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
|
||||||
'items.unit',
|
$productionOrder->user = User::find($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', [
|
return Inertia::render('Production/Show', [
|
||||||
'productionOrder' => $productionOrder,
|
'productionOrder' => $productionOrder,
|
||||||
@@ -207,57 +218,67 @@ class ProductionOrderController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得倉庫內可用庫存(供 BOM 選擇)
|
* 取得倉庫內可用庫存
|
||||||
*/
|
*/
|
||||||
public function getWarehouseInventories(Warehouse $warehouse)
|
public function getWarehouseInventories($warehouseId)
|
||||||
{
|
{
|
||||||
$inventories = Inventory::with(['product.baseUnit', 'product.largeUnit'])
|
$inventories = $this->inventoryService->getInventoriesByWarehouse($warehouseId);
|
||||||
->where('warehouse_id', $warehouse->id)
|
|
||||||
->where('quantity', '>', 0)
|
$data = $inventories->map(function ($inv) {
|
||||||
->where('quality_status', 'normal')
|
|
||||||
->orderBy('arrival_date', 'asc') // FIFO:舊的排前面
|
|
||||||
->get()
|
|
||||||
->map(function ($inv) {
|
|
||||||
return [
|
return [
|
||||||
'id' => $inv->id,
|
'id' => $inv->id,
|
||||||
'product_id' => $inv->product_id,
|
'product_id' => $inv->product_id,
|
||||||
'product_name' => $inv->product->name,
|
'product_name' => $inv->product->name ?? '未知商品',
|
||||||
'product_code' => $inv->product->code,
|
'product_code' => $inv->product->code ?? '',
|
||||||
'batch_number' => $inv->batch_number,
|
'batch_number' => $inv->batch_number,
|
||||||
'box_number' => $inv->box_number,
|
'box_number' => $inv->box_number,
|
||||||
'quantity' => $inv->quantity,
|
'quantity' => $inv->quantity,
|
||||||
'arrival_date' => $inv->arrival_date?->format('Y-m-d'),
|
'arrival_date' => $inv->arrival_date ? $inv->arrival_date->format('Y-m-d') : null,
|
||||||
'expiry_date' => $inv->expiry_date?->format('Y-m-d'),
|
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
'unit_name' => $inv->product->baseUnit?->name,
|
'unit_name' => $inv->product->baseUnit->name ?? '',
|
||||||
'base_unit_id' => $inv->product->base_unit_id,
|
'base_unit_id' => $inv->product->base_unit_id ?? null,
|
||||||
'base_unit_name' => $inv->product->baseUnit?->name,
|
'large_unit_id' => $inv->product->large_unit_id ?? null,
|
||||||
'large_unit_id' => $inv->product->large_unit_id,
|
'conversion_rate' => $inv->product->conversion_rate ?? 1,
|
||||||
'large_unit_name' => $inv->product->largeUnit?->name,
|
|
||||||
'conversion_rate' => $inv->product->conversion_rate,
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json($inventories);
|
return response()->json($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 編輯生產單(僅限草稿狀態)
|
* 編輯生產單
|
||||||
*/
|
*/
|
||||||
public function edit(ProductionOrder $productionOrder): Response
|
public function edit(ProductionOrder $productionOrder): Response
|
||||||
{
|
{
|
||||||
// 只有草稿可以編輯
|
|
||||||
if ($productionOrder->status !== 'draft') {
|
if ($productionOrder->status !== 'draft') {
|
||||||
return redirect()->route('production-orders.show', $productionOrder->id)
|
return redirect()->route('production-orders.show', $productionOrder->id)
|
||||||
->with('error', '只有草稿狀態的生產單可以編輯');
|
->with('error', '只有草稿狀態的生產單可以編輯');
|
||||||
}
|
}
|
||||||
|
|
||||||
$productionOrder->load(['product', 'warehouse', 'items.inventory.product', 'items.unit']);
|
// 基本水和
|
||||||
|
$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', [
|
return Inertia::render('Production/Edit', [
|
||||||
'productionOrder' => $productionOrder,
|
'productionOrder' => $productionOrder,
|
||||||
'products' => Product::with(['baseUnit'])->get(),
|
'products' => $this->inventoryService->getAllProducts(),
|
||||||
'warehouses' => Warehouse::all(),
|
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||||
'units' => Unit::all(),
|
'units' => $this->inventoryService->getUnits(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,85 +287,60 @@ class ProductionOrderController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Request $request, ProductionOrder $productionOrder)
|
public function update(Request $request, ProductionOrder $productionOrder)
|
||||||
{
|
{
|
||||||
// 只有草稿可以編輯
|
|
||||||
if ($productionOrder->status !== 'draft') {
|
if ($productionOrder->status !== 'draft') {
|
||||||
return redirect()->route('production-orders.show', $productionOrder->id)
|
return redirect()->route('production-orders.show', $productionOrder->id)
|
||||||
->with('error', '只有草稿狀態的生產單可以編輯');
|
->with('error', '只有草稿可以修改');
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = $request->input('status', 'draft');
|
$status = $request->input('status', 'draft');
|
||||||
|
|
||||||
// 共用驗證規則
|
// 基礎驗證規則
|
||||||
$baseRules = [
|
$baseRules = [
|
||||||
'product_id' => 'required|exists:products,id',
|
'product_id' => 'required|exists:products,id',
|
||||||
'output_batch_number' => 'required|string|max:50',
|
'output_batch_number' => 'required|string|max:50',
|
||||||
'status' => 'nullable|in:draft,completed',
|
'status' => 'required|in:draft,completed',
|
||||||
|
'remark' => 'nullable|string',
|
||||||
];
|
];
|
||||||
|
|
||||||
// 完成模式需要完整驗證
|
// 完工時的嚴格驗證規則
|
||||||
$completedRules = [
|
$completedRules = [
|
||||||
'warehouse_id' => 'required|exists:warehouses,id',
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
'output_quantity' => 'required|numeric|min:0.01',
|
'output_quantity' => 'required|numeric|min:0.01',
|
||||||
'output_box_count' => 'nullable|string|max:10',
|
|
||||||
'production_date' => 'required|date',
|
'production_date' => 'required|date',
|
||||||
'expiry_date' => 'nullable|date|after_or_equal:production_date',
|
'expiry_date' => 'nullable|date',
|
||||||
'remark' => 'nullable|string',
|
|
||||||
'items' => 'required|array|min:1',
|
'items' => 'required|array|min:1',
|
||||||
'items.*.inventory_id' => 'required|exists:inventories,id',
|
'items.*.inventory_id' => 'required|exists:inventories,id',
|
||||||
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
||||||
'items.*.unit_id' => 'nullable|exists:units,id',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 草稿模式的寬鬆規則
|
// 若狀態切換為 completed,需合併驗證規則
|
||||||
$draftRules = [
|
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
|
||||||
'warehouse_id' => 'nullable|exists:warehouses,id',
|
|
||||||
'output_quantity' => 'nullable|numeric|min:0',
|
|
||||||
'output_box_count' => 'nullable|string|max:10',
|
|
||||||
'production_date' => 'nullable|date',
|
|
||||||
'expiry_date' => 'nullable|date',
|
|
||||||
'remark' => 'nullable|string',
|
|
||||||
'items' => 'nullable|array',
|
|
||||||
'items.*.inventory_id' => 'nullable|exists:inventories,id',
|
|
||||||
'items.*.quantity_used' => 'nullable|numeric|min:0',
|
|
||||||
'items.*.unit_id' => 'nullable|exists:units,id',
|
|
||||||
];
|
|
||||||
|
|
||||||
$rules = $status === 'completed'
|
$validated = $request->validate($rules);
|
||||||
? array_merge($baseRules, $completedRules)
|
|
||||||
: array_merge($baseRules, $draftRules);
|
|
||||||
|
|
||||||
$validated = $request->validate($rules, [
|
DB::transaction(function () use ($validated, $request, $status, $productionOrder) {
|
||||||
'product_id.required' => '請選擇成品商品',
|
|
||||||
'output_batch_number.required' => '請輸入成品批號',
|
|
||||||
'warehouse_id.required' => '請選擇入庫倉庫',
|
|
||||||
'output_quantity.required' => '請輸入生產數量',
|
|
||||||
'production_date.required' => '請選擇生產日期',
|
|
||||||
'items.required' => '請至少新增一項原物料',
|
|
||||||
'items.min' => '請至少新增一項原物料',
|
|
||||||
]);
|
|
||||||
|
|
||||||
DB::transaction(function () use ($validated, $status, $productionOrder) {
|
|
||||||
// 更新生產工單基本資料
|
|
||||||
$productionOrder->update([
|
$productionOrder->update([
|
||||||
'product_id' => $validated['product_id'],
|
'product_id' => $validated['product_id'],
|
||||||
'warehouse_id' => $validated['warehouse_id'] ?? null,
|
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
|
||||||
'output_quantity' => $validated['output_quantity'] ?? 0,
|
'output_quantity' => $validated['output_quantity'] ?? 0,
|
||||||
'output_batch_number' => $validated['output_batch_number'],
|
'output_batch_number' => $validated['output_batch_number'],
|
||||||
'output_box_count' => $validated['output_box_count'] ?? null,
|
'output_box_count' => $request->output_box_count,
|
||||||
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
||||||
'expiry_date' => $validated['expiry_date'] ?? null,
|
'expiry_date' => $request->expiry_date,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'remark' => $validated['remark'] ?? null,
|
'remark' => $request->remark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 刪除舊的明細
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('updated');
|
||||||
|
|
||||||
|
// 重新建立明細
|
||||||
$productionOrder->items()->delete();
|
$productionOrder->items()->delete();
|
||||||
|
|
||||||
// 重新建立明細 (草稿與完成模式皆需儲存)
|
if (!empty($request->items)) {
|
||||||
if (!empty($validated['items'])) {
|
foreach ($request->items as $item) {
|
||||||
foreach ($validated['items'] as $item) {
|
|
||||||
if (empty($item['inventory_id'])) continue;
|
|
||||||
|
|
||||||
ProductionOrderItem::create([
|
ProductionOrderItem::create([
|
||||||
'production_order_id' => $productionOrder->id,
|
'production_order_id' => $productionOrder->id,
|
||||||
'inventory_id' => $item['inventory_id'],
|
'inventory_id' => $item['inventory_id'],
|
||||||
@@ -352,35 +348,63 @@ class ProductionOrderController extends Controller
|
|||||||
'unit_id' => $item['unit_id'] ?? null,
|
'unit_id' => $item['unit_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 若為完成模式,則扣減原物料庫存
|
|
||||||
if ($status === 'completed') {
|
if ($status === 'completed') {
|
||||||
$inventory = Inventory::findOrFail($item['inventory_id']);
|
$this->inventoryService->decreaseInventoryQuantity(
|
||||||
$inventory->decrement('quantity', $item['quantity_used']);
|
$item['inventory_id'],
|
||||||
|
$item['quantity_used'],
|
||||||
|
"生產單 #{$productionOrder->code} 耗料",
|
||||||
|
ProductionOrder::class,
|
||||||
|
$productionOrder->id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 若為完成模式,執行成品入庫
|
|
||||||
if ($status === 'completed') {
|
if ($status === 'completed') {
|
||||||
Inventory::create([
|
$this->inventoryService->createInventoryRecord([
|
||||||
'warehouse_id' => $validated['warehouse_id'],
|
'warehouse_id' => $validated['warehouse_id'],
|
||||||
'product_id' => $validated['product_id'],
|
'product_id' => $validated['product_id'],
|
||||||
'quantity' => $validated['output_quantity'],
|
'quantity' => $validated['output_quantity'],
|
||||||
'batch_number' => $validated['output_batch_number'],
|
'batch_number' => $validated['output_batch_number'],
|
||||||
'box_number' => $validated['output_box_count'],
|
'box_number' => $request->output_box_count,
|
||||||
'origin_country' => 'TW',
|
|
||||||
'arrival_date' => $validated['production_date'],
|
'arrival_date' => $validated['production_date'],
|
||||||
'expiry_date' => $validated['expiry_date'] ?? null,
|
'expiry_date' => $request->expiry_date,
|
||||||
'quality_status' => 'normal',
|
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
|
||||||
|
'reference_type' => ProductionOrder::class,
|
||||||
|
'reference_id' => $productionOrder->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($productionOrder)
|
||||||
|
->causedBy(auth()->user())
|
||||||
|
->log('completed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$message = $status === 'completed'
|
|
||||||
? '生產單已完成,原物料已扣減,成品已入庫'
|
|
||||||
: '生產單草稿已更新';
|
|
||||||
|
|
||||||
return redirect()->route('production-orders.index')
|
return redirect()->route('production-orders.index')
|
||||||
->with('success', $message);
|
->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', '配方已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,12 @@ namespace App\Modules\Production\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Modules\Inventory\Models\Product;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
use App\Modules\Inventory\Models\Warehouse;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use App\Modules\Core\Models\User;
|
|
||||||
|
|
||||||
class ProductionOrder extends Model
|
class ProductionOrder extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\ProductionOrderFactory> */
|
use HasFactory, LogsActivity;
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
@@ -27,6 +25,38 @@ class ProductionOrder extends Model
|
|||||||
'remark',
|
'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()
|
public static function generateCode()
|
||||||
{
|
{
|
||||||
$prefix = 'PO' . now()->format('Ymd');
|
$prefix = 'PO' . now()->format('Ymd');
|
||||||
@@ -40,27 +70,28 @@ class ProductionOrder extends Model
|
|||||||
return $prefix . $sequence;
|
return $prefix . $sequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected $casts = [
|
/**
|
||||||
'order_date' => 'date',
|
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
|
||||||
'start_date' => 'datetime',
|
*/
|
||||||
'completion_date' => 'datetime',
|
public function product()
|
||||||
'quantity' => 'decimal:2',
|
|
||||||
'produced_quantity' => 'decimal:2',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Product::class);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/**
|
||||||
|
* @deprecated 使用 InventoryServiceInterface 獲取倉庫資訊
|
||||||
|
*/
|
||||||
|
public function warehouse()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Warehouse::class);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/**
|
||||||
|
* @deprecated 使用 CoreServiceInterface 獲取使用者資訊
|
||||||
|
*/
|
||||||
|
public function user()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
|
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
|||||||
@@ -22,14 +22,20 @@ class ProductionOrderItem extends Model
|
|||||||
'quantity_used' => 'decimal:4',
|
'quantity_used' => 'decimal:4',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 使用 InventoryServiceInterface 獲取庫存資訊
|
||||||
|
*/
|
||||||
public function inventory()
|
public function inventory()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
public function unit()
|
public function unit()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
@@ -37,8 +43,11 @@ class ProductionOrderItem extends Model
|
|||||||
return $this->belongsTo(ProductionOrder::class);
|
return $this->belongsTo(ProductionOrder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/**
|
||||||
|
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
|
||||||
|
*/
|
||||||
|
public function product()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Product::class);
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Modules\Production\Controllers\ProductionOrderController;
|
use App\Modules\Production\Controllers\ProductionOrderController;
|
||||||
|
use App\Modules\Production\Controllers\RecipeController;
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
// 配方管理
|
||||||
|
Route::resource('recipes', RecipeController::class);
|
||||||
|
|
||||||
// 生產管理
|
// 生產管理
|
||||||
Route::middleware('permission:production_orders.view')->group(function () {
|
Route::middleware('permission:production_orders.view')->group(function () {
|
||||||
Route::get('/production-orders', [ProductionOrderController::class, 'index'])->name('production-orders.index');
|
Route::get('/production-orders', [ProductionOrderController::class, 'index'])->name('production-orders.index');
|
||||||
|
|||||||
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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,20 @@ class ModuleServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
// $moduleName = basename($module);
|
// $moduleName = basename($module);
|
||||||
|
// Load Routes
|
||||||
$routesPath = $module . '/Routes/web.php';
|
$routesPath = $module . '/Routes/web.php';
|
||||||
|
|
||||||
if (File::exists($routesPath)) {
|
if (File::exists($routesPath)) {
|
||||||
Route::middleware('web')
|
Route::middleware('web')
|
||||||
->group($routesPath);
|
->group($routesPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Service Provider
|
||||||
|
$moduleName = basename($module);
|
||||||
|
$providerClass = "App\\Modules\\{$moduleName}\\{$moduleName}ServiceProvider";
|
||||||
|
|
||||||
|
if (class_exists($providerClass)) {
|
||||||
|
$this->app->register($providerClass);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ use Illuminate\Support\Str;
|
|||||||
*/
|
*/
|
||||||
class UserFactory extends Factory
|
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.
|
* The current password being used by the factory.
|
||||||
*/
|
*/
|
||||||
@@ -25,6 +32,7 @@ class UserFactory extends Factory
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => fake()->name(),
|
||||||
|
'username' => fake()->unique()->userName(),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,21 +13,21 @@ class UnitSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$units = [
|
$units = [
|
||||||
['name' => '個', 'code' => 'pc'],
|
['name' => '個', 'code' => 'PCE'], // Piece
|
||||||
['name' => '箱', 'code' => 'box'],
|
['name' => '箱', 'code' => 'BX'], // Box
|
||||||
['name' => '瓶', 'code' => 'btl'],
|
['name' => '瓶', 'code' => 'BO'], // Bottle
|
||||||
['name' => '包', 'code' => 'pkg'],
|
['name' => '包', 'code' => 'PK'], // Package
|
||||||
['name' => '公斤', 'code' => 'kg'],
|
['name' => '公斤', 'code' => 'KGM'], // Kilogram
|
||||||
['name' => '公克', 'code' => 'g'],
|
['name' => '公克', 'code' => 'GRM'], // Gram
|
||||||
['name' => '公升', 'code' => 'l'],
|
['name' => '公升', 'code' => 'LTR'], // Litre
|
||||||
['name' => '毫升', 'code' => 'ml'],
|
['name' => '毫升', 'code' => 'MLT'], // Millilitre
|
||||||
['name' => '籃', 'code' => 'bsk'],
|
['name' => '籃', 'code' => 'BK'], // Basket
|
||||||
['name' => '桶', 'code' => 'bucket'],
|
['name' => '桶', 'code' => 'BJ'], // Bucket
|
||||||
['name' => '罐', 'code' => 'can'],
|
['name' => '罐', 'code' => 'CA'], // Can
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($units as $unit) {
|
foreach ($units as $unit) {
|
||||||
Unit::firstOrCreate(
|
Unit::updateOrCreate(
|
||||||
['name' => $unit['name']],
|
['name' => $unit['name']],
|
||||||
['code' => $unit['code']]
|
['code' => $unit['code']]
|
||||||
);
|
);
|
||||||
|
|||||||
164
lang/zh_TW.json
Normal file
164
lang/zh_TW.json
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"accepted": ":attribute 必須接受。",
|
||||||
|
"active_url": ":attribute 並非一個有效的網址。",
|
||||||
|
"after": ":attribute 必須在 :date 之後。",
|
||||||
|
"after_or_equal": ":attribute 必須在 :date 之後或相等。",
|
||||||
|
"alpha": ":attribute 只能由字母組成。",
|
||||||
|
"alpha_dash": ":attribute 只能由字母、數字、破折號與底線組成。",
|
||||||
|
"alpha_num": ":attribute 只能由字母與數字組成。",
|
||||||
|
"array": ":attribute 必須是一個陣列。",
|
||||||
|
"before": ":attribute 必須在 :date 之前。",
|
||||||
|
"before_or_equal": ":attribute 必須在 :date 之前或相等。",
|
||||||
|
"between": {
|
||||||
|
"numeric": ":attribute 必須介於 :min 至 :max 之間。",
|
||||||
|
"file": ":attribute 必須介於 :min 至 :max KB 之間。",
|
||||||
|
"string": ":attribute 必須介於 :min 至 :max 個字元之間。",
|
||||||
|
"array": ":attribute 必須介於 :min 至 :max 個項目之間。"
|
||||||
|
},
|
||||||
|
"boolean": ":attribute 必須為布林值。",
|
||||||
|
"confirmed": ":attribute 確認欄位不一致。",
|
||||||
|
"date": ":attribute 並非一個有效的日期。",
|
||||||
|
"date_equals": ":attribute 必須等於 :date。",
|
||||||
|
"date_format": ":attribute 不符合 :format 的格式。",
|
||||||
|
"different": ":attribute 與 :other 必須不同。",
|
||||||
|
"digits": ":attribute 必須是 :digits 位數字。",
|
||||||
|
"digits_between": ":attribute 必須介於 :min 至 :max 位數字之間。",
|
||||||
|
"dimensions": ":attribute 圖片尺寸不正確。",
|
||||||
|
"distinct": ":attribute 已經存在。",
|
||||||
|
"email": ":attribute 必須是一個有效的電子郵件地址。",
|
||||||
|
"ends_with": ":attribute 結尾必須包含下列之一::values。",
|
||||||
|
"exists": "所選的 :attribute 選項無效。",
|
||||||
|
"file": ":attribute 必須是一個檔案。",
|
||||||
|
"filled": ":attribute 屬性是必填的。",
|
||||||
|
"gt": {
|
||||||
|
"numeric": ":attribute 必須大於 :value。",
|
||||||
|
"file": ":attribute 必須大於 :value KB。",
|
||||||
|
"string": ":attribute 必須多於 :value 個字元。",
|
||||||
|
"array": ":attribute 必須多於 :value 個項目。"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"numeric": ":attribute 必須大於或等於 :value。",
|
||||||
|
"file": ":attribute 必須大於或等於 :value KB。",
|
||||||
|
"string": ":attribute 必須多於或等於 :value 個字元。",
|
||||||
|
"array": ":attribute 必須多於或等於 :value 個項目。"
|
||||||
|
},
|
||||||
|
"image": ":attribute 必須是一張圖片。",
|
||||||
|
"in": "所選的 :attribute 選項無效。",
|
||||||
|
"in_array": ":attribute 沒有在 :other 中。",
|
||||||
|
"integer": ":attribute 必須是一個整數。",
|
||||||
|
"ip": ":attribute 必須是一個有效的 IP 地址。",
|
||||||
|
"ipv4": ":attribute 必須是一個有效的 IPv4 地址。",
|
||||||
|
"ipv6": ":attribute 必須是一個有效的 IPv6 地址。",
|
||||||
|
"json": ":attribute 必須是一個有效的 JSON 字串。",
|
||||||
|
"lt": {
|
||||||
|
"numeric": ":attribute 必須小於 :value。",
|
||||||
|
"file": ":attribute 必須小於 :value KB。",
|
||||||
|
"string": ":attribute 必須少於 :value 個字元。",
|
||||||
|
"array": ":attribute 必須少於 :value 個項目。"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"numeric": ":attribute 必須小於或等於 :value。",
|
||||||
|
"file": ":attribute 必須小於或等於 :value KB。",
|
||||||
|
"string": ":attribute 必須少於或等於 :value 個字元。",
|
||||||
|
"array": ":attribute 必須少於或等於 :value 個項目。"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"numeric": ":attribute 不能大於 :max。",
|
||||||
|
"file": ":attribute 不能大於 :max KB。",
|
||||||
|
"string": ":attribute 不能多於 :max 個字元。",
|
||||||
|
"array": ":attribute 最多有 :max 個項目。"
|
||||||
|
},
|
||||||
|
"mimes": ":attribute 必須是一個 :values 格式的檔案。",
|
||||||
|
"mimetypes": ":attribute 必須是一個 :values 格式的檔案。",
|
||||||
|
"min": {
|
||||||
|
"numeric": ":attribute 不能小於 :min。",
|
||||||
|
"file": ":attribute 不能小於 :min KB。",
|
||||||
|
"string": ":attribute 不能少於 :min 個字元。",
|
||||||
|
"array": ":attribute 至少有 :min 個項目。"
|
||||||
|
},
|
||||||
|
"multiple_of": ":attribute 必須為 :value 的倍數。",
|
||||||
|
"not_in": "所選的 :attribute 選項無效。",
|
||||||
|
"not_regex": ":attribute 的格式錯誤。",
|
||||||
|
"numeric": ":attribute 必須是一個數字。",
|
||||||
|
"password": "密碼錯誤。",
|
||||||
|
"present": ":attribute 必須存在。",
|
||||||
|
"regex": ":attribute 的格式錯誤。",
|
||||||
|
"required": ":attribute 欄位必填。",
|
||||||
|
"required_if": "當 :other 是 :value 時,:attribute 欄位必填。",
|
||||||
|
"required_unless": "當 :other 不是 :value 時,:attribute 欄位必填。",
|
||||||
|
"required_with": "當 :values 出現時,:attribute 欄位必填。",
|
||||||
|
"required_with_all": "當 :values 出現時,:attribute 欄位必填。",
|
||||||
|
"required_without": "當 :values 留空時,:attribute 欄位必填。",
|
||||||
|
"required_without_all": "當 :values 留空時,:attribute 欄位必填。",
|
||||||
|
"same": ":attribute 與 :other 必須相同。",
|
||||||
|
"size": {
|
||||||
|
"numeric": ":attribute 的大小必須是 :size。",
|
||||||
|
"file": ":attribute 的大小必須是 :size KB。",
|
||||||
|
"string": ":attribute 必須是 :size 個字元。",
|
||||||
|
"array": ":attribute 必須包含 :size 個項目。"
|
||||||
|
},
|
||||||
|
"starts_with": ":attribute 開頭必須包含下列之一::values。",
|
||||||
|
"string": ":attribute 必須是一個字串。",
|
||||||
|
"timezone": ":attribute 必須是一個有效的時區。",
|
||||||
|
"unique": ":attribute 已經存在。",
|
||||||
|
"uploaded": ":attribute 上傳失敗。",
|
||||||
|
"url": ":attribute 的格式錯誤。",
|
||||||
|
"uuid": ":attribute 必須是一個有效的 UUID。",
|
||||||
|
"auth.failed": "帳號或密碼錯誤。",
|
||||||
|
"auth.password": "密碼錯誤。",
|
||||||
|
"auth.throttle": "嘗試登入次數過多,請在 :seconds 秒後再試。",
|
||||||
|
"passwords.reset": "密碼已重設!",
|
||||||
|
"passwords.sent": "密碼重設連結已發送!",
|
||||||
|
"passwords.throttled": "請稍候再試。",
|
||||||
|
"passwords.token": "密碼重設連結已失效。",
|
||||||
|
"passwords.user": "找不到該電子郵件地址的使用者。",
|
||||||
|
"attributes": {
|
||||||
|
"name": "名稱",
|
||||||
|
"username": "使用者名稱",
|
||||||
|
"email": "電子郵件",
|
||||||
|
"first_name": "名",
|
||||||
|
"last_name": "姓",
|
||||||
|
"password": "密碼",
|
||||||
|
"password_confirmation": "確認密碼",
|
||||||
|
"city": "城市",
|
||||||
|
"country": "國家",
|
||||||
|
"address": "地址",
|
||||||
|
"phone": "電話",
|
||||||
|
"mobile": "手機",
|
||||||
|
"age": "年齡",
|
||||||
|
"sex": "性別",
|
||||||
|
"gender": "性別",
|
||||||
|
"day": "天",
|
||||||
|
"month": "月",
|
||||||
|
"year": "年",
|
||||||
|
"hour": "時",
|
||||||
|
"minute": "分",
|
||||||
|
"second": "秒",
|
||||||
|
"title": "標題",
|
||||||
|
"content": "內容",
|
||||||
|
"description": "描述",
|
||||||
|
"excerpt": "摘要",
|
||||||
|
"date": "日期",
|
||||||
|
"time": "時間",
|
||||||
|
"available": "可用的",
|
||||||
|
"size": "大小",
|
||||||
|
"product_id": "商品",
|
||||||
|
"vendor_id": "供應商",
|
||||||
|
"warehouse_id": "倉庫",
|
||||||
|
"unit_id": "單位",
|
||||||
|
"items": "明細項目",
|
||||||
|
"quantity": "數量",
|
||||||
|
"yield_quantity": "標準產出量",
|
||||||
|
"production_date": "生產日期",
|
||||||
|
"output_batch_number": "成品批號",
|
||||||
|
"output_quantity": "生產數量",
|
||||||
|
"source_warehouse_id": "來源倉庫",
|
||||||
|
"target_warehouse_id": "目標倉庫",
|
||||||
|
"expected_delivery_date": "預計到貨日期",
|
||||||
|
"invoice_number": "發票號碼",
|
||||||
|
"invoice_date": "發票日期",
|
||||||
|
"invoice_amount": "發票金額",
|
||||||
|
"tax_amount": "稅額",
|
||||||
|
"remark": "備註"
|
||||||
|
}
|
||||||
|
}
|
||||||
166
lang/zh_TW/auth.php
Normal file
166
lang/zh_TW/auth.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'accepted' => ':attribute 必須接受。',
|
||||||
|
'active_url' => ':attribute 並非一個有效的網址。',
|
||||||
|
'after' => ':attribute 必須在 :date 之後。',
|
||||||
|
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
|
||||||
|
'alpha' => ':attribute 只能由字母組成。',
|
||||||
|
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
|
||||||
|
'alpha_num' => ':attribute 只能由字母與數字組成。',
|
||||||
|
'array' => ':attribute 必須是一個陣列。',
|
||||||
|
'before' => ':attribute 必須在 :date 之前。',
|
||||||
|
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
|
||||||
|
'between' => [
|
||||||
|
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
|
||||||
|
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
|
||||||
|
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
|
||||||
|
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
|
||||||
|
],
|
||||||
|
'boolean' => ':attribute 必須為布林值。',
|
||||||
|
'confirmed' => ':attribute 確認欄位不一致。',
|
||||||
|
'date' => ':attribute 並非一個有效的日期。',
|
||||||
|
'date_equals' => ':attribute 必須等於 :date。',
|
||||||
|
'date_format' => ':attribute 不符合 :format 的格式。',
|
||||||
|
'different' => ':attribute 與 :other 必須不同。',
|
||||||
|
'digits' => ':attribute 必須是 :digits 位數字。',
|
||||||
|
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
|
||||||
|
'dimensions' => ':attribute 圖片尺寸不正確。',
|
||||||
|
'distinct' => ':attribute 已經存在。',
|
||||||
|
'email' => ':attribute 必須是一個有效的電子郵件地址。',
|
||||||
|
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
|
||||||
|
'exists' => '所選的 :attribute 選項無效。',
|
||||||
|
'file' => ':attribute 必須是一個檔案。',
|
||||||
|
'filled' => ':attribute 屬性是必填的。',
|
||||||
|
'gt' => [
|
||||||
|
'numeric' => ':attribute 必須大於 :value。',
|
||||||
|
'file' => ':attribute 必須大於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'numeric' => ':attribute 必須大於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須大於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'image' => ':attribute 必須是一張圖片。',
|
||||||
|
'in' => '所選的 :attribute 選項無效。',
|
||||||
|
'in_array' => ':attribute 沒有在 :other 中。',
|
||||||
|
'integer' => ':attribute 必須是一個整數。',
|
||||||
|
'ip' => ':attribute 必須是一個有效的 IP 地址。',
|
||||||
|
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
|
||||||
|
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
|
||||||
|
'json' => ':attribute 必須是一個有效的 JSON 字串。',
|
||||||
|
'lt' => [
|
||||||
|
'numeric' => ':attribute 必須小於 :value。',
|
||||||
|
'file' => ':attribute 必須小於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'numeric' => ':attribute 必須小於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須小於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'max' => [
|
||||||
|
'numeric' => ':attribute 不能大於 :max。',
|
||||||
|
'file' => ':attribute 不能大於 :max KB。',
|
||||||
|
'string' => ':attribute 不能多於 :max 個字元。',
|
||||||
|
'array' => ':attribute 最多有 :max 個項目。',
|
||||||
|
],
|
||||||
|
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'min' => [
|
||||||
|
'numeric' => ':attribute 不能小於 :min。',
|
||||||
|
'file' => ':attribute 不能小於 :min KB。',
|
||||||
|
'string' => ':attribute 不能少於 :min 個字元。',
|
||||||
|
'array' => ':attribute 至少有 :min 個項目。',
|
||||||
|
],
|
||||||
|
'multiple_of' => ':attribute 必須為 :value 的倍數。',
|
||||||
|
'not_in' => '所選的 :attribute 選項無效。',
|
||||||
|
'not_regex' => ':attribute 的格式錯誤。',
|
||||||
|
'numeric' => ':attribute 必須是一個數字。',
|
||||||
|
'password' => '密碼錯誤。',
|
||||||
|
'present' => ':attribute 必須存在。',
|
||||||
|
'regex' => ':attribute 的格式錯誤。',
|
||||||
|
'required' => ':attribute 欄位必填。',
|
||||||
|
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'same' => ':attribute 與 :other 必須相同。',
|
||||||
|
'size' => [
|
||||||
|
'numeric' => ':attribute 的大小必須是 :size。',
|
||||||
|
'file' => ':attribute 的大小必須是 :size KB。',
|
||||||
|
'string' => ':attribute 必須是 :size 個字元。',
|
||||||
|
'array' => ':attribute 必須包含 :size 個項目。',
|
||||||
|
],
|
||||||
|
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
|
||||||
|
'string' => ':attribute 必須是一個字串。',
|
||||||
|
'timezone' => ':attribute 必須是一個有效的時區。',
|
||||||
|
'unique' => ':attribute 已經存在。',
|
||||||
|
'uploaded' => ':attribute 上傳失敗。',
|
||||||
|
'url' => ':attribute 的格式錯誤。',
|
||||||
|
'uuid' => ':attribute 必須是一個有效的 UUID。',
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'attributes' => [
|
||||||
|
'name' => '名稱',
|
||||||
|
'username' => '使用者名稱',
|
||||||
|
'email' => '電子郵件',
|
||||||
|
'first_name' => '名',
|
||||||
|
'last_name' => '姓',
|
||||||
|
'password' => '密碼',
|
||||||
|
'password_confirmation' => '確認密碼',
|
||||||
|
'city' => '城市',
|
||||||
|
'country' => '國家',
|
||||||
|
'address' => '地址',
|
||||||
|
'phone' => '電話',
|
||||||
|
'mobile' => '手機',
|
||||||
|
'age' => '年齡',
|
||||||
|
'sex' => '性別',
|
||||||
|
'gender' => '性別',
|
||||||
|
'day' => '天',
|
||||||
|
'month' => '月',
|
||||||
|
'year' => '年',
|
||||||
|
'hour' => '時',
|
||||||
|
'minute' => '分',
|
||||||
|
'second' => '秒',
|
||||||
|
'title' => '標題',
|
||||||
|
'content' => '內容',
|
||||||
|
'description' => '描述',
|
||||||
|
'excerpt' => '摘要',
|
||||||
|
'date' => '日期',
|
||||||
|
'time' => '時間',
|
||||||
|
'available' => '可用的',
|
||||||
|
'size' => '大小',
|
||||||
|
'product_id' => '商品',
|
||||||
|
'vendor_id' => '供應商',
|
||||||
|
'warehouse_id' => '倉庫',
|
||||||
|
'unit_id' => '單位',
|
||||||
|
'items' => '明細項目',
|
||||||
|
'quantity' => '數量',
|
||||||
|
'yield_quantity' => '標準產出量',
|
||||||
|
'production_date' => '生產日期',
|
||||||
|
'output_batch_number' => '成品批號',
|
||||||
|
'output_quantity' => '生產數量',
|
||||||
|
'source_warehouse_id' => '來源倉庫',
|
||||||
|
'target_warehouse_id' => '目標倉庫',
|
||||||
|
'expected_delivery_date' => '預計到貨日期',
|
||||||
|
'invoice_number' => '發票號碼',
|
||||||
|
'invoice_date' => '發票日期',
|
||||||
|
'invoice_amount' => '發票金額',
|
||||||
|
'tax_amount' => '稅額',
|
||||||
|
'remark' => '備註',
|
||||||
|
'code' => '代號',
|
||||||
|
],
|
||||||
|
];
|
||||||
166
lang/zh_TW/pagination.php
Normal file
166
lang/zh_TW/pagination.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'accepted' => ':attribute 必須接受。',
|
||||||
|
'active_url' => ':attribute 並非一個有效的網址。',
|
||||||
|
'after' => ':attribute 必須在 :date 之後。',
|
||||||
|
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
|
||||||
|
'alpha' => ':attribute 只能由字母組成。',
|
||||||
|
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
|
||||||
|
'alpha_num' => ':attribute 只能由字母與數字組成。',
|
||||||
|
'array' => ':attribute 必須是一個陣列。',
|
||||||
|
'before' => ':attribute 必須在 :date 之前。',
|
||||||
|
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
|
||||||
|
'between' => [
|
||||||
|
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
|
||||||
|
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
|
||||||
|
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
|
||||||
|
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
|
||||||
|
],
|
||||||
|
'boolean' => ':attribute 必須為布林值。',
|
||||||
|
'confirmed' => ':attribute 確認欄位不一致。',
|
||||||
|
'date' => ':attribute 並非一個有效的日期。',
|
||||||
|
'date_equals' => ':attribute 必須等於 :date。',
|
||||||
|
'date_format' => ':attribute 不符合 :format 的格式。',
|
||||||
|
'different' => ':attribute 與 :other 必須不同。',
|
||||||
|
'digits' => ':attribute 必須是 :digits 位數字。',
|
||||||
|
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
|
||||||
|
'dimensions' => ':attribute 圖片尺寸不正確。',
|
||||||
|
'distinct' => ':attribute 已經存在。',
|
||||||
|
'email' => ':attribute 必須是一個有效的電子郵件地址。',
|
||||||
|
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
|
||||||
|
'exists' => '所選的 :attribute 選項無效。',
|
||||||
|
'file' => ':attribute 必須是一個檔案。',
|
||||||
|
'filled' => ':attribute 屬性是必填的。',
|
||||||
|
'gt' => [
|
||||||
|
'numeric' => ':attribute 必須大於 :value。',
|
||||||
|
'file' => ':attribute 必須大於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'numeric' => ':attribute 必須大於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須大於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'image' => ':attribute 必須是一張圖片。',
|
||||||
|
'in' => '所選的 :attribute 選項無效。',
|
||||||
|
'in_array' => ':attribute 沒有在 :other 中。',
|
||||||
|
'integer' => ':attribute 必須是一個整數。',
|
||||||
|
'ip' => ':attribute 必須是一個有效的 IP 地址。',
|
||||||
|
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
|
||||||
|
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
|
||||||
|
'json' => ':attribute 必須是一個有效的 JSON 字串。',
|
||||||
|
'lt' => [
|
||||||
|
'numeric' => ':attribute 必須小於 :value。',
|
||||||
|
'file' => ':attribute 必須小於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'numeric' => ':attribute 必須小於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須小於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'max' => [
|
||||||
|
'numeric' => ':attribute 不能大於 :max。',
|
||||||
|
'file' => ':attribute 不能大於 :max KB。',
|
||||||
|
'string' => ':attribute 不能多於 :max 個字元。',
|
||||||
|
'array' => ':attribute 最多有 :max 個項目。',
|
||||||
|
],
|
||||||
|
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'min' => [
|
||||||
|
'numeric' => ':attribute 不能小於 :min。',
|
||||||
|
'file' => ':attribute 不能小於 :min KB。',
|
||||||
|
'string' => ':attribute 不能少於 :min 個字元。',
|
||||||
|
'array' => ':attribute 至少有 :min 個項目。',
|
||||||
|
],
|
||||||
|
'multiple_of' => ':attribute 必須為 :value 的倍數。',
|
||||||
|
'not_in' => '所選的 :attribute 選項無效。',
|
||||||
|
'not_regex' => ':attribute 的格式錯誤。',
|
||||||
|
'numeric' => ':attribute 必須是一個數字。',
|
||||||
|
'password' => '密碼錯誤。',
|
||||||
|
'present' => ':attribute 必須存在。',
|
||||||
|
'regex' => ':attribute 的格式錯誤。',
|
||||||
|
'required' => ':attribute 欄位必填。',
|
||||||
|
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'same' => ':attribute 與 :other 必須相同。',
|
||||||
|
'size' => [
|
||||||
|
'numeric' => ':attribute 的大小必須是 :size。',
|
||||||
|
'file' => ':attribute 的大小必須是 :size KB。',
|
||||||
|
'string' => ':attribute 必須是 :size 個字元。',
|
||||||
|
'array' => ':attribute 必須包含 :size 個項目。',
|
||||||
|
],
|
||||||
|
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
|
||||||
|
'string' => ':attribute 必須是一個字串。',
|
||||||
|
'timezone' => ':attribute 必須是一個有效的時區。',
|
||||||
|
'unique' => ':attribute 已經存在。',
|
||||||
|
'uploaded' => ':attribute 上傳失敗。',
|
||||||
|
'url' => ':attribute 的格式錯誤。',
|
||||||
|
'uuid' => ':attribute 必須是一個有效的 UUID。',
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'attributes' => [
|
||||||
|
'name' => '名稱',
|
||||||
|
'username' => '使用者名稱',
|
||||||
|
'email' => '電子郵件',
|
||||||
|
'first_name' => '名',
|
||||||
|
'last_name' => '姓',
|
||||||
|
'password' => '密碼',
|
||||||
|
'password_confirmation' => '確認密碼',
|
||||||
|
'city' => '城市',
|
||||||
|
'country' => '國家',
|
||||||
|
'address' => '地址',
|
||||||
|
'phone' => '電話',
|
||||||
|
'mobile' => '手機',
|
||||||
|
'age' => '年齡',
|
||||||
|
'sex' => '性別',
|
||||||
|
'gender' => '性別',
|
||||||
|
'day' => '天',
|
||||||
|
'month' => '月',
|
||||||
|
'year' => '年',
|
||||||
|
'hour' => '時',
|
||||||
|
'minute' => '分',
|
||||||
|
'second' => '秒',
|
||||||
|
'title' => '標題',
|
||||||
|
'content' => '內容',
|
||||||
|
'description' => '描述',
|
||||||
|
'excerpt' => '摘要',
|
||||||
|
'date' => '日期',
|
||||||
|
'time' => '時間',
|
||||||
|
'available' => '可用的',
|
||||||
|
'size' => '大小',
|
||||||
|
'product_id' => '商品',
|
||||||
|
'vendor_id' => '供應商',
|
||||||
|
'warehouse_id' => '倉庫',
|
||||||
|
'unit_id' => '單位',
|
||||||
|
'items' => '明細項目',
|
||||||
|
'quantity' => '數量',
|
||||||
|
'yield_quantity' => '標準產出量',
|
||||||
|
'production_date' => '生產日期',
|
||||||
|
'output_batch_number' => '成品批號',
|
||||||
|
'output_quantity' => '生產數量',
|
||||||
|
'source_warehouse_id' => '來源倉庫',
|
||||||
|
'target_warehouse_id' => '目標倉庫',
|
||||||
|
'expected_delivery_date' => '預計到貨日期',
|
||||||
|
'invoice_number' => '發票號碼',
|
||||||
|
'invoice_date' => '發票日期',
|
||||||
|
'invoice_amount' => '發票金額',
|
||||||
|
'tax_amount' => '稅額',
|
||||||
|
'remark' => '備註',
|
||||||
|
'code' => '代號',
|
||||||
|
],
|
||||||
|
];
|
||||||
166
lang/zh_TW/passwords.php
Normal file
166
lang/zh_TW/passwords.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'accepted' => ':attribute 必須接受。',
|
||||||
|
'active_url' => ':attribute 並非一個有效的網址。',
|
||||||
|
'after' => ':attribute 必須在 :date 之後。',
|
||||||
|
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
|
||||||
|
'alpha' => ':attribute 只能由字母組成。',
|
||||||
|
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
|
||||||
|
'alpha_num' => ':attribute 只能由字母與數字組成。',
|
||||||
|
'array' => ':attribute 必須是一個陣列。',
|
||||||
|
'before' => ':attribute 必須在 :date 之前。',
|
||||||
|
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
|
||||||
|
'between' => [
|
||||||
|
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
|
||||||
|
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
|
||||||
|
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
|
||||||
|
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
|
||||||
|
],
|
||||||
|
'boolean' => ':attribute 必須為布林值。',
|
||||||
|
'confirmed' => ':attribute 確認欄位不一致。',
|
||||||
|
'date' => ':attribute 並非一個有效的日期。',
|
||||||
|
'date_equals' => ':attribute 必須等於 :date。',
|
||||||
|
'date_format' => ':attribute 不符合 :format 的格式。',
|
||||||
|
'different' => ':attribute 與 :other 必須不同。',
|
||||||
|
'digits' => ':attribute 必須是 :digits 位數字。',
|
||||||
|
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
|
||||||
|
'dimensions' => ':attribute 圖片尺寸不正確。',
|
||||||
|
'distinct' => ':attribute 已經存在。',
|
||||||
|
'email' => ':attribute 必須是一個有效的電子郵件地址。',
|
||||||
|
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
|
||||||
|
'exists' => '所選的 :attribute 選項無效。',
|
||||||
|
'file' => ':attribute 必須是一個檔案。',
|
||||||
|
'filled' => ':attribute 屬性是必填的。',
|
||||||
|
'gt' => [
|
||||||
|
'numeric' => ':attribute 必須大於 :value。',
|
||||||
|
'file' => ':attribute 必須大於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'numeric' => ':attribute 必須大於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須大於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'image' => ':attribute 必須是一張圖片。',
|
||||||
|
'in' => '所選的 :attribute 選項無效。',
|
||||||
|
'in_array' => ':attribute 沒有在 :other 中。',
|
||||||
|
'integer' => ':attribute 必須是一個整數。',
|
||||||
|
'ip' => ':attribute 必須是一個有效的 IP 地址。',
|
||||||
|
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
|
||||||
|
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
|
||||||
|
'json' => ':attribute 必須是一個有效的 JSON 字串。',
|
||||||
|
'lt' => [
|
||||||
|
'numeric' => ':attribute 必須小於 :value。',
|
||||||
|
'file' => ':attribute 必須小於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'numeric' => ':attribute 必須小於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須小於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'max' => [
|
||||||
|
'numeric' => ':attribute 不能大於 :max。',
|
||||||
|
'file' => ':attribute 不能大於 :max KB。',
|
||||||
|
'string' => ':attribute 不能多於 :max 個字元。',
|
||||||
|
'array' => ':attribute 最多有 :max 個項目。',
|
||||||
|
],
|
||||||
|
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'min' => [
|
||||||
|
'numeric' => ':attribute 不能小於 :min。',
|
||||||
|
'file' => ':attribute 不能小於 :min KB。',
|
||||||
|
'string' => ':attribute 不能少於 :min 個字元。',
|
||||||
|
'array' => ':attribute 至少有 :min 個項目。',
|
||||||
|
],
|
||||||
|
'multiple_of' => ':attribute 必須為 :value 的倍數。',
|
||||||
|
'not_in' => '所選的 :attribute 選項無效。',
|
||||||
|
'not_regex' => ':attribute 的格式錯誤。',
|
||||||
|
'numeric' => ':attribute 必須是一個數字。',
|
||||||
|
'password' => '密碼錯誤。',
|
||||||
|
'present' => ':attribute 必須存在。',
|
||||||
|
'regex' => ':attribute 的格式錯誤。',
|
||||||
|
'required' => ':attribute 欄位必填。',
|
||||||
|
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'same' => ':attribute 與 :other 必須相同。',
|
||||||
|
'size' => [
|
||||||
|
'numeric' => ':attribute 的大小必須是 :size。',
|
||||||
|
'file' => ':attribute 的大小必須是 :size KB。',
|
||||||
|
'string' => ':attribute 必須是 :size 個字元。',
|
||||||
|
'array' => ':attribute 必須包含 :size 個項目。',
|
||||||
|
],
|
||||||
|
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
|
||||||
|
'string' => ':attribute 必須是一個字串。',
|
||||||
|
'timezone' => ':attribute 必須是一個有效的時區。',
|
||||||
|
'unique' => ':attribute 已經存在。',
|
||||||
|
'uploaded' => ':attribute 上傳失敗。',
|
||||||
|
'url' => ':attribute 的格式錯誤。',
|
||||||
|
'uuid' => ':attribute 必須是一個有效的 UUID。',
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'attributes' => [
|
||||||
|
'name' => '名稱',
|
||||||
|
'username' => '使用者名稱',
|
||||||
|
'email' => '電子郵件',
|
||||||
|
'first_name' => '名',
|
||||||
|
'last_name' => '姓',
|
||||||
|
'password' => '密碼',
|
||||||
|
'password_confirmation' => '確認密碼',
|
||||||
|
'city' => '城市',
|
||||||
|
'country' => '國家',
|
||||||
|
'address' => '地址',
|
||||||
|
'phone' => '電話',
|
||||||
|
'mobile' => '手機',
|
||||||
|
'age' => '年齡',
|
||||||
|
'sex' => '性別',
|
||||||
|
'gender' => '性別',
|
||||||
|
'day' => '天',
|
||||||
|
'month' => '月',
|
||||||
|
'year' => '年',
|
||||||
|
'hour' => '時',
|
||||||
|
'minute' => '分',
|
||||||
|
'second' => '秒',
|
||||||
|
'title' => '標題',
|
||||||
|
'content' => '內容',
|
||||||
|
'description' => '描述',
|
||||||
|
'excerpt' => '摘要',
|
||||||
|
'date' => '日期',
|
||||||
|
'time' => '時間',
|
||||||
|
'available' => '可用的',
|
||||||
|
'size' => '大小',
|
||||||
|
'product_id' => '商品',
|
||||||
|
'vendor_id' => '供應商',
|
||||||
|
'warehouse_id' => '倉庫',
|
||||||
|
'unit_id' => '單位',
|
||||||
|
'items' => '明細項目',
|
||||||
|
'quantity' => '數量',
|
||||||
|
'yield_quantity' => '標準產出量',
|
||||||
|
'production_date' => '生產日期',
|
||||||
|
'output_batch_number' => '成品批號',
|
||||||
|
'output_quantity' => '生產數量',
|
||||||
|
'source_warehouse_id' => '來源倉庫',
|
||||||
|
'target_warehouse_id' => '目標倉庫',
|
||||||
|
'expected_delivery_date' => '預計到貨日期',
|
||||||
|
'invoice_number' => '發票號碼',
|
||||||
|
'invoice_date' => '發票日期',
|
||||||
|
'invoice_amount' => '發票金額',
|
||||||
|
'tax_amount' => '稅額',
|
||||||
|
'remark' => '備註',
|
||||||
|
'code' => '代號',
|
||||||
|
],
|
||||||
|
];
|
||||||
225
lang/zh_TW/validation.php
Normal file
225
lang/zh_TW/validation.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'accepted' => ':attribute 必須接受。',
|
||||||
|
'active_url' => ':attribute 並非一個有效的網址。',
|
||||||
|
'after' => ':attribute 必須在 :date 之後。',
|
||||||
|
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
|
||||||
|
'alpha' => ':attribute 只能由字母組成。',
|
||||||
|
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
|
||||||
|
'alpha_num' => ':attribute 只能由字母與數字組成。',
|
||||||
|
'array' => ':attribute 必須是一個陣列。',
|
||||||
|
'before' => ':attribute 必須在 :date 之前。',
|
||||||
|
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
|
||||||
|
'between' => [
|
||||||
|
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
|
||||||
|
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
|
||||||
|
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
|
||||||
|
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
|
||||||
|
],
|
||||||
|
'boolean' => ':attribute 必須為布林值。',
|
||||||
|
'confirmed' => ':attribute 確認欄位不一致。',
|
||||||
|
'date' => ':attribute 並非一個有效的日期。',
|
||||||
|
'date_equals' => ':attribute 必須等於 :date。',
|
||||||
|
'date_format' => ':attribute 不符合 :format 的格式。',
|
||||||
|
'different' => ':attribute 與 :other 必須不同。',
|
||||||
|
'digits' => ':attribute 必須是 :digits 位數字。',
|
||||||
|
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
|
||||||
|
'dimensions' => ':attribute 圖片尺寸不正確。',
|
||||||
|
'distinct' => ':attribute 已經存在。',
|
||||||
|
'email' => ':attribute 必須是一個有效的電子郵件地址。',
|
||||||
|
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
|
||||||
|
'exists' => '所選的 :attribute 選項無效。',
|
||||||
|
'file' => ':attribute 必須是一個檔案。',
|
||||||
|
'filled' => ':attribute 屬性是必填的。',
|
||||||
|
'gt' => [
|
||||||
|
'numeric' => ':attribute 必須大於 :value。',
|
||||||
|
'file' => ':attribute 必須大於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'numeric' => ':attribute 必須大於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須大於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須多於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須多於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'image' => ':attribute 必須是一張圖片。',
|
||||||
|
'in' => '所選的 :attribute 選項無效。',
|
||||||
|
'in_array' => ':attribute 沒有在 :other 中。',
|
||||||
|
'integer' => ':attribute 必須是一個整數。',
|
||||||
|
'ip' => ':attribute 必須是一個有效的 IP 地址。',
|
||||||
|
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
|
||||||
|
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
|
||||||
|
'json' => ':attribute 必須是一個有效的 JSON 字串。',
|
||||||
|
'lt' => [
|
||||||
|
'numeric' => ':attribute 必須小於 :value。',
|
||||||
|
'file' => ':attribute 必須小於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'numeric' => ':attribute 必須小於或等於 :value。',
|
||||||
|
'file' => ':attribute 必須小於或等於 :value KB。',
|
||||||
|
'string' => ':attribute 必須少於或等於 :value 個字元。',
|
||||||
|
'array' => ':attribute 必須少於或等於 :value 個項目。',
|
||||||
|
],
|
||||||
|
'max' => [
|
||||||
|
'numeric' => ':attribute 不能大於 :max。',
|
||||||
|
'file' => ':attribute 不能大於 :max KB。',
|
||||||
|
'string' => ':attribute 不能多於 :max 個字元。',
|
||||||
|
'array' => ':attribute 最多有 :max 個項目。',
|
||||||
|
],
|
||||||
|
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
|
||||||
|
'min' => [
|
||||||
|
'numeric' => ':attribute 不能小於 :min。',
|
||||||
|
'file' => ':attribute 不能小於 :min KB。',
|
||||||
|
'string' => ':attribute 不能少於 :min 個字元。',
|
||||||
|
'array' => ':attribute 至少有 :min 個項目。',
|
||||||
|
],
|
||||||
|
'multiple_of' => ':attribute 必須為 :value 的倍數。',
|
||||||
|
'not_in' => '所選的 :attribute 選項無效。',
|
||||||
|
'not_regex' => ':attribute 的格式錯誤。',
|
||||||
|
'numeric' => ':attribute 必須是一個數字。',
|
||||||
|
'password' => '密碼錯誤。',
|
||||||
|
'present' => ':attribute 必須存在。',
|
||||||
|
'regex' => ':attribute 的格式錯誤。',
|
||||||
|
'required' => ':attribute 欄位必填。',
|
||||||
|
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
|
||||||
|
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
|
||||||
|
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
|
||||||
|
'same' => ':attribute 與 :other 必須相同。',
|
||||||
|
'size' => [
|
||||||
|
'numeric' => ':attribute 的大小必須是 :size。',
|
||||||
|
'file' => ':attribute 的大小必須是 :size KB。',
|
||||||
|
'string' => ':attribute 必須是 :size 個字元。',
|
||||||
|
'array' => ':attribute 必須包含 :size 個項目。',
|
||||||
|
],
|
||||||
|
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
|
||||||
|
'string' => ':attribute 必須是一個字串。',
|
||||||
|
'timezone' => ':attribute 必須是一個有效的時區。',
|
||||||
|
'unique' => ':attribute 已經存在。',
|
||||||
|
'uploaded' => ':attribute 上傳失敗。',
|
||||||
|
'url' => ':attribute 的格式錯誤。',
|
||||||
|
'uuid' => ':attribute 必須是一個有效的 UUID。',
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'attributes' => [
|
||||||
|
'name' => '名稱',
|
||||||
|
'username' => '使用者名稱',
|
||||||
|
'email' => '電子郵件',
|
||||||
|
'first_name' => '名',
|
||||||
|
'last_name' => '姓',
|
||||||
|
'password' => '密碼',
|
||||||
|
'password_confirmation' => '確認密碼',
|
||||||
|
'city' => '城市',
|
||||||
|
'country' => '國家',
|
||||||
|
'address' => '地址',
|
||||||
|
'phone' => '電話',
|
||||||
|
'mobile' => '手機',
|
||||||
|
'age' => '年齡',
|
||||||
|
'sex' => '性別',
|
||||||
|
'gender' => '性別',
|
||||||
|
'day' => '天',
|
||||||
|
'month' => '月',
|
||||||
|
'year' => '年',
|
||||||
|
'hour' => '時',
|
||||||
|
'minute' => '分',
|
||||||
|
'second' => '秒',
|
||||||
|
'title' => '標題',
|
||||||
|
'content' => '內容',
|
||||||
|
'description' => '描述',
|
||||||
|
'excerpt' => '摘要',
|
||||||
|
'date' => '日期',
|
||||||
|
'time' => '時間',
|
||||||
|
'available' => '可用的',
|
||||||
|
'size' => '大小',
|
||||||
|
'product_id' => '商品',
|
||||||
|
'productId' => '商品',
|
||||||
|
'vendor_id' => '供應商',
|
||||||
|
'vendorId' => '供應商',
|
||||||
|
'warehouse_id' => '倉庫',
|
||||||
|
'warehouseId' => '倉庫',
|
||||||
|
'unit_id' => '單位',
|
||||||
|
'unitId' => '單位',
|
||||||
|
'items' => '明細項目',
|
||||||
|
'quantity' => '數量',
|
||||||
|
'yield_quantity' => '標準產出量',
|
||||||
|
'yieldQuantity' => '標準產出量',
|
||||||
|
'production_date' => '生產日期',
|
||||||
|
'productionDate' => '生產日期',
|
||||||
|
'output_batch_number' => '成品批號',
|
||||||
|
'outputBatchNumber' => '成品批號',
|
||||||
|
'output_quantity' => '生產數量',
|
||||||
|
'outputQuantity' => '生產數量',
|
||||||
|
'output_box_count' => '生產箱數',
|
||||||
|
'outputBoxCount' => '生產箱數',
|
||||||
|
'source_warehouse_id' => '來源倉庫',
|
||||||
|
'sourceWarehouseId' => '來源倉庫',
|
||||||
|
'target_warehouse_id' => '目標倉庫',
|
||||||
|
'targetWarehouseId' => '目標倉庫',
|
||||||
|
'expected_delivery_date' => '預計到貨日期',
|
||||||
|
'expectedDeliveryDate' => '預計到貨日期',
|
||||||
|
'invoice_number' => '發票號碼',
|
||||||
|
'invoiceNumber' => '發票號碼',
|
||||||
|
'invoice_date' => '發票日期',
|
||||||
|
'invoiceDate' => '發票日期',
|
||||||
|
'invoice_amount' => '發票金額',
|
||||||
|
'invoiceAmount' => '發票金額',
|
||||||
|
'tax_amount' => '稅額',
|
||||||
|
'taxAmount' => '稅額',
|
||||||
|
'remark' => '備註',
|
||||||
|
'code' => '代號',
|
||||||
|
'short_name' => '簡稱',
|
||||||
|
'shortName' => '簡稱',
|
||||||
|
'tax_id' => '統編',
|
||||||
|
'taxId' => '統編',
|
||||||
|
'owner' => '負責人',
|
||||||
|
'contact_name' => '聯絡人',
|
||||||
|
'contactName' => '聯絡人',
|
||||||
|
'tel' => '電話',
|
||||||
|
'phone' => '手機',
|
||||||
|
'address' => '地址',
|
||||||
|
'brand' => '品牌',
|
||||||
|
'specification' => '規格',
|
||||||
|
'base_unit_id' => '基本單位',
|
||||||
|
'baseUnitId' => '基本單位',
|
||||||
|
'large_unit_id' => '大單位',
|
||||||
|
'largeUnitId' => '大單位',
|
||||||
|
'purchase_unit_id' => '採購單位',
|
||||||
|
'purchaseUnitId' => '採購單位',
|
||||||
|
'conversion_rate' => '換算率',
|
||||||
|
'conversionRate' => '換算率',
|
||||||
|
'category_id' => '分類',
|
||||||
|
'categoryId' => '分類',
|
||||||
|
'inventory_id' => '庫存項目',
|
||||||
|
'inventoryId' => '庫存項目',
|
||||||
|
'arrival_date' => '到貨日期',
|
||||||
|
'arrivalDate' => '到貨日期',
|
||||||
|
'expiry_date' => '效期',
|
||||||
|
'expiryDate' => '效期',
|
||||||
|
'quantity_used' => '使用數量',
|
||||||
|
'quantityUsed' => '使用數量',
|
||||||
|
'items.*.product_id' => '明細商品',
|
||||||
|
'items.*.productId' => '明細商品',
|
||||||
|
'items.*.quantity' => '明細數量',
|
||||||
|
'items.*.unit_id' => '明細單位',
|
||||||
|
'items.*.unitId' => '明細單位',
|
||||||
|
'items.*.remark' => '明細備註',
|
||||||
|
'items.*.inventory_id' => '明細批號',
|
||||||
|
'items.*.inventoryId' => '明細批號',
|
||||||
|
'items.*.quantity_used' => '明細用量',
|
||||||
|
'items.*.quantityUsed' => '明細用量',
|
||||||
|
'items.*.subtotal' => '明細小計',
|
||||||
|
'items.*.subtotalAmount' => '明細小計',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -41,7 +41,7 @@ interface Props {
|
|||||||
activity: Activity | null;
|
activity: Activity | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field translation map
|
// 欄位翻譯對照表
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
name: '名稱',
|
name: '名稱',
|
||||||
code: '商品代號',
|
code: '商品代號',
|
||||||
@@ -66,19 +66,19 @@ const fieldLabels: Record<string, string> = {
|
|||||||
role_id: '角色',
|
role_id: '角色',
|
||||||
email_verified_at: '電子郵件驗證時間',
|
email_verified_at: '電子郵件驗證時間',
|
||||||
remember_token: '登入權杖',
|
remember_token: '登入權杖',
|
||||||
// Snapshot fields
|
// 快照欄位
|
||||||
category_name: '分類名稱',
|
category_name: '分類名稱',
|
||||||
base_unit_name: '基本單位名稱',
|
base_unit_name: '基本單位名稱',
|
||||||
large_unit_name: '大單位名稱',
|
large_unit_name: '大單位名稱',
|
||||||
purchase_unit_name: '採購單位名稱',
|
purchase_unit_name: '採購單位名稱',
|
||||||
// Vendor fields
|
// 廠商欄位
|
||||||
short_name: '簡稱',
|
short_name: '簡稱',
|
||||||
tax_id: '統編',
|
tax_id: '統編',
|
||||||
owner: '負責人',
|
owner: '負責人',
|
||||||
contact_name: '聯絡人',
|
contact_name: '聯絡人',
|
||||||
tel: '電話',
|
tel: '電話',
|
||||||
remark: '備註',
|
remark: '備註',
|
||||||
// Warehouse & Inventory fields
|
// 倉庫與庫存欄位
|
||||||
warehouse_name: '倉庫名稱',
|
warehouse_name: '倉庫名稱',
|
||||||
product_name: '商品名稱',
|
product_name: '商品名稱',
|
||||||
warehouse_id: '倉庫',
|
warehouse_id: '倉庫',
|
||||||
@@ -86,7 +86,7 @@ const fieldLabels: Record<string, string> = {
|
|||||||
quantity: '數量',
|
quantity: '數量',
|
||||||
safety_stock: '安全庫存',
|
safety_stock: '安全庫存',
|
||||||
location: '儲位',
|
location: '儲位',
|
||||||
// Inventory fields
|
// 庫存欄位
|
||||||
batch_number: '批號',
|
batch_number: '批號',
|
||||||
box_number: '箱號',
|
box_number: '箱號',
|
||||||
origin_country: '來源國家',
|
origin_country: '來源國家',
|
||||||
@@ -95,7 +95,7 @@ const fieldLabels: Record<string, string> = {
|
|||||||
source_purchase_order_id: '來源採購單',
|
source_purchase_order_id: '來源採購單',
|
||||||
quality_status: '品質狀態',
|
quality_status: '品質狀態',
|
||||||
quality_remark: '品質備註',
|
quality_remark: '品質備註',
|
||||||
// Purchase Order fields
|
// 採購單欄位
|
||||||
po_number: '採購單號',
|
po_number: '採購單號',
|
||||||
vendor_id: '廠商',
|
vendor_id: '廠商',
|
||||||
vendor_name: '廠商名稱',
|
vendor_name: '廠商名稱',
|
||||||
@@ -110,13 +110,13 @@ const fieldLabels: Record<string, string> = {
|
|||||||
invoice_date: '發票日期',
|
invoice_date: '發票日期',
|
||||||
invoice_amount: '發票金額',
|
invoice_amount: '發票金額',
|
||||||
last_price: '供貨價格',
|
last_price: '供貨價格',
|
||||||
// Utility Fee fields
|
// 公共事業費欄位
|
||||||
transaction_date: '費用日期',
|
transaction_date: '費用日期',
|
||||||
category: '費用類別',
|
category: '費用類別',
|
||||||
amount: '金額',
|
amount: '金額',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Purchase Order Status Map
|
// 採購單狀態對照表
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
draft: '草稿',
|
draft: '草稿',
|
||||||
pending: '待審核',
|
pending: '待審核',
|
||||||
@@ -127,7 +127,7 @@ const statusMap: Record<string, string> = {
|
|||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inventory Quality Status Map
|
// 庫存品質狀態對照表
|
||||||
const qualityStatusMap: Record<string, string> = {
|
const qualityStatusMap: Record<string, string> = {
|
||||||
normal: '正常',
|
normal: '正常',
|
||||||
frozen: '凍結',
|
frozen: '凍結',
|
||||||
@@ -141,17 +141,17 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
const old = activity.properties?.old || {};
|
const old = activity.properties?.old || {};
|
||||||
const snapshot = activity.properties?.snapshot || {};
|
const snapshot = activity.properties?.snapshot || {};
|
||||||
|
|
||||||
// Get all keys from both attributes and old to ensure we show all changes
|
// 取得屬性和舊值的所有鍵,以確保顯示所有變更
|
||||||
const allKeys = Array.from(new Set([...Object.keys(attributes), ...Object.keys(old)]));
|
const allKeys = Array.from(new Set([...Object.keys(attributes), ...Object.keys(old)]));
|
||||||
|
|
||||||
// Custom sort order for fields
|
// 自訂欄位排序順序
|
||||||
const sortOrder = [
|
const sortOrder = [
|
||||||
'po_number', 'vendor_name', 'warehouse_name', 'expected_delivery_date', 'status', 'remark',
|
'po_number', 'vendor_name', 'warehouse_name', 'expected_delivery_date', 'status', 'remark',
|
||||||
'invoice_number', 'invoice_date', 'invoice_amount',
|
'invoice_number', 'invoice_date', 'invoice_amount',
|
||||||
'total_amount', 'tax_amount', 'grand_total' // Ensure specific order for amounts
|
'total_amount', 'tax_amount', 'grand_total' // 確保金額的特定順序
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter out internal keys often logged but not useful for users
|
// 過濾掉通常會記錄但對使用者無用的內部鍵
|
||||||
const filteredKeys = allKeys
|
const filteredKeys = allKeys
|
||||||
.filter(key =>
|
.filter(key =>
|
||||||
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key)
|
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key)
|
||||||
@@ -160,16 +160,16 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
const indexA = sortOrder.indexOf(a);
|
const indexA = sortOrder.indexOf(a);
|
||||||
const indexB = sortOrder.indexOf(b);
|
const indexB = sortOrder.indexOf(b);
|
||||||
|
|
||||||
// If both are in sortOrder, compare indices
|
// 如果兩者都在排序順序中,比較索引
|
||||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||||
// If only A is in sortOrder, it comes first (or wherever logic dictates, usually put known fields first)
|
// 如果只有 A 在排序順序中,它排在前面(或根據邏輯,通常將已知欄位排在前面)
|
||||||
if (indexA !== -1) return -1;
|
if (indexA !== -1) return -1;
|
||||||
if (indexB !== -1) return 1;
|
if (indexB !== -1) return 1;
|
||||||
// Otherwise alphabetical or default
|
// 否則按字母順序或預設
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to check if a key is a snapshot name field
|
// 檢查鍵是否為快照名稱欄位的輔助函式
|
||||||
const isSnapshotField = (key: string) => {
|
const isSnapshotField = (key: string) => {
|
||||||
return [
|
return [
|
||||||
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
|
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
|
||||||
@@ -197,26 +197,26 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatValue = (key: string, value: any) => {
|
const formatValue = (key: string, value: any) => {
|
||||||
// Mask password
|
// 遮蔽密碼
|
||||||
if (key === 'password') return '******';
|
if (key === 'password') return '******';
|
||||||
|
|
||||||
if (value === null || value === undefined) return '-';
|
if (value === null || value === undefined) return '-';
|
||||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||||
if (key === 'is_active') return value ? '啟用' : '停用';
|
if (key === 'is_active') return value ? '啟用' : '停用';
|
||||||
|
|
||||||
// Handle Purchase Order Status
|
// 處理採購單狀態
|
||||||
if (key === 'status' && typeof value === 'string' && statusMap[value]) {
|
if (key === 'status' && typeof value === 'string' && statusMap[value]) {
|
||||||
return statusMap[value];
|
return statusMap[value];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Inventory Quality Status
|
// 處理庫存品質狀態
|
||||||
if (key === 'quality_status' && typeof value === 'string' && qualityStatusMap[value]) {
|
if (key === 'quality_status' && typeof value === 'string' && qualityStatusMap[value]) {
|
||||||
return qualityStatusMap[value];
|
return qualityStatusMap[value];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Date Fields (YYYY-MM-DD)
|
// 處理日期欄位 (YYYY-MM-DD)
|
||||||
if ((key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') {
|
if ((key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') {
|
||||||
// Take only the date part (YYYY-MM-DD)
|
// 僅取日期部分 (YYYY-MM-DD)
|
||||||
return value.split('T')[0].split(' ')[0];
|
return value.split('T')[0].split(' ')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +224,10 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFormattedValue = (key: string, value: any) => {
|
const getFormattedValue = (key: string, value: any) => {
|
||||||
// If it's an ID field, try to find a corresponding name in snapshot or attributes
|
// 如果是 ID 欄位,嘗試在快照或屬性中尋找對應名稱
|
||||||
if (key.endsWith('_id')) {
|
if (key.endsWith('_id')) {
|
||||||
const nameKey = key.replace('_id', '_name');
|
const nameKey = key.replace('_id', '_name');
|
||||||
// Check snapshot first, then attributes
|
// 先檢查快照,然後檢查屬性
|
||||||
const nameValue = snapshot[nameKey] || attributes[nameKey];
|
const nameValue = snapshot[nameKey] || attributes[nameKey];
|
||||||
if (nameValue) {
|
if (nameValue) {
|
||||||
return `${nameValue}`;
|
return `${nameValue}`;
|
||||||
@@ -236,14 +236,14 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
return formatValue(key, value);
|
return formatValue(key, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to get translated field label
|
// 取得翻譯欄位標籤的輔助函式
|
||||||
const getFieldLabel = (key: string) => {
|
const getFieldLabel = (key: string) => {
|
||||||
return fieldLabels[key] || key;
|
return fieldLabels[key] || key;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get subject name for header
|
// 取得標題的主題名稱
|
||||||
const getSubjectName = () => {
|
const getSubjectName = () => {
|
||||||
// Special handling for Inventory: show "Warehouse - Product"
|
// 庫存的特殊處理:顯示 "倉庫 - 商品"
|
||||||
if ((snapshot.warehouse_name || attributes.warehouse_name) && (snapshot.product_name || attributes.product_name)) {
|
if ((snapshot.warehouse_name || attributes.warehouse_name) && (snapshot.product_name || attributes.product_name)) {
|
||||||
const wName = snapshot.warehouse_name || attributes.warehouse_name;
|
const wName = snapshot.warehouse_name || attributes.warehouse_name;
|
||||||
const pName = snapshot.product_name || attributes.product_name;
|
const pName = snapshot.product_name || attributes.product_name;
|
||||||
@@ -276,7 +276,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modern Metadata Strip */}
|
{/* 現代化元數據條 */}
|
||||||
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
|
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<User className="w-4 h-4 text-gray-400" />
|
<User className="w-4 h-4 text-gray-400" />
|
||||||
@@ -293,7 +293,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
{activity.properties?.sub_subject || activity.subject_type}
|
{activity.properties?.sub_subject || activity.subject_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Only show 'description' if it differs from event name (unlikely but safe) */}
|
{/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */}
|
||||||
{activity.description !== getEventLabel(activity.event) &&
|
{activity.description !== getEventLabel(activity.event) &&
|
||||||
activity.description !== 'created' && activity.description !== 'updated' && (
|
activity.description !== 'created' && activity.description !== 'updated' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -367,7 +367,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
const newValue = attributes[key];
|
const newValue = attributes[key];
|
||||||
const isChanged = JSON.stringify(oldValue) !== JSON.stringify(newValue);
|
const isChanged = JSON.stringify(oldValue) !== JSON.stringify(newValue);
|
||||||
|
|
||||||
// For deleted events, we want to show the current attributes in the "Before" column
|
// 對於刪除事件,我們希望在 "變更前" 欄位顯示當前屬性
|
||||||
const displayBefore = activity.event === 'deleted'
|
const displayBefore = activity.event === 'deleted'
|
||||||
? getFormattedValue(key, newValue || oldValue)
|
? getFormattedValue(key, newValue || oldValue)
|
||||||
: getFormattedValue(key, oldValue);
|
: getFormattedValue(key, oldValue);
|
||||||
@@ -399,7 +399,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Items Diff Section (Special for Purchase Orders) */}
|
{/* 項目差異區塊(採購單專用) */}
|
||||||
{activity.properties?.items_diff && (
|
{activity.properties?.items_diff && (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1">
|
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1">
|
||||||
@@ -417,7 +417,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{/* Updated Items */}
|
{/* 更新項目 */}
|
||||||
{activity.properties.items_diff.updated.map((item: any, idx: number) => (
|
{activity.properties.items_diff.updated.map((item: any, idx: number) => (
|
||||||
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
|
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
|
||||||
<TableCell className="font-medium">{item.product_name}</TableCell>
|
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||||
@@ -440,7 +440,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Added Items */}
|
{/* 新增項目 */}
|
||||||
{activity.properties.items_diff.added.map((item: any, idx: number) => (
|
{activity.properties.items_diff.added.map((item: any, idx: number) => (
|
||||||
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
|
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
|
||||||
<TableCell className="font-medium">{item.product_name}</TableCell>
|
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||||
@@ -453,7 +453,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Removed Items */}
|
{/* 移除項目 */}
|
||||||
{activity.properties.items_diff.removed.map((item: any, idx: number) => (
|
{activity.properties.items_diff.removed.map((item: any, idx: number) => (
|
||||||
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
|
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
|
||||||
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
|
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface LogTableProps {
|
|||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
onSort?: (field: string) => void;
|
onSort?: (field: string) => void;
|
||||||
onViewDetail: (activity: Activity) => void;
|
onViewDetail: (activity: Activity) => void;
|
||||||
from?: number; // Starting index number (paginator.from)
|
from?: number; // 起始索引編號 (paginator.from)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogTable({
|
export default function LogTable({
|
||||||
@@ -61,12 +61,12 @@ export default function LogTable({
|
|||||||
const old = props.old || {};
|
const old = props.old || {};
|
||||||
const snapshot = props.snapshot || {};
|
const snapshot = props.snapshot || {};
|
||||||
|
|
||||||
// Try to find a name in snapshot, attributes or old values
|
// 嘗試在快照、屬性或舊值中尋找名稱
|
||||||
// Priority: snapshot > specific name fields > generic name > code > ID
|
// 優先順序:快照 > 特定名稱欄位 > 通用名稱 > 代碼 > ID
|
||||||
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category'];
|
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category'];
|
||||||
let subjectName = '';
|
let subjectName = '';
|
||||||
|
|
||||||
// Special handling for Inventory: show "Warehouse - Product"
|
// 庫存的特殊處理:顯示 "倉庫 - 商品"
|
||||||
if ((snapshot.warehouse_name || attrs.warehouse_name) && (snapshot.product_name || attrs.product_name)) {
|
if ((snapshot.warehouse_name || attrs.warehouse_name) && (snapshot.product_name || attrs.product_name)) {
|
||||||
const wName = snapshot.warehouse_name || attrs.warehouse_name;
|
const wName = snapshot.warehouse_name || attrs.warehouse_name;
|
||||||
const pName = snapshot.product_name || attrs.product_name;
|
const pName = snapshot.product_name || attrs.product_name;
|
||||||
@@ -74,7 +74,7 @@ export default function LogTable({
|
|||||||
} else if (old.warehouse_name && old.product_name) {
|
} else if (old.warehouse_name && old.product_name) {
|
||||||
subjectName = `${old.warehouse_name} - ${old.product_name}`;
|
subjectName = `${old.warehouse_name} - ${old.product_name}`;
|
||||||
} else {
|
} else {
|
||||||
// Default fallback
|
// 預設備案
|
||||||
for (const param of nameParams) {
|
for (const param of nameParams) {
|
||||||
if (snapshot[param]) {
|
if (snapshot[param]) {
|
||||||
subjectName = snapshot[param];
|
subjectName = snapshot[param];
|
||||||
@@ -91,12 +91,12 @@ export default function LogTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no name found, try ID but format it nicely if possible, or just don't show it if it's redundant with subject_type
|
// 如果找不到名稱,嘗試使用 ID,如果可能則格式化顯示,或者如果與主題類型重複則不顯示
|
||||||
if (!subjectName && (attrs.id || old.id)) {
|
if (!subjectName && (attrs.id || old.id)) {
|
||||||
subjectName = `#${attrs.id || old.id}`;
|
subjectName = `#${attrs.id || old.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine parts: [Causer] [Action] [Name] [Subject]
|
// 組合部分:[操作者] [動作] [名稱] [主題]
|
||||||
// Example: Admin 新增 可樂 商品
|
// Example: Admin 新增 可樂 商品
|
||||||
// Example: Admin 更新 台北倉 - 可樂 庫存
|
// Example: Admin 更新 台北倉 - 可樂 庫存
|
||||||
return (
|
return (
|
||||||
@@ -114,7 +114,7 @@ export default function LogTable({
|
|||||||
<span className="text-gray-700">{activity.subject_type}</span>
|
<span className="text-gray-700">{activity.subject_type}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Display reason/source if available (e.g., from Replenishment) */}
|
{/* 如果有原因/來源則顯示(例如:來自補貨) */}
|
||||||
{(attrs._reason || old._reason) && (
|
{(attrs._reason || old._reason) && (
|
||||||
<span className="text-gray-500 text-xs">
|
<span className="text-gray-500 text-xs">
|
||||||
(來自 {attrs._reason || old._reason})
|
(來自 {attrs._reason || old._reason})
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ export default function ProductDialog({
|
|||||||
setData({
|
setData({
|
||||||
code: product.code,
|
code: product.code,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
category_id: product.category_id.toString(),
|
category_id: product.categoryId.toString(),
|
||||||
brand: product.brand || "",
|
brand: product.brand || "",
|
||||||
specification: product.specification || "",
|
specification: product.specification || "",
|
||||||
base_unit_id: product.base_unit_id?.toString() || "",
|
base_unit_id: product.baseUnitId?.toString() || "",
|
||||||
large_unit_id: product.large_unit_id?.toString() || "",
|
large_unit_id: product.largeUnitId?.toString() || "",
|
||||||
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
|
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
|
||||||
purchase_unit_id: product.purchase_unit_id?.toString() || "",
|
purchase_unit_id: product.purchaseUnitId?.toString() || "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
reset();
|
reset();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import type { Product } from "@/Pages/Product/Index";
|
|||||||
interface ProductTableProps {
|
interface ProductTableProps {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
onEdit: (product: Product) => void;
|
onEdit: (product: Product) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: string) => void;
|
||||||
|
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
sortField: string | null;
|
sortField: string | null;
|
||||||
@@ -125,11 +125,11 @@ export default function ProductTable({
|
|||||||
{product.category?.name || '-'}
|
{product.category?.name || '-'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{product.base_unit?.name || '-'}</TableCell>
|
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{product.large_unit ? (
|
{product.largeUnit ? (
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
1 {product.large_unit?.name} = {Number(product.conversion_rate)} {product.base_unit?.name}
|
1 {product.largeUnit?.name} = {Number(product.conversionRate)} {product.baseUnit?.name}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function PurchaseOrderItemsTable({
|
|||||||
) : (
|
) : (
|
||||||
items.map((item, index) => {
|
items.map((item, index) => {
|
||||||
// 計算換算後的單價 (基本單位單價)
|
// 計算換算後的單價 (基本單位單價)
|
||||||
// unitPrice is derived from subtotal / quantity
|
// 單價由 小計 / 數量 推導得出
|
||||||
const currentUnitPrice = item.unitPrice;
|
const currentUnitPrice = item.unitPrice;
|
||||||
|
|
||||||
const convertedUnitPrice = item.selectedUnit === 'large' && item.conversion_rate
|
const convertedUnitPrice = item.selectedUnit === 'large' && item.conversion_rate
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { toast } from "sonner";
|
|||||||
import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react";
|
import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export interface Unit {
|
export interface Unit {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string | null;
|
code: string | null;
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ export default function UnitManagerDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
units,
|
units,
|
||||||
}: UnitManagerDialogProps) {
|
}: UnitManagerDialogProps) {
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editCode, setEditCode] = useState("");
|
const [editCode, setEditCode] = useState("");
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export default function UnitManagerDialog({
|
|||||||
setEditCode("");
|
setEditCode("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = (id: number) => {
|
const saveEdit = (id: string) => {
|
||||||
if (!editName.trim()) return;
|
if (!editName.trim()) return;
|
||||||
|
|
||||||
router.put(route("units.update", id), { name: editName, code: editCode }, {
|
router.put(route("units.update", id), { name: editName, code: editCode }, {
|
||||||
@@ -98,7 +98,7 @@ export default function UnitManagerDialog({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDelete = (id: string) => {
|
||||||
router.delete(route("units.destroy", id), {
|
router.delete(route("units.destroy", id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// 由全域 flash 處理
|
// 由全域 flash 處理
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ export default function VendorDialog({
|
|||||||
if (vendor) {
|
if (vendor) {
|
||||||
setData({
|
setData({
|
||||||
name: vendor.name,
|
name: vendor.name,
|
||||||
short_name: vendor.short_name || "",
|
short_name: vendor.shortName || "",
|
||||||
tax_id: vendor.tax_id || "",
|
tax_id: vendor.taxId || "",
|
||||||
owner: vendor.owner || "",
|
owner: vendor.owner || "",
|
||||||
contact_name: vendor.contact_name || "",
|
contact_name: vendor.contactName || "",
|
||||||
tel: vendor.tel || "",
|
tel: vendor.tel || "",
|
||||||
phone: vendor.phone || "",
|
phone: vendor.phone || "",
|
||||||
email: vendor.email || "",
|
email: vendor.email || "",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface VendorTableProps {
|
|||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
onView: (vendor: Vendor) => void;
|
onView: (vendor: Vendor) => void;
|
||||||
onEdit: (vendor: Vendor) => void;
|
onEdit: (vendor: Vendor) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: string) => void;
|
||||||
sortField: string | null;
|
sortField: string | null;
|
||||||
sortDirection: "asc" | "desc" | null;
|
sortDirection: "asc" | "desc" | null;
|
||||||
onSort: (field: string) => void;
|
onSort: (field: string) => void;
|
||||||
@@ -107,11 +107,11 @@ export default function VendorTable({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{vendor.name}</span>
|
<span className="font-medium">{vendor.name}</span>
|
||||||
{vendor.short_name && <span className="text-xs text-gray-400">{vendor.short_name}</span>}
|
{vendor.shortName && <span className="text-xs text-gray-400">{vendor.shortName}</span>}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{vendor.owner || '-'}</TableCell>
|
<TableCell>{vendor.owner || '-'}</TableCell>
|
||||||
<TableCell>{vendor.contact_name || '-'}</TableCell>
|
<TableCell>{vendor.contactName || '-'}</TableCell>
|
||||||
<TableCell>{vendor.phone || vendor.tel || '-'}</TableCell>
|
<TableCell>{vendor.phone || vendor.tel || '-'}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default function AddSafetyStockDialog({
|
|||||||
// 更新商品安全庫存量
|
// 更新商品安全庫存量
|
||||||
const updateQuantity = (productId: string, value: number) => {
|
const updateQuantity = (productId: string, value: number) => {
|
||||||
const newQuantities = new Map(productQuantities);
|
const newQuantities = new Map(productQuantities);
|
||||||
newQuantities.set(productId, value); // Allow 0
|
newQuantities.set(productId, value); // 允許為 0
|
||||||
setProductQuantities(newQuantities);
|
setProductQuantities(newQuantities);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface TransferOrderDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
order: TransferOrder | null;
|
order: TransferOrder | null;
|
||||||
warehouses: Warehouse[];
|
warehouses: Warehouse[];
|
||||||
// inventories: WarehouseInventory[]; // Removed as we fetch from API
|
// inventories: WarehouseInventory[]; // 因從 API 獲取而移除
|
||||||
onSave: (order: Omit<TransferOrder, "id" | "createdAt" | "orderNumber">) => void;
|
onSave: (order: Omit<TransferOrder, "id" | "createdAt" | "orderNumber">) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ interface AvailableProduct {
|
|||||||
batchNumber: string;
|
batchNumber: string;
|
||||||
availableQty: number;
|
availableQty: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
|
expiryDate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransferOrderDialog({
|
export default function TransferOrderDialog({
|
||||||
@@ -99,7 +100,15 @@ export default function TransferOrderDialog({
|
|||||||
if (formData.sourceWarehouseId) {
|
if (formData.sourceWarehouseId) {
|
||||||
axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId))
|
axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
setAvailableProducts(response.data);
|
const mappedData = response.data.map((item: any) => ({
|
||||||
|
productId: item.product_id,
|
||||||
|
productName: item.product_name,
|
||||||
|
batchNumber: item.batch_number,
|
||||||
|
availableQty: item.quantity,
|
||||||
|
unit: item.unit_name,
|
||||||
|
expiryDate: item.expiry_date
|
||||||
|
}));
|
||||||
|
setAvailableProducts(mappedData);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error("Failed to fetch inventories:", error);
|
console.error("Failed to fetch inventories:", error);
|
||||||
@@ -240,7 +249,7 @@ export default function TransferOrderDialog({
|
|||||||
onValueChange={handleProductChange}
|
onValueChange={handleProductChange}
|
||||||
disabled={!formData.sourceWarehouseId || !!order}
|
disabled={!formData.sourceWarehouseId || !!order}
|
||||||
options={availableProducts.map((product) => ({
|
options={availableProducts.map((product) => ({
|
||||||
label: `${product.productName} (庫存: ${product.availableQty} ${product.unit})`,
|
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})`,
|
||||||
value: `${product.productId}|||${product.batchNumber}`,
|
value: `${product.productId}|||${product.batchNumber}`,
|
||||||
}))}
|
}))}
|
||||||
placeholder="選擇商品與批號"
|
placeholder="選擇商品與批號"
|
||||||
|
|||||||
@@ -78,8 +78,17 @@ export default function WarehouseCard({
|
|||||||
{warehouse.description || "無描述"}
|
{warehouse.description || "無描述"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 統計區塊 - 庫存警告 */}
|
|
||||||
|
{/* 統計區塊 - 狀態標籤 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* 銷售狀態 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">銷售狀態</span>
|
||||||
|
<Badge variant={warehouse.is_sellable ? "default" : "secondary"} className={warehouse.is_sellable ? "bg-green-600" : "bg-gray-400"}>
|
||||||
|
{warehouse.is_sellable ? "可銷售" : "暫停銷售"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 低庫存警告狀態 */}
|
{/* 低庫存警告狀態 */}
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||||
<div className="flex items-center gap-2 text-gray-600">
|
<div className="flex items-center gap-2 text-gray-600">
|
||||||
|
|||||||
@@ -51,11 +51,13 @@ export default function WarehouseDialog({
|
|||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
is_sellable: boolean;
|
||||||
}>({
|
}>({
|
||||||
code: "",
|
code: "",
|
||||||
name: "",
|
name: "",
|
||||||
address: "",
|
address: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
is_sellable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
@@ -67,6 +69,7 @@ export default function WarehouseDialog({
|
|||||||
name: warehouse.name,
|
name: warehouse.name,
|
||||||
address: warehouse.address || "",
|
address: warehouse.address || "",
|
||||||
description: warehouse.description || "",
|
description: warehouse.description || "",
|
||||||
|
is_sellable: warehouse.is_sellable ?? true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -74,6 +77,7 @@ export default function WarehouseDialog({
|
|||||||
name: "",
|
name: "",
|
||||||
address: "",
|
address: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
is_sellable: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [warehouse, open]);
|
}, [warehouse, open]);
|
||||||
@@ -148,6 +152,23 @@ export default function WarehouseDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 銷售設定 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b pb-2">
|
||||||
|
<h4 className="text-sm text-gray-700">銷售設定</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_sellable"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary-main focus:ring-primary-main"
|
||||||
|
checked={formData.is_sellable}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_sellable: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is_sellable">此倉庫可進行銷售扣庫</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 區塊 B:位置 */}
|
{/* 區塊 B:位置 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border-b pb-2">
|
<div className="border-b pb-2">
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Wallet,
|
Wallet,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
FileSpreadsheet
|
FileSpreadsheet,
|
||||||
|
BookOpen
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
@@ -133,8 +134,15 @@ export default function AuthenticatedLayout({
|
|||||||
id: "production-management",
|
id: "production-management",
|
||||||
label: "生產管理",
|
label: "生產管理",
|
||||||
icon: <Boxes className="h-5 w-5" />,
|
icon: <Boxes className="h-5 w-5" />,
|
||||||
permission: "production_orders.view",
|
permission: ["production_orders.view", "recipes.view"],
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
id: "recipe-list",
|
||||||
|
label: "配方管理",
|
||||||
|
icon: <BookOpen className="h-4 w-4" />,
|
||||||
|
route: "/recipes",
|
||||||
|
permission: "recipes.view",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "production-order-list",
|
id: "production-order-list",
|
||||||
label: "生產工單",
|
label: "生產工單",
|
||||||
@@ -532,7 +540,7 @@ export default function AuthenticatedLayout({
|
|||||||
"flex-1 flex flex-col transition-all duration-300 min-h-screen overflow-auto",
|
"flex-1 flex flex-col transition-all duration-300 min-h-screen overflow-auto",
|
||||||
"lg:ml-64",
|
"lg:ml-64",
|
||||||
isCollapsed && "lg:ml-20",
|
isCollapsed && "lg:ml-20",
|
||||||
"pt-16" // Always allow space for header
|
"pt-16" // 始終為頁首保留空間
|
||||||
)}>
|
)}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="container mx-auto px-6 pt-6 max-w-7xl">
|
<div className="container mx-auto px-6 pt-6 max-w-7xl">
|
||||||
|
|||||||
@@ -20,22 +20,22 @@ export interface Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
category_id: number;
|
categoryId: number;
|
||||||
category?: Category;
|
category?: Category;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
base_unit_id: number;
|
baseUnitId: number;
|
||||||
base_unit?: Unit;
|
baseUnit?: Unit;
|
||||||
large_unit_id?: number;
|
largeUnitId?: number;
|
||||||
large_unit?: Unit;
|
largeUnit?: Unit;
|
||||||
conversion_rate?: number;
|
conversionRate?: number;
|
||||||
purchase_unit_id?: number;
|
purchaseUnitId?: number;
|
||||||
purchase_unit?: Unit;
|
purchaseUnit?: Unit;
|
||||||
created_at: string;
|
createdAt?: string;
|
||||||
updated_at: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -163,7 +163,7 @@ export default function ProductManagement({ products, categories, units, filters
|
|||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProduct = (id: number) => {
|
const handleDeleteProduct = (id: string) => {
|
||||||
router.delete(route('products.destroy', id), {
|
router.delete(route('products.destroy', id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Toast handled by flash message
|
// Toast handled by flash message
|
||||||
|
|||||||
@@ -53,18 +53,18 @@ interface InventoryOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BomItem {
|
interface BomItem {
|
||||||
// Backend required
|
// 後端必填
|
||||||
inventory_id: string; // The selected inventory record ID (Specific Batch)
|
inventory_id: string; // 所選庫存記錄 ID(特定批號)
|
||||||
quantity_used: string; // The converted final quantity (Base Unit)
|
quantity_used: string; // 轉換後的最終數量(基本單位)
|
||||||
unit_id: string; // The unit ID (Base Unit ID usually)
|
unit_id: string; // 單位 ID(通常為基本單位 ID)
|
||||||
|
|
||||||
// UI State
|
// UI 狀態
|
||||||
ui_warehouse_id: string; // Source Warehouse
|
ui_warehouse_id: string; // 來源倉庫
|
||||||
ui_product_id: string; // Filter for batch list
|
ui_product_id: string; // 批號列表篩選
|
||||||
ui_input_quantity: string; // User typed quantity
|
ui_input_quantity: string; // 使用者輸入數量
|
||||||
ui_selected_unit: 'base' | 'large'; // User selected unit
|
ui_selected_unit: 'base' | 'large'; // 使用者選擇單位
|
||||||
|
|
||||||
// UI Helpers / Cache
|
// UI 輔助 / 快取
|
||||||
ui_product_name?: string;
|
ui_product_name?: string;
|
||||||
ui_batch_number?: string;
|
ui_batch_number?: string;
|
||||||
ui_available_qty?: number;
|
ui_available_qty?: number;
|
||||||
@@ -83,8 +83,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductionCreate({ products, warehouses }: Props) {
|
export default function ProductionCreate({ products, warehouses }: Props) {
|
||||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // Output Warehouse
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
||||||
// Cache map: warehouse_id -> inventories
|
// 快取對照表:warehouse_id -> inventories
|
||||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to fetch warehouse data
|
// 獲取倉庫資料的輔助函式
|
||||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
const fetchWarehouseInventory = async (warehouseId: string) => {
|
||||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
||||||
|
|
||||||
|
|||||||
@@ -52,18 +52,18 @@ interface InventoryOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BomItem {
|
interface BomItem {
|
||||||
// Backend required
|
// 後端必填
|
||||||
inventory_id: string;
|
inventory_id: string;
|
||||||
quantity_used: string;
|
quantity_used: string;
|
||||||
unit_id: string;
|
unit_id: string;
|
||||||
|
|
||||||
// UI State
|
// UI 狀態
|
||||||
ui_warehouse_id: string; // Source Warehouse
|
ui_warehouse_id: string; // 來源倉庫
|
||||||
ui_product_id: string;
|
ui_product_id: string;
|
||||||
ui_input_quantity: string;
|
ui_input_quantity: string;
|
||||||
ui_selected_unit: 'base' | 'large';
|
ui_selected_unit: 'base' | 'large';
|
||||||
|
|
||||||
// UI Helpers / Cache
|
// UI 輔助 / 快取
|
||||||
ui_product_name?: string;
|
ui_product_name?: string;
|
||||||
ui_batch_number?: string;
|
ui_batch_number?: string;
|
||||||
ui_available_qty?: number;
|
ui_available_qty?: number;
|
||||||
@@ -134,13 +134,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
|
|
||||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
|
||||||
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
||||||
); // Output Warehouse
|
); // 產出倉庫
|
||||||
|
|
||||||
// Cache map: warehouse_id -> inventories
|
// 快取對照表:warehouse_id -> inventories
|
||||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// Helper to fetch warehouse data
|
// 獲取倉庫資料的輔助函式
|
||||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
const fetchWarehouseInventory = async (warehouseId: string) => {
|
||||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
|
ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
|
||||||
ui_selected_unit: 'base',
|
ui_selected_unit: 'base',
|
||||||
|
|
||||||
// UI Helpers
|
// UI 輔助
|
||||||
ui_product_name: item.inventory?.product?.name,
|
ui_product_name: item.inventory?.product?.name,
|
||||||
ui_batch_number: item.inventory?.batch_number,
|
ui_batch_number: item.inventory?.batch_number,
|
||||||
ui_available_qty: item.inventory?.quantity,
|
ui_available_qty: item.inventory?.quantity,
|
||||||
@@ -600,7 +600,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
||||||
).values());
|
).values());
|
||||||
|
|
||||||
// Fallback for initial state before fetch
|
// 在獲取前初始狀態的備案
|
||||||
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
|
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
|
||||||
|
|
||||||
const batchOptions = currentOptions
|
const batchOptions = currentOptions
|
||||||
@@ -610,7 +610,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
value: String(inv.id)
|
value: String(inv.id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fallback
|
// 備案
|
||||||
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
320
resources/js/Pages/Production/Recipe/Create.tsx
Normal file
320
resources/js/Pages/Production/Recipe/Create.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* 新增配方頁面
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import { Head, router, useForm, Link } from "@inertiajs/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { Textarea } from "@/Components/ui/textarea";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
base_unit_id?: number;
|
||||||
|
large_unit_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Unit {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipeItem {
|
||||||
|
product_id: string;
|
||||||
|
quantity: string;
|
||||||
|
unit_id: string;
|
||||||
|
remark: string;
|
||||||
|
// UI Helpers
|
||||||
|
ui_product_name?: string;
|
||||||
|
ui_product_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
products: Product[];
|
||||||
|
units: Unit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeCreate({ products, units }: Props) {
|
||||||
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
|
product_id: "",
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
yield_quantity: "1",
|
||||||
|
items: [] as RecipeItem[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自動產生配方名稱 (當選擇商品時)
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.product_id && !data.name) {
|
||||||
|
const product = products.find(p => String(p.id) === data.product_id);
|
||||||
|
if (product) {
|
||||||
|
setData(d => ({ ...d, name: `${product.name} 標準配方` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自動產生代號 (簡易版)
|
||||||
|
if (data.product_id && !data.code) {
|
||||||
|
const product = products.find(p => String(p.id) === data.product_id);
|
||||||
|
if (product) {
|
||||||
|
setData(d => ({ ...d, code: `REC-${product.code}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data.product_id]);
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setData('items', [
|
||||||
|
...data.items,
|
||||||
|
{ product_id: "", quantity: "1", unit_id: "", remark: "" }
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
setData('items', data.items.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (index: number, field: keyof RecipeItem, value: string) => {
|
||||||
|
const newItems = [...data.items];
|
||||||
|
newItems[index] = { ...newItems[index], [field]: value };
|
||||||
|
|
||||||
|
// Auto-fill unit when product selected
|
||||||
|
if (field === 'product_id') {
|
||||||
|
const product = products.find(p => String(p.id) === value);
|
||||||
|
if (product) {
|
||||||
|
newItems[index].ui_product_name = product.name;
|
||||||
|
newItems[index].ui_product_code = product.code;
|
||||||
|
// Default to base unit
|
||||||
|
if (product.base_unit_id) {
|
||||||
|
newItems[index].unit_id = String(product.base_unit_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData('items', newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post(route('recipes.store'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("配方已建立");
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
toast.error("儲存失敗,請檢查欄位");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "新增", isPage: true }])}>
|
||||||
|
<Head title="新增配方" />
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href={route('recipes.index')}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 button-outlined-primary mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6 text-primary-main" />
|
||||||
|
新增配方
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
定義新的生產配方
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={processing}
|
||||||
|
className="button-filled-primary gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
儲存配方
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 左側:基本資料 */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">基本資料</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">對應成品 *</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={data.product_id}
|
||||||
|
onValueChange={(v) => setData('product_id', v)}
|
||||||
|
options={products.map(p => ({
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
value: String(p.id),
|
||||||
|
}))}
|
||||||
|
placeholder="選擇商品"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{errors.product_id && <p className="text-red-500 text-xs">{errors.product_id}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">配方代號 *</Label>
|
||||||
|
<Input
|
||||||
|
value={data.code}
|
||||||
|
onChange={(e) => setData('code', e.target.value)}
|
||||||
|
placeholder="例如: REC-P001"
|
||||||
|
/>
|
||||||
|
{errors.code && <p className="text-red-500 text-xs">{errors.code}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">配方名稱 *</Label>
|
||||||
|
<Input
|
||||||
|
value={data.name}
|
||||||
|
onChange={(e) => setData('name', e.target.value)}
|
||||||
|
placeholder="例如: 草莓冰標準配方"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">標準產出量 *</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={data.yield_quantity}
|
||||||
|
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">份</span>
|
||||||
|
</div>
|
||||||
|
{errors.yield_quantity && <p className="text-red-500 text-xs">{errors.yield_quantity}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">描述</Label>
|
||||||
|
<Textarea
|
||||||
|
value={data.description}
|
||||||
|
onChange={(e) => setData('description', e.target.value)}
|
||||||
|
placeholder="備註說明..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右側:配方明細 */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-full">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">配方明細 (BOM)</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addItem}
|
||||||
|
className="gap-2 button-filled-primary text-white"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新增原料
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[35%]">原物料商品</TableHead>
|
||||||
|
<TableHead className="w-[20%]">標準用量</TableHead>
|
||||||
|
<TableHead className="w-[20%]">單位</TableHead>
|
||||||
|
<TableHead className="w-[20%]">備註</TableHead>
|
||||||
|
<TableHead className="w-[5%]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="h-24 text-center text-gray-500">
|
||||||
|
請新增原物料項目
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((item, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.product_id}
|
||||||
|
onValueChange={(v) => updateItem(index, 'product_id', v)}
|
||||||
|
options={products.map(p => ({
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
value: String(p.id)
|
||||||
|
}))}
|
||||||
|
placeholder="選擇原料"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||||
|
placeholder="數量"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.unit_id}
|
||||||
|
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||||
|
options={units.map(u => ({
|
||||||
|
label: u.name,
|
||||||
|
value: String(u.id)
|
||||||
|
}))}
|
||||||
|
placeholder="單位"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Input
|
||||||
|
value={item.remark}
|
||||||
|
onChange={(e) => updateItem(index, 'remark', e.target.value)}
|
||||||
|
placeholder="備註"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
resources/js/Pages/Production/Recipe/Edit.tsx
Normal file
344
resources/js/Pages/Production/Recipe/Edit.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* 編輯配方頁面
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import { Head, router, useForm, Link } from "@inertiajs/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { Textarea } from "@/Components/ui/textarea";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
base_unit_id?: number;
|
||||||
|
large_unit_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Unit {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend Model Structure
|
||||||
|
interface RecipeItemModel {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
quantity: number;
|
||||||
|
unit_id: number;
|
||||||
|
remark: string | null;
|
||||||
|
product?: Product;
|
||||||
|
unit?: Unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipeModel {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
yield_quantity: number;
|
||||||
|
items: RecipeItemModel[];
|
||||||
|
product?: Product;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form State Structure
|
||||||
|
interface RecipeItemForm {
|
||||||
|
product_id: string;
|
||||||
|
quantity: string;
|
||||||
|
unit_id: string;
|
||||||
|
remark: string;
|
||||||
|
// UI Helpers
|
||||||
|
ui_product_name?: string;
|
||||||
|
ui_product_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recipe: RecipeModel;
|
||||||
|
products: Product[];
|
||||||
|
units: Unit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeEdit({ recipe, products, units }: Props) {
|
||||||
|
const { data, setData, put, processing, errors } = useForm({
|
||||||
|
product_id: String(recipe.product_id),
|
||||||
|
code: recipe.code,
|
||||||
|
name: recipe.name,
|
||||||
|
description: recipe.description || "",
|
||||||
|
yield_quantity: String(recipe.yield_quantity),
|
||||||
|
items: recipe.items.map(item => ({
|
||||||
|
product_id: String(item.product_id),
|
||||||
|
quantity: String(item.quantity),
|
||||||
|
unit_id: String(item.unit_id),
|
||||||
|
remark: item.remark || "",
|
||||||
|
ui_product_name: item.product?.name,
|
||||||
|
ui_product_code: item.product?.code
|
||||||
|
})) as RecipeItemForm[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自動產生配方名稱 (當選擇商品時) - 僅在名稱為空時觸發,避免覆蓋舊資料
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.product_id && !data.name) {
|
||||||
|
const product = products.find(p => String(p.id) === data.product_id);
|
||||||
|
if (product) {
|
||||||
|
setData(d => ({ ...d, name: `${product.name} 標準配方` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data.product_id]);
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setData('items', [
|
||||||
|
...data.items,
|
||||||
|
{ product_id: "", quantity: "1", unit_id: "", remark: "" }
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
setData('items', data.items.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (index: number, field: keyof RecipeItemForm, value: string) => {
|
||||||
|
const newItems = [...data.items];
|
||||||
|
newItems[index] = { ...newItems[index], [field]: value };
|
||||||
|
|
||||||
|
// Auto-fill unit when product selected
|
||||||
|
if (field === 'product_id') {
|
||||||
|
const product = products.find(p => String(p.id) === value);
|
||||||
|
if (product) {
|
||||||
|
newItems[index].ui_product_name = product.name;
|
||||||
|
newItems[index].ui_product_code = product.code;
|
||||||
|
// Default to base unit if not set
|
||||||
|
if (product.base_unit_id) {
|
||||||
|
newItems[index].unit_id = String(product.base_unit_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData('items', newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
put(route('recipes.update', recipe.id), {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("配方已更新");
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
toast.error("儲存失敗,請檢查欄位");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "編輯", isPage: true }])}>
|
||||||
|
<Head title="編輯配方" />
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href={route('recipes.index')}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 button-outlined-primary mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6 text-primary-main" />
|
||||||
|
編輯配方
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
修改 {recipe.name} ({recipe.code})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={processing}
|
||||||
|
className="button-filled-primary gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
儲存變更
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 左側:基本資料 */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">基本資料</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">對應成品 *</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={data.product_id}
|
||||||
|
onValueChange={(v) => setData('product_id', v)}
|
||||||
|
options={products.map(p => ({
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
value: String(p.id),
|
||||||
|
}))}
|
||||||
|
placeholder="選擇商品"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{errors.product_id && <p className="text-red-500 text-xs">{errors.product_id}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">配方代號 *</Label>
|
||||||
|
<Input
|
||||||
|
value={data.code}
|
||||||
|
onChange={(e) => setData('code', e.target.value)}
|
||||||
|
placeholder="例如: REC-P001"
|
||||||
|
/>
|
||||||
|
{errors.code && <p className="text-red-500 text-xs">{errors.code}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">配方名稱 *</Label>
|
||||||
|
<Input
|
||||||
|
value={data.name}
|
||||||
|
onChange={(e) => setData('name', e.target.value)}
|
||||||
|
placeholder="例如: 草莓冰標準配方"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">標準產出量 *</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={data.yield_quantity}
|
||||||
|
onChange={(e) => setData('yield_quantity', e.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">份</span>
|
||||||
|
</div>
|
||||||
|
{errors.yield_quantity && <p className="text-red-500 text-xs">{errors.yield_quantity}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">描述</Label>
|
||||||
|
<Textarea
|
||||||
|
value={data.description}
|
||||||
|
onChange={(e) => setData('description', e.target.value)}
|
||||||
|
placeholder="備註說明..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右側:配方明細 */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-full">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">配方明細 (BOM)</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addItem}
|
||||||
|
className="gap-2 button-filled-primary text-white"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新增原料
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[35%]">原物料商品</TableHead>
|
||||||
|
<TableHead className="w-[20%]">標準用量</TableHead>
|
||||||
|
<TableHead className="w-[20%]">單位</TableHead>
|
||||||
|
<TableHead className="w-[20%]">備註</TableHead>
|
||||||
|
<TableHead className="w-[5%]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="h-24 text-center text-gray-500">
|
||||||
|
請新增原物料項目
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((item, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.product_id}
|
||||||
|
onValueChange={(v) => updateItem(index, 'product_id', v)}
|
||||||
|
options={products.map(p => ({
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
value: String(p.id)
|
||||||
|
}))}
|
||||||
|
placeholder="選擇原料"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, 'quantity', e.target.value)}
|
||||||
|
placeholder="數量"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.unit_id}
|
||||||
|
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
||||||
|
options={units.map(u => ({
|
||||||
|
label: u.name,
|
||||||
|
value: String(u.id)
|
||||||
|
}))}
|
||||||
|
placeholder="單位"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Input
|
||||||
|
value={item.remark}
|
||||||
|
onChange={(e) => updateItem(index, 'remark', e.target.value)}
|
||||||
|
placeholder="備註"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
resources/js/Pages/Production/Recipe/Index.tsx
Normal file
263
resources/js/Pages/Production/Recipe/Index.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* 配方管理主頁面
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen } from 'lucide-react';
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import { Head, router, Link } from "@inertiajs/react";
|
||||||
|
import Pagination from "@/Components/shared/Pagination";
|
||||||
|
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
|
||||||
|
interface Recipe {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
product_id: number;
|
||||||
|
product?: { id: number; name: string; code: string };
|
||||||
|
yield_quantity: number;
|
||||||
|
is_active: boolean;
|
||||||
|
description: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recipes: {
|
||||||
|
data: Recipe[];
|
||||||
|
links: any[];
|
||||||
|
total: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
search?: string;
|
||||||
|
per_page?: string;
|
||||||
|
sort_field?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeIndex({ recipes, filters }: Props) {
|
||||||
|
const [search, setSearch] = useState(filters.search || "");
|
||||||
|
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch(filters.search || "");
|
||||||
|
setPerPage(filters.per_page || "10");
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const handleFilter = () => {
|
||||||
|
router.get(
|
||||||
|
route('recipes.index'),
|
||||||
|
{
|
||||||
|
search,
|
||||||
|
per_page: perPage,
|
||||||
|
},
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearch("");
|
||||||
|
router.get(route('recipes.index'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePerPageChange = (value: string) => {
|
||||||
|
setPerPage(value);
|
||||||
|
router.get(
|
||||||
|
route("recipes.index"),
|
||||||
|
{ ...filters, per_page: value },
|
||||||
|
{ preserveState: false, replace: true, preserveScroll: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
if (confirm("確定要刪除此配方嗎?")) {
|
||||||
|
router.delete(route('recipes.destroy', id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
|
||||||
|
<Head title="配方管理" />
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6 text-primary-main" />
|
||||||
|
配方管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
管理產品的標準生產配方與用量
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href={route('recipes.create')}>
|
||||||
|
<Button className="gap-2 button-filled-primary">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新增配方
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 篩選區塊 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mb-6 overflow-hidden">
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||||
|
<div className="md:col-span-12 space-y-1">
|
||||||
|
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜尋配方代號、名稱、產品名稱..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 h-9 block"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end px-5 py-4 bg-gray-50/50 border-t border-gray-100 gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="button-outlined-primary h-9 gap-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleFilter}
|
||||||
|
className="button-filled-primary h-9 px-6 gap-2"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
搜尋
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 配方列表 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[120px]">配方代號</TableHead>
|
||||||
|
<TableHead>配方名稱</TableHead>
|
||||||
|
<TableHead>對應成品</TableHead>
|
||||||
|
<TableHead className="text-right">標準產量</TableHead>
|
||||||
|
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
||||||
|
<TableHead className="w-[150px]">更新時間</TableHead>
|
||||||
|
<TableHead className="text-center w-[120px]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recipes.data.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-32 text-center text-gray-500">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<BookOpen className="h-10 w-10 text-gray-300" />
|
||||||
|
<p>尚無配方資料</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
recipes.data.map((recipe) => (
|
||||||
|
<TableRow key={recipe.id}>
|
||||||
|
<TableCell className="font-medium text-gray-900">
|
||||||
|
{recipe.code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-gray-900">{recipe.name}</span>
|
||||||
|
{recipe.description && (
|
||||||
|
<span className="text-gray-400 text-xs truncate max-w-[200px]">
|
||||||
|
{recipe.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{recipe.product ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{recipe.product.name}</span>
|
||||||
|
<span className="text-xs text-gray-400">{recipe.product.code}</span>
|
||||||
|
</div>
|
||||||
|
) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{recipe.yield_quantity}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant={recipe.is_active ? "default" : "secondary"}>
|
||||||
|
{recipe.is_active ? "啟用" : "停用"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{new Date(recipe.updated_at).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Link href={route('recipes.edit', recipe.id)}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
title="編輯"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(recipe.id)}
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
title="刪除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分頁 */}
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>每頁顯示</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={perPage}
|
||||||
|
onValueChange={handlePerPageChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: "10" },
|
||||||
|
{ label: "20", value: "20" },
|
||||||
|
{ label: "50", value: "50" },
|
||||||
|
{ label: "100", value: "100" }
|
||||||
|
]}
|
||||||
|
className="w-[100px] h-8"
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<span>筆</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
|
||||||
|
<Pagination links={recipes.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
resources/js/Pages/Vendor/Index.tsx
vendored
14
resources/js/Pages/Vendor/Index.tsx
vendored
@@ -13,20 +13,20 @@ import Pagination from "@/Components/shared/Pagination";
|
|||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
|
||||||
export interface Vendor {
|
export interface Vendor {
|
||||||
id: number;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
short_name?: string;
|
shortName?: string;
|
||||||
tax_id?: string;
|
taxId?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
contact_name?: string;
|
contactName?: string;
|
||||||
tel?: string;
|
tel?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
created_at: string;
|
createdAt?: string;
|
||||||
updated_at: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -126,7 +126,7 @@ export default function VendorManagement({ vendors, filters }: PageProps) {
|
|||||||
router.get(route("vendors.show", vendor.id));
|
router.get(route("vendors.show", vendor.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteVendor = (id: number) => {
|
const handleDeleteVendor = (id: string) => {
|
||||||
router.delete(route('vendors.destroy', id));
|
router.delete(route('vendors.destroy', id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Pagination from "@/Components/shared/Pagination";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
import { Can } from "@/Components/Permission/Can";
|
import { Can } from "@/Components/Permission/Can";
|
||||||
|
import { Card, CardContent } from "@/Components/ui/card";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
warehouses: {
|
warehouses: {
|
||||||
@@ -22,12 +23,16 @@ interface PageProps {
|
|||||||
last_page: number;
|
last_page: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
totals: {
|
||||||
|
available_stock: number;
|
||||||
|
book_stock: number;
|
||||||
|
};
|
||||||
filters: {
|
filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
export default function WarehouseIndex({ warehouses, totals, filters }: PageProps) {
|
||||||
// 篩選狀態
|
// 篩選狀態
|
||||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||||
|
|
||||||
@@ -119,6 +124,31 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 統計區塊 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-gray-500 mb-1">可用庫存總計</span>
|
||||||
|
<span className="text-3xl font-bold text-blue-600">
|
||||||
|
{totals.available_stock.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-gray-500 mb-1">帳面庫存總計</span>
|
||||||
|
<span className="text-3xl font-bold text-gray-700">
|
||||||
|
{totals.book_stock.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 工具列 */}
|
{/* 工具列 */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export interface Warehouse {
|
|||||||
total_quantity?: number;
|
total_quantity?: number;
|
||||||
low_stock_count?: number;
|
low_stock_count?: number;
|
||||||
type?: WarehouseType;
|
type?: WarehouseType;
|
||||||
|
is_sellable?: boolean; // 新增欄位
|
||||||
|
book_stock?: number; // 帳面庫存
|
||||||
|
available_stock?: number; // 可用庫存
|
||||||
}
|
}
|
||||||
// 倉庫中的庫存項目
|
// 倉庫中的庫存項目
|
||||||
export interface WarehouseInventory {
|
export interface WarehouseInventory {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const BREADCRUMB_MAP: Record<string, BreadcrumbItemType[]> = {
|
|||||||
{ label: "生產工單", href: "/production-orders" },
|
{ label: "生產工單", href: "/production-orders" },
|
||||||
{ label: "詳情", isPage: true }
|
{ label: "詳情", isPage: true }
|
||||||
],
|
],
|
||||||
|
recipes: [
|
||||||
|
{ label: "生產管理" },
|
||||||
|
{ label: "配方管理", href: "/recipes", isPage: true }
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
123
tests/Feature/PurchaseOrderTest.php
Normal file
123
tests/Feature/PurchaseOrderTest.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Modules\Core\Models\User;
|
||||||
|
use App\Modules\Inventory\Models\Product;
|
||||||
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
|
use App\Modules\Inventory\Models\Unit;
|
||||||
|
use App\Modules\Inventory\Models\Category;
|
||||||
|
use App\Modules\Procurement\Models\Vendor;
|
||||||
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PurchaseOrderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected $tenant;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Create a unique tenant for this test run
|
||||||
|
$tenantId = 'test_' . str_replace('.', '', microtime(true));
|
||||||
|
|
||||||
|
$this->tenant = \App\Modules\Core\Models\Tenant::create([
|
||||||
|
'id' => $tenantId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tenant->domains()->create(['domain' => $tenantId . '.test']);
|
||||||
|
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
|
||||||
|
// Run PermissionSeeder to ensure roles/permissions exist
|
||||||
|
$this->seed(\Database\Seeders\PermissionSeeder::class);
|
||||||
|
|
||||||
|
// Ensure Unit exists (in Tenant DB)
|
||||||
|
Unit::firstOrCreate(['code' => 'PC'], ['name' => 'Piece']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setupUserWithRole()
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->assignRole('super-admin');
|
||||||
|
$this->actingAs($user);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_hydrates_warehouse()
|
||||||
|
{
|
||||||
|
$user = $this->setupUserWithRole();
|
||||||
|
|
||||||
|
$warehouse = Warehouse::create(['name' => 'Main Warehouse', 'code' => 'WH01']);
|
||||||
|
$vendor = Vendor::create(['name' => 'Tech Corp', 'code' => 'V01']);
|
||||||
|
|
||||||
|
PurchaseOrder::create([
|
||||||
|
'code' => 'PO-TEST-001',
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'warehouse_id' => $warehouse->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'draft',
|
||||||
|
'total_amount' => 1000,
|
||||||
|
'tax_amount' => 50,
|
||||||
|
'grand_total' => 1050,
|
||||||
|
'expected_delivery_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('purchase-orders.index'));
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('PurchaseOrder/Index')
|
||||||
|
->where('orders.data.0.warehouse_name', 'Main Warehouse')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_hydrates_vendor_products()
|
||||||
|
{
|
||||||
|
$this->setupUserWithRole();
|
||||||
|
|
||||||
|
// Setup Data
|
||||||
|
$vendor = Vendor::create(['name' => 'Mega Supplier', 'code' => 'V02']);
|
||||||
|
|
||||||
|
$unit = Unit::first() ?? Unit::create(['name' => 'Box', 'code' => 'BX']);
|
||||||
|
$category = Category::create(['name' => 'General', 'code' => 'GEN']);
|
||||||
|
|
||||||
|
// Manual Product Creation
|
||||||
|
$product = Product::forceCreate([
|
||||||
|
'name' => 'Super Widget',
|
||||||
|
'code' => 'WIDGET-01',
|
||||||
|
'base_unit_id' => $unit->id,
|
||||||
|
'purchase_unit_id' => $unit->id,
|
||||||
|
'large_unit_id' => $unit->id,
|
||||||
|
'conversion_rate' => 1,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attach to Pivot manually (Strict Mode: no relations!)
|
||||||
|
DB::table('product_vendor')->insert([
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'last_price' => 150.00,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('purchase-orders.create'));
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
// Verify Hydration Logic in suppliers prop
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('PurchaseOrder/Create')
|
||||||
|
->where('suppliers.0.name', 'Mega Supplier')
|
||||||
|
->has('suppliers.0.commonProducts')
|
||||||
|
->where('suppliers.0.commonProducts.0.productName', 'Super Widget')
|
||||||
|
->where('suppliers.0.commonProducts.0.lastPrice', 150) // Changed from 150.0 to 150
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user