From 106de4e94554f04b9de76b50104951d8dab781b7 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 26 Jan 2026 14:59:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=AD=A3=E5=BA=AB=E5=AD=98?= =?UTF-8?q?=E8=88=87=E6=92=A5=E8=A3=9C=E5=96=AE=E9=82=8F=E8=BC=AF=E4=B8=A6?= =?UTF-8?q?=E6=95=B4=E5=90=88=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修復倉庫統計數據加總與樣式。 2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。 3. 撥補單商品列表加入批號與效期顯示。 4. 修正撥補單儲存邏輯以支援精確批號轉移。 5. 整合 FEATURES.md 至 README.md。 --- FEATURES.md | 158 ------- README.md | 98 ++++- .../Core/Contracts/CoreServiceInterface.php | 31 ++ .../Controllers/ActivityLogController.php | 4 +- .../Core/Controllers/RoleController.php | 14 +- .../Core/Controllers/UserController.php | 32 +- app/Modules/Core/CoreServiceProvider.php | 20 + app/Modules/Core/Models/User.php | 16 +- app/Modules/Core/Services/CoreService.php | 42 ++ .../Contracts/FinanceServiceInterface.php | 32 ++ .../AccountingReportController.php | 127 ++---- .../Controllers/UtilityFeeController.php | 116 +----- .../Finance/FinanceServiceProvider.php | 20 + app/Modules/Finance/Models/UtilityFee.php | 31 +- .../Finance/Services/FinanceService.php | 104 +++++ .../Contracts/InventoryServiceInterface.php | 100 +++++ .../Controllers/InventoryController.php | 59 +-- .../Controllers/ProductController.php | 56 ++- .../Controllers/TransferOrderController.php | 22 +- .../Inventory/Controllers/UnitController.php | 8 +- .../Controllers/WarehouseController.php | 38 +- .../Inventory/InventoryServiceProvider.php | 20 + app/Modules/Inventory/Models/Inventory.php | 20 +- .../Inventory/Models/InventoryTransaction.php | 2 +- app/Modules/Inventory/Models/Product.php | 15 +- app/Modules/Inventory/Models/Warehouse.php | 12 +- .../Inventory/Services/InventoryService.php | 168 ++++++++ .../Contracts/ProcurementServiceInterface.php | 27 ++ .../Controllers/PurchaseOrderController.php | 360 ++++++++++------ .../Controllers/VendorController.php | 79 +++- .../Controllers/VendorProductController.php | 12 +- .../Procurement/Models/PurchaseOrder.php | 28 +- .../Procurement/Models/PurchaseOrderItem.php | 5 +- app/Modules/Procurement/Models/Vendor.php | 18 +- .../ProcurementServiceProvider.php | 20 + .../Services/ProcurementService.php | 23 ++ .../Controllers/ProductionOrderController.php | 390 ++++++++++-------- .../Controllers/RecipeController.php | 191 +++++++++ .../Production/Models/ProductionOrder.php | 69 +++- .../Production/Models/ProductionOrderItem.php | 17 +- app/Modules/Production/Models/Recipe.php | 34 ++ app/Modules/Production/Models/RecipeItem.php | 31 ++ app/Modules/Production/Routes/web.php | 4 + .../Shared/Contracts/ServiceInterface.php | 12 + app/Modules/Shared/SharedServiceProvider.php | 18 + app/Providers/ModuleServiceProvider.php | 10 +- database/factories/UserFactory.php | 8 + ...026_01_26_000001_create_recipes_tables.php | 45 ++ ...26_01_26_000002_add_recipe_permissions.php | 68 +++ ...23_add_is_sellable_to_warehouses_table.php | 28 ++ database/seeders/UnitSeeder.php | 24 +- lang/zh_TW.json | 164 ++++++++ lang/zh_TW/auth.php | 166 ++++++++ lang/zh_TW/pagination.php | 166 ++++++++ lang/zh_TW/passwords.php | 166 ++++++++ lang/zh_TW/validation.php | 225 ++++++++++ .../ActivityLog/ActivityDetailDialog.tsx | 68 +-- .../js/Components/ActivityLog/LogTable.tsx | 16 +- .../js/Components/Product/ProductDialog.tsx | 10 +- .../js/Components/Product/ProductTable.tsx | 8 +- .../PurchaseOrder/PurchaseOrderItemsTable.tsx | 2 +- .../js/Components/Unit/UnitManagerDialog.tsx | 8 +- .../js/Components/Vendor/VendorDialog.tsx | 6 +- .../js/Components/Vendor/VendorTable.tsx | 6 +- .../SafetyStock/AddSafetyStockDialog.tsx | 2 +- .../Warehouse/TransferOrderDialog.tsx | 15 +- .../js/Components/Warehouse/WarehouseCard.tsx | 11 +- .../Components/Warehouse/WarehouseDialog.tsx | 27 +- resources/js/Layouts/AuthenticatedLayout.tsx | 14 +- resources/js/Pages/Product/Index.tsx | 24 +- resources/js/Pages/Production/Create.tsx | 26 +- resources/js/Pages/Production/Edit.tsx | 20 +- .../js/Pages/Production/Recipe/Create.tsx | 320 ++++++++++++++ resources/js/Pages/Production/Recipe/Edit.tsx | 344 +++++++++++++++ .../js/Pages/Production/Recipe/Index.tsx | 263 ++++++++++++ resources/js/Pages/Production/Show.tsx | 2 +- resources/js/Pages/Vendor/Index.tsx | 14 +- resources/js/Pages/Warehouse/Index.tsx | 32 +- resources/js/types/warehouse.ts | 3 + resources/js/utils/breadcrumb.ts | 4 + tests/Feature/PurchaseOrderTest.php | 123 ++++++ 81 files changed, 4118 insertions(+), 1023 deletions(-) delete mode 100644 FEATURES.md create mode 100644 app/Modules/Core/Contracts/CoreServiceInterface.php create mode 100644 app/Modules/Core/CoreServiceProvider.php create mode 100644 app/Modules/Core/Services/CoreService.php create mode 100644 app/Modules/Finance/Contracts/FinanceServiceInterface.php create mode 100644 app/Modules/Finance/FinanceServiceProvider.php create mode 100644 app/Modules/Finance/Services/FinanceService.php create mode 100644 app/Modules/Inventory/Contracts/InventoryServiceInterface.php create mode 100644 app/Modules/Inventory/InventoryServiceProvider.php create mode 100644 app/Modules/Inventory/Services/InventoryService.php create mode 100644 app/Modules/Procurement/Contracts/ProcurementServiceInterface.php create mode 100644 app/Modules/Procurement/ProcurementServiceProvider.php create mode 100644 app/Modules/Procurement/Services/ProcurementService.php create mode 100644 app/Modules/Production/Controllers/RecipeController.php create mode 100644 app/Modules/Production/Models/Recipe.php create mode 100644 app/Modules/Production/Models/RecipeItem.php create mode 100644 app/Modules/Shared/Contracts/ServiceInterface.php create mode 100644 app/Modules/Shared/SharedServiceProvider.php create mode 100644 database/migrations/tenant/2026_01_26_000001_create_recipes_tables.php create mode 100644 database/migrations/tenant/2026_01_26_000002_add_recipe_permissions.php create mode 100644 database/migrations/tenant/2026_01_26_141823_add_is_sellable_to_warehouses_table.php create mode 100644 lang/zh_TW.json create mode 100644 lang/zh_TW/auth.php create mode 100644 lang/zh_TW/pagination.php create mode 100644 lang/zh_TW/passwords.php create mode 100644 lang/zh_TW/validation.php create mode 100644 resources/js/Pages/Production/Recipe/Create.tsx create mode 100644 resources/js/Pages/Production/Recipe/Edit.tsx create mode 100644 resources/js/Pages/Production/Recipe/Index.tsx create mode 100644 tests/Feature/PurchaseOrderTest.php diff --git a/FEATURES.md b/FEATURES.md deleted file mode 100644 index edd8d50..0000000 --- a/FEATURES.md +++ /dev/null @@ -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)**:全系統關鍵行為軌跡留存。 diff --git a/README.md b/README.md index 8addd3c..fd8294a 100644 --- a/README.md +++ b/README.md @@ -11,28 +11,86 @@ Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)** 與 **Tailwind - **UI 框架**: Tailwind CSS - **基礎設施**: Docker (Laravel Sail), Nginx Reverse Proxy, MySQL 8.0, Redis -## 📂 系統選單結構 (Sidebar) +## 📂 系統功能詳細說明 -以下為 ERP 系統之側邊導覽結構及其對應之權限: +### 🌳 系統功能架構樹 (含 2.0 升級規劃) +```text +Star ERP +├── 🏠 儀表板 (Dashboard) +│ ├── 📊 數據看板 (原有) +│ ├── 🔔 營運警示 (原有) +│ ├── ✨ 銷售熱力圖 (新) +│ ├── ✨ 庫存效期預警 (新) +│ └── ✨ 待出貨監控 (新) +├── ✨ 🤝 銷售與全通路 (Sales & CRM) 【New】 +│ ├── ✨ 全通路訂單整合 +│ ├── ✨ 客戶管理 (CRM) +│ └── ✨ 促銷活動 +├── 📦 商品與庫存管理 +│ ├── 📄 商品資料 (原有) +│ ├── 🏢 倉庫管理 (原有) +│ ├── 🚚 內調撥 (原有) +│ ├── ✨ 屬性管理 (過敏原/成分) +│ ├── ✨ 效期監控 (FEFO) +│ └── ✨ 智慧補貨建議 (AI) +├── ✨ 🚚 智慧物流 (Logistics) 【New】 +│ ├── ✨ 路徑規劃 +│ └── ✨ 裝車單管理 +├── 🏭 生產與品質管理 +│ ├── 📝 生產工單 (原有) +│ ├── 🧪 原料耗用 (原有) +│ ├── ✨ 配方管理 (Recipe) +│ ├── ✨ 品質檢驗 (QC) +│ └── ✨ 雙向溯源 (原料<->成品) +├── 🛒 採購與廠商 +│ ├── 👥 廠商資料 (原有) +│ ├── 📝 採購單 (原有) +│ └── ✨ 供應商評鑑 (新) +├── 💰 財務管理 +│ ├── 🧾 公共事業費 (原有) +│ ├── ✨ 應收/應付帳款 (AR/AP) +│ └── ✨ 成本精算 (料工費) +├── 📊 報表管理 +│ └── 📑 會計報表 (原有) +└── ⚙️ 系統管理 (原有) + ├── 👤 使用者管理 + ├── 🛡️ 角色與權限 + └── 📜 操作紀錄 +``` -- 🏠 **儀表板** (`/`) -- 📦 **商品與庫存管理** - - 📄 **商品資料管理** (`/products`) - `products.view` - - 🏢 **倉庫管理** (`/warehouses`) - `warehouses.view` -- 🚚 **廠商管理** - - 👥 **廠商資料管理** (`/vendors`) - `vendors.view` -- 🛒 **採購管理** - - 📝 **採購單管理** (`/purchase-orders`) - `purchase_orders.view` -- 🏭 **生產管理** - - 📦 **生產工單** (`/production-orders`) - `production_orders.view` -- 💰 **財務管理** - - 🧾 **公共事業費** (`/utility-fees`) - `utility_fees.view` -- 📊 **報表管理** - - 📑 **會計報表** (`/accounting-report`) - `accounting.view` -- ⚙️ **系統管理** - - 👤 **使用者管理** (`/admin/users`) - `users.view` - - 🛡️ **角色與權限** (`/admin/roles`) - `roles.view` - - 📜 **操作紀錄** (`/admin/activity-logs`) - `system.view_logs` +--- + +#### 1. 🏠 儀表板 (Dashboard) +- **數據看板**:顯示商品總數、供應商數、活躍倉庫數等。 +- **營運警示**:低庫存商品與待辦事項警示。 +- **✨ 強化功能**:銷售熱力圖、庫存效期預警、待出貨監控。 + +#### 2. ✨ 🤝 銷售與全通路 (Sales & CRM) +- **全通路訂單**:整合 POS、品牌電商、智慧販賣機訂單。 +- **客戶管理 (CRM)**:會員資料庫、消費歷史與等級。 +- **促銷活動**:滿額折、買一送一、組合價等折扣引擎。 + +#### 3. 📦 商品與庫存管理 +- **商品資料**:品名、規格、多單位換算。 +- **倉庫管理**:多站點庫存監控、銷售設定。 +- **內調撥**:倉庫間庫存轉移。 +- **✨ 強化功能**:過敏原/成分管理、**FEFO (先到期先出)** 效期監控、AI 智慧補貨建議。 + +#### 4. 🏭 生產與品質管理 +- **生產工單**:排程管理、生產入庫。 +- **✨ 強化功能**:配方管理 (Recipe V.C.)、QC 檢驗流程 (IQC/IPQC/FQC)、**雙向溯源** (原料 <-> 成品)。 + +#### 5. 🛒 採購與廠商 +- **採購單**:詢價、下單、收貨與驗收流程。 +- **✨ 強化功能**:供應商評鑑系統。 + +#### 6. 💰 財務管理 +- **公共事業費**:水電氣網等固定支出。 +- **✨ 強化功能**:應收/應付帳款 (AR/AP) 管理、**成本精算** (料工費分攤)。 + +#### 7. ⚙️ 系統管理 +- **使用者與權限**:RBAC 細緻權限控管。 +- **操作紀錄**:全系統關鍵行為軌跡 (Audit Log)。 ## 🚀 快速開始 diff --git a/app/Modules/Core/Contracts/CoreServiceInterface.php b/app/Modules/Core/Contracts/CoreServiceInterface.php new file mode 100644 index 0000000..d57b3aa --- /dev/null +++ b/app/Modules/Core/Contracts/CoreServiceInterface.php @@ -0,0 +1,31 @@ +getSubjectMap())->map(function ($label, $value) { return ['label' => $label, 'value' => $value]; })->values(); - // Get users for causer filter + // 取得用於操作者篩選的使用者 $users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get() ->map(function ($user) { return ['label' => $user->name, 'value' => (string) $user->id]; diff --git a/app/Modules/Core/Controllers/RoleController.php b/app/Modules/Core/Controllers/RoleController.php index 7e1f464..4942d0e 100644 --- a/app/Modules/Core/Controllers/RoleController.php +++ b/app/Modules/Core/Controllers/RoleController.php @@ -13,7 +13,7 @@ use Illuminate\Validation\Rule; class RoleController extends Controller { /** - * Display a listing of the resource. + * 顯示資源列表。 */ public function index(Request $request) { @@ -23,7 +23,7 @@ class RoleController extends Controller $query = Role::withCount('users', 'permissions') ->with('users:id,name,username'); - // Handle sorting + // 處理排序 if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) { $query->orderBy($sortBy, $sortOrder); } else { @@ -39,7 +39,7 @@ class RoleController extends Controller } /** - * Show the form for creating a new resource. + * 顯示建立新資源的表單。 */ public function create() { @@ -51,7 +51,7 @@ class RoleController extends Controller } /** - * Store a newly created resource in storage. + * 將新建立的資源儲存到儲存體中。 */ 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) { @@ -97,7 +97,7 @@ class RoleController extends Controller } /** - * Update the specified resource in storage. + * 更新儲存體中的指定資源。 */ 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) { diff --git a/app/Modules/Core/Controllers/UserController.php b/app/Modules/Core/Controllers/UserController.php index 291ae24..e2cf0da 100644 --- a/app/Modules/Core/Controllers/UserController.php +++ b/app/Modules/Core/Controllers/UserController.php @@ -14,7 +14,7 @@ use Illuminate\Support\Facades\Hash; class UserController extends Controller { /** - * Display a listing of the resource. + * 顯示資源列表。 */ public function index(Request $request) { @@ -26,7 +26,7 @@ class UserController extends Controller $query = User::with(['roles:id,name,display_name']); - // Handle Search + // 處理搜尋 if ($search) { $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") @@ -35,14 +35,14 @@ class UserController extends Controller }); } - // Handle Role Filter + // 處理角色篩選 if ($roleId && $roleId !== 'all') { $query->whereHas('roles', function ($q) use ($roleId) { $q->where('id', $roleId); }); } - // Handle sorting + // 處理排序 if (in_array($sortBy, ['name', 'created_at'])) { $query->orderBy($sortBy, $sortOrder); } else { @@ -60,7 +60,7 @@ class UserController extends Controller } /** - * Show the form for creating a new resource. + * 顯示建立新資源的表單。 */ public function create() { @@ -72,7 +72,7 @@ class UserController extends Controller } /** - * Store a newly created resource in storage. + * 將新建立的資源儲存到儲存體中。 */ public function store(Request $request) { @@ -98,7 +98,7 @@ class UserController extends Controller if (!empty($validated['roles'])) { $user->syncRoles($validated['roles']); - // Update the 'created' log to include roles + // 更新 'created' 紀錄以包含角色資訊 $activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user)) ->where('subject_id', $user->id) ->where('event', 'created') @@ -118,7 +118,7 @@ class UserController extends Controller } /** - * Show the form for editing the specified resource. + * 顯示編輯指定資源的表單。 */ 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) { @@ -150,7 +150,7 @@ class UserController extends Controller 'password.confirmed' => '密碼確認不符', ]); - // 1. Prepare data and detect changes + // 1. 準備資料並偵測變更 $userData = [ 'name' => $validated['name'], 'email' => $validated['email'], @@ -163,7 +163,7 @@ class UserController extends Controller $user->fill($userData); - // Capture dirty attributes for manual logging + // 捕捉變更屬性以進行手動記錄 $dirty = $user->getDirty(); $oldAttributes = []; $newAttributes = []; @@ -173,10 +173,10 @@ class UserController extends Controller $newAttributes[$key] = $value; } - // Save without triggering events (prevents duplicate log) + // 儲存但不觸發事件(防止重複記錄) $user->saveQuietly(); - // 2. Handle Roles + // 2. 處理角色 $roleChanges = null; if (isset($validated['roles'])) { $oldRoles = $user->roles()->pluck('display_name')->join(', '); @@ -191,7 +191,7 @@ class UserController extends Controller } } - // 3. Manually Log activity (Single Consolidated Log) + // 3. 手動記錄活動(單一整合記錄) if (!empty($newAttributes) || $roleChanges) { $properties = [ 'attributes' => $newAttributes, @@ -209,7 +209,7 @@ class UserController extends Controller ->event('updated') ->withProperties($properties) ->tap(function (\Spatie\Activitylog\Contracts\Activity $activity) use ($user) { - // Manually add snapshot since we aren't using the model's LogOptions due to saveQuietly + // 手動加入快照,因為使用 saveQuietly 所以不使用模型的 LogOptions $activity->properties = $activity->properties->merge([ 'snapshot' => [ 'name' => $user->name, @@ -224,7 +224,7 @@ class UserController extends Controller } /** - * Remove the specified resource from storage. + * 從儲存體中移除指定資源。 */ public function destroy(string $id) { diff --git a/app/Modules/Core/CoreServiceProvider.php b/app/Modules/Core/CoreServiceProvider.php new file mode 100644 index 0000000..807b902 --- /dev/null +++ b/app/Modules/Core/CoreServiceProvider.php @@ -0,0 +1,20 @@ +app->bind(CoreServiceInterface::class, CoreService::class); + } + + public function boot(): void + { + // + } +} diff --git a/app/Modules/Core/Models/User.php b/app/Modules/Core/Models/User.php index 2d96d1f..d7f15ce 100644 --- a/app/Modules/Core/Models/User.php +++ b/app/Modules/Core/Models/User.php @@ -16,10 +16,20 @@ class User extends Authenticatable use HasFactory, Notifiable, HasRoles, LogsActivity; /** - * The attributes that are mass assignable. + * 可批量賦值的屬性。 * * @var list */ + /** + * 建立模型的新工廠實例。 + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + protected static function newFactory() + { + return \Database\Factories\UserFactory::new(); + } + protected $fillable = [ 'name', 'email', @@ -28,7 +38,7 @@ class User extends Authenticatable ]; /** - * The attributes that should be hidden for serialization. + * 序列化時應隱藏的屬性。 * * @var list */ @@ -38,7 +48,7 @@ class User extends Authenticatable ]; /** - * Get the attributes that should be cast. + * 取得應進行轉換的屬性。 * * @return array */ diff --git a/app/Modules/Core/Services/CoreService.php b/app/Modules/Core/Services/CoreService.php new file mode 100644 index 0000000..2c59c77 --- /dev/null +++ b/app/Modules/Core/Services/CoreService.php @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/app/Modules/Finance/Contracts/FinanceServiceInterface.php b/app/Modules/Finance/Contracts/FinanceServiceInterface.php new file mode 100644 index 0000000..39fe0fb --- /dev/null +++ b/app/Modules/Finance/Contracts/FinanceServiceInterface.php @@ -0,0 +1,32 @@ +financeService = $financeService; + } + public function index(Request $request) { $dateStart = $request->input('date_start', Carbon::now()->toDateString()); $dateEnd = $request->input('date_end', Carbon::now()->toDateString()); - // 1. Get Purchase Orders (Completed or Received that are ready for accounting) - $purchaseOrders = PurchaseOrder::with(['vendor']) - ->whereIn('status', ['received', 'completed']) - ->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59']) - ->get() - ->map(function ($po) { - return [ - 'id' => 'PO-' . $po->id, - 'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(), - 'source' => '採購單', - 'category' => '進貨支出', - 'item' => $po->vendor->name ?? '未知廠商', - 'reference' => $po->code, - 'invoice_number' => $po->invoice_number, - 'amount' => $po->grand_total, - ]; - }); - - // 2. Get Utility Fees - $utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd]) - ->get() - ->map(function ($fee) { - return [ - 'id' => 'UF-' . $fee->id, - 'date' => $fee->transaction_date->format('Y-m-d'), - 'source' => '公共事業費', - 'category' => $fee->category, - 'item' => $fee->description ?: $fee->category, - 'reference' => '-', - 'invoice_number' => $fee->invoice_number, - 'amount' => $fee->amount, - ]; - }); - - // Combine and Sort - $allRecords = $purchaseOrders->concat($utilityFees) - ->sortByDesc('date') - ->values(); + $reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd); + $allRecords = $reportData['records']; // 3. Manual Pagination $perPage = $request->input('per_page', 10); @@ -70,16 +39,9 @@ class AccountingReportController extends Controller ['path' => $request->url(), 'query' => $request->query()] ); - $summary = [ - 'total_amount' => $allRecords->sum('amount'), - 'purchase_total' => $purchaseOrders->sum('amount'), - 'utility_total' => $utilityFees->sum('amount'), - 'record_count' => $allRecords->count(), - ]; - return Inertia::render('Accounting/Report', [ 'records' => $paginatedRecords, - 'summary' => $summary, + 'summary' => $reportData['summary'], 'filters' => [ 'date_start' => $dateStart, 'date_end' => $dateEnd, @@ -94,60 +56,25 @@ class AccountingReportController extends Controller $dateEnd = $request->input('date_end', Carbon::now()->toDateString()); $selectedIdsParam = $request->input('selected_ids'); - $purchaseOrdersQuery = PurchaseOrder::with(['vendor']) - ->whereIn('status', ['received', 'completed']); - - $utilityFeesQuery = UtilityFee::query(); + $reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd); + $allRecords = $reportData['records']; if ($selectedIdsParam) { $ids = explode(',', $selectedIdsParam); - $poIds = []; - $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 = $allRecords->whereIn('id', $ids); } - $allRecords = collect(); - - foreach ($purchaseOrders as $po) { - $allRecords->push([ - Carbon::parse($po->created_at)->toDateString(), - '採購單', - '進貨支出', - $po->vendor->name ?? '', - $po->code, - $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); + $exportData = $allRecords->map(function ($record) { + return [ + $record['date'], + $record['source'], + $record['category'], + $record['item'], + $record['reference'], + $record['invoice_number'], + $record['amount'], + ]; + }); $filename = "accounting_report_{$dateStart}_{$dateEnd}.csv"; $headers = [ @@ -155,14 +82,14 @@ class AccountingReportController extends Controller 'Content-Disposition' => "attachment; filename=\"{$filename}\"", ]; - $callback = function () use ($allRecords) { + $callback = function () use ($exportData) { $file = fopen('php://output', 'w'); // BOM for Excel compatibility with UTF-8 fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']); - foreach ($allRecords as $row) { + foreach ($exportData as $row) { fputcsv($file, $row); } fclose($file); diff --git a/app/Modules/Finance/Controllers/UtilityFeeController.php b/app/Modules/Finance/Controllers/UtilityFeeController.php index 057b908..332f9df 100644 --- a/app/Modules/Finance/Controllers/UtilityFeeController.php +++ b/app/Modules/Finance/Controllers/UtilityFeeController.php @@ -4,57 +4,30 @@ namespace App\Modules\Finance\Controllers; use App\Http\Controllers\Controller; use App\Modules\Finance\Models\UtilityFee; - +use App\Modules\Finance\Contracts\FinanceServiceInterface; use Illuminate\Http\Request; use Inertia\Inertia; class UtilityFeeController extends Controller { + protected $financeService; + + public function __construct(FinanceServiceInterface $financeService) + { + $this->financeService = $financeService; + } + public function index(Request $request) { - $query = UtilityFee::query(); - - // Search - if ($request->has('search')) { - $search = $request->input('search'); - $query->where(function($q) use ($search) { - $q->where('category', 'like', "%{$search}%") - ->orWhere('invoice_number', 'like', "%{$search}%") - ->orWhere('description', 'like', "%{$search}%"); - }); - } - - // Filtering - if ($request->filled('category') && $request->input('category') !== 'all') { - $query->where('category', $request->input('category')); - } - - if ($request->filled('date_start')) { - $query->where('transaction_date', '>=', $request->input('date_start')); - } - - if ($request->filled('date_end')) { - $query->where('transaction_date', '<=', $request->input('date_end')); - } - - // Sorting - $sortField = $request->input('sort_field'); - $sortDirection = $request->input('sort_direction'); - - if ($sortField && $sortDirection) { - $query->orderBy($sortField, $sortDirection); - } else { - $query->orderBy('created_at', 'desc'); - } - - $fees = $query->paginate($request->input('per_page', 10))->withQueryString(); - - $availableCategories = UtilityFee::distinct()->pluck('category'); + $filters = $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']); + + $fees = $this->financeService->getUtilityFees($filters)->withQueryString(); + $availableCategories = $this->financeService->getUniqueCategories(); return Inertia::render('UtilityFee/Index', [ 'fees' => $fees, 'availableCategories' => $availableCategories, - 'filters' => $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); - // Log activity activity() ->performedOn($fee) ->causedBy(auth()->user()) ->event('created') - ->withProperties([ - 'attributes' => $fee->getAttributes(), - 'snapshot' => [ - 'category' => $fee->category, - 'amount' => $fee->amount, - 'transaction_date' => $fee->transaction_date->format('Y-m-d'), - ] - ]) ->log('created'); return redirect()->back(); @@ -98,52 +62,12 @@ class UtilityFeeController extends Controller 'description' => 'nullable|string', ]); - // Capture old attributes before update - $oldAttributes = $utility_fee->getAttributes(); - $utility_fee->update($validated); - // Capture new attributes - $newAttributes = $utility_fee->getAttributes(); - - // Manual logOnlyDirty: Filter attributes to only include changes - $changedAttributes = []; - $changedOldAttributes = []; - - foreach ($newAttributes as $key => $value) { - // Skip timestamps if they are the only change (optional, but good practice) - if (in_array($key, ['updated_at'])) continue; - - $oldValue = $oldAttributes[$key] ?? null; - - // Simple comparison (casting to string to handle date objects vs strings if necessary, - // but Eloquent attributes are usually consistent if casted. - // Using loose comparison != handles most cases correctly) - if ($value != $oldValue) { - $changedAttributes[$key] = $value; - $changedOldAttributes[$key] = $oldValue; - } - } - - // Only log if there are changes (excluding just updated_at) - if (empty($changedAttributes)) { - return redirect()->back(); - } - - // Log activity with before/after comparison activity() ->performedOn($utility_fee) ->causedBy(auth()->user()) ->event('updated') - ->withProperties([ - 'attributes' => $changedAttributes, - 'old' => $changedOldAttributes, - 'snapshot' => [ - 'category' => $utility_fee->category, - 'amount' => $utility_fee->amount, - 'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'), - ] - ]) ->log('updated'); return redirect()->back(); @@ -151,24 +75,10 @@ class UtilityFeeController extends Controller public function destroy(UtilityFee $utility_fee) { - // Capture data snapshot before deletion - $snapshot = [ - 'category' => $utility_fee->category, - 'amount' => $utility_fee->amount, - 'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'), - 'invoice_number' => $utility_fee->invoice_number, - 'description' => $utility_fee->description, - ]; - - // Log activity before deletion activity() ->performedOn($utility_fee) ->causedBy(auth()->user()) ->event('deleted') - ->withProperties([ - 'attributes' => $utility_fee->getAttributes(), - 'snapshot' => $snapshot - ]) ->log('deleted'); $utility_fee->delete(); diff --git a/app/Modules/Finance/FinanceServiceProvider.php b/app/Modules/Finance/FinanceServiceProvider.php new file mode 100644 index 0000000..cff7ffa --- /dev/null +++ b/app/Modules/Finance/FinanceServiceProvider.php @@ -0,0 +1,20 @@ +app->bind(FinanceServiceInterface::class, FinanceService::class); + } + + public function boot(): void + { + // + } +} diff --git a/app/Modules/Finance/Models/UtilityFee.php b/app/Modules/Finance/Models/UtilityFee.php index db9fbca..cecc88f 100644 --- a/app/Modules/Finance/Models/UtilityFee.php +++ b/app/Modules/Finance/Models/UtilityFee.php @@ -11,26 +11,25 @@ class UtilityFee extends Model use HasFactory; protected $fillable = [ - 'type', // 'electricity', 'water', 'gas', etc. - 'billing_period_start', - 'billing_period_end', - 'due_date', + 'transaction_date', + 'category', 'amount', - 'usage_amount', // kWh, m3, etc. - 'unit', // 度, 立方米 - 'status', // 'pending', 'paid', 'overdue' - 'paid_at', - 'payment_method', - 'notes', - 'receipt_image_path', + 'invoice_number', + 'description', ]; protected $casts = [ - 'billing_period_start' => 'date', - 'billing_period_end' => 'date', - 'due_date' => 'date', - 'paid_at' => 'datetime', + 'transaction_date' => 'date', '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, + ]); + } } diff --git a/app/Modules/Finance/Services/FinanceService.php b/app/Modules/Finance/Services/FinanceService.php new file mode 100644 index 0000000..913d838 --- /dev/null +++ b/app/Modules/Finance/Services/FinanceService.php @@ -0,0 +1,104 @@ +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'); + } +} diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php new file mode 100644 index 0000000..7f057c3 --- /dev/null +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -0,0 +1,100 @@ +load([ 'inventories.product.category', @@ -17,7 +22,7 @@ class InventoryController extends Controller 'inventories.lastIncomingTransaction', 'inventories.lastOutgoingTransaction' ]); - $allProducts = \App\Modules\Inventory\Models\Product::with('category')->get(); + $allProducts = Product::with('category')->get(); // 1. 準備 availableProducts $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, 'inventories' => $inventories, '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') ->get() ->map(function ($product) { @@ -123,13 +128,13 @@ class InventoryController extends Controller ]; }); - return \Inertia\Inertia::render('Warehouse/AddInventory', [ + return Inertia::render('Warehouse/AddInventory', [ 'warehouse' => $warehouse, '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([ 'inboundDate' => 'required|date', @@ -144,22 +149,22 @@ class InventoryController extends Controller '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) { $inventory = null; if ($item['batchMode'] === 'existing') { // 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加) - $inventory = \App\Modules\Inventory\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']); + $inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']); if ($inventory->trashed()) { $inventory->restore(); } } else { // 模式 B:建立新批號 $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', $originCountry, $validated['inboundDate'] @@ -210,12 +215,12 @@ class InventoryController extends Controller /** * 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'); $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) ->get() ->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'; if ($product) { - $batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber( + $batchNumber = Inventory::generateBatchNumber( $product->code ?? 'UNK', $originCountry, $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),需要特殊處理 @@ -254,7 +259,7 @@ class InventoryController extends Controller 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'); }, '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, 'inventory' => $inventoryData, '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 // 但新路由我們傳的是 inventory ID // 為了相容,我們先判斷 $inventoryId 是 inventory ID - $inventory = \App\Modules\Inventory\Models\Inventory::find($inventoryId); + $inventory = Inventory::find($inventoryId); // 如果找不到 (可能是舊路由傳 product ID) if (!$inventory) { @@ -322,7 +327,7 @@ class InventoryController extends Controller 'lastOutboundDate' => 'nullable|date', ]); - return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) { + return DB::transaction(function () use ($validated, $inventory) { $currentQty = (float) $inventory->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 不允許刪除 (哪怕是軟刪除) if ($inventory->quantity > 0) { @@ -430,7 +435,7 @@ class InventoryController extends Controller if ($productId) { // 商品層級查詢 - $inventories = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id) + $inventories = Inventory::where('warehouse_id', $warehouse->id) ->where('product_id', $productId) ->with(['product', 'transactions' => function($query) { $query->orderBy('actual_time', 'desc')->orderBy('id', 'desc'); @@ -491,7 +496,7 @@ class InventoryController extends Controller ]; })->values(); - return \Inertia\Inertia::render('Warehouse/InventoryHistory', [ + return Inertia::render('Warehouse/InventoryHistory', [ 'warehouse' => $warehouse, 'inventory' => [ 'id' => 'product-' . $productId, @@ -505,7 +510,7 @@ class InventoryController extends Controller 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'); }, '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, 'inventory' => [ 'id' => (string) $inventory->id, diff --git a/app/Modules/Inventory/Controllers/ProductController.php b/app/Modules/Inventory/Controllers/ProductController.php index f8348fc..99685dc 100644 --- a/app/Modules/Inventory/Controllers/ProductController.php +++ b/app/Modules/Inventory/Controllers/ProductController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Modules\Inventory\Models\Product; use App\Modules\Inventory\Models\Unit; +use App\Modules\Inventory\Models\Category; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -13,7 +14,7 @@ use Inertia\Response; class ProductController extends Controller { /** - * Display a listing of the resource. + * 顯示資源列表。 */ public function index(Request $request): Response { @@ -40,7 +41,7 @@ class ProductController extends Controller $sortField = $request->input('sort_field', 'id'); $sortDirection = $request->input('sort_direction', 'desc'); - // Define allowed sort fields to prevent SQL injection + // 定義允許的排序欄位以防止 SQL 注入 $allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate']; if (!in_array($sortField, $allowedSorts)) { $sortField = 'id'; @@ -49,11 +50,11 @@ class ProductController extends Controller $sortDirection = 'desc'; } - // Handle relation sorting (category name) separately if needed, or simple join + // 如果需要,分別處理關聯排序(分類名稱),或簡單的 join if ($sortField === 'category_id') { - // Join categories for sorting by name? Or just by ID? - // Simple approach: sort by ID for now, or join if user wants name sort. - // Let's assume standard field sorting first. + // 加入分類以便按名稱排序?還是僅按 ID? + // 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。 + // 先假設標準欄位排序。 $query->orderBy('category_id', $sortDirection); } else { $query->orderBy($sortField, $sortDirection); @@ -61,18 +62,49 @@ class ProductController extends Controller $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', [ 'products' => $products, - 'categories' => $categories, - 'units' => Unit::all(), + 'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]), + 'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]), 'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']), ]); } /** - * Store a newly created resource in storage. + * 將新建立的資源儲存到儲存體中。 */ public function store(Request $request) { @@ -107,7 +139,7 @@ class ProductController extends Controller } /** - * Update the specified resource in storage. + * 更新儲存體中的指定資源。 */ 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) { diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index 72c8807..d98ad30 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -29,25 +29,30 @@ class TransferOrderController extends Controller ]); return DB::transaction(function () use ($validated) { - // 1. 檢查來源倉庫庫存 + // 1. 檢查來源倉庫庫存 (精確匹配產品與批號) $sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId']) ->where('product_id', $validated['productId']) + ->where('batch_number', $validated['batchNumber']) ->first(); if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) { throw ValidationException::withMessages([ - 'quantity' => ['來源倉庫庫存不足'], + 'quantity' => ['來源倉庫指定批號庫存不足'], ]); } - // 2. 獲取或建立目標倉庫庫存 + // 2. 獲取或建立目標倉庫庫存 (精確匹配產品與批號,並繼承效期與品質狀態) $targetInventory = Inventory::firstOrCreate( [ 'warehouse_id' => $validated['targetWarehouseId'], 'product_id' => $validated['productId'], + 'batch_number' => $validated['batchNumber'], ], [ 'quantity' => 0, + 'expiry_date' => $sourceInventory->expiry_date, + 'quality_status' => $sourceInventory->quality_status, + 'origin_country' => $sourceInventory->origin_country, ] ); @@ -109,11 +114,12 @@ class TransferOrderController extends Controller ->get() ->map(function ($inv) { return [ - 'productId' => (string) $inv->product_id, - 'productName' => $inv->product->name, - 'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號 - 'availableQty' => (float) $inv->quantity, - 'unit' => $inv->product->baseUnit?->name ?? '個', + 'product_id' => (string) $inv->product_id, + 'product_name' => $inv->product->name, + 'batch_number' => $inv->batch_number, + 'quantity' => (float) $inv->quantity, + 'unit_name' => $inv->product->baseUnit?->name ?? '個', + 'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, ]; }); diff --git a/app/Modules/Inventory/Controllers/UnitController.php b/app/Modules/Inventory/Controllers/UnitController.php index e8bb38d..ae90fdc 100644 --- a/app/Modules/Inventory/Controllers/UnitController.php +++ b/app/Modules/Inventory/Controllers/UnitController.php @@ -11,7 +11,7 @@ use Illuminate\Http\Request; class UnitController extends Controller { /** - * Store a newly created resource in storage. + * 將新建立的資源儲存到儲存體中。 */ 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) { @@ -51,11 +51,11 @@ class UnitController extends Controller } /** - * Remove the specified resource from storage. + * 從儲存體中移除指定資源。 */ public function destroy(Unit $unit) { - // Check if unit is used in any product + // 檢查單位是否已被任何商品使用 $isUsed = Product::where('base_unit_id', $unit->id) ->orWhere('large_unit_id', $unit->id) ->orWhere('purchase_unit_id', $unit->id) diff --git a/app/Modules/Inventory/Controllers/WarehouseController.php b/app/Modules/Inventory/Controllers/WarehouseController.php index dc8f99a..205e39a 100644 --- a/app/Modules/Inventory/Controllers/WarehouseController.php +++ b/app/Modules/Inventory/Controllers/WarehouseController.php @@ -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') ->paginate(10) ->withQueryString(); + // 修正各倉庫列表中的可用庫存計算:若倉庫不可銷售,則可用庫存為 0 + $warehouses->getCollection()->transform(function ($w) { + if (!$w->is_sellable) { + $w->available_stock = 0; + } + return $w; + }); + + // 計算全域總計 (不分頁) + $totals = [ + 'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0) + ->where('quality_status', 'normal') + ->whereHas('warehouse', function ($q) { + $q->where('is_sellable', true); + }) + ->where(function ($q) { + $q->whereNull('expiry_date') + ->orWhere('expiry_date', '>=', now()); + })->sum('quantity'), + 'book_stock' => \App\Modules\Inventory\Models\Inventory::sum('quantity'), + ]; + return Inertia::render('Warehouse/Index', [ 'warehouses' => $warehouses, + 'totals' => $totals, 'filters' => $request->only(['search']), ]); } @@ -41,9 +73,10 @@ class WarehouseController extends Controller 'name' => 'required|string|max:50', 'address' => 'nullable|string|max:255', 'description' => 'nullable|string', + 'is_sellable' => 'nullable|boolean', ]); - // Auto-generate code + // 自動產生代碼 $prefix = 'WH'; $lastWarehouse = Warehouse::latest('id')->first(); $nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1; @@ -62,6 +95,7 @@ class WarehouseController extends Controller 'name' => 'required|string|max:50', 'address' => 'nullable|string|max:255', 'description' => 'nullable|string', + 'is_sellable' => 'nullable|boolean', ]); $warehouse->update($validated); diff --git a/app/Modules/Inventory/InventoryServiceProvider.php b/app/Modules/Inventory/InventoryServiceProvider.php new file mode 100644 index 0000000..7b5fda2 --- /dev/null +++ b/app/Modules/Inventory/InventoryServiceProvider.php @@ -0,0 +1,20 @@ +app->bind(InventoryServiceInterface::class, InventoryService::class); + } + + public function boot(): void + { + // + } +} diff --git a/app/Modules/Inventory/Models/Inventory.php b/app/Modules/Inventory/Models/Inventory.php index 7dcb64b..4789515 100644 --- a/app/Modules/Inventory/Models/Inventory.php +++ b/app/Modules/Inventory/Models/Inventory.php @@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency + 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"). - * This is not stored in the database column but used for logging context. + * 用於活動記錄的暫時屬性(例如 "補貨 #123")。 + * 此屬性不存儲在資料庫欄位中,但用於記錄上下文。 * @var string|null */ public $activityLogReason; @@ -55,12 +55,12 @@ class Inventory extends Model $attributes = $properties['attributes'] ?? []; $snapshot = $properties['snapshot'] ?? []; - // Always snapshot names for context, even if IDs didn't change - // $this refers to the Inventory model instance + // 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改 + // $this 指的是 Inventory 模型實例 $snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null); $snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null); - // Capture the reason if set + // 如果已設定原因,則進行捕捉 if ($this->activityLogReason) { $attributes['_reason'] = $this->activityLogReason; } @@ -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'); - } + /** * 產生批號 diff --git a/app/Modules/Inventory/Models/InventoryTransaction.php b/app/Modules/Inventory/Models/InventoryTransaction.php index 39b38f1..fe0b0fa 100644 --- a/app/Modules/Inventory/Models/InventoryTransaction.php +++ b/app/Modules/Inventory/Models/InventoryTransaction.php @@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; 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 { diff --git a/app/Modules/Inventory/Models/Product.php b/app/Modules/Inventory/Models/Product.php index d7b9402..ce8f790 100644 --- a/app/Modules/Inventory/Models/Product.php +++ b/app/Modules/Inventory/Models/Product.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; -use App\Modules\Procurement\Models\Vendor; // Cross-module dependency (Procurement) + class Product extends Model { @@ -32,7 +32,7 @@ class Product extends Model ]; /** - * Get the category that owns the product. + * 取得該商品所屬的分類。 */ public function category(): BelongsTo { @@ -54,10 +54,7 @@ class Product extends Model return $this->belongsTo(Unit::class, 'purchase_unit_id'); } - public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany - { - return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps(); - } + public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany { @@ -83,13 +80,13 @@ class Product extends Model $attributes = $properties['attributes'] ?? []; $snapshot = $properties['snapshot'] ?? []; - // Handle Category Name Snapshot + // 處理分類名稱快照 if (isset($attributes['category_id'])) { $category = Category::find($attributes['category_id']); $snapshot['category_name'] = $category ? $category->name : null; } - // Handle Unit Name Snapshots + // 處理單位名稱快照 $unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id']; foreach ($unitFields as $field) { if (isset($attributes[$field])) { @@ -99,7 +96,7 @@ class Product extends Model } } - // Always snapshot self name for context (so logs always show "Cola") + // 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂") $snapshot['name'] = $this->name; $properties['attributes'] = $attributes; diff --git a/app/Modules/Inventory/Models/Warehouse.php b/app/Modules/Inventory/Models/Warehouse.php index 2e332e0..2a80495 100644 --- a/app/Modules/Inventory/Models/Warehouse.php +++ b/app/Modules/Inventory/Models/Warehouse.php @@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency (Procurement) + class Warehouse extends Model { @@ -17,6 +17,11 @@ class Warehouse extends Model 'name', 'address', 'description', + 'is_sellable', + ]; + + protected $casts = [ + 'is_sellable' => 'boolean', ]; public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions @@ -43,10 +48,7 @@ class Warehouse extends Model return $this->hasMany(Inventory::class); } - public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany - { - return $this->hasMany(PurchaseOrder::class); - } + public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany { diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php new file mode 100644 index 0000000..765f0f3 --- /dev/null +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -0,0 +1,168 @@ +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(), + ]); + }); + } +} diff --git a/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php new file mode 100644 index 0000000..df4f4a1 --- /dev/null +++ b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php @@ -0,0 +1,27 @@ +inventoryService = $inventoryService; + $this->coreService = $coreService; + } + public function index(Request $request) { - $query = PurchaseOrder::with(['vendor', 'warehouse', 'user']); + // 1. 從關聯中移除 'warehouse' 與 'user' + $query = PurchaseOrder::with(['vendor']); - // Search + // 搜尋 if ($request->search) { $query->where(function($q) use ($request) { $q->where('code', 'like', "%{$request->search}%") @@ -27,7 +39,7 @@ class PurchaseOrderController extends Controller }); } - // Filters + // 篩選 if ($request->status && $request->status !== 'all') { $query->where('status', $request->status); } @@ -36,7 +48,7 @@ class PurchaseOrderController extends Controller $query->where('warehouse_id', $request->warehouse_id); } - // Date Range + // 日期範圍 if ($request->date_start) { $query->whereDate('created_at', '>=', $request->date_start); } @@ -45,7 +57,7 @@ class PurchaseOrderController extends Controller $query->whereDate('created_at', '<=', $request->date_end); } - // Sorting + // 排序 $sortField = $request->sort_field ?? 'id'; $sortDirection = $request->sort_direction ?? 'desc'; $allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date']; @@ -57,36 +69,89 @@ class PurchaseOrderController extends Controller $perPage = $request->input('per_page', 10); $orders = $query->paginate($perPage)->withQueryString(); + // 2. 手動注入倉庫與使用者資料 + $warehouses = $this->inventoryService->getAllWarehouses(); + $userIds = $orders->getCollection()->pluck('user_id')->unique()->toArray(); + $users = $this->coreService->getUsersByIds($userIds)->keyBy('id'); + + $orders->getCollection()->transform(function ($order) use ($warehouses, $users) { + // 水和倉庫 + $warehouse = $warehouses->firstWhere('id', $order->warehouse_id); + $order->setRelation('warehouse', $warehouse); + + // 水和使用者 + $user = $users->get($order->user_id); + $order->setRelation('user', $user); + + // 轉換為前端期望的格式 (camelCase) + return (object) [ + 'id' => (string) $order->id, + 'poNumber' => $order->code, + 'supplierId' => (string) $order->vendor_id, + 'supplierName' => $order->vendor?->name ?? 'Unknown', + 'expectedDate' => $order->expected_delivery_date?->toISOString(), + 'status' => $order->status, + 'totalAmount' => (float) $order->total_amount, + 'taxAmount' => (float) $order->tax_amount, + 'grandTotal' => (float) $order->grand_total, + 'createdAt' => $order->created_at->toISOString(), + 'createdBy' => $user?->name ?? 'System', + 'warehouse_id' => (int) $order->warehouse_id, + 'warehouse_name' => $warehouse?->name ?? 'Unknown', + 'remark' => $order->remark, + ]; + }); + + return Inertia::render('PurchaseOrder/Index', [ 'orders' => $orders, 'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']), - 'warehouses' => Warehouse::all(['id', 'name']), + 'warehouses' => $warehouses->map(fn($w)=>(object)['id'=>$w->id, 'name'=>$w->name]), ]); } public function create() { - $vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) { + // 1. 獲取廠商(無關聯) + $vendors = Vendor::all(); + + // 2. 手動注入:獲取 Pivot 資料 + $vendorIds = $vendors->pluck('id')->toArray(); + $pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get(); + $productIds = $pivots->pluck('product_id')->unique()->toArray(); + + // 3. 從服務獲取商品 + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + // 4. 重建前端結構 + $vendors = $vendors->map(function ($vendor) use ($pivots, $products) { + $vendorProductPivots = $pivots->where('vendor_id', $vendor->id); + + $commonProducts = $vendorProductPivots->map(function($pivot) use ($products) { + $product = $products[$pivot->product_id] ?? null; + if (!$product) return null; + + return [ + 'productId' => (string) $product->id, + 'productName' => $product->name, + 'base_unit_id' => $product->base_unit_id, + 'base_unit_name' => $product->baseUnit?->name, + 'large_unit_id' => $product->large_unit_id, + 'large_unit_name' => $product->largeUnit?->name, + 'purchase_unit_id' => $product->purchase_unit_id, + 'conversion_rate' => (float) $product->conversion_rate, + 'lastPrice' => (float) $pivot->last_price, + ]; + })->filter()->values(); + return [ 'id' => (string) $vendor->id, 'name' => $vendor->name, - 'commonProducts' => $vendor->products->map(function ($product) { - return [ - 'productId' => (string) $product->id, - 'productName' => $product->name, - 'base_unit_id' => $product->base_unit_id, - 'base_unit_name' => $product->baseUnit?->name, - 'large_unit_id' => $product->large_unit_id, - 'large_unit_name' => $product->largeUnit?->name, - 'purchase_unit_id' => $product->purchase_unit_id, - 'conversion_rate' => (float) $product->conversion_rate, - 'lastPrice' => (float) ($product->pivot->last_price ?? 0), - ]; - }) + 'commonProducts' => $commonProducts ]; }); - $warehouses = Warehouse::all()->map(function ($w) { + $warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) { return [ 'id' => (string) $w->id, 'name' => $w->name, @@ -141,7 +206,7 @@ class PurchaseOrderController extends Controller $totalAmount += $item['subtotal']; } - // Tax calculation + // 稅額計算 $taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2); $grandTotal = $totalAmount + $taxAmount; @@ -200,120 +265,148 @@ class PurchaseOrderController extends Controller public function show($id) { - $order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product.baseUnit', 'items.product.largeUnit'])->findOrFail($id); + $order = PurchaseOrder::with(['vendor', 'items'])->findOrFail($id); - $order->items->transform(function ($item) use ($order) { - $product = $item->product; - if ($product) { - // 手動附加所有必要的屬性 - $item->productId = (string) $product->id; - $item->productName = $product->name; - $item->base_unit_id = $product->base_unit_id; - $item->base_unit_name = $product->baseUnit?->name; - $item->large_unit_id = $product->large_unit_id; - $item->large_unit_name = $product->largeUnit?->name; - $item->purchase_unit_id = $product->purchase_unit_id; - - $item->conversion_rate = (float) $product->conversion_rate; - - // Fetch last price - $lastPrice = DB::table('product_vendor') - ->where('vendor_id', $order->vendor_id) - ->where('product_id', $product->id) - ->value('last_price'); - $item->previousPrice = (float) ($lastPrice ?? 0); + // 手動注入 + $order->setRelation('warehouse', $this->inventoryService->getWarehouse($order->warehouse_id)); + $order->setRelation('user', $this->coreService->getUser($order->user_id)); - // 設定當前選中的單位 ID (from saved item) - $item->unitId = $item->unit_id; + $productIds = $order->items->pluck('product_id')->unique()->toArray(); + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); - // 決定 selectedUnit (用於 UI 顯示) - if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) { - $item->selectedUnit = 'large'; - } else { - $item->selectedUnit = 'base'; - } - - $item->unitPrice = (float) $item->unit_price; - } - return $item; + $formattedItems = $order->items->map(function ($item) use ($order, $products) { + $product = $products[$item->product_id] ?? null; + return (object) [ + 'productId' => (string) $item->product_id, + 'productName' => $product?->name ?? 'Unknown', + 'quantity' => (float) $item->quantity, + 'unitId' => $item->unit_id, + 'base_unit_id' => $product?->base_unit_id, + 'base_unit_name' => $product?->baseUnit?->name, + 'large_unit_id' => $product?->large_unit_id, + 'large_unit_name' => $product?->largeUnit?->name, + 'purchase_unit_id' => $product?->purchase_unit_id, + 'conversion_rate' => (float) ($product?->conversion_rate ?? 1), + 'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base', + 'unitPrice' => (float) $item->unit_price, + 'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $order->vendor_id)->where('product_id', $item->product_id)->value('last_price') ?? 0), + 'subtotal' => (float) $item->subtotal, + ]; }); + $formattedOrder = (object) [ + 'id' => (string) $order->id, + 'poNumber' => $order->code, + 'supplierId' => (string) $order->vendor_id, + 'supplierName' => $order->vendor?->name ?? 'Unknown', + 'expectedDate' => $order->expected_delivery_date?->toISOString(), + 'status' => $order->status, + 'items' => $formattedItems, + 'totalAmount' => (float) $order->total_amount, + 'taxAmount' => (float) $order->tax_amount, + 'grandTotal' => (float) $order->grand_total, + 'createdAt' => $order->created_at->toISOString(), + 'createdBy' => $order->user?->name ?? 'System', + 'warehouse_id' => (int) $order->warehouse_id, + 'warehouse_name' => $order->warehouse?->name ?? 'Unknown', + 'remark' => $order->remark, + 'invoiceNumber' => $order->invoice_number, + 'invoiceDate' => $order->invoice_date, + 'invoiceAmount' => (float) $order->invoice_amount, + ]; + return Inertia::render('PurchaseOrder/Show', [ - 'order' => $order + 'order' => $formattedOrder ]); } public function edit($id) { - $order = PurchaseOrder::with(['items.product'])->findOrFail($id); + // 1. 獲取訂單 + $order = PurchaseOrder::with(['items'])->findOrFail($id); + + // 2. 獲取廠商與商品(與 create 邏輯一致) + $vendors = Vendor::all(); + $vendorIds = $vendors->pluck('id')->toArray(); + $pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get(); + $productIds = $pivots->pluck('product_id')->unique()->toArray(); + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + $vendors = $vendors->map(function ($vendor) use ($pivots, $products) { + $vendorProductPivots = $pivots->where('vendor_id', $vendor->id); + $commonProducts = $vendorProductPivots->map(function($pivot) use ($products) { + $product = $products[$pivot->product_id] ?? null; + if (!$product) return null; + return [ + 'productId' => (string) $product->id, + 'productName' => $product->name, + 'base_unit_id' => $product->base_unit_id, + 'base_unit_name' => $product->baseUnit?->name, + 'large_unit_id' => $product->large_unit_id, + 'large_unit_name' => $product->largeUnit?->name, + 'purchase_unit_id' => $product->purchase_unit_id, + 'conversion_rate' => (float) $product->conversion_rate, + 'lastPrice' => (float) $pivot->last_price, + ]; + })->filter()->values(); - $vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) { return [ 'id' => (string) $vendor->id, 'name' => $vendor->name, - 'commonProducts' => $vendor->products->map(function ($product) { - return [ - 'productId' => (string) $product->id, - 'productName' => $product->name, - 'base_unit_id' => $product->base_unit_id, - 'base_unit_name' => $product->baseUnit?->name, - 'large_unit_id' => $product->large_unit_id, - 'large_unit_name' => $product->largeUnit?->name, - 'purchase_unit_id' => $product->purchase_unit_id, - 'conversion_rate' => (float) $product->conversion_rate, - 'lastPrice' => (float) ($product->pivot->last_price ?? 0), - ]; - }) + 'commonProducts' => $commonProducts ]; }); - $warehouses = Warehouse::all()->map(function ($w) { + // 3. 獲取倉庫 + $warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) { return [ 'id' => (string) $w->id, 'name' => $w->name, ]; }); - // Transform items for frontend form - // Transform items for frontend form + // 4. 注入訂單項目特定資料 + // 2. 注入訂單項目 + $itemProductIds = $order->items->pluck('product_id')->toArray(); + $itemProducts = $this->inventoryService->getProductsByIds($itemProductIds)->keyBy('id'); + $vendorId = $order->vendor_id; - $order->items->transform(function ($item) use ($vendorId) { - $product = $item->product; - if ($product) { - // 手動附加所有必要的屬性 - $item->productId = (string) $product->id; - $item->productName = $product->name; - $item->base_unit_id = $product->base_unit_id; - $item->base_unit_name = $product->baseUnit?->name; - $item->large_unit_id = $product->large_unit_id; - $item->large_unit_name = $product->largeUnit?->name; - - $item->conversion_rate = (float) $product->conversion_rate; - - // Fetch last price - $lastPrice = DB::table('product_vendor') - ->where('vendor_id', $vendorId) - ->where('product_id', $product->id) - ->value('last_price'); - $item->previousPrice = (float) ($lastPrice ?? 0); - - // 設定當前選中的單位 ID - $item->unitId = $item->unit_id; // 資料庫中的 unit_id - - // 決定 selectedUnit (用於 UI 狀態) - if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) { - $item->selectedUnit = 'large'; - } else { - $item->selectedUnit = 'base'; - } - - $item->unitPrice = (float) $item->unit_price; - } - return $item; + $formattedItems = $order->items->map(function ($item) use ($vendorId, $itemProducts) { + $product = $itemProducts[$item->product_id] ?? null; + return (object) [ + 'productId' => (string) $item->product_id, + 'productName' => $product?->name ?? 'Unknown', + 'quantity' => (float) $item->quantity, + 'unitId' => $item->unit_id, + 'base_unit_id' => $product?->base_unit_id, + 'base_unit_name' => $product?->baseUnit?->name, + 'large_unit_id' => $product?->large_unit_id, + 'large_unit_name' => $product?->largeUnit?->name, + 'conversion_rate' => (float) ($product?->conversion_rate ?? 1), + 'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base', + 'unitPrice' => (float) $item->unit_price, + 'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $vendorId)->where('product_id', $item->product_id)->value('last_price') ?? 0), + 'subtotal' => (float) $item->subtotal, + ]; }); + $formattedOrder = (object) [ + 'id' => (string) $order->id, + 'poNumber' => $order->code, + 'supplierId' => (string) $order->vendor_id, + 'warehouse_id' => (int) $order->warehouse_id, + 'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'), + 'status' => $order->status, + 'items' => $formattedItems, + 'remark' => $order->remark, + 'invoiceNumber' => $order->invoice_number, + 'invoiceDate' => $order->invoice_date, + 'invoiceAmount' => (float) $order->invoice_amount, + 'taxAmount' => (float) $order->tax_amount, + ]; + return Inertia::render('PurchaseOrder/Create', [ - 'order' => $order, + 'order' => $formattedOrder, 'suppliers' => $vendors, 'warehouses' => $warehouses, ]); @@ -337,7 +430,7 @@ class PurchaseOrderController extends Controller 'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.subtotal' => 'required|numeric|min:0', // 總金額 'items.*.unitId' => 'nullable|exists:units,id', - // Allow both tax_amount and taxAmount for compatibility + // 允許 tax_amount 和 taxAmount 以保持相容性 'tax_amount' => 'nullable|numeric|min:0', 'taxAmount' => 'nullable|numeric|min:0', ]); @@ -350,12 +443,12 @@ class PurchaseOrderController extends Controller $totalAmount += $item['subtotal']; } - // Tax calculation (handle both keys) + // 稅額計算(處理兩個鍵) $inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null; $taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2); $grandTotal = $totalAmount + $taxAmount; - // 1. Fill attributes but don't save yet to capture changes + // 1. 填充屬性但暫不儲存以捕捉變更 $order->fill([ 'vendor_id' => $validated['vendor_id'], 'warehouse_id' => $validated['warehouse_id'], @@ -370,7 +463,7 @@ class PurchaseOrderController extends Controller 'invoice_amount' => $validated['invoice_amount'] ?? null, ]); - // Capture attribute changes for manual logging + // 捕捉變更屬性以進行手動記錄 $dirty = $order->getDirty(); $oldAttributes = []; $newAttributes = []; @@ -380,10 +473,10 @@ class PurchaseOrderController extends Controller $newAttributes[$key] = $value; } - // Save without triggering events (prevents duplicate log) + // 儲存但不觸發事件(防止重複記錄) $order->saveQuietly(); - // 2. Capture old items with product names for diffing + // 2. 捕捉包含商品名稱的舊項目以進行比對 $oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) { return [ 'id' => $item->id, @@ -396,7 +489,7 @@ class PurchaseOrderController extends Controller ]; })->keyBy('product_id'); - // Sync items (Original logic) + // 同步項目(原始邏輯) $order->items()->delete(); $newItemsData = []; @@ -414,14 +507,14 @@ class PurchaseOrderController extends Controller $newItemsData[] = $newItem; } - // 3. Calculate Item Diffs + // 3. 計算項目差異 $itemDiffs = [ 'added' => [], 'removed' => [], 'updated' => [], ]; - // Re-fetch new items to ensure we have fresh relations + // 重新獲取新項目以確保擁有最新的關聯 $newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) { return [ 'product_id' => $item->product_id, @@ -433,20 +526,20 @@ class PurchaseOrderController extends Controller ]; })->keyBy('product_id'); - // Find removed + // 找出已移除的項目 foreach ($oldItems as $productId => $oldItem) { if (!$newItemsFormatted->has($productId)) { $itemDiffs['removed'][] = $oldItem; } } - // Find added and updated + // 找出新增和更新的項目 foreach ($newItemsFormatted as $productId => $newItem) { if (!$oldItems->has($productId)) { $itemDiffs['added'][] = $newItem; } else { $oldItem = $oldItems[$productId]; - // Compare fields + // 比對欄位 if ( $oldItem['quantity'] != $newItem['quantity'] || $oldItem['unit_id'] != $newItem['unit_id'] || @@ -469,8 +562,8 @@ class PurchaseOrderController extends Controller } } - // 4. Manually Log activity (Single Consolidated Log) - // Log if there are attribute changes OR item changes + // 4. 手動記錄活動(單一整合記錄) + // 如果有屬性變更或項目變更則記錄 if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) { activity() ->performedOn($order) @@ -505,19 +598,24 @@ class PurchaseOrderController extends Controller try { DB::beginTransaction(); - $order = PurchaseOrder::with(['items.product', 'items.unit'])->findOrFail($id); + $order = PurchaseOrder::with(['items'])->findOrFail($id); - // Capture items for logging - $items = $order->items->map(function ($item) { + // 為記錄注入資料 + $productIds = $order->items->pluck('product_id')->unique()->toArray(); + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + // 捕捉項目以進行記錄 + $items = $order->items->map(function ($item) use ($products) { + $product = $products[$item->product_id] ?? null; return [ - 'product_name' => $item->product_name, + 'product_name' => $product?->name ?? 'Unknown', 'quantity' => floatval($item->quantity), - 'unit_name' => $item->unit_name, + 'unit_name' => 'N/A', 'subtotal' => floatval($item->subtotal), ]; })->toArray(); - // Manually log the deletion with items + // 手動記錄包含項目的刪除操作 activity() ->performedOn($order) ->causedBy(auth()->user()) @@ -538,10 +636,10 @@ class PurchaseOrderController extends Controller ]) ->log('deleted'); - // Disable automatic logging for this operation + // 對此操作停用自動記錄 $order->disableLogging(); - // Delete associated items first + // 先刪除關聯項目 $order->items()->delete(); $order->delete(); diff --git a/app/Modules/Procurement/Controllers/VendorController.php b/app/Modules/Procurement/Controllers/VendorController.php index 8992c62..9dc4cf2 100644 --- a/app/Modules/Procurement/Controllers/VendorController.php +++ b/app/Modules/Procurement/Controllers/VendorController.php @@ -4,15 +4,21 @@ namespace App\Modules\Procurement\Controllers; use App\Http\Controllers\Controller; use App\Modules\Procurement\Models\Vendor; - +use App\Modules\Inventory\Contracts\InventoryServiceInterface; use Illuminate\Http\Request; +use Inertia\Inertia; +use Inertia\Response; class VendorController extends Controller { + public function __construct( + protected InventoryServiceInterface $inventoryService + ) {} + /** - * Display a listing of the resource. + * 顯示資源列表。 */ - public function index(\Illuminate\Http\Request $request): \Inertia\Response + public function index(Request $request): Response { $query = Vendor::query(); @@ -44,28 +50,71 @@ class VendorController extends Controller ->paginate($perPage) ->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, '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']); - return \Inertia\Inertia::render('Vendor/Show', [ - 'vendor' => $vendor, - 'products' => \App\Modules\Inventory\Models\Product::with('baseUnit')->get(), + + $formattedVendor = (object) [ + 'id' => (string) $vendor->id, + 'code' => $vendor->code, + 'name' => $vendor->name, + 'shortName' => $vendor->short_name, + 'taxId' => $vendor->tax_id, + 'owner' => $vendor->owner, + 'contactName' => $vendor->contact_name, + 'phone' => $vendor->phone, + 'tel' => $vendor->tel, + 'email' => $vendor->email, + 'address' => $vendor->address, + 'remark' => $vendor->remark, + 'supplyProducts' => $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([ 'name' => 'required|string|max:255', @@ -80,7 +129,7 @@ class VendorController extends Controller 'remark' => 'nullable|string', ]); - // Auto-generate code + // 自動產生代碼 $prefix = 'V'; $lastVendor = Vendor::latest('id')->first(); $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([ 'name' => 'required|string|max:255', @@ -117,7 +166,7 @@ class VendorController extends Controller } /** - * Remove the specified resource from storage. + * 從儲存體中移除指定資源。 */ public function destroy(Vendor $vendor) { diff --git a/app/Modules/Procurement/Controllers/VendorProductController.php b/app/Modules/Procurement/Controllers/VendorProductController.php index 4b4fad9..d242f5d 100644 --- a/app/Modules/Procurement/Controllers/VendorProductController.php +++ b/app/Modules/Procurement/Controllers/VendorProductController.php @@ -4,12 +4,16 @@ namespace App\Modules\Procurement\Controllers; use App\Http\Controllers\Controller; use App\Modules\Procurement\Models\Vendor; - +use App\Modules\Inventory\Contracts\InventoryServiceInterface; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; class VendorProductController extends Controller { + public function __construct( + protected InventoryServiceInterface $inventoryService + ) {} + /** * 新增供貨商品 (Attach) */ @@ -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() ->performedOn($vendor) ->withProperties([ @@ -68,7 +72,7 @@ class VendorProductController extends Controller ]); // 記錄操作 - $product = \App\Modules\Inventory\Models\Product::find($productId); + $product = $this->inventoryService->getProduct($productId); activity() ->performedOn($vendor) ->withProperties([ @@ -97,7 +101,7 @@ class VendorProductController extends Controller public function destroy(Vendor $vendor, $productId) { // 記錄操作 (需在 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; $vendor->products()->detach($productId); diff --git a/app/Modules/Procurement/Models/PurchaseOrder.php b/app/Modules/Procurement/Models/PurchaseOrder.php index 0161889..75ae401 100644 --- a/app/Modules/Procurement/Models/PurchaseOrder.php +++ b/app/Modules/Procurement/Models/PurchaseOrder.php @@ -4,8 +4,7 @@ namespace App\Modules\Procurement\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use App\Modules\Inventory\Models\Warehouse; -use App\Modules\Core\Models\User; + class PurchaseOrder extends Model { @@ -14,19 +13,19 @@ class PurchaseOrder extends Model use \Spatie\Activitylog\Traits\LogsActivity; protected $fillable = [ - 'po_number', + 'code', 'vendor_id', 'warehouse_id', 'user_id', - 'order_date', 'expected_delivery_date', 'status', 'total_amount', - 'notes', + 'tax_amount', + 'grand_total', + 'remark', ]; protected $casts = [ - 'order_date' => 'date', 'expected_delivery_date' => 'date', 'total_amount' => 'decimal:2', ]; @@ -43,14 +42,13 @@ class PurchaseOrder extends Model { $snapshot = $activity->properties['snapshot'] ?? []; - $snapshot['po_number'] = $this->po_number; + $snapshot['po_number'] = $this->code; if ($this->vendor) { $snapshot['vendor_name'] = $this->vendor->name; } - if ($this->warehouse) { - $snapshot['warehouse_name'] = $this->warehouse->name; - } + // Warehouse relation removed in Strict Mode. Snapshot should be set via manual hydration if needed, + // or during the procurement process where warehouse_id is known. $activity->properties = $activity->properties->merge([ 'snapshot' => $snapshot @@ -62,15 +60,9 @@ class PurchaseOrder extends Model 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 { diff --git a/app/Modules/Procurement/Models/PurchaseOrderItem.php b/app/Modules/Procurement/Models/PurchaseOrderItem.php index de53384..92aee66 100644 --- a/app/Modules/Procurement/Models/PurchaseOrderItem.php +++ b/app/Modules/Procurement/Models/PurchaseOrderItem.php @@ -37,8 +37,5 @@ class PurchaseOrderItem extends Model return $this->belongsTo(PurchaseOrder::class); } - public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo - { - return $this->belongsTo(Product::class); - } + } diff --git a/app/Modules/Procurement/Models/Vendor.php b/app/Modules/Procurement/Models/Vendor.php index 144c29a..06c6375 100644 --- a/app/Modules/Procurement/Models/Vendor.php +++ b/app/Modules/Procurement/Models/Vendor.php @@ -16,18 +16,18 @@ class Vendor extends Model protected $fillable = [ 'code', 'name', - 'contact_person', - 'email', - 'phone', - 'address', + 'short_name', '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 { diff --git a/app/Modules/Procurement/ProcurementServiceProvider.php b/app/Modules/Procurement/ProcurementServiceProvider.php new file mode 100644 index 0000000..bd87b78 --- /dev/null +++ b/app/Modules/Procurement/ProcurementServiceProvider.php @@ -0,0 +1,20 @@ +app->bind(ProcurementServiceInterface::class, ProcurementService::class); + } + + public function boot(): void + { + // + } +} diff --git a/app/Modules/Procurement/Services/ProcurementService.php b/app/Modules/Procurement/Services/ProcurementService.php new file mode 100644 index 0000000..44014cf --- /dev/null +++ b/app/Modules/Procurement/Services/ProcurementService.php @@ -0,0 +1,23 @@ +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(); + } +} diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php index c312e9e..0e29555 100644 --- a/app/Modules/Production/Controllers/ProductionOrderController.php +++ b/app/Modules/Production/Controllers/ProductionOrderController.php @@ -4,11 +4,10 @@ namespace App\Modules\Production\Controllers; use App\Http\Controllers\Controller; -use App\Modules\Inventory\Models\Product; use App\Modules\Production\Models\ProductionOrder; use App\Modules\Production\Models\ProductionOrderItem; -use App\Modules\Inventory\Models\Unit; -use App\Modules\Inventory\Models\Warehouse; +use App\Modules\Inventory\Contracts\InventoryServiceInterface; +use App\Modules\Core\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Inertia\Inertia; @@ -16,20 +15,31 @@ use Inertia\Response; class ProductionOrderController extends Controller { + protected $inventoryService; + + public function __construct(InventoryServiceInterface $inventoryService) + { + $this->inventoryService = $inventoryService; + } + /** * 生產工單列表 */ 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')) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('code', 'like', "%{$search}%") - ->orWhere('output_batch_number', 'like', "%{$search}%") - ->orWhereHas('product', fn($pq) => $pq->where('name', 'like', "%{$search}%")); + ->orWhere('output_batch_number', '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); } - // 排序 - $sortField = $request->input('sort_field', 'created_at'); - $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); + // 排除軟刪除 + $query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc')); // 分頁 $perPage = $request->input('per_page', 10); $productionOrders = $query->paginate($perPage)->withQueryString(); + // --- 手動資料水和 (Manual Hydration) --- + $productIds = $productionOrders->pluck('product_id')->unique()->filter()->toArray(); + $warehouseIds = $productionOrders->pluck('warehouse_id')->unique()->filter()->toArray(); + $userIds = $productionOrders->pluck('user_id')->unique()->filter()->toArray(); + + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + $warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id'); + $users = 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', [ 'productionOrders' => $productionOrders, 'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']), @@ -63,9 +83,9 @@ class ProductionOrderController extends Controller public function create(): Response { return Inertia::render('Production/Create', [ - 'products' => Product::with(['baseUnit'])->get(), - 'warehouses' => Warehouse::all(), - 'units' => Unit::all(), + 'products' => $this->inventoryService->getAllProducts(), + 'warehouses' => $this->inventoryService->getAllWarehouses(), + 'units' => $this->inventoryService->getUnits(), ]); } @@ -74,56 +94,26 @@ class ProductionOrderController extends Controller */ public function store(Request $request) { - $status = $request->input('status', 'draft'); // 預設為草稿 + $status = $request->input('status', 'draft'); - // 共用驗證規則 $baseRules = [ - 'product_id' => 'required|exists:products,id', + 'product_id' => 'required', 'output_batch_number' => 'required|string|max:50', 'status' => 'nullable|in:draft,completed', ]; - // 完成模式需要完整驗證 $completedRules = [ - 'warehouse_id' => 'required|exists:warehouses,id', + 'warehouse_id' => 'required', 'output_quantity' => 'required|numeric|min:0.01', - 'output_box_count' => 'nullable|string|max:10', 'production_date' => 'required|date', - 'expiry_date' => 'nullable|date|after_or_equal:production_date', - 'remark' => 'nullable|string', '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.*.unit_id' => 'nullable|exists:units,id', ]; - // 草稿模式的寬鬆規則 - $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' ? array_merge($baseRules, $completedRules) : $baseRules; - $rules = $status === 'completed' - ? 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' => '請至少新增一項原物料', - ]); + $validated = $request->validate($rules); DB::transaction(function () use ($validated, $request, $status) { // 1. 建立生產工單 @@ -133,20 +123,22 @@ class ProductionOrderController extends Controller 'warehouse_id' => $validated['warehouse_id'] ?? null, 'output_quantity' => $validated['output_quantity'] ?? 0, '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(), - 'expiry_date' => $validated['expiry_date'] ?? null, + 'expiry_date' => $request->expiry_date, 'user_id' => auth()->id(), 'status' => $status, - 'remark' => $validated['remark'] ?? null, + 'remark' => $request->remark, ]); - // 2. 建立明細 (草稿與完成模式皆需儲存) - if (!empty($validated['items'])) { - foreach ($validated['items'] as $item) { - if (empty($item['inventory_id'])) continue; + activity() + ->performedOn($productionOrder) + ->causedBy(auth()->user()) + ->log('created'); - // 建立明細 + // 2. 處理明細 + if (!empty($request->items)) { + foreach ($request->items as $item) { ProductionOrderItem::create([ 'production_order_id' => $productionOrder->id, 'inventory_id' => $item['inventory_id'], @@ -154,52 +146,71 @@ class ProductionOrderController extends Controller 'unit_id' => $item['unit_id'] ?? null, ]); - // 若為完成模式,則扣減原物料庫存 if ($status === 'completed') { - $inventory = Inventory::findOrFail($item['inventory_id']); - $inventory->decrement('quantity', $item['quantity_used']); + $this->inventoryService->decreaseInventoryQuantity( + $item['inventory_id'], + $item['quantity_used'], + "生產單 #{$productionOrder->code} 耗料", + ProductionOrder::class, + $productionOrder->id + ); } } } - // 3. 若為完成模式,執行成品入庫 + // 3. 成品入庫 if ($status === 'completed') { - $product = Product::findOrFail($validated['product_id']); - Inventory::create([ + $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $validated['warehouse_id'], 'product_id' => $validated['product_id'], 'quantity' => $validated['output_quantity'], 'batch_number' => $validated['output_batch_number'], - 'box_number' => $validated['output_box_count'], - 'origin_country' => 'TW', // 生產預設為本地 + 'box_number' => $request->output_box_count, 'arrival_date' => $validated['production_date'], - 'expiry_date' => $validated['expiry_date'] ?? null, - 'quality_status' => 'normal', + 'expiry_date' => $request->expiry_date, + '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') - ->with('success', $message); + ->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存'); } /** - * 檢視生產單詳情(含追溯資訊) + * 檢視生產單詳情 */ public function show(ProductionOrder $productionOrder): Response { - $productionOrder->load([ - 'product.baseUnit', - 'warehouse', - 'user', - 'items.inventory.product', - 'items.inventory.sourcePurchaseOrder.vendor', - 'items.unit', - ]); + // 手動水和主表資料 + $productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id); + if ($productionOrder->product) { + $productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first(); + } + $productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id); + $productionOrder->user = 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', [ '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']) - ->where('warehouse_id', $warehouse->id) - ->where('quantity', '>', 0) - ->where('quality_status', 'normal') - ->orderBy('arrival_date', 'asc') // FIFO:舊的排前面 - ->get() - ->map(function ($inv) { - return [ - 'id' => $inv->id, - 'product_id' => $inv->product_id, - 'product_name' => $inv->product->name, - 'product_code' => $inv->product->code, - 'batch_number' => $inv->batch_number, - 'box_number' => $inv->box_number, - 'quantity' => $inv->quantity, - 'arrival_date' => $inv->arrival_date?->format('Y-m-d'), - 'expiry_date' => $inv->expiry_date?->format('Y-m-d'), - 'unit_name' => $inv->product->baseUnit?->name, - 'base_unit_id' => $inv->product->base_unit_id, - 'base_unit_name' => $inv->product->baseUnit?->name, - 'large_unit_id' => $inv->product->large_unit_id, - 'large_unit_name' => $inv->product->largeUnit?->name, - 'conversion_rate' => $inv->product->conversion_rate, - ]; - }); + $inventories = $this->inventoryService->getInventoriesByWarehouse($warehouseId); + + $data = $inventories->map(function ($inv) { + return [ + 'id' => $inv->id, + 'product_id' => $inv->product_id, + 'product_name' => $inv->product->name ?? '未知商品', + 'product_code' => $inv->product->code ?? '', + 'batch_number' => $inv->batch_number, + 'box_number' => $inv->box_number, + 'quantity' => $inv->quantity, + 'arrival_date' => $inv->arrival_date ? $inv->arrival_date->format('Y-m-d') : null, + 'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, + 'unit_name' => $inv->product->baseUnit->name ?? '', + 'base_unit_id' => $inv->product->base_unit_id ?? null, + 'large_unit_id' => $inv->product->large_unit_id ?? null, + 'conversion_rate' => $inv->product->conversion_rate ?? 1, + ]; + }); - return response()->json($inventories); + return response()->json($data); } /** - * 編輯生產單(僅限草稿狀態) + * 編輯生產單 */ public function edit(ProductionOrder $productionOrder): Response { - // 只有草稿可以編輯 if ($productionOrder->status !== 'draft') { return redirect()->route('production-orders.show', $productionOrder->id) ->with('error', '只有草稿狀態的生產單可以編輯'); } - $productionOrder->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', [ 'productionOrder' => $productionOrder, - 'products' => Product::with(['baseUnit'])->get(), - 'warehouses' => Warehouse::all(), - 'units' => Unit::all(), + 'products' => $this->inventoryService->getAllProducts(), + 'warehouses' => $this->inventoryService->getAllWarehouses(), + 'units' => $this->inventoryService->getUnits(), ]); } @@ -266,85 +287,60 @@ class ProductionOrderController extends Controller */ public function update(Request $request, ProductionOrder $productionOrder) { - // 只有草稿可以編輯 if ($productionOrder->status !== 'draft') { - return redirect()->route('production-orders.show', $productionOrder->id) - ->with('error', '只有草稿狀態的生產單可以編輯'); + return redirect()->route('production-orders.show', $productionOrder->id) + ->with('error', '只有草稿可以修改'); } $status = $request->input('status', 'draft'); - // 共用驗證規則 + // 基礎驗證規則 $baseRules = [ 'product_id' => 'required|exists:products,id', 'output_batch_number' => 'required|string|max:50', - 'status' => 'nullable|in:draft,completed', + 'status' => 'required|in:draft,completed', + 'remark' => 'nullable|string', ]; - // 完成模式需要完整驗證 + // 完工時的嚴格驗證規則 $completedRules = [ 'warehouse_id' => 'required|exists:warehouses,id', 'output_quantity' => 'required|numeric|min:0.01', - 'output_box_count' => 'nullable|string|max:10', 'production_date' => 'required|date', - 'expiry_date' => 'nullable|date|after_or_equal:production_date', - 'remark' => 'nullable|string', + 'expiry_date' => 'nullable|date', 'items' => 'required|array|min:1', 'items.*.inventory_id' => 'required|exists:inventories,id', 'items.*.quantity_used' => 'required|numeric|min:0.0001', - 'items.*.unit_id' => 'nullable|exists:units,id', ]; - // 草稿模式的寬鬆規則 - $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', - ]; + // 若狀態切換為 completed,需合併驗證規則 + $rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules; + + $validated = $request->validate($rules); - $rules = $status === 'completed' - ? 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, $status, $productionOrder) { - // 更新生產工單基本資料 + DB::transaction(function () use ($validated, $request, $status, $productionOrder) { $productionOrder->update([ '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_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(), - 'expiry_date' => $validated['expiry_date'] ?? null, + 'expiry_date' => $request->expiry_date, 'status' => $status, - 'remark' => $validated['remark'] ?? null, + 'remark' => $request->remark, ]); - // 刪除舊的明細 + activity() + ->performedOn($productionOrder) + ->causedBy(auth()->user()) + ->log('updated'); + + // 重新建立明細 $productionOrder->items()->delete(); - // 重新建立明細 (草稿與完成模式皆需儲存) - if (!empty($validated['items'])) { - foreach ($validated['items'] as $item) { - if (empty($item['inventory_id'])) continue; - + if (!empty($request->items)) { + foreach ($request->items as $item) { ProductionOrderItem::create([ 'production_order_id' => $productionOrder->id, 'inventory_id' => $item['inventory_id'], @@ -352,35 +348,63 @@ class ProductionOrderController extends Controller 'unit_id' => $item['unit_id'] ?? null, ]); - // 若為完成模式,則扣減原物料庫存 if ($status === 'completed') { - $inventory = Inventory::findOrFail($item['inventory_id']); - $inventory->decrement('quantity', $item['quantity_used']); + $this->inventoryService->decreaseInventoryQuantity( + $item['inventory_id'], + $item['quantity_used'], + "生產單 #{$productionOrder->code} 耗料", + ProductionOrder::class, + $productionOrder->id + ); } } } - // 若為完成模式,執行成品入庫 if ($status === 'completed') { - Inventory::create([ + $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $validated['warehouse_id'], 'product_id' => $validated['product_id'], 'quantity' => $validated['output_quantity'], 'batch_number' => $validated['output_batch_number'], - 'box_number' => $validated['output_box_count'], - 'origin_country' => 'TW', + 'box_number' => $request->output_box_count, 'arrival_date' => $validated['production_date'], - 'expiry_date' => $validated['expiry_date'] ?? null, - 'quality_status' => 'normal', + 'expiry_date' => $request->expiry_date, + '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') - ->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', '生產單已刪除'); } } diff --git a/app/Modules/Production/Controllers/RecipeController.php b/app/Modules/Production/Controllers/RecipeController.php new file mode 100644 index 0000000..b91165b --- /dev/null +++ b/app/Modules/Production/Controllers/RecipeController.php @@ -0,0 +1,191 @@ +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', '配方已刪除'); + } +} diff --git a/app/Modules/Production/Models/ProductionOrder.php b/app/Modules/Production/Models/ProductionOrder.php index 6617b2d..917fcb6 100644 --- a/app/Modules/Production/Models/ProductionOrder.php +++ b/app/Modules/Production/Models/ProductionOrder.php @@ -4,14 +4,12 @@ namespace App\Modules\Production\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use App\Modules\Inventory\Models\Product; -use App\Modules\Inventory\Models\Warehouse; -use App\Modules\Core\Models\User; +use Spatie\Activitylog\Traits\LogsActivity; +use Spatie\Activitylog\LogOptions; class ProductionOrder extends Model { - /** @use HasFactory<\Database\Factories\ProductionOrderFactory> */ - use HasFactory; + use HasFactory, LogsActivity; protected $fillable = [ 'code', @@ -27,6 +25,38 @@ class ProductionOrder extends Model 'remark', ]; + protected $casts = [ + 'production_date' => 'date', + 'expiry_date' => 'date', + 'output_quantity' => 'decimal:2', + ]; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly([ + 'code', + 'status', + 'output_quantity', + 'output_batch_number', + 'production_date', + 'remark' + ]) + ->logOnlyDirty() + ->dontSubmitEmptyLogs() + ->setDescriptionForEvent(fn(string $eventName) => "生產工單已{$this->getEventDescription($eventName)}"); + } + + protected function getEventDescription($eventName): string + { + return match ($eventName) { + 'created' => '建立', + 'updated' => '更新', + 'deleted' => '刪除', + default => $eventName, + }; + } + public static function generateCode() { $prefix = 'PO' . now()->format('Ymd'); @@ -40,27 +70,28 @@ class ProductionOrder extends Model return $prefix . $sequence; } - protected $casts = [ - 'order_date' => 'date', - 'start_date' => 'datetime', - 'completion_date' => 'datetime', - 'quantity' => 'decimal:2', - 'produced_quantity' => 'decimal:2', - ]; - - public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo + /** + * @deprecated 使用 InventoryServiceInterface 獲取產品資訊 + */ + public function product() { - 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 diff --git a/app/Modules/Production/Models/ProductionOrderItem.php b/app/Modules/Production/Models/ProductionOrderItem.php index 23bc855..e0cbc91 100644 --- a/app/Modules/Production/Models/ProductionOrderItem.php +++ b/app/Modules/Production/Models/ProductionOrderItem.php @@ -22,14 +22,20 @@ class ProductionOrderItem extends Model 'quantity_used' => 'decimal:4', ]; + /** + * @deprecated 使用 InventoryServiceInterface 獲取庫存資訊 + */ public function inventory() { - return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class); + return null; } + /** + * @deprecated + */ public function unit() { - return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class); + return null; } public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo @@ -37,8 +43,11 @@ class ProductionOrderItem extends Model 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; } } diff --git a/app/Modules/Production/Models/Recipe.php b/app/Modules/Production/Models/Recipe.php new file mode 100644 index 0000000..ea0834a --- /dev/null +++ b/app/Modules/Production/Models/Recipe.php @@ -0,0 +1,34 @@ + 'decimal:2', + 'is_active' => 'boolean', + ]; + + + + public function items() + { + return $this->hasMany(RecipeItem::class); + } +} diff --git a/app/Modules/Production/Models/RecipeItem.php b/app/Modules/Production/Models/RecipeItem.php new file mode 100644 index 0000000..32520cb --- /dev/null +++ b/app/Modules/Production/Models/RecipeItem.php @@ -0,0 +1,31 @@ + 'decimal:4', + ]; + + public function recipe() + { + return $this->belongsTo(Recipe::class); + } + + +} diff --git a/app/Modules/Production/Routes/web.php b/app/Modules/Production/Routes/web.php index b4e8a66..56cdc08 100644 --- a/app/Modules/Production/Routes/web.php +++ b/app/Modules/Production/Routes/web.php @@ -2,8 +2,12 @@ use Illuminate\Support\Facades\Route; use App\Modules\Production\Controllers\ProductionOrderController; +use App\Modules\Production\Controllers\RecipeController; Route::middleware('auth')->group(function () { + // 配方管理 + Route::resource('recipes', RecipeController::class); + // 生產管理 Route::middleware('permission:production_orders.view')->group(function () { Route::get('/production-orders', [ProductionOrderController::class, 'index'])->name('production-orders.index'); diff --git a/app/Modules/Shared/Contracts/ServiceInterface.php b/app/Modules/Shared/Contracts/ServiceInterface.php new file mode 100644 index 0000000..6004b3d --- /dev/null +++ b/app/Modules/Shared/Contracts/ServiceInterface.php @@ -0,0 +1,12 @@ +group($routesPath); } + + // Load Service Provider + $moduleName = basename($module); + $providerClass = "App\\Modules\\{$moduleName}\\{$moduleName}ServiceProvider"; + + if (class_exists($providerClass)) { + $this->app->register($providerClass); + } } } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index a32daed..f053204 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -11,6 +11,13 @@ use Illuminate\Support\Str; */ class UserFactory extends Factory { + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = \App\Modules\Core\Models\User::class; + /** * The current password being used by the factory. */ @@ -25,6 +32,7 @@ class UserFactory extends Factory { return [ 'name' => fake()->name(), + 'username' => fake()->unique()->userName(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), diff --git a/database/migrations/tenant/2026_01_26_000001_create_recipes_tables.php b/database/migrations/tenant/2026_01_26_000001_create_recipes_tables.php new file mode 100644 index 0000000..0c7d661 --- /dev/null +++ b/database/migrations/tenant/2026_01_26_000001_create_recipes_tables.php @@ -0,0 +1,45 @@ +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'); + } +}; diff --git a/database/migrations/tenant/2026_01_26_000002_add_recipe_permissions.php b/database/migrations/tenant/2026_01_26_000002_add_recipe_permissions.php new file mode 100644 index 0000000..60e9c85 --- /dev/null +++ b/database/migrations/tenant/2026_01_26_000002_add_recipe_permissions.php @@ -0,0 +1,68 @@ + '檢視配方', + '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(); + } + } +}; diff --git a/database/migrations/tenant/2026_01_26_141823_add_is_sellable_to_warehouses_table.php b/database/migrations/tenant/2026_01_26_141823_add_is_sellable_to_warehouses_table.php new file mode 100644 index 0000000..7155580 --- /dev/null +++ b/database/migrations/tenant/2026_01_26_141823_add_is_sellable_to_warehouses_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/seeders/UnitSeeder.php b/database/seeders/UnitSeeder.php index d914994..0bc28c7 100644 --- a/database/seeders/UnitSeeder.php +++ b/database/seeders/UnitSeeder.php @@ -13,21 +13,21 @@ class UnitSeeder extends Seeder public function run(): void { $units = [ - ['name' => '個', 'code' => 'pc'], - ['name' => '箱', 'code' => 'box'], - ['name' => '瓶', 'code' => 'btl'], - ['name' => '包', 'code' => 'pkg'], - ['name' => '公斤', 'code' => 'kg'], - ['name' => '公克', 'code' => 'g'], - ['name' => '公升', 'code' => 'l'], - ['name' => '毫升', 'code' => 'ml'], - ['name' => '籃', 'code' => 'bsk'], - ['name' => '桶', 'code' => 'bucket'], - ['name' => '罐', 'code' => 'can'], + ['name' => '個', 'code' => 'PCE'], // Piece + ['name' => '箱', 'code' => 'BX'], // Box + ['name' => '瓶', 'code' => 'BO'], // Bottle + ['name' => '包', 'code' => 'PK'], // Package + ['name' => '公斤', 'code' => 'KGM'], // Kilogram + ['name' => '公克', 'code' => 'GRM'], // Gram + ['name' => '公升', 'code' => 'LTR'], // Litre + ['name' => '毫升', 'code' => 'MLT'], // Millilitre + ['name' => '籃', 'code' => 'BK'], // Basket + ['name' => '桶', 'code' => 'BJ'], // Bucket + ['name' => '罐', 'code' => 'CA'], // Can ]; foreach ($units as $unit) { - Unit::firstOrCreate( + Unit::updateOrCreate( ['name' => $unit['name']], ['code' => $unit['code']] ); diff --git a/lang/zh_TW.json b/lang/zh_TW.json new file mode 100644 index 0000000..49bb01c --- /dev/null +++ b/lang/zh_TW.json @@ -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": "備註" + } +} \ No newline at end of file diff --git a/lang/zh_TW/auth.php b/lang/zh_TW/auth.php new file mode 100644 index 0000000..44f996e --- /dev/null +++ b/lang/zh_TW/auth.php @@ -0,0 +1,166 @@ + ':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' => '代號', + ], +]; diff --git a/lang/zh_TW/pagination.php b/lang/zh_TW/pagination.php new file mode 100644 index 0000000..44f996e --- /dev/null +++ b/lang/zh_TW/pagination.php @@ -0,0 +1,166 @@ + ':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' => '代號', + ], +]; diff --git a/lang/zh_TW/passwords.php b/lang/zh_TW/passwords.php new file mode 100644 index 0000000..44f996e --- /dev/null +++ b/lang/zh_TW/passwords.php @@ -0,0 +1,166 @@ + ':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' => '代號', + ], +]; diff --git a/lang/zh_TW/validation.php b/lang/zh_TW/validation.php new file mode 100644 index 0000000..4b7aa72 --- /dev/null +++ b/lang/zh_TW/validation.php @@ -0,0 +1,225 @@ + ':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' => '明細小計', + ], +]; diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 36d71e5..fba4884 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -41,7 +41,7 @@ interface Props { activity: Activity | null; } -// Field translation map +// 欄位翻譯對照表 const fieldLabels: Record = { name: '名稱', code: '商品代號', @@ -66,19 +66,19 @@ const fieldLabels: Record = { role_id: '角色', email_verified_at: '電子郵件驗證時間', remember_token: '登入權杖', - // Snapshot fields + // 快照欄位 category_name: '分類名稱', base_unit_name: '基本單位名稱', large_unit_name: '大單位名稱', purchase_unit_name: '採購單位名稱', - // Vendor fields + // 廠商欄位 short_name: '簡稱', tax_id: '統編', owner: '負責人', contact_name: '聯絡人', tel: '電話', remark: '備註', - // Warehouse & Inventory fields + // 倉庫與庫存欄位 warehouse_name: '倉庫名稱', product_name: '商品名稱', warehouse_id: '倉庫', @@ -86,7 +86,7 @@ const fieldLabels: Record = { quantity: '數量', safety_stock: '安全庫存', location: '儲位', - // Inventory fields + // 庫存欄位 batch_number: '批號', box_number: '箱號', origin_country: '來源國家', @@ -95,7 +95,7 @@ const fieldLabels: Record = { source_purchase_order_id: '來源採購單', quality_status: '品質狀態', quality_remark: '品質備註', - // Purchase Order fields + // 採購單欄位 po_number: '採購單號', vendor_id: '廠商', vendor_name: '廠商名稱', @@ -110,13 +110,13 @@ const fieldLabels: Record = { invoice_date: '發票日期', invoice_amount: '發票金額', last_price: '供貨價格', - // Utility Fee fields + // 公共事業費欄位 transaction_date: '費用日期', category: '費用類別', amount: '金額', }; -// Purchase Order Status Map +// 採購單狀態對照表 const statusMap: Record = { draft: '草稿', pending: '待審核', @@ -127,7 +127,7 @@ const statusMap: Record = { completed: '已完成', }; -// Inventory Quality Status Map +// 庫存品質狀態對照表 const qualityStatusMap: Record = { normal: '正常', frozen: '凍結', @@ -141,17 +141,17 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P const old = activity.properties?.old || {}; 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)])); - // Custom sort order for fields + // 自訂欄位排序順序 const sortOrder = [ 'po_number', 'vendor_name', 'warehouse_name', 'expected_delivery_date', 'status', 'remark', '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 .filter(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 indexB = sortOrder.indexOf(b); - // If both are in sortOrder, compare indices + // 如果兩者都在排序順序中,比較索引 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 (indexB !== -1) return 1; - // Otherwise alphabetical or default + // 否則按字母順序或預設 return a.localeCompare(b); }); - // Helper to check if a key is a snapshot name field + // 檢查鍵是否為快照名稱欄位的輔助函式 const isSnapshotField = (key: string) => { return [ '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) => { - // Mask password + // 遮蔽密碼 if (key === 'password') return '******'; if (value === null || value === undefined) return '-'; if (typeof value === 'boolean') return value ? '是' : '否'; if (key === 'is_active') return value ? '啟用' : '停用'; - // Handle Purchase Order Status + // 處理採購單狀態 if (key === 'status' && typeof value === 'string' && statusMap[value]) { return statusMap[value]; } - // Handle Inventory Quality Status + // 處理庫存品質狀態 if (key === 'quality_status' && typeof value === 'string' && 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') { - // Take only the date part (YYYY-MM-DD) + // 僅取日期部分 (YYYY-MM-DD) 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) => { - // If it's an ID field, try to find a corresponding name in snapshot or attributes + // 如果是 ID 欄位,嘗試在快照或屬性中尋找對應名稱 if (key.endsWith('_id')) { const nameKey = key.replace('_id', '_name'); - // Check snapshot first, then attributes + // 先檢查快照,然後檢查屬性 const nameValue = snapshot[nameKey] || attributes[nameKey]; if (nameValue) { return `${nameValue}`; @@ -236,14 +236,14 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return formatValue(key, value); }; - // Helper to get translated field label + // 取得翻譯欄位標籤的輔助函式 const getFieldLabel = (key: string) => { return fieldLabels[key] || key; }; - // Get subject name for header + // 取得標題的主題名稱 const getSubjectName = () => { - // Special handling for Inventory: show "Warehouse - Product" + // 庫存的特殊處理:顯示 "倉庫 - 商品" if ((snapshot.warehouse_name || attributes.warehouse_name) && (snapshot.product_name || attributes.product_name)) { const wName = snapshot.warehouse_name || attributes.warehouse_name; const pName = snapshot.product_name || attributes.product_name; @@ -276,7 +276,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P - {/* Modern Metadata Strip */} + {/* 現代化元數據條 */}
@@ -293,7 +293,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P {activity.properties?.sub_subject || activity.subject_type}
- {/* Only show 'description' if it differs from event name (unlikely but safe) */} + {/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */} {activity.description !== getEventLabel(activity.event) && activity.description !== 'created' && activity.description !== 'updated' && (
@@ -367,7 +367,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P const newValue = attributes[key]; 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' ? getFormattedValue(key, newValue || oldValue) : getFormattedValue(key, oldValue); @@ -399,7 +399,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
)} - {/* Items Diff Section (Special for Purchase Orders) */} + {/* 項目差異區塊(採購單專用) */} {activity.properties?.items_diff && (

@@ -417,7 +417,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P - {/* Updated Items */} + {/* 更新項目 */} {activity.properties.items_diff.updated.map((item: any, idx: number) => ( {item.product_name} @@ -440,7 +440,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P ))} - {/* Added Items */} + {/* 新增項目 */} {activity.properties.items_diff.added.map((item: any, idx: number) => ( {item.product_name} @@ -453,7 +453,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P ))} - {/* Removed Items */} + {/* 移除項目 */} {activity.properties.items_diff.removed.map((item: any, idx: number) => ( {item.product_name} diff --git a/resources/js/Components/ActivityLog/LogTable.tsx b/resources/js/Components/ActivityLog/LogTable.tsx index 8e30c2a..7ed9652 100644 --- a/resources/js/Components/ActivityLog/LogTable.tsx +++ b/resources/js/Components/ActivityLog/LogTable.tsx @@ -26,7 +26,7 @@ interface LogTableProps { sortOrder?: 'asc' | 'desc'; onSort?: (field: string) => void; onViewDetail: (activity: Activity) => void; - from?: number; // Starting index number (paginator.from) + from?: number; // 起始索引編號 (paginator.from) } export default function LogTable({ @@ -61,12 +61,12 @@ export default function LogTable({ const old = props.old || {}; 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']; let subjectName = ''; - // Special handling for Inventory: show "Warehouse - Product" + // 庫存的特殊處理:顯示 "倉庫 - 商品" if ((snapshot.warehouse_name || attrs.warehouse_name) && (snapshot.product_name || attrs.product_name)) { const wName = snapshot.warehouse_name || attrs.warehouse_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) { subjectName = `${old.warehouse_name} - ${old.product_name}`; } else { - // Default fallback + // 預設備案 for (const param of nameParams) { if (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)) { subjectName = `#${attrs.id || old.id}`; } - // Combine parts: [Causer] [Action] [Name] [Subject] + // 組合部分:[操作者] [動作] [名稱] [主題] // Example: Admin 新增 可樂 商品 // Example: Admin 更新 台北倉 - 可樂 庫存 return ( @@ -114,7 +114,7 @@ export default function LogTable({ {activity.subject_type} )} - {/* Display reason/source if available (e.g., from Replenishment) */} + {/* 如果有原因/來源則顯示(例如:來自補貨) */} {(attrs._reason || old._reason) && ( (來自 {attrs._reason || old._reason}) diff --git a/resources/js/Components/Product/ProductDialog.tsx b/resources/js/Components/Product/ProductDialog.tsx index d7d95bd..932a787 100644 --- a/resources/js/Components/Product/ProductDialog.tsx +++ b/resources/js/Components/Product/ProductDialog.tsx @@ -53,13 +53,13 @@ export default function ProductDialog({ setData({ code: product.code, name: product.name, - category_id: product.category_id.toString(), + category_id: product.categoryId.toString(), brand: product.brand || "", specification: product.specification || "", - base_unit_id: product.base_unit_id?.toString() || "", - large_unit_id: product.large_unit_id?.toString() || "", - conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "", - purchase_unit_id: product.purchase_unit_id?.toString() || "", + base_unit_id: product.baseUnitId?.toString() || "", + large_unit_id: product.largeUnitId?.toString() || "", + conversion_rate: product.conversionRate ? product.conversionRate.toString() : "", + purchase_unit_id: product.purchaseUnitId?.toString() || "", }); } else { reset(); diff --git a/resources/js/Components/Product/ProductTable.tsx b/resources/js/Components/Product/ProductTable.tsx index bd5be3a..9e3e2b9 100644 --- a/resources/js/Components/Product/ProductTable.tsx +++ b/resources/js/Components/Product/ProductTable.tsx @@ -26,7 +26,7 @@ import type { Product } from "@/Pages/Product/Index"; interface ProductTableProps { products: Product[]; onEdit: (product: Product) => void; - onDelete: (id: number) => void; + onDelete: (id: string) => void; startIndex: number; sortField: string | null; @@ -125,11 +125,11 @@ export default function ProductTable({ {product.category?.name || '-'} - {product.base_unit?.name || '-'} + {product.baseUnit?.name || '-'} - {product.large_unit ? ( + {product.largeUnit ? ( - 1 {product.large_unit?.name} = {Number(product.conversion_rate)} {product.base_unit?.name} + 1 {product.largeUnit?.name} = {Number(product.conversionRate)} {product.baseUnit?.name} ) : ( '-' diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx index fe4c9ea..3b4fa79 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx @@ -62,7 +62,7 @@ export function PurchaseOrderItemsTable({ ) : ( items.map((item, index) => { // 計算換算後的單價 (基本單位單價) - // unitPrice is derived from subtotal / quantity + // 單價由 小計 / 數量 推導得出 const currentUnitPrice = item.unitPrice; const convertedUnitPrice = item.selectedUnit === 'large' && item.conversion_rate diff --git a/resources/js/Components/Unit/UnitManagerDialog.tsx b/resources/js/Components/Unit/UnitManagerDialog.tsx index 80cc5df..d560eb5 100644 --- a/resources/js/Components/Unit/UnitManagerDialog.tsx +++ b/resources/js/Components/Unit/UnitManagerDialog.tsx @@ -26,7 +26,7 @@ import { toast } from "sonner"; import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react"; export interface Unit { - id: number; + id: string; name: string; code: string | null; } @@ -42,7 +42,7 @@ export default function UnitManagerDialog({ onOpenChange, units, }: UnitManagerDialogProps) { - const [editingId, setEditingId] = useState(null); + const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const [editCode, setEditCode] = useState(""); @@ -85,7 +85,7 @@ export default function UnitManagerDialog({ setEditCode(""); }; - const saveEdit = (id: number) => { + const saveEdit = (id: string) => { if (!editName.trim()) return; 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), { onSuccess: () => { // 由全域 flash 處理 diff --git a/resources/js/Components/Vendor/VendorDialog.tsx b/resources/js/Components/Vendor/VendorDialog.tsx index 0391a3a..57ee8d1 100644 --- a/resources/js/Components/Vendor/VendorDialog.tsx +++ b/resources/js/Components/Vendor/VendorDialog.tsx @@ -45,10 +45,10 @@ export default function VendorDialog({ if (vendor) { setData({ name: vendor.name, - short_name: vendor.short_name || "", - tax_id: vendor.tax_id || "", + short_name: vendor.shortName || "", + tax_id: vendor.taxId || "", owner: vendor.owner || "", - contact_name: vendor.contact_name || "", + contact_name: vendor.contactName || "", tel: vendor.tel || "", phone: vendor.phone || "", email: vendor.email || "", diff --git a/resources/js/Components/Vendor/VendorTable.tsx b/resources/js/Components/Vendor/VendorTable.tsx index 18f7a66..5bdac77 100644 --- a/resources/js/Components/Vendor/VendorTable.tsx +++ b/resources/js/Components/Vendor/VendorTable.tsx @@ -26,7 +26,7 @@ interface VendorTableProps { vendors: Vendor[]; onView: (vendor: Vendor) => void; onEdit: (vendor: Vendor) => void; - onDelete: (id: number) => void; + onDelete: (id: string) => void; sortField: string | null; sortDirection: "asc" | "desc" | null; onSort: (field: string) => void; @@ -107,11 +107,11 @@ export default function VendorTable({
{vendor.name} - {vendor.short_name && {vendor.short_name}} + {vendor.shortName && {vendor.shortName}}
{vendor.owner || '-'} - {vendor.contact_name || '-'} + {vendor.contactName || '-'} {vendor.phone || vendor.tel || '-'}
diff --git a/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx b/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx index edcd562..51e16ea 100644 --- a/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx +++ b/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx @@ -95,7 +95,7 @@ export default function AddSafetyStockDialog({ // 更新商品安全庫存量 const updateQuantity = (productId: string, value: number) => { const newQuantities = new Map(productQuantities); - newQuantities.set(productId, value); // Allow 0 + newQuantities.set(productId, value); // 允許為 0 setProductQuantities(newQuantities); }; diff --git a/resources/js/Components/Warehouse/TransferOrderDialog.tsx b/resources/js/Components/Warehouse/TransferOrderDialog.tsx index 3e09772..b2a9fc1 100644 --- a/resources/js/Components/Warehouse/TransferOrderDialog.tsx +++ b/resources/js/Components/Warehouse/TransferOrderDialog.tsx @@ -31,7 +31,7 @@ interface TransferOrderDialogProps { onOpenChange: (open: boolean) => void; order: TransferOrder | null; warehouses: Warehouse[]; - // inventories: WarehouseInventory[]; // Removed as we fetch from API + // inventories: WarehouseInventory[]; // 因從 API 獲取而移除 onSave: (order: Omit) => void; } @@ -41,6 +41,7 @@ interface AvailableProduct { batchNumber: string; availableQty: number; unit: string; + expiryDate: string | null; } export default function TransferOrderDialog({ @@ -99,7 +100,15 @@ export default function TransferOrderDialog({ if (formData.sourceWarehouseId) { axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId)) .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 => { console.error("Failed to fetch inventories:", error); @@ -240,7 +249,7 @@ export default function TransferOrderDialog({ onValueChange={handleProductChange} disabled={!formData.sourceWarehouseId || !!order} 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}`, }))} placeholder="選擇商品與批號" diff --git a/resources/js/Components/Warehouse/WarehouseCard.tsx b/resources/js/Components/Warehouse/WarehouseCard.tsx index 8a1b5f7..c4b2d3b 100644 --- a/resources/js/Components/Warehouse/WarehouseCard.tsx +++ b/resources/js/Components/Warehouse/WarehouseCard.tsx @@ -78,8 +78,17 @@ export default function WarehouseCard({ {warehouse.description || "無描述"}
- {/* 統計區塊 - 庫存警告 */} + + {/* 統計區塊 - 狀態標籤 */}
+ {/* 銷售狀態 */} +
+ 銷售狀態 + + {warehouse.is_sellable ? "可銷售" : "暫停銷售"} + +
+ {/* 低庫存警告狀態 */}
diff --git a/resources/js/Components/Warehouse/WarehouseDialog.tsx b/resources/js/Components/Warehouse/WarehouseDialog.tsx index 79a1b83..f9aa120 100644 --- a/resources/js/Components/Warehouse/WarehouseDialog.tsx +++ b/resources/js/Components/Warehouse/WarehouseDialog.tsx @@ -51,11 +51,13 @@ export default function WarehouseDialog({ name: string; address: string; description: string; + is_sellable: boolean; }>({ code: "", name: "", address: "", description: "", + is_sellable: true, }); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -67,6 +69,7 @@ export default function WarehouseDialog({ name: warehouse.name, address: warehouse.address || "", description: warehouse.description || "", + is_sellable: warehouse.is_sellable ?? true, }); } else { setFormData({ @@ -74,6 +77,7 @@ export default function WarehouseDialog({ name: "", address: "", description: "", + is_sellable: true, }); } }, [warehouse, open]); @@ -148,6 +152,23 @@ export default function WarehouseDialog({
+ {/* 銷售設定 */} +
+
+

銷售設定

+
+
+ setFormData({ ...formData, is_sellable: e.target.checked })} + /> + +
+
+ {/* 區塊 B:位置 */}
@@ -210,10 +231,10 @@ export default function WarehouseDialog({ - + {/* 刪除確認對話框 */} - + < AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} > 確認刪除倉庫 @@ -231,7 +252,7 @@ export default function WarehouseDialog({ - + ); } \ No newline at end of file diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index f558424..616bd78 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -20,7 +20,8 @@ import { FileText, Wallet, BarChart3, - FileSpreadsheet + FileSpreadsheet, + BookOpen } from "lucide-react"; import { toast, Toaster } from "sonner"; import { useState, useEffect, useMemo } from "react"; @@ -133,8 +134,15 @@ export default function AuthenticatedLayout({ id: "production-management", label: "生產管理", icon: , - permission: "production_orders.view", + permission: ["production_orders.view", "recipes.view"], children: [ + { + id: "recipe-list", + label: "配方管理", + icon: , + route: "/recipes", + permission: "recipes.view", + }, { id: "production-order-list", label: "生產工單", @@ -532,7 +540,7 @@ export default function AuthenticatedLayout({ "flex-1 flex flex-col transition-all duration-300 min-h-screen overflow-auto", "lg:ml-64", isCollapsed && "lg:ml-20", - "pt-16" // Always allow space for header + "pt-16" // 始終為頁首保留空間 )}>
diff --git a/resources/js/Pages/Product/Index.tsx b/resources/js/Pages/Product/Index.tsx index 044c16d..548cc5e 100644 --- a/resources/js/Pages/Product/Index.tsx +++ b/resources/js/Pages/Product/Index.tsx @@ -20,22 +20,22 @@ export interface Category { } export interface Product { - id: number; + id: string; code: string; name: string; - category_id: number; + categoryId: number; category?: Category; brand?: string; specification?: string; - base_unit_id: number; - base_unit?: Unit; - large_unit_id?: number; - large_unit?: Unit; - conversion_rate?: number; - purchase_unit_id?: number; - purchase_unit?: Unit; - created_at: string; - updated_at: string; + baseUnitId: number; + baseUnit?: Unit; + largeUnitId?: number; + largeUnit?: Unit; + conversionRate?: number; + purchaseUnitId?: number; + purchaseUnit?: Unit; + createdAt?: string; + updatedAt?: string; } interface PageProps { @@ -163,7 +163,7 @@ export default function ProductManagement({ products, categories, units, filters setIsDialogOpen(true); }; - const handleDeleteProduct = (id: number) => { + const handleDeleteProduct = (id: string) => { router.delete(route('products.destroy', id), { onSuccess: () => { // Toast handled by flash message diff --git a/resources/js/Pages/Production/Create.tsx b/resources/js/Pages/Production/Create.tsx index 39aa0fd..59beba4 100644 --- a/resources/js/Pages/Production/Create.tsx +++ b/resources/js/Pages/Production/Create.tsx @@ -53,18 +53,18 @@ interface InventoryOption { } interface BomItem { - // Backend required - inventory_id: string; // The selected inventory record ID (Specific Batch) - quantity_used: string; // The converted final quantity (Base Unit) - unit_id: string; // The unit ID (Base Unit ID usually) + // 後端必填 + inventory_id: string; // 所選庫存記錄 ID(特定批號) + quantity_used: string; // 轉換後的最終數量(基本單位) + unit_id: string; // 單位 ID(通常為基本單位 ID) - // UI State - ui_warehouse_id: string; // Source Warehouse - ui_product_id: string; // Filter for batch list - ui_input_quantity: string; // User typed quantity - ui_selected_unit: 'base' | 'large'; // User selected unit + // UI 狀態 + ui_warehouse_id: string; // 來源倉庫 + ui_product_id: string; // 批號列表篩選 + ui_input_quantity: string; // 使用者輸入數量 + ui_selected_unit: 'base' | 'large'; // 使用者選擇單位 - // UI Helpers / Cache + // UI 輔助 / 快取 ui_product_name?: string; ui_batch_number?: string; ui_available_qty?: number; @@ -83,8 +83,8 @@ interface Props { } export default function ProductionCreate({ products, warehouses }: Props) { - const [selectedWarehouse, setSelectedWarehouse] = useState(""); // Output Warehouse - // Cache map: warehouse_id -> inventories + const [selectedWarehouse, setSelectedWarehouse] = useState(""); // 產出倉庫 + // 快取對照表:warehouse_id -> inventories const [inventoryMap, setInventoryMap] = useState>({}); const [loadingWarehouses, setLoadingWareStates] = useState>({}); @@ -102,7 +102,7 @@ export default function ProductionCreate({ products, warehouses }: Props) { items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], }); - // Helper to fetch warehouse data + // 獲取倉庫資料的輔助函式 const fetchWarehouseInventory = async (warehouseId: string) => { if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return; diff --git a/resources/js/Pages/Production/Edit.tsx b/resources/js/Pages/Production/Edit.tsx index 3e16883..acf6c35 100644 --- a/resources/js/Pages/Production/Edit.tsx +++ b/resources/js/Pages/Production/Edit.tsx @@ -52,18 +52,18 @@ interface InventoryOption { } interface BomItem { - // Backend required + // 後端必填 inventory_id: string; quantity_used: string; unit_id: string; - // UI State - ui_warehouse_id: string; // Source Warehouse + // UI 狀態 + ui_warehouse_id: string; // 來源倉庫 ui_product_id: string; ui_input_quantity: string; ui_selected_unit: 'base' | 'large'; - // UI Helpers / Cache + // UI 輔助 / 快取 ui_product_name?: string; ui_batch_number?: string; ui_available_qty?: number; @@ -134,13 +134,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses } const [selectedWarehouse, setSelectedWarehouse] = useState( productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "" - ); // Output Warehouse + ); // 產出倉庫 - // Cache map: warehouse_id -> inventories + // 快取對照表:warehouse_id -> inventories const [inventoryMap, setInventoryMap] = useState>({}); const [loadingWarehouses, setLoadingWareStates] = useState>({}); - // Helper to fetch warehouse data + // 獲取倉庫資料的輔助函式 const fetchWarehouseInventory = async (warehouseId: string) => { 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_selected_unit: 'base', - // UI Helpers + // UI 輔助 ui_product_name: item.inventory?.product?.name, ui_batch_number: item.inventory?.batch_number, 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) }]) ).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 batchOptions = currentOptions @@ -610,7 +610,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses } 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 }] : []); diff --git a/resources/js/Pages/Production/Recipe/Create.tsx b/resources/js/Pages/Production/Recipe/Create.tsx new file mode 100644 index 0000000..7674b1e --- /dev/null +++ b/resources/js/Pages/Production/Recipe/Create.tsx @@ -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 ( + + +
+
+ + + + +
+
+

+ + 新增配方 +

+

+ 定義新的生產配方 +

+
+ +
+
+ +
+ {/* 左側:基本資料 */} +
+
+

基本資料

+
+
+ + setData('product_id', v)} + options={products.map(p => ({ + label: `${p.name} (${p.code})`, + value: String(p.id), + }))} + placeholder="選擇商品" + className="w-full" + /> + {errors.product_id &&

{errors.product_id}

} +
+ +
+ + setData('code', e.target.value)} + placeholder="例如: REC-P001" + /> + {errors.code &&

{errors.code}

} +
+ +
+ + setData('name', e.target.value)} + placeholder="例如: 草莓冰標準配方" + /> + {errors.name &&

{errors.name}

} +
+ +
+ +
+ setData('yield_quantity', e.target.value)} + placeholder="1" + /> + +
+ {errors.yield_quantity &&

{errors.yield_quantity}

} +
+ +
+ +