Compare commits

..

2 Commits

Author SHA1 Message Date
106de4e945 feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
2026-01-26 14:59:24 +08:00
b0848a6bb8 chore: 完善模組化架構遷移與修復前端顯示錯誤
- 修正所有模組 Controller 的 Model 引用路徑 (App\Modules\...)
- 更新 ProductionOrder 與 ProductionOrderItem 模型結構以符合新版邏輯
- 修復 resources/js/utils/format.ts 在處理空值時導致 toLocaleString 崩潰的問題
- 清除全域路徑與 Controller 遷移殘留檔案
2026-01-26 10:37:47 +08:00
125 changed files with 5292 additions and 2083 deletions

View File

@@ -5,73 +5,64 @@ trigger: always_on
---
trigger: always_on
---
預設專案運行於 WSL2 的 Laravel Sail (Docker) 環境。
開發框架規範說明書ERP 系統 (koori-erp)
1. 專案概述
目標: 打造一個強大且穩定的 ERP 後台管理系統。
核心架構: 採用 單體式架構配現代化前端 (Monolith with a Modern Frontend)。使用 Laravel、Inertia.js 及 React。
# 開發框架規範說明書ERP 系統 (star-erp)
工作流程: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。
## 1. 專案概述
* **目標** 打造一個強大且穩定的 ERP 後台管理系統。
* **核心架構** 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。
* **工作流程** 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。
2. 技術棧 (Tech Stack)
後端 PHP 8.5 / Laravel 12
## 2. 技術棧 (Tech Stack)
* **後端** PHP 8.5 / Laravel 12
* **前端橋樑** Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
* **前端庫** React (以 Functional Components 與 Hooks 為主)
* **樣式處理** Tailwind CSS (確保與 UI/UX 設計稿完全一致)
* **資料庫** MySQL 8.0
* **開發環境** Laravel Sail (Docker / WSL2)
* **未來擴充** 針對高併發或跨平台模組,預留 Golang 微服務接口。
前端橋樑: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
## 3. 目錄結構與慣例
前端庫: React (以 Functional Components 與 Hooks 為主)
### 3.1 後端 (Laravel - Modular Monolith)
系統採用模組化架構,核心邏輯位於 `app/Modules/` 下:
樣式處理: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
* **Modules** 位於 `app/Modules/{ModuleName}/`
* **Controllers** `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`
* **Models** `app/Modules/{ModuleName}/Models/`
* **Routes** `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。
* **Global Routes** `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。
資料庫: MySQL 8.0
### 3.2 前端 (React)
* **Pages (頁面)** 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。
* **Components (組件)** 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
* **Layouts (版面)** 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。
開發環境: Laravel Sail (Docker / WSL2)
## 4. 整合指南 (UI/UX 轉換至 Laravel)
* **組件遷移** 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。
* **資料傳遞** 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。
* **狀態管理** 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
未來擴充: 針對高併發或跨平台模組,預留 Golang 微服務接口。
## 5. 開發標準 (Coding Standards)
* **命名規範**
* Controllers: `PascalCaseController.php`
* React Components: `PascalCase.jsx`
* Routes: `kebab-case` (小寫橫線分隔)
* **回傳格式** 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
3. 目錄結構與慣例
3.1 後端 (Laravel)
Controllers 必須回傳 Inertia::render() 來渲染頁面。
## 6. AI 協作規則 (給 Antigravity AI)
* **角色設定** 你是一位專業的全端開發工程師助手。
* **代碼生成指令**
* 所有的解釋說明請使用 **繁體中文**。
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
* 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。
Models 嚴格執行型別標註,使用 Eloquent 進行資料庫操作。
## 7. 運行機制 (Docker / Sail)
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
Routes 統一在 routes/web.php 定義 Inertia 路由。
3.2 前端 (React)
Pages (頁面) 位於 resources/js/Pages/。每個檔案代表一個完整的路由視圖。
Components (組件) 位於 resources/js/Components/。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
Layouts (版面) 位於 resources/js/Layouts/。定義 ERP 的通用版面(例如:包含側邊欄 Sidebar 與導覽列 Navbar 的後台主框架)。
4. 整合指南 (UI/UX 轉換至 Laravel)
組件遷移: 將 UI/UX 的 React 原始碼移入 resources/js/ 時,應進行「原子化」拆解,提高元件複用率。
資料傳遞: 透過 Laravel Controller 的 props 傳送動態資料給 React。除非是後續的異步請求否則避免在 React 初次渲染時使用 axios 抓取資料,應優先使用 Inertia 的資料流。
狀態管理: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
5. 開發標準 (Coding Standards)
命名規範:
Controllers: PascalCaseController.php
React Components: PascalCase.jsx
Routes: kebab-case (小寫橫線分隔)
回傳格式: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
6. AI 協作規則 (給 Antigravity AI)
角色設定: 你是一位專業的全端開發工程師助手。
代碼生成指令:
所有的解釋說明請使用 繁體中文。
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
7.運行機制
因為是運行在docker上 所以要執行php的話 要執行docker exce
* **啟動環境** `./vendor/bin/sail up -d`
* **執行 PHP 指令** `./vendor/bin/sail php -v`
* **執行 Artisan 指令** `./vendor/bin/sail artisan route:list`
* **執行 Composer** `./vendor/bin/sail composer install`
* **執行 Node/NPM** `./vendor/bin/sail npm run dev`

View File

@@ -101,8 +101,8 @@ public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, st
protected function getSubjectMap()
{
return [
'App\Models\Product' => '商品',
'App\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
'App\Modules\Inventory\Models\Product' => '商品',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
];
}
```

View File

@@ -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)**:全系統關鍵行為軌跡留存。

View File

@@ -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)。
## 🚀 快速開始

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Modules\Core\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

View File

@@ -1,171 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\PurchaseOrder;
use App\Models\UtilityFee;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Carbon;
use Illuminate\Pagination\LengthAwarePaginator;
class AccountingReportController extends Controller
{
public function index(Request $request)
{
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
// 1. Get Purchase Orders (Completed or Received that are ready for accounting)
$purchaseOrders = PurchaseOrder::with(['vendor'])
->whereIn('status', ['received', 'completed'])
->whereBetween('created_at', [$dateStart . ' 00:00:00', $dateEnd . ' 23:59:59'])
->get()
->map(function ($po) {
return [
'id' => 'PO-' . $po->id,
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
'source' => '採購單',
'category' => '進貨支出',
'item' => $po->vendor->name ?? '未知廠商',
'reference' => $po->code,
'invoice_number' => $po->invoice_number,
'amount' => $po->grand_total,
];
});
// 2. Get Utility Fees
$utilityFees = UtilityFee::whereBetween('transaction_date', [$dateStart, $dateEnd])
->get()
->map(function ($fee) {
return [
'id' => 'UF-' . $fee->id,
'date' => $fee->transaction_date->format('Y-m-d'),
'source' => '公共事業費',
'category' => $fee->category,
'item' => $fee->description ?: $fee->category,
'reference' => '-',
'invoice_number' => $fee->invoice_number,
'amount' => $fee->amount,
];
});
// Combine and Sort
$allRecords = $purchaseOrders->concat($utilityFees)
->sortByDesc('date')
->values();
// 3. Manual Pagination
$perPage = $request->input('per_page', 10);
$page = $request->input('page', 1);
$offset = ($page - 1) * $perPage;
$paginatedRecords = new LengthAwarePaginator(
$allRecords->slice($offset, $perPage)->values(),
$allRecords->count(),
$perPage,
$page,
['path' => $request->url(), 'query' => $request->query()]
);
$summary = [
'total_amount' => $allRecords->sum('amount'),
'purchase_total' => $purchaseOrders->sum('amount'),
'utility_total' => $utilityFees->sum('amount'),
'record_count' => $allRecords->count(),
];
return Inertia::render('Accounting/Report', [
'records' => $paginatedRecords,
'summary' => $summary,
'filters' => [
'date_start' => $dateStart,
'date_end' => $dateEnd,
'per_page' => (int)$perPage,
],
]);
}
public function export(Request $request)
{
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
$selectedIdsParam = $request->input('selected_ids');
$purchaseOrdersQuery = PurchaseOrder::with(['vendor'])
->whereIn('status', ['received', 'completed']);
$utilityFeesQuery = UtilityFee::query();
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 = 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);
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function () use ($allRecords) {
$file = fopen('php://output', 'w');
// BOM for Excel compatibility with UTF-8
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
foreach ($allRecords as $row) {
fputcsv($file, $row);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Modules\Core\Models\Tenant;
use Inertia\Inertia;
class DashboardController extends Controller

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Modules\Core\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;

View File

@@ -1,385 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Inventory;
use App\Models\Product;
use App\Models\ProductionOrder;
use App\Models\ProductionOrderItem;
use App\Models\Unit;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class ProductionOrderController extends Controller
{
/**
* 生產工單列表
*/
public function index(Request $request): Response
{
$query = ProductionOrder::with(['product', 'warehouse', 'user']);
// 搜尋
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}%"));
});
}
// 狀態篩選
if ($request->filled('status') && $request->status !== 'all') {
$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);
// 分頁
$perPage = $request->input('per_page', 10);
$productionOrders = $query->paginate($perPage)->withQueryString();
return Inertia::render('Production/Index', [
'productionOrders' => $productionOrders,
'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* 新增生產單表單
*/
public function create(): Response
{
return Inertia::render('Production/Create', [
'products' => Product::with(['baseUnit'])->get(),
'warehouses' => Warehouse::all(),
'units' => Unit::all(),
]);
}
/**
* 儲存生產單(含自動扣料與成品入庫)
*/
public function store(Request $request)
{
$status = $request->input('status', 'draft'); // 預設為草稿
// 共用驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
];
// 完成模式需要完整驗證
$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',
'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',
];
$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, $request, $status) {
// 1. 建立生產工單
$productionOrder = ProductionOrder::create([
'code' => ProductionOrder::generateCode(),
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_box_count' => $validated['output_box_count'] ?? null,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $validated['expiry_date'] ?? null,
'user_id' => auth()->id(),
'status' => $status,
'remark' => $validated['remark'] ?? null,
]);
// 2. 建立明細 (草稿與完成模式皆需儲存)
if (!empty($validated['items'])) {
foreach ($validated['items'] as $item) {
if (empty($item['inventory_id'])) continue;
// 建立明細
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
// 若為完成模式,則扣減原物料庫存
if ($status === 'completed') {
$inventory = Inventory::findOrFail($item['inventory_id']);
$inventory->decrement('quantity', $item['quantity_used']);
}
}
}
// 3. 若為完成模式,執行成品入庫
if ($status === 'completed') {
$product = Product::findOrFail($validated['product_id']);
Inventory::create([
'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', // 生產預設為本地
'arrival_date' => $validated['production_date'],
'expiry_date' => $validated['expiry_date'] ?? null,
'quality_status' => 'normal',
]);
}
});
$message = $status === 'completed'
? '生產單已建立,原物料已扣減,成品已入庫'
: '生產單草稿已儲存';
return redirect()->route('production-orders.index')
->with('success', $message);
}
/**
* 檢視生產單詳情(含追溯資訊)
*/
public function show(ProductionOrder $productionOrder): Response
{
$productionOrder->load([
'product.baseUnit',
'warehouse',
'user',
'items.inventory.product',
'items.inventory.sourcePurchaseOrder.vendor',
'items.unit',
]);
return Inertia::render('Production/Show', [
'productionOrder' => $productionOrder,
]);
}
/**
* 取得倉庫內可用庫存(供 BOM 選擇)
*/
public function getWarehouseInventories(Warehouse $warehouse)
{
$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,
];
});
return response()->json($inventories);
}
/**
* 編輯生產單(僅限草稿狀態)
*/
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']);
return Inertia::render('Production/Edit', [
'productionOrder' => $productionOrder,
'products' => Product::with(['baseUnit'])->get(),
'warehouses' => Warehouse::all(),
'units' => Unit::all(),
]);
}
/**
* 更新生產單
*/
public function update(Request $request, ProductionOrder $productionOrder)
{
// 只有草稿可以編輯
if ($productionOrder->status !== 'draft') {
return redirect()->route('production-orders.show', $productionOrder->id)
->with('error', '只有草稿狀態的生產單可以編輯');
}
$status = $request->input('status', 'draft');
// 共用驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
];
// 完成模式需要完整驗證
$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',
'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',
];
$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) {
// 更新生產工單基本資料
$productionOrder->update([
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_box_count' => $validated['output_box_count'] ?? null,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $validated['expiry_date'] ?? null,
'status' => $status,
'remark' => $validated['remark'] ?? null,
]);
// 刪除舊的明細
$productionOrder->items()->delete();
// 重新建立明細 (草稿與完成模式皆需儲存)
if (!empty($validated['items'])) {
foreach ($validated['items'] as $item) {
if (empty($item['inventory_id'])) continue;
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
// 若為完成模式,則扣減原物料庫存
if ($status === 'completed') {
$inventory = Inventory::findOrFail($item['inventory_id']);
$inventory->decrement('quantity', $item['quantity_used']);
}
}
}
// 若為完成模式,執行成品入庫
if ($status === 'completed') {
Inventory::create([
'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',
'arrival_date' => $validated['production_date'],
'expiry_date' => $validated['expiry_date'] ?? null,
'quality_status' => 'normal',
]);
}
});
$message = $status === 'completed'
? '生產單已完成,原物料已扣減,成品已入庫'
: '生產單草稿已更新';
return redirect()->route('production-orders.index')
->with('success', $message);
}
}

View File

@@ -1,176 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\UtilityFee;
use Illuminate\Http\Request;
use Inertia\Inertia;
class UtilityFeeController extends Controller
{
public function index(Request $request)
{
$query = UtilityFee::query();
// Search
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('category', 'like', "%{$search}%")
->orWhere('invoice_number', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
// Filtering
if ($request->filled('category') && $request->input('category') !== 'all') {
$query->where('category', $request->input('category'));
}
if ($request->filled('date_start')) {
$query->where('transaction_date', '>=', $request->input('date_start'));
}
if ($request->filled('date_end')) {
$query->where('transaction_date', '<=', $request->input('date_end'));
}
// Sorting
$sortField = $request->input('sort_field');
$sortDirection = $request->input('sort_direction');
if ($sortField && $sortDirection) {
$query->orderBy($sortField, $sortDirection);
} else {
$query->orderBy('created_at', 'desc');
}
$fees = $query->paginate($request->input('per_page', 10))->withQueryString();
$availableCategories = UtilityFee::distinct()->pluck('category');
return Inertia::render('UtilityFee/Index', [
'fees' => $fees,
'availableCategories' => $availableCategories,
'filters' => $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'transaction_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$fee = UtilityFee::create($validated);
// Log activity
activity()
->performedOn($fee)
->causedBy(auth()->user())
->event('created')
->withProperties([
'attributes' => $fee->getAttributes(),
'snapshot' => [
'category' => $fee->category,
'amount' => $fee->amount,
'transaction_date' => $fee->transaction_date->format('Y-m-d'),
]
])
->log('created');
return redirect()->back();
}
public function update(Request $request, UtilityFee $utility_fee)
{
$validated = $request->validate([
'transaction_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
// Capture old attributes before update
$oldAttributes = $utility_fee->getAttributes();
$utility_fee->update($validated);
// Capture new attributes
$newAttributes = $utility_fee->getAttributes();
// Manual logOnlyDirty: Filter attributes to only include changes
$changedAttributes = [];
$changedOldAttributes = [];
foreach ($newAttributes as $key => $value) {
// Skip timestamps if they are the only change (optional, but good practice)
if (in_array($key, ['updated_at'])) continue;
$oldValue = $oldAttributes[$key] ?? null;
// Simple comparison (casting to string to handle date objects vs strings if necessary,
// but Eloquent attributes are usually consistent if casted.
// Using loose comparison != handles most cases correctly)
if ($value != $oldValue) {
$changedAttributes[$key] = $value;
$changedOldAttributes[$key] = $oldValue;
}
}
// Only log if there are changes (excluding just updated_at)
if (empty($changedAttributes)) {
return redirect()->back();
}
// Log activity with before/after comparison
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => $changedAttributes,
'old' => $changedOldAttributes,
'snapshot' => [
'category' => $utility_fee->category,
'amount' => $utility_fee->amount,
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
]
])
->log('updated');
return redirect()->back();
}
public function destroy(UtilityFee $utility_fee)
{
// Capture data snapshot before deletion
$snapshot = [
'category' => $utility_fee->category,
'amount' => $utility_fee->amount,
'transaction_date' => $utility_fee->transaction_date->format('Y-m-d'),
'invoice_number' => $utility_fee->invoice_number,
'description' => $utility_fee->description,
];
// Log activity before deletion
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('deleted')
->withProperties([
'attributes' => $utility_fee->getAttributes(),
'snapshot' => $snapshot
])
->log('deleted');
$utility_fee->delete();
return redirect()->back();
}
}

View File

@@ -1,120 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class ProductionOrder extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'code',
'product_id',
'output_batch_number',
'output_box_count',
'output_quantity',
'warehouse_id',
'production_date',
'expiry_date',
'user_id',
'status',
'remark',
];
protected $casts = [
'production_date' => 'date:Y-m-d',
'expiry_date' => 'date:Y-m-d',
'output_quantity' => 'decimal:2',
];
/**
* 成品商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* 入庫倉庫
*/
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
/**
* 操作人員
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 生產工單明細 (BOM)
*/
public function items(): HasMany
{
return $this->hasMany(ProductionOrderItem::class);
}
/**
* 活動日誌設定
*/
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* 活動日誌快照
*/
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// 快照關鍵名稱
$snapshot['production_code'] = $this->code;
$snapshot['product_name'] = $this->product ? $this->product->name : null;
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
$snapshot['user_name'] = $this->user ? $this->user->name : null;
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
/**
* 產生生產單號
*/
public static function generateCode(): string
{
$date = now()->format('Ymd');
$prefix = "PRO-{$date}-";
$lastOrder = static::where('code', 'like', "{$prefix}%")
->orderByDesc('code')
->first();
if ($lastOrder) {
$lastNumber = (int) substr($lastOrder->code, -3);
$nextNumber = str_pad($lastNumber + 1, 3, '0', STR_PAD_LEFT);
} else {
$nextNumber = '001';
}
return $prefix . $nextNumber;
}
}

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductionOrderItem extends Model
{
use HasFactory;
protected $fillable = [
'production_order_id',
'inventory_id',
'quantity_used',
'unit_id',
];
protected $casts = [
'quantity_used' => 'decimal:4',
];
/**
* 所屬生產工單
*/
public function productionOrder(): BelongsTo
{
return $this->belongsTo(ProductionOrder::class);
}
/**
* 使用的庫存紀錄
*/
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/**
* 單位
*/
public function unit(): BelongsTo
{
return $this->belongsTo(Unit::class);
}
}

View File

@@ -1,166 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class PurchaseOrder extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'code',
'vendor_id',
'warehouse_id',
'user_id',
'status',
'expected_delivery_date',
'total_amount',
'tax_amount',
'grand_total',
'remark',
'invoice_number',
'invoice_date',
'invoice_amount',
];
protected $casts = [
'expected_delivery_date' => 'date:Y-m-d',
'invoice_date' => 'date:Y-m-d',
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'grand_total' => 'decimal:2',
'invoice_amount' => 'decimal:2',
];
protected $appends = [
'poNumber',
'supplierId',
'supplierName',
'expectedDate',
'totalAmount',
'taxAmount', // Add this
'grandTotal', // Add this
'createdBy',
'warehouse_name',
'createdAt',
'invoiceNumber',
'invoiceDate',
'invoiceAmount',
];
public function getCreatedAtAttribute()
{
return $this->attributes['created_at'];
}
public function getPoNumberAttribute(): string
{
return $this->code;
}
public function getSupplierIdAttribute(): string
{
return (string) $this->vendor_id;
}
public function getSupplierNameAttribute(): string
{
return $this->vendor ? $this->vendor->name : '';
}
public function getExpectedDateAttribute(): ?string
{
return $this->attributes['expected_delivery_date'] ?? null;
}
public function getTotalAmountAttribute(): float
{
return (float) ($this->attributes['total_amount'] ?? 0);
}
public function getTaxAmountAttribute(): float
{
return (float) ($this->attributes['tax_amount'] ?? 0);
}
public function getGrandTotalAttribute(): float
{
return (float) ($this->attributes['grand_total'] ?? 0);
}
public function getCreatedByAttribute(): string
{
return $this->user ? $this->user->name : '系統';
}
public function getWarehouseNameAttribute(): string
{
return $this->warehouse ? $this->warehouse->name : '';
}
public function getInvoiceNumberAttribute(): ?string
{
return $this->attributes['invoice_number'] ?? null;
}
public function getInvoiceDateAttribute(): ?string
{
return $this->attributes['invoice_date'] ?? null;
}
public function getInvoiceAmountAttribute(): ?float
{
return isset($this->attributes['invoice_amount']) ? (float) $this->attributes['invoice_amount'] : null;
}
public function vendor(): BelongsTo
{
return $this->belongsTo(Vendor::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(PurchaseOrderItem::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// Snapshot key names
$snapshot['po_number'] = $this->code;
$snapshot['vendor_name'] = $this->vendor ? $this->vendor->name : null;
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
$snapshot['user_name'] = $this->user ? $this->user->name : null;
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
}

View File

@@ -1,68 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PurchaseOrderItem extends Model
{
use HasFactory;
protected $fillable = [
'purchase_order_id',
'product_id',
'quantity',
'unit_id', // 新增單位ID欄位
'unit_price',
'subtotal',
'received_quantity',
];
protected $casts = [
'quantity' => 'decimal:2',
'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2',
'received_quantity' => 'decimal:2',
];
public function getProductNameAttribute(): string
{
return $this->product?->name ?? '';
}
// 關聯單位
public function unit(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Unit::class);
}
public function getUnitNameAttribute(): string
{
// 優先使用關聯的 unit
if ($this->unit) {
return $this->unit->name;
}
if (!$this->product) {
return '';
}
// Fallback: 嘗試從 Product 的關聯單位獲取
return $this->product->purchaseUnit?->name
?? $this->product->largeUnit?->name
?? $this->product->baseUnit?->name
?? '';
}
public function purchaseOrder(): BelongsTo
{
return $this->belongsTo(PurchaseOrder::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UtilityFee extends Model
{
use HasFactory;
protected $fillable = [
'transaction_date',
'category',
'amount',
'invoice_number',
'description',
];
protected $casts = [
'transaction_date' => 'date:Y-m-d',
'amount' => 'decimal:2',
];
}

0
app/Modules/.gitkeep Normal file
View File

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Core\Contracts;
use Illuminate\Support\Collection;
interface CoreServiceInterface
{
/**
* Get multiple users by their IDs.
*
* @param array $ids
* @return Collection
*/
public function getUsersByIds(array $ids): Collection;
/**
* Get a specific user by ID.
*
* @param int $id
* @return object|null
*/
public function getUser(int $id): ?object;
/**
* Get all users.
*
* @return Collection
*/
public function getAllUsers(): Collection;
}

View File

@@ -1,8 +1,9 @@
<?php
namespace App\Http\Controllers\Admin;
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Activitylog\Models\Activity;
@@ -12,16 +13,16 @@ class ActivityLogController extends Controller
private function getSubjectMap()
{
return [
'App\Models\User' => '使用者',
'App\Models\Role' => '角色',
'App\Models\Product' => '商品',
'App\Models\Vendor' => '廠商',
'App\Models\Category' => '商品分類',
'App\Models\Unit' => '單位',
'App\Models\PurchaseOrder' => '採購單',
'App\Models\Warehouse' => '倉庫',
'App\Models\Inventory' => '庫存',
'App\Models\UtilityFee' => '公共事業費',
'App\Modules\Core\Models\User' => '使用者',
'App\Modules\Core\Models\Role' => '角色',
'App\Modules\Inventory\Models\Product' => '商品',
'App\Modules\Procurement\Models\Vendor' => '廠商',
'App\Modules\Inventory\Models\Category' => '商品分類',
'App\Modules\Inventory\Models\Unit' => '單位',
'App\Modules\Procurement\Models\PurchaseOrder' => '採購單',
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
'App\Modules\Inventory\Models\Inventory' => '庫存',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
];
}
@@ -95,13 +96,13 @@ class ActivityLogController extends Controller
];
});
// Prepare subject types for frontend filter
// 準備用於前端篩選的主題類型
$subjectTypes = collect($this->getSubjectMap())->map(function ($label, $value) {
return ['label' => $label, 'value' => $value];
})->values();
// Get users for causer filter
$users = \App\Models\User::select('id', 'name')->orderBy('name')->get()
// 取得用於操作者篩選的使用者
$users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
->map(function ($user) {
return ['label' => $user->name, 'value' => (string) $user->id];
});

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Auth;
namespace App\Modules\Core\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,13 +1,15 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Core\Controllers;
use App\Models\Product;
use App\Models\Vendor;
use App\Models\PurchaseOrder;
use App\Models\Warehouse;
use App\Models\Inventory;
use App\Models\WarehouseProductSafetyStock;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Product;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use Inertia\Inertia;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

View File

@@ -1,8 +1,9 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;

View File

@@ -1,8 +1,9 @@
<?php
namespace App\Http\Controllers\Admin;
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
@@ -12,7 +13,7 @@ use Illuminate\Validation\Rule;
class RoleController extends Controller
{
/**
* Display a listing of the resource.
* 顯示資源列表。
*/
public function index(Request $request)
{
@@ -22,7 +23,7 @@ class RoleController extends Controller
$query = Role::withCount('users', 'permissions')
->with('users:id,name,username');
// Handle sorting
// 處理排序
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
$query->orderBy($sortBy, $sortOrder);
} else {
@@ -38,7 +39,7 @@ class RoleController extends Controller
}
/**
* Show the form for creating a new resource.
* 顯示建立新資源的表單。
*/
public function create()
{
@@ -50,7 +51,7 @@ class RoleController extends Controller
}
/**
* Store a newly created resource in storage.
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
@@ -74,7 +75,7 @@ class RoleController extends Controller
}
/**
* Show the form for editing the specified resource.
* 顯示編輯指定資源的表單。
*/
public function edit(string $id)
{
@@ -96,7 +97,7 @@ class RoleController extends Controller
}
/**
* Update the specified resource in storage.
* 更新儲存體中的指定資源。
*/
public function update(Request $request, string $id)
{
@@ -126,7 +127,7 @@ class RoleController extends Controller
}
/**
* Remove the specified resource from storage.
* 從儲存體中移除指定資源。
*/
public function destroy(string $id)
{

View File

@@ -1,9 +1,10 @@
<?php
namespace App\Http\Controllers\Admin;
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Core\Models\User;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Inertia\Inertia;
@@ -13,7 +14,7 @@ use Illuminate\Support\Facades\Hash;
class UserController extends Controller
{
/**
* Display a listing of the resource.
* 顯示資源列表。
*/
public function index(Request $request)
{
@@ -25,7 +26,7 @@ class UserController extends Controller
$query = User::with(['roles:id,name,display_name']);
// Handle Search
// 處理搜尋
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
@@ -34,14 +35,14 @@ class UserController extends Controller
});
}
// Handle Role Filter
// 處理角色篩選
if ($roleId && $roleId !== 'all') {
$query->whereHas('roles', function ($q) use ($roleId) {
$q->where('id', $roleId);
});
}
// Handle sorting
// 處理排序
if (in_array($sortBy, ['name', 'created_at'])) {
$query->orderBy($sortBy, $sortOrder);
} else {
@@ -59,7 +60,7 @@ class UserController extends Controller
}
/**
* Show the form for creating a new resource.
* 顯示建立新資源的表單。
*/
public function create()
{
@@ -71,7 +72,7 @@ class UserController extends Controller
}
/**
* Store a newly created resource in storage.
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
@@ -97,7 +98,7 @@ class UserController extends Controller
if (!empty($validated['roles'])) {
$user->syncRoles($validated['roles']);
// Update the 'created' log to include roles
// 更新 'created' 紀錄以包含角色資訊
$activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user))
->where('subject_id', $user->id)
->where('event', 'created')
@@ -117,7 +118,7 @@ class UserController extends Controller
}
/**
* Show the form for editing the specified resource.
* 顯示編輯指定資源的表單。
*/
public function edit(string $id)
{
@@ -132,7 +133,7 @@ class UserController extends Controller
}
/**
* Update the specified resource in storage.
* 更新儲存體中的指定資源。
*/
public function update(Request $request, string $id)
{
@@ -149,7 +150,7 @@ class UserController extends Controller
'password.confirmed' => '密碼確認不符',
]);
// 1. Prepare data and detect changes
// 1. 準備資料並偵測變更
$userData = [
'name' => $validated['name'],
'email' => $validated['email'],
@@ -162,7 +163,7 @@ class UserController extends Controller
$user->fill($userData);
// Capture dirty attributes for manual logging
// 捕捉變更屬性以進行手動記錄
$dirty = $user->getDirty();
$oldAttributes = [];
$newAttributes = [];
@@ -172,10 +173,10 @@ class UserController extends Controller
$newAttributes[$key] = $value;
}
// Save without triggering events (prevents duplicate log)
// 儲存但不觸發事件(防止重複記錄)
$user->saveQuietly();
// 2. Handle Roles
// 2. 處理角色
$roleChanges = null;
if (isset($validated['roles'])) {
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
@@ -190,7 +191,7 @@ class UserController extends Controller
}
}
// 3. Manually Log activity (Single Consolidated Log)
// 3. 手動記錄活動(單一整合記錄)
if (!empty($newAttributes) || $roleChanges) {
$properties = [
'attributes' => $newAttributes,
@@ -208,7 +209,7 @@ class UserController extends Controller
->event('updated')
->withProperties($properties)
->tap(function (\Spatie\Activitylog\Contracts\Activity $activity) use ($user) {
// Manually add snapshot since we aren't using the model's LogOptions due to saveQuietly
// 手動加入快照,因為使用 saveQuietly 所以不使用模型的 LogOptions
$activity->properties = $activity->properties->merge([
'snapshot' => [
'name' => $user->name,
@@ -223,7 +224,7 @@ class UserController extends Controller
}
/**
* Remove the specified resource from storage.
* 從儲存體中移除指定資源。
*/
public function destroy(string $id)
{

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Core;
use Illuminate\Support\ServiceProvider;
use App\Modules\Core\Contracts\CoreServiceInterface;
use App\Modules\Core\Services\CoreService;
class CoreServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(CoreServiceInterface::class, CoreService::class);
}
public function boot(): void
{
//
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace App\Modules\Core\Models;
use Spatie\Permission\Models\Role as SpatieRole;
use Spatie\Activitylog\Traits\LogsActivity;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace App\Modules\Core\Models;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace App\Modules\Core\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -16,10 +16,20 @@ class User extends Authenticatable
use HasFactory, Notifiable, HasRoles, LogsActivity;
/**
* The attributes that are mass assignable.
* 可批量賦值的屬性。
*
* @var list<string>
*/
/**
* 建立模型的新工廠實例。
*
* @return \Illuminate\Database\Eloquent\Factories\Factory
*/
protected static function newFactory()
{
return \Database\Factories\UserFactory::new();
}
protected $fillable = [
'name',
'email',
@@ -28,7 +38,7 @@ class User extends Authenticatable
];
/**
* The attributes that should be hidden for serialization.
* 序列化時應隱藏的屬性。
*
* @var list<string>
*/
@@ -38,7 +48,7 @@ class User extends Authenticatable
];
/**
* Get the attributes that should be cast.
* 取得應進行轉換的屬性。
*
* @return array<string, string>
*/

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Core\Controllers\Auth\LoginController;
use App\Modules\Core\Controllers\DashboardController;
use App\Modules\Core\Controllers\ProfileController;
use App\Modules\Core\Controllers\RoleController;
use App\Modules\Core\Controllers\UserController;
use App\Modules\Core\Controllers\ActivityLogController;
// 登入/登出路由
Route::get('/login', [LoginController::class, 'show'])->name('login');
Route::post('/login', [LoginController::class, 'store']);
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
Route::middleware('auth')->group(function () {
// 儀表板 - 所有登入使用者皆可存取
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
// 使用者帳號設定
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
// 系統管理
Route::prefix('admin')->group(function () {
Route::middleware('permission:roles.view')->group(function () {
Route::get('/roles', [RoleController::class, 'index'])->name('roles.index');
Route::middleware('permission:roles.create')->group(function () {
Route::get('/roles/create', [RoleController::class, 'create'])->name('roles.create');
Route::post('/roles', [RoleController::class, 'store'])->name('roles.store');
});
Route::get('/roles/{role}/edit', [RoleController::class, 'edit'])->middleware('permission:roles.edit')->name('roles.edit');
Route::put('/roles/{role}', [RoleController::class, 'update'])->middleware('permission:roles.edit')->name('roles.update');
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('permission:roles.delete')->name('roles.destroy');
});
Route::middleware('permission:users.view')->group(function () {
Route::get('/users', [UserController::class, 'index'])->name('users.index');
Route::middleware('permission:users.create')->group(function () {
Route::get('/users/create', [UserController::class, 'create'])->name('users.create');
Route::post('/users', [UserController::class, 'store'])->name('users.store');
});
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
});
Route::middleware('permission:system.view_logs')->group(function () {
Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
});
});
});

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Modules\Core\Services;
use App\Modules\Core\Contracts\CoreServiceInterface;
use App\Modules\Core\Models\User;
use Illuminate\Support\Collection;
class CoreService implements CoreServiceInterface
{
/**
* Get multiple users by their IDs.
*
* @param array $ids
* @return Collection
*/
public function getUsersByIds(array $ids): Collection
{
return User::whereIn('id', $ids)->get();
}
/**
* Get a specific user by ID.
*
* @param int $id
* @return object|null
*/
public function getUser(int $id): ?object
{
return User::find($id);
}
/**
* Get all users.
*
* @return Collection
*/
public function getAllUsers(): Collection
{
return User::all();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Modules\Finance\Contracts;
use Illuminate\Support\Collection;
interface FinanceServiceInterface
{
/**
* Get accounting report data.
*
* @param string $start
* @param string $end
* @return array
*/
public function getAccountingReportData(string $start, string $end): array;
/**
* Get all utility fees with filters.
*
* @param array $filters
* @return mixed
*/
public function getUtilityFees(array $filters);
/**
* Get unique categories of utility fees.
*
* @return Collection
*/
public function getUniqueCategories(): Collection;
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Carbon;
use Illuminate\Pagination\LengthAwarePaginator;
class AccountingReportController extends Controller
{
protected $financeService;
public function __construct(FinanceServiceInterface $financeService)
{
$this->financeService = $financeService;
}
public function index(Request $request)
{
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
$allRecords = $reportData['records'];
// 3. Manual Pagination
$perPage = $request->input('per_page', 10);
$page = $request->input('page', 1);
$offset = ($page - 1) * $perPage;
$paginatedRecords = new LengthAwarePaginator(
$allRecords->slice($offset, $perPage)->values(),
$allRecords->count(),
$perPage,
$page,
['path' => $request->url(), 'query' => $request->query()]
);
return Inertia::render('Accounting/Report', [
'records' => $paginatedRecords,
'summary' => $reportData['summary'],
'filters' => [
'date_start' => $dateStart,
'date_end' => $dateEnd,
'per_page' => (int)$perPage,
],
]);
}
public function export(Request $request)
{
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
$selectedIdsParam = $request->input('selected_ids');
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
$allRecords = $reportData['records'];
if ($selectedIdsParam) {
$ids = explode(',', $selectedIdsParam);
$allRecords = $allRecords->whereIn('id', $ids);
}
$exportData = $allRecords->map(function ($record) {
return [
$record['date'],
$record['source'],
$record['category'],
$record['item'],
$record['reference'],
$record['invoice_number'],
$record['amount'],
];
});
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function () use ($exportData) {
$file = fopen('php://output', 'w');
// BOM for Excel compatibility with UTF-8
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
foreach ($exportData as $row) {
fputcsv($file, $row);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
class UtilityFeeController extends Controller
{
protected $financeService;
public function __construct(FinanceServiceInterface $financeService)
{
$this->financeService = $financeService;
}
public function index(Request $request)
{
$filters = $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']);
$fees = $this->financeService->getUtilityFees($filters)->withQueryString();
$availableCategories = $this->financeService->getUniqueCategories();
return Inertia::render('UtilityFee/Index', [
'fees' => $fees,
'availableCategories' => $availableCategories,
'filters' => $filters,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'transaction_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$fee = UtilityFee::create($validated);
activity()
->performedOn($fee)
->causedBy(auth()->user())
->event('created')
->log('created');
return redirect()->back();
}
public function update(Request $request, UtilityFee $utility_fee)
{
$validated = $request->validate([
'transaction_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$utility_fee->update($validated);
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('updated')
->log('updated');
return redirect()->back();
}
public function destroy(UtilityFee $utility_fee)
{
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('deleted')
->log('deleted');
$utility_fee->delete();
return redirect()->back();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Finance;
use Illuminate\Support\ServiceProvider;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use App\Modules\Finance\Services\FinanceService;
class FinanceServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(FinanceServiceInterface::class, FinanceService::class);
}
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Modules\Finance\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UtilityFee extends Model
{
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
use HasFactory;
protected $fillable = [
'transaction_date',
'category',
'amount',
'invoice_number',
'description',
];
protected $casts = [
'transaction_date' => 'date',
'amount' => 'decimal:2',
];
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$activity->properties = $activity->properties->put('snapshot', [
'transaction_date' => $this->transaction_date->format('Y-m-d'),
'category' => $this->category,
'amount' => $this->amount,
'invoice_number' => $this->invoice_number,
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Finance\Controllers\UtilityFeeController;
use App\Modules\Finance\Controllers\AccountingReportController;
Route::middleware('auth')->group(function () {
// 公共事業費管理
Route::middleware('permission:utility_fees.view')->group(function () {
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
});
Route::middleware('permission:utility_fees.create')->group(function () {
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
});
Route::middleware('permission:utility_fees.edit')->group(function () {
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update');
});
Route::middleware('permission:utility_fees.delete')->group(function () {
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
});
// 會計報表
Route::middleware('permission:accounting.view')->prefix('accounting-report')->group(function () {
Route::get('/', [AccountingReportController::class, 'index'])->name('accounting.report');
Route::get('/export', [AccountingReportController::class, 'export'])
->middleware('permission:accounting.export')
->name('accounting.export');
});
});

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Modules\Finance\Services;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
class FinanceService implements FinanceServiceInterface
{
protected $procurementService;
public function __construct(ProcurementServiceInterface $procurementService)
{
$this->procurementService = $procurementService;
}
public function getAccountingReportData(string $start, string $end): array
{
// 1. 獲取採購單資料
$purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end)
->map(function ($po) {
return [
'id' => 'PO-' . $po->id,
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
'source' => '採購單',
'category' => '進貨支出',
'item' => $po->vendor->name ?? '未知廠商',
'reference' => $po->code,
'invoice_number' => $po->invoice_number,
'amount' => (float)$po->grand_total,
];
});
// 2. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date)
$utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end])
->get()
->map(function ($fee) {
return [
'id' => 'UF-' . $fee->id,
'date' => $fee->transaction_date->format('Y-m-d'),
'source' => '公共事業費',
'category' => $fee->category,
'item' => $fee->description ?: $fee->category,
'reference' => '-',
'invoice_number' => $fee->invoice_number,
'amount' => (float)$fee->amount,
];
});
$allRecords = $purchaseOrders->concat($utilityFees)
->sortByDesc('date')
->values();
return [
'records' => $allRecords,
'summary' => [
'total_amount' => $allRecords->sum('amount'),
'purchase_total' => $purchaseOrders->sum('amount'),
'utility_total' => $utilityFees->sum('amount'),
'record_count' => $allRecords->count(),
]
];
}
public function getUtilityFees(array $filters)
{
$query = UtilityFee::query();
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function($q) use ($search) {
$q->where('category', 'like', "%{$search}%")
->orWhere('invoice_number', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if (!empty($filters['category']) && $filters['category'] !== 'all') {
$query->where('category', $filters['category']);
}
if (!empty($filters['date_start'])) {
$query->where('transaction_date', '>=', $filters['date_start']);
}
if (!empty($filters['date_end'])) {
$query->where('transaction_date', '<=', $filters['date_end']);
}
$sortField = $filters['sort_field'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
return $query->paginate($filters['per_page'] ?? 10);
}
public function getUniqueCategories(): Collection
{
return UtilityFee::distinct()->pluck('category');
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Modules\Inventory\Contracts;
interface InventoryServiceInterface
{
/**
* Check if a product has sufficient stock in a specific warehouse.
*
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @return bool
*/
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
/**
* Decrease stock for a product (e.g., when an order is placed).
*
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @param string|null $reason
* @return void
*/
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void;
/**
* Get all active warehouses.
*
* @return \Illuminate\Support\Collection
*/
public function getAllWarehouses();
/**
* Get multiple products by their IDs.
*
* @param array $ids
* @return \Illuminate\Support\Collection
*/
public function getProductsByIds(array $ids);
/**
* Get a specific product by ID.
*
* @param int $id
* @return object|null
*/
public function getProduct(int $id);
/**
* Get a specific warehouse by ID.
*
* @param int $id
* @return object|null
*/
public function getWarehouse(int $id);
/**
* Get all available inventories in a specific warehouse.
*
* @param int $warehouseId
* @return \Illuminate\Support\Collection
*/
public function getInventoriesByWarehouse(int $warehouseId);
/**
* Get all products.
*
* @return \Illuminate\Support\Collection
*/
public function getAllProducts();
/**
* Get all units.
*
* @return \Illuminate\Support\Collection
*/
public function getUnits();
/**
* Create a new inventory record (e.g., for finished goods).
*
* @param array $data
* @return object
*/
public function createInventoryRecord(array $data);
/**
* Decrease quantity of a specific inventory record.
*
* @param int $inventoryId
* @param float $quantity
* @param string|null $reason
* @param string|null $referenceType
* @param int|string|null $referenceId
* @return void
*/
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
}

View File

@@ -1,8 +1,10 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Models\Category;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller

View File

@@ -1,13 +1,20 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\WarehouseProductSafetyStock;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
class InventoryController extends Controller
{
public function index(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
public function index(Request $request, Warehouse $warehouse)
{
$warehouse->load([
'inventories.product.category',
@@ -15,7 +22,7 @@ class InventoryController extends Controller
'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction'
]);
$allProducts = \App\Models\Product::with('category')->get();
$allProducts = Product::with('category')->get();
// 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) {
@@ -96,7 +103,7 @@ class InventoryController extends Controller
];
});
return \Inertia\Inertia::render('Warehouse/Inventory', [
return Inertia::render('Warehouse/Inventory', [
'warehouse' => $warehouse,
'inventories' => $inventories,
'safetyStockSettings' => $safetyStockSettings,
@@ -104,10 +111,10 @@ class InventoryController extends Controller
]);
}
public function create(\App\Models\Warehouse $warehouse)
public function create(Warehouse $warehouse)
{
// 取得所有商品供前端選單使用
$products = \App\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) {
@@ -121,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\Models\Warehouse $warehouse)
public function store(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'inboundDate' => 'required|date',
@@ -142,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\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\Models\Product::find($item['productId']);
$product = Product::find($item['productId']);
$batchNumber = \App\Models\Inventory::generateBatchNumber(
$batchNumber = Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$validated['inboundDate']
@@ -208,12 +215,12 @@ class InventoryController extends Controller
/**
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
*/
public function getBatches(\App\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\Models\Inventory::where('warehouse_id', $warehouse->id)
$batches = Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->get()
->map(function ($inventory) {
@@ -227,10 +234,10 @@ class InventoryController extends Controller
});
// 計算下一個流水號
$product = \App\Models\Product::find($productId);
$product = Product::find($productId);
$nextSequence = '01';
if ($product) {
$batchNumber = \App\Models\Inventory::generateBatchNumber(
$batchNumber = Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$arrivalDate
@@ -244,7 +251,7 @@ class InventoryController extends Controller
]);
}
public function edit(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId)
public function edit(Request $request, Warehouse $warehouse, $inventoryId)
{
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
@@ -252,7 +259,7 @@ class InventoryController extends Controller
return redirect()->back()->with('error', '無法編輯範例資料');
}
$inventory = \App\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);
@@ -282,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\Models\Warehouse $warehouse, $inventoryId)
public function update(Request $request, Warehouse $warehouse, $inventoryId)
{
// 若是 product ID (舊邏輯),先轉為 inventory
// 但新路由我們傳的是 inventory ID
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$inventory = \App\Models\Inventory::find($inventoryId);
$inventory = Inventory::find($inventoryId);
// 如果找不到 (可能是舊路由傳 product ID)
if (!$inventory) {
@@ -320,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'];
@@ -393,9 +400,9 @@ class InventoryController extends Controller
});
}
public function destroy(\App\Models\Warehouse $warehouse, $inventoryId)
public function destroy(Warehouse $warehouse, $inventoryId)
{
$inventory = \App\Models\Inventory::findOrFail($inventoryId);
$inventory = Inventory::findOrFail($inventoryId);
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
if ($inventory->quantity > 0) {
@@ -421,14 +428,14 @@ class InventoryController extends Controller
->with('success', '庫存品項已刪除');
}
public function history(Request $request, \App\Models\Warehouse $warehouse)
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{
$inventoryId = $request->query('inventoryId');
$productId = $request->query('productId');
if ($productId) {
// 商品層級查詢
$inventories = \App\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');
@@ -489,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,
@@ -503,7 +510,7 @@ class InventoryController extends Controller
if ($inventoryId) {
// 單一批號查詢
$inventory = \App\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);
@@ -519,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,

View File

@@ -1,9 +1,12 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Models\Product;
use App\Models\Unit;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Unit;
use App\Modules\Inventory\Models\Category;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -11,7 +14,7 @@ use Inertia\Response;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
* 顯示資源列表。
*/
public function index(Request $request): Response
{
@@ -38,7 +41,7 @@ class ProductController extends Controller
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
// Define allowed sort fields to prevent SQL injection
// 定義允許的排序欄位以防止 SQL 注入
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
@@ -47,11 +50,11 @@ class ProductController extends Controller
$sortDirection = 'desc';
}
// Handle relation sorting (category name) separately if needed, or simple join
// 如果需要,分別處理關聯排序(分類名稱),或簡單的 join
if ($sortField === 'category_id') {
// Join categories for sorting by name? Or just by ID?
// Simple approach: sort by ID for now, or join if user wants name sort.
// Let's assume standard field sorting first.
// 加入分類以便按名稱排序?還是僅按 ID
// 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。
// 先假設標準欄位排序。
$query->orderBy('category_id', $sortDirection);
} else {
$query->orderBy($sortField, $sortDirection);
@@ -59,18 +62,49 @@ class ProductController extends Controller
$products = $query->paginate($perPage)->withQueryString();
$categories = \App\Models\Category::where('is_active', true)->get();
$products->getCollection()->transform(function ($product) {
return (object) [
'id' => (string) $product->id,
'code' => $product->code,
'name' => $product->name,
'categoryId' => $product->category_id,
'category' => $product->category ? (object) [
'id' => $product->category->id,
'name' => $product->category->name,
] : null,
'brand' => $product->brand,
'specification' => $product->specification,
'baseUnitId' => $product->base_unit_id,
'baseUnit' => $product->baseUnit ? (object) [
'id' => $product->baseUnit->id,
'name' => $product->baseUnit->name,
] : null,
'largeUnitId' => $product->large_unit_id,
'largeUnit' => $product->largeUnit ? (object) [
'id' => $product->largeUnit->id,
'name' => $product->largeUnit->name,
] : null,
'purchaseUnitId' => $product->purchase_unit_id,
'purchaseUnit' => $product->purchaseUnit ? (object) [
'id' => $product->purchaseUnit->id,
'name' => $product->purchaseUnit->name,
] : null,
'conversionRate' => (float) $product->conversion_rate,
];
});
$categories = Category::where('is_active', true)->get();
return Inertia::render('Product/Index', [
'products' => $products,
'categories' => $categories,
'units' => Unit::all(),
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* Store a newly created resource in storage.
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
@@ -105,7 +139,7 @@ class ProductController extends Controller
}
/**
* Update the specified resource in storage.
* 更新儲存體中的指定資源。
*/
public function update(Request $request, Product $product)
{
@@ -139,7 +173,7 @@ class ProductController extends Controller
}
/**
* Remove the specified resource from storage.
* 從儲存體中移除指定資源。
*/
public function destroy(Product $product)
{

View File

@@ -1,11 +1,13 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Models\Warehouse;
use App\Models\WarehouseProductSafetyStock;
use App\Models\Product;
use App\Models\Inventory;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;

View File

@@ -1,9 +1,11 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Models\Inventory;
use App\Models\Warehouse;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -27,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,
]
);
@@ -107,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,
];
});

View File

@@ -1,15 +1,17 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Models\Unit;
use App\Models\Product; // Import Product to check for usage
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Unit;
use App\Modules\Inventory\Models\Product; // Import Product to check for usage
use Illuminate\Http\Request;
class UnitController extends Controller
{
/**
* Store a newly created resource in storage.
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
@@ -29,7 +31,7 @@ class UnitController extends Controller
}
/**
* Update the specified resource in storage.
* 更新儲存體中的指定資源。
*/
public function update(Request $request, Unit $unit)
{
@@ -49,11 +51,11 @@ class UnitController extends Controller
}
/**
* Remove the specified resource from storage.
* 從儲存體中移除指定資源。
*/
public function destroy(Unit $unit)
{
// Check if unit is used in any product
// 檢查單位是否已被任何商品使用
$isUsed = Product::where('base_unit_id', $unit->id)
->orWhere('large_unit_id', $unit->id)
->orWhere('purchase_unit_id', $unit->id)

View File

@@ -1,10 +1,12 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Warehouse;
use App\Modules\Inventory\Models\Warehouse;
use Inertia\Inertia;
@@ -22,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']),
]);
}
@@ -39,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;
@@ -60,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);

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Inventory;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Services\InventoryService;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
}
public function boot(): void
{
//
}
}

View File

@@ -1,37 +1,27 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Category extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'name',
'description',
'is_active',
];
protected $fillable = ['name', 'description'];
protected $casts = [
'is_active' => 'boolean',
];
/**
* Get the products for the category.
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
public function getActivitylogOptions(): LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();

View File

@@ -1,10 +1,11 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Inventory extends Model
{
/** @use HasFactory<\Database\Factories\InventoryFactory> */
@@ -34,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;
@@ -54,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;
}
@@ -104,13 +105,7 @@ class Inventory extends Model
});
}
/**
* 來源採購單
*/
public function sourcePurchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(PurchaseOrder::class, 'source_purchase_order_id');
}
/**
* 產生批號

View File

@@ -1,11 +1,10 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Inventory;
use App\Models\User;
use App\Modules\Core\Models\User; // 跨模組核心依賴
class InventoryTransaction extends Model
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Product extends Model
{
use HasFactory, LogsActivity, SoftDeletes;
@@ -31,7 +32,7 @@ class Product extends Model
];
/**
* Get the category that owns the product.
* 取得該商品所屬的分類。
*/
public function category(): BelongsTo
{
@@ -53,10 +54,7 @@ class Product extends Model
return $this->belongsTo(Unit::class, 'purchase_unit_id');
}
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
}
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
@@ -82,13 +80,13 @@ class Product extends Model
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// Handle Category Name Snapshot
// 處理分類名稱快照
if (isset($attributes['category_id'])) {
$category = Category::find($attributes['category_id']);
$snapshot['category_name'] = $category ? $category->name : null;
}
// Handle Unit Name Snapshots
// 處理單位名稱快照
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
foreach ($unitFields as $field) {
if (isset($attributes[$field])) {
@@ -98,7 +96,7 @@ class Product extends Model
}
}
// Always snapshot self name for context (so logs always show "Cola")
// 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂"
$snapshot['name'] = $this->name;
$properties['attributes'] = $attributes;

View File

@@ -1,24 +1,32 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Unit extends Model
{
/** @use HasFactory<\Database\Factories\UnitFactory> */
use HasFactory, LogsActivity;
protected $fillable = [
'name',
'code',
];
protected $fillable = ['name', 'abbreviation'];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
public function productsAsBase(): HasMany
{
return \Spatie\Activitylog\LogOptions::defaults()
return $this->hasMany(Product::class, 'base_unit_id');
}
public function productsAsLarge(): HasMany
{
return $this->hasMany(Product::class, 'large_unit_id');
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();

View File

@@ -1,10 +1,11 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Warehouse extends Model
{
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
@@ -16,6 +17,11 @@ class Warehouse extends Model
'name',
'address',
'description',
'is_sellable',
];
protected $casts = [
'is_sellable' => 'boolean',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
@@ -42,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
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

View File

@@ -0,0 +1,80 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Inventory\Controllers\CategoryController;
use App\Modules\Inventory\Controllers\UnitController;
use App\Modules\Inventory\Controllers\ProductController;
use App\Modules\Inventory\Controllers\WarehouseController;
use App\Modules\Inventory\Controllers\InventoryController;
use App\Modules\Inventory\Controllers\SafetyStockController;
use App\Modules\Inventory\Controllers\TransferOrderController;
Route::middleware('auth')->group(function () {
// 類別管理 (用於商品對話框) - 需要商品權限
Route::middleware('permission:products.view')->group(function () {
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
Route::post('/categories', [CategoryController::class, 'store'])->middleware('permission:products.create')->name('categories.store');
Route::put('/categories/{category}', [CategoryController::class, 'update'])->middleware('permission:products.edit')->name('categories.update');
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->middleware('permission:products.delete')->name('categories.destroy');
});
// 單位管理 - 需要商品權限
Route::middleware('permission:products.create|products.edit')->group(function () {
Route::post('/units', [UnitController::class, 'store'])->name('units.store');
Route::put('/units/{unit}', [UnitController::class, 'update'])->name('units.update');
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->name('units.destroy');
});
// 商品管理
Route::middleware('permission:products.view')->group(function () {
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete')->name('products.destroy');
});
// 倉庫管理
Route::middleware('permission:warehouses.view')->group(function () {
Route::get('/warehouses', [WarehouseController::class, 'index'])->name('warehouses.index');
Route::post('/warehouses', [WarehouseController::class, 'store'])->middleware('permission:warehouses.create')->name('warehouses.store');
Route::put('/warehouses/{warehouse}', [WarehouseController::class, 'update'])->middleware('permission:warehouses.edit')->name('warehouses.update');
Route::delete('/warehouses/{warehouse}', [WarehouseController::class, 'destroy'])->middleware('permission:warehouses.delete')->name('warehouses.destroy');
// 倉庫庫存管理 - 需要庫存權限
Route::middleware('permission:inventory.view')->group(function () {
Route::get('/warehouses/{warehouse}/inventory', [InventoryController::class, 'index'])->name('warehouses.inventory.index');
Route::get('/warehouses/{warehouse}/inventory-history', [InventoryController::class, 'history'])->name('warehouses.inventory.history');
Route::middleware('permission:inventory.adjust')->group(function () {
Route::get('/warehouses/{warehouse}/inventory/create', [InventoryController::class, 'create'])->name('warehouses.inventory.create');
Route::post('/warehouses/{warehouse}/inventory', [InventoryController::class, 'store'])->name('warehouses.inventory.store');
Route::get('/warehouses/{warehouse}/inventory/{inventoryId}/edit', [InventoryController::class, 'edit'])->name('warehouses.inventory.edit');
Route::put('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'update'])->name('warehouses.inventory.update');
Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy');
});
// API: 取得商品在特定倉庫的所有批號
Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches'])
->name('api.warehouses.inventory.batches');
});
// 安全庫存設定
Route::middleware('permission:inventory.view')->group(function () {
Route::get('/warehouses/{warehouse}/safety-stock', [SafetyStockController::class, 'index'])->name('warehouses.safety-stock.index');
Route::middleware('permission:inventory.safety_stock')->group(function () {
Route::post('/warehouses/{warehouse}/safety-stock', [SafetyStockController::class, 'store'])->name('warehouses.safety-stock.store');
Route::put('/warehouses/{warehouse}/safety-stock/{safetyStock}', [SafetyStockController::class, 'update'])->name('warehouses.safety-stock.update');
Route::delete('/warehouses/{warehouse}/safety-stock/{safetyStock}', [SafetyStockController::class, 'destroy'])->name('warehouses.safety-stock.destroy');
});
});
});
// 撥補單 (在庫存調撥時使用)
Route::middleware('permission:inventory.transfer')->group(function () {
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
});
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
->middleware('permission:inventory.view')
->name('api.warehouses.inventories');
});

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use Illuminate\Support\Facades\DB;
class InventoryService implements InventoryServiceInterface
{
public function getAllWarehouses()
{
return Warehouse::all();
}
public function getAllProducts()
{
return Product::with(['baseUnit'])->get();
}
public function getUnits()
{
return \App\Modules\Inventory\Models\Unit::all();
}
public function getInventoriesByIds(array $ids, array $with = [])
{
return Inventory::whereIn('id', $ids)->with($with)->get();
}
public function getProduct(int $id)
{
return Product::find($id);
}
public function getProductsByIds(array $ids)
{
return Product::whereIn('id', $ids)->get();
}
public function getWarehouse(int $id)
{
return Warehouse::find($id);
}
public function checkStock(int $productId, int $warehouseId, float $quantity): bool
{
$stock = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->sum('quantity');
return $stock >= $quantity;
}
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void
{
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason) {
$inventories = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('quantity', '>', 0)
->orderBy('arrival_date', 'asc')
->get();
$remainingToDecrease = $quantity;
foreach ($inventories as $inventory) {
if ($remainingToDecrease <= 0) break;
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason);
$remainingToDecrease -= $decreaseAmount;
}
if ($remainingToDecrease > 0) {
// 這裡可以選擇報錯或允許負庫存,目前為了嚴謹拋出異常
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
}
});
}
public function getInventoriesByWarehouse(int $warehouseId)
{
return Inventory::with(['product.baseUnit', 'product.largeUnit'])
->where('warehouse_id', $warehouseId)
->where('quantity', '>', 0)
->orderBy('arrival_date', 'asc')
->get();
}
public function createInventoryRecord(array $data)
{
return DB::transaction(function () use ($data) {
// 嘗試查找是否已有相同批號的庫存
$inventory = Inventory::where('warehouse_id', $data['warehouse_id'])
->where('product_id', $data['product_id'])
->where('batch_number', $data['batch_number'] ?? null)
->first();
$balanceBefore = 0;
if ($inventory) {
// 若存在,則更新數量與相關資訊 (鎖定行以避免併發問題)
$inventory = Inventory::lockForUpdate()->find($inventory->id);
$balanceBefore = $inventory->quantity;
$inventory->quantity += $data['quantity'];
// 更新其他可能變更的欄位 (如最後入庫日)
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
$inventory->save();
} else {
// 若不存在,則建立新紀錄
$inventory = Inventory::create([
'warehouse_id' => $data['warehouse_id'],
'product_id' => $data['product_id'],
'quantity' => $data['quantity'],
'batch_number' => $data['batch_number'] ?? null,
'box_number' => $data['box_number'] ?? null,
'origin_country' => $data['origin_country'] ?? 'TW',
'arrival_date' => $data['arrival_date'] ?? now(),
'expiry_date' => $data['expiry_date'] ?? null,
'quality_status' => $data['quality_status'] ?? 'normal',
'source_purchase_order_id' => $data['source_purchase_order_id'] ?? null,
]);
}
\App\Modules\Inventory\Models\InventoryTransaction::create([
'inventory_id' => $inventory->id,
'type' => '入庫',
'quantity' => $data['quantity'],
'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity,
'reason' => $data['reason'] ?? '手動入庫',
'reference_type' => $data['reference_type'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
'user_id' => auth()->id(),
'actual_time' => now(),
]);
return $inventory;
});
}
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null): void
{
DB::transaction(function () use ($inventoryId, $quantity, $reason, $referenceType, $referenceId) {
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
$balanceBefore = $inventory->quantity;
$inventory->decrement('quantity', $quantity);
$inventory->refresh();
\App\Modules\Inventory\Models\InventoryTransaction::create([
'inventory_id' => $inventory->id,
'type' => '出庫',
'quantity' => -$quantity,
'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity,
'reason' => $reason ?? '庫存扣減',
'reference_type' => $referenceType,
'reference_id' => $referenceId,
'user_id' => auth()->id(),
'actual_time' => now(),
]);
});
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Modules\Procurement\Contracts;
use Illuminate\Support\Collection;
interface ProcurementServiceInterface
{
/**
* Get purchase orders within a date range.
*
* @param string $start
* @param string $end
* @param array $statuses
* @return Collection
*/
public function getPurchaseOrdersByDate(string $start, string $end, array $statuses = ['received', 'completed']): Collection;
/**
* Get purchase orders by multiple IDs.
*
* @param array $ids
* @param array $with
* @return Collection
*/
public function getPurchaseOrdersByIds(array $ids, array $with = []): Collection;
}

View File

@@ -1,21 +1,35 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Procurement\Controllers;
use App\Models\PurchaseOrder;
use App\Models\Vendor;
use App\Models\Warehouse;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Procurement\Models\Vendor;
// use App\Modules\Inventory\Models\Warehouse; // REFACTORED: 移除直接依賴
use App\Modules\Inventory\Contracts\InventoryServiceInterface; // NEW: 使用契約
use App\Modules\Core\Contracts\CoreServiceInterface; // NEW: 使用核心服務契約
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class PurchaseOrderController extends Controller
{
protected $inventoryService;
protected $coreService;
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
{
$this->inventoryService = $inventoryService;
$this->coreService = $coreService;
}
public function index(Request $request)
{
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
// 1. 從關聯中移除 'warehouse''user'
$query = PurchaseOrder::with(['vendor']);
// Search
// 搜尋
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
@@ -25,7 +39,7 @@ class PurchaseOrderController extends Controller
});
}
// Filters
// 篩選
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
@@ -34,7 +48,7 @@ class PurchaseOrderController extends Controller
$query->where('warehouse_id', $request->warehouse_id);
}
// Date Range
// 日期範圍
if ($request->date_start) {
$query->whereDate('created_at', '>=', $request->date_start);
}
@@ -43,7 +57,7 @@ class PurchaseOrderController extends Controller
$query->whereDate('created_at', '<=', $request->date_end);
}
// Sorting
// 排序
$sortField = $request->sort_field ?? 'id';
$sortDirection = $request->sort_direction ?? 'desc';
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
@@ -55,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,
@@ -139,16 +206,16 @@ class PurchaseOrderController extends Controller
$totalAmount += $item['subtotal'];
}
// Tax calculation
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = \App\Models\User::first();
$user = \App\Modules\Core\Models\User::first();
if (!$user) {
$user = \App\Models\User::create([
$user = \App\Modules\Core\Models\User::create([
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
@@ -198,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;
// 手動注入
$order->setRelation('warehouse', $this->inventoryService->getWarehouse($order->warehouse_id));
$order->setRelation('user', $this->coreService->getUser($order->user_id));
$item->conversion_rate = (float) $product->conversion_rate;
$productIds = $order->items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// Fetch last price
$lastPrice = DB::table('product_vendor')
->where('vendor_id', $order->vendor_id)
->where('product_id', $product->id)
->value('last_price');
$item->previousPrice = (float) ($lastPrice ?? 0);
// 設定當前選中的單位 ID (from saved item)
$item->unitId = $item->unit_id;
// 決定 selectedUnit (用於 UI 顯示)
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
$item->selectedUnit = 'large';
} else {
$item->selectedUnit = 'base';
}
$item->unitPrice = (float) $item->unit_price;
}
return $item;
$formattedItems = $order->items->map(function ($item) use ($order, $products) {
$product = $products[$item->product_id] ?? null;
return (object) [
'productId' => (string) $item->product_id,
'productName' => $product?->name ?? 'Unknown',
'quantity' => (float) $item->quantity,
'unitId' => $item->unit_id,
'base_unit_id' => $product?->base_unit_id,
'base_unit_name' => $product?->baseUnit?->name,
'large_unit_id' => $product?->large_unit_id,
'large_unit_name' => $product?->largeUnit?->name,
'purchase_unit_id' => $product?->purchase_unit_id,
'conversion_rate' => (float) ($product?->conversion_rate ?? 1),
'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base',
'unitPrice' => (float) $item->unit_price,
'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $order->vendor_id)->where('product_id', $item->product_id)->value('last_price') ?? 0),
'subtotal' => (float) $item->subtotal,
];
});
$formattedOrder = (object) [
'id' => (string) $order->id,
'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id,
'supplierName' => $order->vendor?->name ?? 'Unknown',
'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,
]);
@@ -335,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',
]);
@@ -348,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'],
@@ -368,7 +463,7 @@ class PurchaseOrderController extends Controller
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
// Capture attribute changes for manual logging
// 捕捉變更屬性以進行手動記錄
$dirty = $order->getDirty();
$oldAttributes = [];
$newAttributes = [];
@@ -378,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,
@@ -394,7 +489,7 @@ class PurchaseOrderController extends Controller
];
})->keyBy('product_id');
// Sync items (Original logic)
// 同步項目(原始邏輯)
$order->items()->delete();
$newItemsData = [];
@@ -412,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,
@@ -431,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'] ||
@@ -467,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)
@@ -503,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())
@@ -536,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();

View File

@@ -1,16 +1,24 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Procurement\Controllers;
use App\Models\Vendor;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request;
use 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();
@@ -42,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\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',
@@ -78,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;
@@ -92,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',
@@ -115,7 +166,7 @@ class VendorController extends Controller
}
/**
* Remove the specified resource from storage.
* 從儲存體中移除指定資源。
*/
public function destroy(Vendor $vendor)
{

View File

@@ -1,13 +1,19 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Procurement\Controllers;
use App\Models\Vendor;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class VendorProductController extends Controller
{
public function __construct(
protected InventoryServiceInterface $inventoryService
) {}
/**
* 新增供貨商品 (Attach)
*/
@@ -28,7 +34,7 @@ class VendorProductController extends Controller
]);
// 記錄操作
$product = \App\Models\Product::find($validated['product_id']);
$product = $this->inventoryService->getProduct($validated['product_id']);
activity()
->performedOn($vendor)
->withProperties([
@@ -66,7 +72,7 @@ class VendorProductController extends Controller
]);
// 記錄操作
$product = \App\Models\Product::find($productId);
$product = $this->inventoryService->getProduct($productId);
activity()
->performedOn($vendor)
->withProperties([
@@ -95,7 +101,7 @@ class VendorProductController extends Controller
public function destroy(Vendor $vendor, $productId)
{
// 記錄操作 (需在 detach 前獲取資訊)
$product = \App\Models\Product::find($productId);
$product = $this->inventoryService->getProduct($productId);
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
$vendor->products()->detach($productId);

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PurchaseOrder extends Model
{
/** @use HasFactory<\Database\Factories\PurchaseOrderFactory> */
use HasFactory;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'code',
'vendor_id',
'warehouse_id',
'user_id',
'expected_delivery_date',
'status',
'total_amount',
'tax_amount',
'grand_total',
'remark',
];
protected $casts = [
'expected_delivery_date' => 'date',
'total_amount' => 'decimal:2',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$snapshot = $activity->properties['snapshot'] ?? [];
$snapshot['po_number'] = $this->code;
if ($this->vendor) {
$snapshot['vendor_name'] = $this->vendor->name;
}
// Warehouse relation removed in Strict Mode. Snapshot should be set via manual hydration if needed,
// or during the procurement process where warehouse_id is known.
$activity->properties = $activity->properties->merge([
'snapshot' => $snapshot
]);
}
public function vendor(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Vendor::class);
}
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrderItem::class);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Inventory\Models\Product;
class PurchaseOrderItem extends Model
{
/** @use HasFactory<\Database\Factories\PurchaseOrderItemFactory> */
use HasFactory;
protected $fillable = [
'purchase_order_id',
'product_id',
'quantity',
'unit_price',
'subtotal',
// 驗收欄位
'received_quantity',
// 批號與效期 (驗收時填寫)
'batch_number',
'expiry_date',
];
protected $casts = [
'quantity' => 'decimal:2',
'unit_price' => 'decimal:4',
'subtotal' => 'decimal:2',
'received_quantity' => 'decimal:2',
'expiry_date' => 'date',
];
public function purchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(PurchaseOrder::class);
}
}

View File

@@ -1,16 +1,17 @@
<?php
namespace App\Models;
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Inventory\Models\Product;
class Vendor extends Model
{
use LogsActivity;
/** @use HasFactory<\Database\Factories\VendorFactory> */
use HasFactory, LogsActivity;
protected $fillable = [
'code',
@@ -23,16 +24,12 @@ class Vendor extends Model
'phone',
'email',
'address',
'remark'
'remark',
];
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_vendor')
->withPivot('last_price')
->withTimestamps();
}
public function purchaseOrders(): HasMany
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrder::class);
}
@@ -49,12 +46,8 @@ class Vendor extends Model
{
$properties = $activity->properties;
// Store name in 'snapshot' for context, keeping 'attributes' clean
$snapshot = $properties['snapshot'] ?? [];
// Only set name if it's not already set (e.g. by controller for specific context like supply product)
if (!isset($snapshot['name'])) {
$snapshot['name'] = $this->name;
}
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Procurement;
use Illuminate\Support\ServiceProvider;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Procurement\Services\ProcurementService;
class ProcurementServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(ProcurementServiceInterface::class, ProcurementService::class);
}
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Procurement\Controllers\VendorController;
use App\Modules\Procurement\Controllers\VendorProductController;
use App\Modules\Procurement\Controllers\PurchaseOrderController;
Route::middleware('auth')->group(function () {
// 廠商管理
Route::middleware('permission:vendors.view')->group(function () {
Route::get('/vendors', [VendorController::class, 'index'])->name('vendors.index');
Route::get('/vendors/{vendor}', [VendorController::class, 'show'])->name('vendors.show');
Route::post('/vendors', [VendorController::class, 'store'])->middleware('permission:vendors.create')->name('vendors.store');
Route::put('/vendors/{vendor}', [VendorController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.update');
Route::delete('/vendors/{vendor}', [VendorController::class, 'destroy'])->middleware('permission:vendors.delete')->name('vendors.destroy');
// 供貨商品相關路由
Route::post('/vendors/{vendor}/products', [VendorProductController::class, 'store'])->middleware('permission:vendors.edit')->name('vendors.products.store');
Route::put('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.products.update');
Route::delete('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'destroy'])->middleware('permission:vendors.edit')->name('vendors.products.destroy');
});
// 採購單管理
Route::middleware('permission:purchase_orders.view')->group(function () {
Route::get('/purchase-orders', [PurchaseOrderController::class, 'index'])->name('purchase-orders.index');
Route::middleware('permission:purchase_orders.create')->group(function () {
Route::get('/purchase-orders/create', [PurchaseOrderController::class, 'create'])->name('purchase-orders.create');
Route::post('/purchase-orders', [PurchaseOrderController::class, 'store'])->name('purchase-orders.store');
});
Route::get('/purchase-orders/{id}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show');
Route::get('/purchase-orders/{id}/edit', [PurchaseOrderController::class, 'edit'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.edit');
Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update');
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
});
});

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Modules\Procurement\Services;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Procurement\Models\PurchaseOrder;
use Illuminate\Support\Collection;
class ProcurementService implements ProcurementServiceInterface
{
public function getPurchaseOrdersByDate(string $start, string $end, array $statuses = ['received', 'completed']): Collection
{
return PurchaseOrder::with(['vendor'])
->whereIn('status', $statuses)
->whereBetween('created_at', [$start . ' 00:00:00', $end . ' 23:59:59'])
->get();
}
public function getPurchaseOrdersByIds(array $ids, array $with = []): Collection
{
return PurchaseOrder::whereIn('id', $ids)->with($with)->get();
}
}

View File

@@ -0,0 +1,410 @@
<?php
namespace App\Modules\Production\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Core\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class ProductionOrderController extends Controller
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 生產工單列表
*/
public function index(Request $request): Response
{
// 不再使用 with(),避免跨模組 Eager Loading
$query = ProductionOrder::query();
// 搜尋 (此處 orWhereHas 暫時保留,因 Laravel query builder 仍可作用於資料表層級,
// 但實務上若模組完全隔離,應考慮搜尋引擎或 ID 預選)
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
$productIds = \App\Modules\Inventory\Models\Product::where('name', 'like', "%{$search}%")->pluck('id');
$q->orWhereIn('product_id', $productIds);
});
}
// 狀態篩選
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
// 排除軟刪除
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
// 分頁
$perPage = $request->input('per_page', 10);
$productionOrders = $query->paginate($perPage)->withQueryString();
// --- 手動資料水和 (Manual Hydration) ---
$productIds = $productionOrders->pluck('product_id')->unique()->filter()->toArray();
$warehouseIds = $productionOrders->pluck('warehouse_id')->unique()->filter()->toArray();
$userIds = $productionOrders->pluck('user_id')->unique()->filter()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id');
$users = 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']),
]);
}
/**
* 新增生產單表單
*/
public function create(): Response
{
return Inertia::render('Production/Create', [
'products' => $this->inventoryService->getAllProducts(),
'warehouses' => $this->inventoryService->getAllWarehouses(),
'units' => $this->inventoryService->getUnits(),
]);
}
/**
* 儲存生產單(含自動扣料與成品入庫)
*/
public function store(Request $request)
{
$status = $request->input('status', 'draft');
$baseRules = [
'product_id' => 'required',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
];
$completedRules = [
'warehouse_id' => 'required',
'output_quantity' => 'required|numeric|min:0.01',
'production_date' => 'required|date',
'items' => 'required|array|min:1',
'items.*.inventory_id' => 'required',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
];
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
$validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status) {
// 1. 建立生產工單
$productionOrder = ProductionOrder::create([
'code' => ProductionOrder::generateCode(),
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(),
'status' => $status,
'remark' => $request->remark,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('created');
// 2. 處理明細
if (!empty($request->items)) {
foreach ($request->items as $item) {
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
if ($status === 'completed') {
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
// 3. 成品入庫
if ($status === 'completed') {
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
return redirect()->route('production-orders.index')
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存');
}
/**
* 檢視生產單詳情
*/
public function show(ProductionOrder $productionOrder): Response
{
// 手動水和主表資料
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
if ($productionOrder->product) {
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
}
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
$productionOrder->user = 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,
]);
}
/**
* 取得倉庫內可用庫存
*/
public function getWarehouseInventories($warehouseId)
{
$inventories = $this->inventoryService->getInventoriesByWarehouse($warehouseId);
$data = $inventories->map(function ($inv) {
return [
'id' => $inv->id,
'product_id' => $inv->product_id,
'product_name' => $inv->product->name ?? '未知商品',
'product_code' => $inv->product->code ?? '',
'batch_number' => $inv->batch_number,
'box_number' => $inv->box_number,
'quantity' => $inv->quantity,
'arrival_date' => $inv->arrival_date ? $inv->arrival_date->format('Y-m-d') : null,
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
'unit_name' => $inv->product->baseUnit->name ?? '',
'base_unit_id' => $inv->product->base_unit_id ?? null,
'large_unit_id' => $inv->product->large_unit_id ?? null,
'conversion_rate' => $inv->product->conversion_rate ?? 1,
];
});
return response()->json($data);
}
/**
* 編輯生產單
*/
public function edit(ProductionOrder $productionOrder): Response
{
if ($productionOrder->status !== 'draft') {
return redirect()->route('production-orders.show', $productionOrder->id)
->with('error', '只有草稿狀態的生產單可以編輯');
}
// 基本水和
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
// 手動水和明細資料
$items = $productionOrder->items;
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
$inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds,
['product.baseUnit']
)->keyBy('id');
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->inventory = $inventories->get($item->inventory_id);
$item->unit = $units->get($item->unit_id);
}
return Inertia::render('Production/Edit', [
'productionOrder' => $productionOrder,
'products' => $this->inventoryService->getAllProducts(),
'warehouses' => $this->inventoryService->getAllWarehouses(),
'units' => $this->inventoryService->getUnits(),
]);
}
/**
* 更新生產單
*/
public function update(Request $request, ProductionOrder $productionOrder)
{
if ($productionOrder->status !== 'draft') {
return redirect()->route('production-orders.show', $productionOrder->id)
->with('error', '只有草稿可以修改');
}
$status = $request->input('status', 'draft');
// 基礎驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'output_batch_number' => 'required|string|max:50',
'status' => 'required|in:draft,completed',
'remark' => 'nullable|string',
];
// 完工時的嚴格驗證規則
$completedRules = [
'warehouse_id' => 'required|exists:warehouses,id',
'output_quantity' => 'required|numeric|min:0.01',
'production_date' => 'required|date',
'expiry_date' => 'nullable|date',
'items' => 'required|array|min:1',
'items.*.inventory_id' => 'required|exists:inventories,id',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
];
// 若狀態切換為 completed需合併驗證規則
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
$validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status, $productionOrder) {
$productionOrder->update([
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $request->expiry_date,
'status' => $status,
'remark' => $request->remark,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('updated');
// 重新建立明細
$productionOrder->items()->delete();
if (!empty($request->items)) {
foreach ($request->items as $item) {
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
if ($status === 'completed') {
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
if ($status === 'completed') {
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
return redirect()->route('production-orders.index')
->with('success', '生產單已更新');
}
/**
* 刪除生產單
*/
public function destroy(ProductionOrder $productionOrder)
{
if ($productionOrder->status === 'completed') {
return redirect()->back()->with('error', '已完工的生產單無法刪除');
}
DB::transaction(function () use ($productionOrder) {
// 紀錄刪除動作 (需在刪除前或使用軟刪除)
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('deleted');
$productionOrder->items()->delete();
$productionOrder->delete();
});
return redirect()->route('production-orders.index')->with('success', '生產單已刪除');
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Modules\Production\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Production\Models\Recipe;
use App\Modules\Production\Models\RecipeItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class RecipeController extends Controller
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 配方列表
*/
public function index(Request $request): Response
{
$query = Recipe::query();
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
$productIds = $this->inventoryService->getProductsByName($search)->pluck('id');
$q->orWhereIn('product_id', $productIds);
});
}
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
$recipes = $query->paginate($request->input('per_page', 10))->withQueryString();
// Manual Hydration
$productIds = $recipes->pluck('product_id')->unique()->filter()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$recipes->getCollection()->transform(function ($recipe) use ($products) {
$recipe->product = $products->get($recipe->product_id);
return $recipe;
});
return Inertia::render('Production/Recipe/Index', [
'recipes' => $recipes,
'filters' => $request->only(['search', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* 新增配方表單
*/
public function create(): Response
{
return Inertia::render('Production/Recipe/Create', [
'products' => $this->inventoryService->getAllProducts(),
'units' => $this->inventoryService->getUnits(),
]);
}
/**
* 儲存配方
*/
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'code' => 'required|string|max:50|unique:recipes,code',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'yield_quantity' => 'required|numeric|min:0.01',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.0001',
'items.*.unit_id' => 'nullable|exists:units,id',
'items.*.remark' => 'nullable|string',
]);
DB::transaction(function () use ($validated) {
$recipe = Recipe::create([
'product_id' => $validated['product_id'],
'code' => $validated['code'],
'name' => $validated['name'],
'description' => $validated['description'],
'yield_quantity' => $validated['yield_quantity'],
'is_active' => true,
]);
foreach ($validated['items'] as $item) {
RecipeItem::create([
'recipe_id' => $recipe->id,
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_id' => $item['unit_id'],
'remark' => $item['remark'],
]);
}
});
return redirect()->route('recipes.index')->with('success', '配方已建立');
}
/**
* 編輯配方表單
*/
public function edit(Recipe $recipe): Response
{
// Hydrate Product
$recipe->product = $this->inventoryService->getProduct($recipe->product_id);
// Load items with details
$items = $recipe->items;
$productIds = $items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->product = $products->get($item->product_id);
$item->unit = $units->get($item->unit_id);
}
return Inertia::render('Production/Recipe/Edit', [
'recipe' => $recipe,
'products' => $this->inventoryService->getAllProducts(),
'units' => $this->inventoryService->getUnits(),
]);
}
/**
* 更新配方
*/
public function update(Request $request, Recipe $recipe)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'code' => 'required|string|max:50|unique:recipes,code,' . $recipe->id,
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'yield_quantity' => 'required|numeric|min:0.01',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.0001',
'items.*.unit_id' => 'nullable|exists:units,id',
'items.*.remark' => 'nullable|string',
]);
DB::transaction(function () use ($validated, $recipe) {
$recipe->update([
'product_id' => $validated['product_id'],
'code' => $validated['code'],
'name' => $validated['name'],
'description' => $validated['description'],
'yield_quantity' => $validated['yield_quantity'],
]);
// Sync items (Delete all and recreate)
$recipe->items()->delete();
foreach ($validated['items'] as $item) {
RecipeItem::create([
'recipe_id' => $recipe->id,
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_id' => $item['unit_id'],
'remark' => $item['remark'],
]);
}
});
return redirect()->route('recipes.index')->with('success', '配方已更新');
}
/**
* 刪除配方
*/
public function destroy(Recipe $recipe)
{
$recipe->delete();
return redirect()->back()->with('success', '配方已刪除');
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class ProductionOrder extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'code',
'product_id',
'warehouse_id',
'output_quantity',
'output_batch_number',
'output_box_count',
'production_date',
'expiry_date',
'user_id',
'status',
'remark',
];
protected $casts = [
'production_date' => 'date',
'expiry_date' => 'date',
'output_quantity' => 'decimal:2',
];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly([
'code',
'status',
'output_quantity',
'output_batch_number',
'production_date',
'remark'
])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn(string $eventName) => "生產工單已{$this->getEventDescription($eventName)}");
}
protected function getEventDescription($eventName): string
{
return match ($eventName) {
'created' => '建立',
'updated' => '更新',
'deleted' => '刪除',
default => $eventName,
};
}
public static function generateCode()
{
$prefix = 'PO' . now()->format('Ymd');
$lastOrder = self::where('code', 'like', $prefix . '%')->latest()->first();
if ($lastOrder) {
$lastSequence = intval(substr($lastOrder->code, -3));
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
} else {
$sequence = '001';
}
return $prefix . $sequence;
}
/**
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
*/
public function product()
{
return null;
}
/**
* @deprecated 使用 InventoryServiceInterface 獲取倉庫資訊
*/
public function warehouse()
{
return null;
}
/**
* @deprecated 使用 CoreServiceInterface 獲取使用者資訊
*/
public function user()
{
return null;
}
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ProductionOrderItem::class);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Inventory\Models\Product;
class ProductionOrderItem extends Model
{
/** @use HasFactory<\Database\Factories\ProductionOrderItemFactory> */
use HasFactory;
protected $fillable = [
'production_order_id',
'inventory_id',
'quantity_used',
'unit_id',
];
protected $casts = [
'quantity_used' => 'decimal:4',
];
/**
* @deprecated 使用 InventoryServiceInterface 獲取庫存資訊
*/
public function inventory()
{
return null;
}
/**
* @deprecated
*/
public function unit()
{
return null;
}
public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(ProductionOrder::class);
}
/**
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
*/
public function product()
{
return null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Recipe extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'product_id',
'code',
'name',
'description',
'yield_quantity',
'is_active',
];
protected $casts = [
'yield_quantity' => 'decimal:2',
'is_active' => 'boolean',
];
public function items()
{
return $this->hasMany(RecipeItem::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RecipeItem extends Model
{
use HasFactory;
protected $fillable = [
'recipe_id',
'product_id',
'quantity',
'unit_id',
'remark',
];
protected $casts = [
'quantity' => 'decimal:4',
];
public function recipe()
{
return $this->belongsTo(Recipe::class);
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Production\Controllers\ProductionOrderController;
use App\Modules\Production\Controllers\RecipeController;
Route::middleware('auth')->group(function () {
// 配方管理
Route::resource('recipes', RecipeController::class);
// 生產管理
Route::middleware('permission:production_orders.view')->group(function () {
Route::get('/production-orders', [ProductionOrderController::class, 'index'])->name('production-orders.index');
Route::middleware('permission:production_orders.create')->group(function () {
Route::get('/production-orders/create', [ProductionOrderController::class, 'create'])->name('production-orders.create');
Route::post('/production-orders', [ProductionOrderController::class, 'store'])->name('production-orders.store');
});
Route::get('/production-orders/{productionOrder}', [ProductionOrderController::class, 'show'])->name('production-orders.show');
Route::middleware('permission:production_orders.edit')->group(function () {
Route::get('/production-orders/{productionOrder}/edit', [ProductionOrderController::class, 'edit'])->name('production-orders.edit');
Route::put('/production-orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('production-orders.update');
});
});
// 生產管理 API
Route::get('/api/production/warehouses/{warehouse}/inventories', [ProductionOrderController::class, 'getWarehouseInventories'])
->middleware('permission:production_orders.create')
->name('api.production.warehouses.inventories');
});

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Modules\Shared\Contracts;
/**
* Base Service Interface
* 所有模組的 Service 都應繼承此介面 (若有通用方法)
*/
interface ServiceInterface
{
// Future common methods
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Modules\Shared;
use Illuminate\Support\ServiceProvider;
class SharedServiceProvider extends ServiceProvider
{
public function register(): void
{
// Register shared services or repositories here
}
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class ModuleServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
$modulesPath = app_path('Modules');
if (File::exists($modulesPath)) {
$modules = File::directories($modulesPath);
foreach ($modules as $module) {
// $moduleName = basename($module);
// Load Routes
$routesPath = $module . '/Routes/web.php';
if (File::exists($routesPath)) {
Route::middleware('web')
->group($routesPath);
}
// Load Service Provider
$moduleName = basename($module);
$providerClass = "App\\Modules\\{$moduleName}\\{$moduleName}ServiceProvider";
if (class_exists($providerClass)) {
$this->app->register($providerClass);
}
}
}
}
}

View File

@@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\TenancyServiceProvider::class,
App\Providers\ModuleServiceProvider::class,
];

View File

@@ -31,6 +31,7 @@
"autoload": {
"psr-4": {
"App\\": "app/",
"App\\Modules\\": "app/Modules/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}

View File

@@ -62,7 +62,7 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', App\Modules\Core\Models\User::class),
],
// 'users' => [

View File

@@ -24,7 +24,7 @@ return [
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => App\Models\Role::class,
'role' => App\Modules\Core\Models\Role::class,
],

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
use Stancl\Tenancy\Database\Models\Domain;
use App\Models\Tenant;
use App\Modules\Core\Models\Tenant;
return [
'tenant_model' => Tenant::class,

View File

@@ -2,11 +2,11 @@
namespace Database\Factories;
use App\Models\Category;
use App\Modules\Inventory\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Modules\Inventory\Models\Product>
*/
class ProductFactory extends Factory
{

View File

@@ -7,10 +7,17 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Modules\Core\Models\User>
*/
class UserFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = \App\Modules\Core\Models\User::class;
/**
* The current password being used by the factory.
*/
@@ -25,6 +32,7 @@ class UserFactory extends Factory
{
return [
'name' => fake()->name(),
'username' => fake()->unique()->userName(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('recipes', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('products')->onDelete('cascade')->comment('連結的成品商品 ID');
$table->string('code')->unique()->comment('配方代號');
$table->string('name')->comment('配方名稱');
$table->text('description')->nullable()->comment('配方描述');
$table->decimal('yield_quantity', 10, 2)->default(1.00)->comment('標準產出數量');
$table->boolean('is_active')->default(true)->comment('是否啟用');
$table->timestamps();
$table->softDeletes();
});
Schema::create('recipe_items', function (Blueprint $table) {
$table->id();
$table->foreignId('recipe_id')->constrained('recipes')->onDelete('cascade');
$table->foreignId('product_id')->constrained('products')->comment('原物料商品 ID');
$table->decimal('quantity', 10, 4)->comment('標準用量');
$table->foreignId('unit_id')->nullable()->constrained('units')->comment('單位 ID');
$table->string('remark')->nullable()->comment('備註');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('recipe_items');
Schema::dropIfExists('recipes');
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return new class extends Migration
{
/**
* Run the migrations.
*
* 新增配方管理權限
*/
public function up(): void
{
$guard = 'web';
// 建立配方管理權限
$permissions = [
'recipes.view' => '檢視配方',
'recipes.create' => '建立配方',
'recipes.edit' => '編輯配方',
'recipes.delete' => '刪除配方',
];
foreach ($permissions as $name => $description) {
Permission::firstOrCreate(
['name' => $name, 'guard_name' => $guard],
['name' => $name, 'guard_name' => $guard]
);
}
// 授予 super-admin 所有新權限
$superAdmin = Role::where('name', 'super-admin')->first();
if ($superAdmin) {
$superAdmin->givePermissionTo(array_keys($permissions));
}
// 授予 admin 所有新權限
$admin = Role::where('name', 'admin')->first();
if ($admin) {
$admin->givePermissionTo(array_keys($permissions));
}
// 授予 warehouse-manager 檢視權限 (配方通常與庫存相關)
$whManager = Role::where('name', 'warehouse-manager')->first();
if ($whManager) {
$whManager->givePermissionTo(['recipes.view']);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$permissions = [
'recipes.view',
'recipes.create',
'recipes.edit',
'recipes.delete',
];
foreach ($permissions as $name) {
Permission::where('name', $name)->delete();
}
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->boolean('is_sellable')->default(true)->after('description')->comment('是否可銷售');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->dropColumn('is_sellable');
});
}
};

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders;
use App\Models\Category;
use App\Modules\Inventory\Models\Category;
use Illuminate\Database\Seeder;
class CategorySeeder extends Seeder

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders;
use App\Models\User;
use App\Modules\Core\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

View File

@@ -5,7 +5,7 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use App\Models\User;
use App\Modules\Core\Models\User;
class PermissionSeeder extends Seeder
{

View File

@@ -2,8 +2,8 @@
namespace Database\Seeders;
use App\Models\Category;
use App\Models\Product;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Product;
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder

View File

@@ -3,7 +3,7 @@
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
use App\Modules\Core\Models\User;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders;
use App\Models\Unit;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Database\Seeder;
class UnitSeeder extends Seeder
@@ -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']]
);

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders;
use App\Models\Vendor;
use App\Modules\Procurement\Models\Vendor;
use Illuminate\Database\Seeder;
class VendorSeeder extends Seeder

60
docs/FRAMEWORK_SPEC.md Normal file
View File

@@ -0,0 +1,60 @@
# 開發框架規範說明書ERP 系統 (star-erp)
## 1. 專案概述
* **目標** 打造一個強大且穩定的 ERP 後台管理系統。
* **核心架構** 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。
* **工作流程** 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。
## 2. 技術棧 (Tech Stack)
* **後端** PHP 8.5 / Laravel 12
* **前端橋樑** Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
* **前端庫** React (以 Functional Components 與 Hooks 為主)
* **樣式處理** Tailwind CSS (確保與 UI/UX 設計稿完全一致)
* **資料庫** MySQL 8.0
* **開發環境** Laravel Sail (Docker / WSL2)
* **未來擴充** 針對高併發或跨平台模組,預留 Golang 微服務接口。
## 3. 目錄結構與慣例
### 3.1 後端 (Laravel - Modular Monolith)
系統採用模組化架構,核心邏輯位於 `app/Modules/` 下:
* **Modules** 位於 `app/Modules/{ModuleName}/`
* **Controllers** `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`
* **Models** `app/Modules/{ModuleName}/Models/`
* **Routes** `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。
* **Global Routes** `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。
### 3.2 前端 (React)
* **Pages (頁面)** 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。
* **Components (組件)** 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
* **Layouts (版面)** 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。
## 4. 整合指南 (UI/UX 轉換至 Laravel)
* **組件遷移** 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。
* **資料傳遞** 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。
* **狀態管理** 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
## 5. 開發標準 (Coding Standards)
* **命名規範**
* Controllers: `PascalCaseController.php`
* React Components: `PascalCase.jsx`
* Routes: `kebab-case` (小寫橫線分隔)
* **回傳格式** 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
## 6. AI 協作規則 (給 Antigravity AI)
* **角色設定** 你是一位專業的全端開發工程師助手。
* **代碼生成指令**
* 所有的解釋說明請使用 **繁體中文**。
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
* 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。
## 7. 運行機制 (Docker / Sail)
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
* **啟動環境** `./vendor/bin/sail up -d`
* **執行 PHP 指令** `./vendor/bin/sail php -v`
* **執行 Artisan 指令** `./vendor/bin/sail artisan route:list`
* **執行 Composer** `./vendor/bin/sail composer install`
* **執行 Node/NPM** `./vendor/bin/sail npm run dev`

View File

@@ -0,0 +1,92 @@
# Star ERP 模組化單體架構 (Modular Monolith)
本文件記錄 Star ERP 的模組化架構現狀、模組邊界定義以及各模組包含之詳細功能。
## 1. 架構概觀
系統採用 **模組化單體 (Modular Monolith)** 架構。
- **後端**:依據業務領域 (Domain) 拆分為獨立模組,位於 `app/Modules/{ModuleName}`
- **前端**:維持統一的 Inertia/React 架構,位於 `resources/js`
- **通訊**:模組間優先透過 Service Class 溝通,但允許在同一資料庫內進行關聯查詢 (Eloquent Relationships)。
---
## 2. 模組列表與功能 (Modules Manifest)
### ✅ Inventory (庫存模組)
**定位**:處理所有與「商品」及「實體庫存」相關的業務。通用於所有產業。
- **Namespace**: `App\Modules\Inventory`
- **狀態**: 🟢 已遷移 (Migrated)
- **功能細項**:
- **商品基礎資料**:
- 商品管理 (CRUD、多規格)
- 商品分類 (Category)
- 計量單位 (Unit, 支援大小單位換算)
- **倉庫管理**:
- 多倉庫設定 (Warehouse)
- 庫存查詢 (Inventory Lookup)
- 庫存異動歷史 (Transaction History)
- **庫存作業**:
- 手動庫存調整 (Adjustments)
- 庫存調撥 (Transfer Orders)
- 批號追蹤 (Batch Tracking, 基礎版)
- **監控**:
- 安全庫存設定 (Safety Stock)
---
### ✅ Core (系統核心模組)
**定位**:系統基礎設施,處理帳號、權限與租戶管理。
- **Namespace**: `App\Modules\Core`
- **狀態**: 🟢 已遷移 (Migrated)
- **功能細項**:
- **身分驗證**: 登入/登出 (Auth)
- **使用者管理**: User CRUD
- **權限控制**: 角色與權限 (RBAC)
- **多租戶**: 租戶管理 (Tenancy)
- **系統監控**: 操作紀錄 (Activity Log)
- **個人化**: 個人設定 (Profile)
---
### ✅ Procurement (採購模組)
**定位**:供應鏈管理,處理進貨源頭。
- **Namespace**: `App\Modules\Procurement`
- **狀態**: 🟢 已遷移 (Migrated)
- **功能細項**:
- **供應商管理**: 廠商資料 (Vendor)、供貨商品清單
- **採購作業**: 採購單 (Purchase Order)、進貨驗收
---
### ✅ Production (生產模組)
**定位**:製造與加工,食品業/製造業核心。
- **Namespace**: `App\Modules\Production`
- **狀態**: 🟢 已遷移 (Migrated)
- **功能細項**:
- **工單管理**: 生產工單 (Production Order)
- **配方管理**: (規劃中) Recipe
- **領料與耗用**: 原料扣庫
---
### ✅ Finance (財務模組)
**定位**:經營分析與帳務。
- **Namespace**: `App\Modules\Finance`
- **狀態**: 🟢 已遷移 (Migrated)
- **功能細項**:
- **費用管理**: 公共事業費 (Utility Fee)
- **報表**: 會計報表 (Accounting Reports)
- **成本分析**: (規劃中) Costing
---
## 3. 未來擴充模組 (Future Verticals)
針對特定產業的垂直擴充模組(可插拔):
| 模組名稱 | 適用產業 | 關鍵功能 |
| :--- | :--- | :--- |
| **Logistics** | 物流/零售 | 路徑規劃、裝車單、司機派送 |
| **Food** | 食品/餐飲 | 嚴格效期控管 (FEFO)、雙向溯源、營養成分標示 |
| **Retail** | 零售/電商 | 全通路訂單整合、促銷引擎 (Promotion)、POS 介接 |
| **Cosmetics**| 化妝品 | 成分分析、過敏原管理 |

164
lang/zh_TW.json Normal file
View File

@@ -0,0 +1,164 @@
{
"accepted": ":attribute 必須接受。",
"active_url": ":attribute 並非一個有效的網址。",
"after": ":attribute 必須在 :date 之後。",
"after_or_equal": ":attribute 必須在 :date 之後或相等。",
"alpha": ":attribute 只能由字母組成。",
"alpha_dash": ":attribute 只能由字母、數字、破折號與底線組成。",
"alpha_num": ":attribute 只能由字母與數字組成。",
"array": ":attribute 必須是一個陣列。",
"before": ":attribute 必須在 :date 之前。",
"before_or_equal": ":attribute 必須在 :date 之前或相等。",
"between": {
"numeric": ":attribute 必須介於 :min 至 :max 之間。",
"file": ":attribute 必須介於 :min 至 :max KB 之間。",
"string": ":attribute 必須介於 :min 至 :max 個字元之間。",
"array": ":attribute 必須介於 :min 至 :max 個項目之間。"
},
"boolean": ":attribute 必須為布林值。",
"confirmed": ":attribute 確認欄位不一致。",
"date": ":attribute 並非一個有效的日期。",
"date_equals": ":attribute 必須等於 :date。",
"date_format": ":attribute 不符合 :format 的格式。",
"different": ":attribute 與 :other 必須不同。",
"digits": ":attribute 必須是 :digits 位數字。",
"digits_between": ":attribute 必須介於 :min 至 :max 位數字之間。",
"dimensions": ":attribute 圖片尺寸不正確。",
"distinct": ":attribute 已經存在。",
"email": ":attribute 必須是一個有效的電子郵件地址。",
"ends_with": ":attribute 結尾必須包含下列之一::values。",
"exists": "所選的 :attribute 選項無效。",
"file": ":attribute 必須是一個檔案。",
"filled": ":attribute 屬性是必填的。",
"gt": {
"numeric": ":attribute 必須大於 :value。",
"file": ":attribute 必須大於 :value KB。",
"string": ":attribute 必須多於 :value 個字元。",
"array": ":attribute 必須多於 :value 個項目。"
},
"gte": {
"numeric": ":attribute 必須大於或等於 :value。",
"file": ":attribute 必須大於或等於 :value KB。",
"string": ":attribute 必須多於或等於 :value 個字元。",
"array": ":attribute 必須多於或等於 :value 個項目。"
},
"image": ":attribute 必須是一張圖片。",
"in": "所選的 :attribute 選項無效。",
"in_array": ":attribute 沒有在 :other 中。",
"integer": ":attribute 必須是一個整數。",
"ip": ":attribute 必須是一個有效的 IP 地址。",
"ipv4": ":attribute 必須是一個有效的 IPv4 地址。",
"ipv6": ":attribute 必須是一個有效的 IPv6 地址。",
"json": ":attribute 必須是一個有效的 JSON 字串。",
"lt": {
"numeric": ":attribute 必須小於 :value。",
"file": ":attribute 必須小於 :value KB。",
"string": ":attribute 必須少於 :value 個字元。",
"array": ":attribute 必須少於 :value 個項目。"
},
"lte": {
"numeric": ":attribute 必須小於或等於 :value。",
"file": ":attribute 必須小於或等於 :value KB。",
"string": ":attribute 必須少於或等於 :value 個字元。",
"array": ":attribute 必須少於或等於 :value 個項目。"
},
"max": {
"numeric": ":attribute 不能大於 :max。",
"file": ":attribute 不能大於 :max KB。",
"string": ":attribute 不能多於 :max 個字元。",
"array": ":attribute 最多有 :max 個項目。"
},
"mimes": ":attribute 必須是一個 :values 格式的檔案。",
"mimetypes": ":attribute 必須是一個 :values 格式的檔案。",
"min": {
"numeric": ":attribute 不能小於 :min。",
"file": ":attribute 不能小於 :min KB。",
"string": ":attribute 不能少於 :min 個字元。",
"array": ":attribute 至少有 :min 個項目。"
},
"multiple_of": ":attribute 必須為 :value 的倍數。",
"not_in": "所選的 :attribute 選項無效。",
"not_regex": ":attribute 的格式錯誤。",
"numeric": ":attribute 必須是一個數字。",
"password": "密碼錯誤。",
"present": ":attribute 必須存在。",
"regex": ":attribute 的格式錯誤。",
"required": ":attribute 欄位必填。",
"required_if": "當 :other 是 :value 時,:attribute 欄位必填。",
"required_unless": "當 :other 不是 :value 時,:attribute 欄位必填。",
"required_with": "當 :values 出現時,:attribute 欄位必填。",
"required_with_all": "當 :values 出現時,:attribute 欄位必填。",
"required_without": "當 :values 留空時,:attribute 欄位必填。",
"required_without_all": "當 :values 留空時,:attribute 欄位必填。",
"same": ":attribute 與 :other 必須相同。",
"size": {
"numeric": ":attribute 的大小必須是 :size。",
"file": ":attribute 的大小必須是 :size KB。",
"string": ":attribute 必須是 :size 個字元。",
"array": ":attribute 必須包含 :size 個項目。"
},
"starts_with": ":attribute 開頭必須包含下列之一::values。",
"string": ":attribute 必須是一個字串。",
"timezone": ":attribute 必須是一個有效的時區。",
"unique": ":attribute 已經存在。",
"uploaded": ":attribute 上傳失敗。",
"url": ":attribute 的格式錯誤。",
"uuid": ":attribute 必須是一個有效的 UUID。",
"auth.failed": "帳號或密碼錯誤。",
"auth.password": "密碼錯誤。",
"auth.throttle": "嘗試登入次數過多,請在 :seconds 秒後再試。",
"passwords.reset": "密碼已重設!",
"passwords.sent": "密碼重設連結已發送!",
"passwords.throttled": "請稍候再試。",
"passwords.token": "密碼重設連結已失效。",
"passwords.user": "找不到該電子郵件地址的使用者。",
"attributes": {
"name": "名稱",
"username": "使用者名稱",
"email": "電子郵件",
"first_name": "名",
"last_name": "姓",
"password": "密碼",
"password_confirmation": "確認密碼",
"city": "城市",
"country": "國家",
"address": "地址",
"phone": "電話",
"mobile": "手機",
"age": "年齡",
"sex": "性別",
"gender": "性別",
"day": "天",
"month": "月",
"year": "年",
"hour": "時",
"minute": "分",
"second": "秒",
"title": "標題",
"content": "內容",
"description": "描述",
"excerpt": "摘要",
"date": "日期",
"time": "時間",
"available": "可用的",
"size": "大小",
"product_id": "商品",
"vendor_id": "供應商",
"warehouse_id": "倉庫",
"unit_id": "單位",
"items": "明細項目",
"quantity": "數量",
"yield_quantity": "標準產出量",
"production_date": "生產日期",
"output_batch_number": "成品批號",
"output_quantity": "生產數量",
"source_warehouse_id": "來源倉庫",
"target_warehouse_id": "目標倉庫",
"expected_delivery_date": "預計到貨日期",
"invoice_number": "發票號碼",
"invoice_date": "發票日期",
"invoice_amount": "發票金額",
"tax_amount": "稅額",
"remark": "備註"
}
}

166
lang/zh_TW/auth.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
return [
'accepted' => ':attribute 必須接受。',
'active_url' => ':attribute 並非一個有效的網址。',
'after' => ':attribute 必須在 :date 之後。',
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
'alpha' => ':attribute 只能由字母組成。',
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
'alpha_num' => ':attribute 只能由字母與數字組成。',
'array' => ':attribute 必須是一個陣列。',
'before' => ':attribute 必須在 :date 之前。',
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
'between' => [
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
],
'boolean' => ':attribute 必須為布林值。',
'confirmed' => ':attribute 確認欄位不一致。',
'date' => ':attribute 並非一個有效的日期。',
'date_equals' => ':attribute 必須等於 :date。',
'date_format' => ':attribute 不符合 :format 的格式。',
'different' => ':attribute 與 :other 必須不同。',
'digits' => ':attribute 必須是 :digits 位數字。',
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
'dimensions' => ':attribute 圖片尺寸不正確。',
'distinct' => ':attribute 已經存在。',
'email' => ':attribute 必須是一個有效的電子郵件地址。',
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
'exists' => '所選的 :attribute 選項無效。',
'file' => ':attribute 必須是一個檔案。',
'filled' => ':attribute 屬性是必填的。',
'gt' => [
'numeric' => ':attribute 必須大於 :value。',
'file' => ':attribute 必須大於 :value KB。',
'string' => ':attribute 必須多於 :value 個字元。',
'array' => ':attribute 必須多於 :value 個項目。',
],
'gte' => [
'numeric' => ':attribute 必須大於或等於 :value。',
'file' => ':attribute 必須大於或等於 :value KB。',
'string' => ':attribute 必須多於或等於 :value 個字元。',
'array' => ':attribute 必須多於或等於 :value 個項目。',
],
'image' => ':attribute 必須是一張圖片。',
'in' => '所選的 :attribute 選項無效。',
'in_array' => ':attribute 沒有在 :other 中。',
'integer' => ':attribute 必須是一個整數。',
'ip' => ':attribute 必須是一個有效的 IP 地址。',
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
'json' => ':attribute 必須是一個有效的 JSON 字串。',
'lt' => [
'numeric' => ':attribute 必須小於 :value。',
'file' => ':attribute 必須小於 :value KB。',
'string' => ':attribute 必須少於 :value 個字元。',
'array' => ':attribute 必須少於 :value 個項目。',
],
'lte' => [
'numeric' => ':attribute 必須小於或等於 :value。',
'file' => ':attribute 必須小於或等於 :value KB。',
'string' => ':attribute 必須少於或等於 :value 個字元。',
'array' => ':attribute 必須少於或等於 :value 個項目。',
],
'max' => [
'numeric' => ':attribute 不能大於 :max。',
'file' => ':attribute 不能大於 :max KB。',
'string' => ':attribute 不能多於 :max 個字元。',
'array' => ':attribute 最多有 :max 個項目。',
],
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
'min' => [
'numeric' => ':attribute 不能小於 :min。',
'file' => ':attribute 不能小於 :min KB。',
'string' => ':attribute 不能少於 :min 個字元。',
'array' => ':attribute 至少有 :min 個項目。',
],
'multiple_of' => ':attribute 必須為 :value 的倍數。',
'not_in' => '所選的 :attribute 選項無效。',
'not_regex' => ':attribute 的格式錯誤。',
'numeric' => ':attribute 必須是一個數字。',
'password' => '密碼錯誤。',
'present' => ':attribute 必須存在。',
'regex' => ':attribute 的格式錯誤。',
'required' => ':attribute 欄位必填。',
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
'same' => ':attribute 與 :other 必須相同。',
'size' => [
'numeric' => ':attribute 的大小必須是 :size。',
'file' => ':attribute 的大小必須是 :size KB。',
'string' => ':attribute 必須是 :size 個字元。',
'array' => ':attribute 必須包含 :size 個項目。',
],
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
'string' => ':attribute 必須是一個字串。',
'timezone' => ':attribute 必須是一個有效的時區。',
'unique' => ':attribute 已經存在。',
'uploaded' => ':attribute 上傳失敗。',
'url' => ':attribute 的格式錯誤。',
'uuid' => ':attribute 必須是一個有效的 UUID。',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => '名稱',
'username' => '使用者名稱',
'email' => '電子郵件',
'first_name' => '名',
'last_name' => '姓',
'password' => '密碼',
'password_confirmation' => '確認密碼',
'city' => '城市',
'country' => '國家',
'address' => '地址',
'phone' => '電話',
'mobile' => '手機',
'age' => '年齡',
'sex' => '性別',
'gender' => '性別',
'day' => '天',
'month' => '月',
'year' => '年',
'hour' => '時',
'minute' => '分',
'second' => '秒',
'title' => '標題',
'content' => '內容',
'description' => '描述',
'excerpt' => '摘要',
'date' => '日期',
'time' => '時間',
'available' => '可用的',
'size' => '大小',
'product_id' => '商品',
'vendor_id' => '供應商',
'warehouse_id' => '倉庫',
'unit_id' => '單位',
'items' => '明細項目',
'quantity' => '數量',
'yield_quantity' => '標準產出量',
'production_date' => '生產日期',
'output_batch_number' => '成品批號',
'output_quantity' => '生產數量',
'source_warehouse_id' => '來源倉庫',
'target_warehouse_id' => '目標倉庫',
'expected_delivery_date' => '預計到貨日期',
'invoice_number' => '發票號碼',
'invoice_date' => '發票日期',
'invoice_amount' => '發票金額',
'tax_amount' => '稅額',
'remark' => '備註',
'code' => '代號',
],
];

166
lang/zh_TW/pagination.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
return [
'accepted' => ':attribute 必須接受。',
'active_url' => ':attribute 並非一個有效的網址。',
'after' => ':attribute 必須在 :date 之後。',
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
'alpha' => ':attribute 只能由字母組成。',
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
'alpha_num' => ':attribute 只能由字母與數字組成。',
'array' => ':attribute 必須是一個陣列。',
'before' => ':attribute 必須在 :date 之前。',
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
'between' => [
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
],
'boolean' => ':attribute 必須為布林值。',
'confirmed' => ':attribute 確認欄位不一致。',
'date' => ':attribute 並非一個有效的日期。',
'date_equals' => ':attribute 必須等於 :date。',
'date_format' => ':attribute 不符合 :format 的格式。',
'different' => ':attribute 與 :other 必須不同。',
'digits' => ':attribute 必須是 :digits 位數字。',
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
'dimensions' => ':attribute 圖片尺寸不正確。',
'distinct' => ':attribute 已經存在。',
'email' => ':attribute 必須是一個有效的電子郵件地址。',
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
'exists' => '所選的 :attribute 選項無效。',
'file' => ':attribute 必須是一個檔案。',
'filled' => ':attribute 屬性是必填的。',
'gt' => [
'numeric' => ':attribute 必須大於 :value。',
'file' => ':attribute 必須大於 :value KB。',
'string' => ':attribute 必須多於 :value 個字元。',
'array' => ':attribute 必須多於 :value 個項目。',
],
'gte' => [
'numeric' => ':attribute 必須大於或等於 :value。',
'file' => ':attribute 必須大於或等於 :value KB。',
'string' => ':attribute 必須多於或等於 :value 個字元。',
'array' => ':attribute 必須多於或等於 :value 個項目。',
],
'image' => ':attribute 必須是一張圖片。',
'in' => '所選的 :attribute 選項無效。',
'in_array' => ':attribute 沒有在 :other 中。',
'integer' => ':attribute 必須是一個整數。',
'ip' => ':attribute 必須是一個有效的 IP 地址。',
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
'json' => ':attribute 必須是一個有效的 JSON 字串。',
'lt' => [
'numeric' => ':attribute 必須小於 :value。',
'file' => ':attribute 必須小於 :value KB。',
'string' => ':attribute 必須少於 :value 個字元。',
'array' => ':attribute 必須少於 :value 個項目。',
],
'lte' => [
'numeric' => ':attribute 必須小於或等於 :value。',
'file' => ':attribute 必須小於或等於 :value KB。',
'string' => ':attribute 必須少於或等於 :value 個字元。',
'array' => ':attribute 必須少於或等於 :value 個項目。',
],
'max' => [
'numeric' => ':attribute 不能大於 :max。',
'file' => ':attribute 不能大於 :max KB。',
'string' => ':attribute 不能多於 :max 個字元。',
'array' => ':attribute 最多有 :max 個項目。',
],
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
'min' => [
'numeric' => ':attribute 不能小於 :min。',
'file' => ':attribute 不能小於 :min KB。',
'string' => ':attribute 不能少於 :min 個字元。',
'array' => ':attribute 至少有 :min 個項目。',
],
'multiple_of' => ':attribute 必須為 :value 的倍數。',
'not_in' => '所選的 :attribute 選項無效。',
'not_regex' => ':attribute 的格式錯誤。',
'numeric' => ':attribute 必須是一個數字。',
'password' => '密碼錯誤。',
'present' => ':attribute 必須存在。',
'regex' => ':attribute 的格式錯誤。',
'required' => ':attribute 欄位必填。',
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
'same' => ':attribute 與 :other 必須相同。',
'size' => [
'numeric' => ':attribute 的大小必須是 :size。',
'file' => ':attribute 的大小必須是 :size KB。',
'string' => ':attribute 必須是 :size 個字元。',
'array' => ':attribute 必須包含 :size 個項目。',
],
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
'string' => ':attribute 必須是一個字串。',
'timezone' => ':attribute 必須是一個有效的時區。',
'unique' => ':attribute 已經存在。',
'uploaded' => ':attribute 上傳失敗。',
'url' => ':attribute 的格式錯誤。',
'uuid' => ':attribute 必須是一個有效的 UUID。',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => '名稱',
'username' => '使用者名稱',
'email' => '電子郵件',
'first_name' => '名',
'last_name' => '姓',
'password' => '密碼',
'password_confirmation' => '確認密碼',
'city' => '城市',
'country' => '國家',
'address' => '地址',
'phone' => '電話',
'mobile' => '手機',
'age' => '年齡',
'sex' => '性別',
'gender' => '性別',
'day' => '天',
'month' => '月',
'year' => '年',
'hour' => '時',
'minute' => '分',
'second' => '秒',
'title' => '標題',
'content' => '內容',
'description' => '描述',
'excerpt' => '摘要',
'date' => '日期',
'time' => '時間',
'available' => '可用的',
'size' => '大小',
'product_id' => '商品',
'vendor_id' => '供應商',
'warehouse_id' => '倉庫',
'unit_id' => '單位',
'items' => '明細項目',
'quantity' => '數量',
'yield_quantity' => '標準產出量',
'production_date' => '生產日期',
'output_batch_number' => '成品批號',
'output_quantity' => '生產數量',
'source_warehouse_id' => '來源倉庫',
'target_warehouse_id' => '目標倉庫',
'expected_delivery_date' => '預計到貨日期',
'invoice_number' => '發票號碼',
'invoice_date' => '發票日期',
'invoice_amount' => '發票金額',
'tax_amount' => '稅額',
'remark' => '備註',
'code' => '代號',
],
];

166
lang/zh_TW/passwords.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
return [
'accepted' => ':attribute 必須接受。',
'active_url' => ':attribute 並非一個有效的網址。',
'after' => ':attribute 必須在 :date 之後。',
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
'alpha' => ':attribute 只能由字母組成。',
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
'alpha_num' => ':attribute 只能由字母與數字組成。',
'array' => ':attribute 必須是一個陣列。',
'before' => ':attribute 必須在 :date 之前。',
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
'between' => [
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
],
'boolean' => ':attribute 必須為布林值。',
'confirmed' => ':attribute 確認欄位不一致。',
'date' => ':attribute 並非一個有效的日期。',
'date_equals' => ':attribute 必須等於 :date。',
'date_format' => ':attribute 不符合 :format 的格式。',
'different' => ':attribute 與 :other 必須不同。',
'digits' => ':attribute 必須是 :digits 位數字。',
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
'dimensions' => ':attribute 圖片尺寸不正確。',
'distinct' => ':attribute 已經存在。',
'email' => ':attribute 必須是一個有效的電子郵件地址。',
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
'exists' => '所選的 :attribute 選項無效。',
'file' => ':attribute 必須是一個檔案。',
'filled' => ':attribute 屬性是必填的。',
'gt' => [
'numeric' => ':attribute 必須大於 :value。',
'file' => ':attribute 必須大於 :value KB。',
'string' => ':attribute 必須多於 :value 個字元。',
'array' => ':attribute 必須多於 :value 個項目。',
],
'gte' => [
'numeric' => ':attribute 必須大於或等於 :value。',
'file' => ':attribute 必須大於或等於 :value KB。',
'string' => ':attribute 必須多於或等於 :value 個字元。',
'array' => ':attribute 必須多於或等於 :value 個項目。',
],
'image' => ':attribute 必須是一張圖片。',
'in' => '所選的 :attribute 選項無效。',
'in_array' => ':attribute 沒有在 :other 中。',
'integer' => ':attribute 必須是一個整數。',
'ip' => ':attribute 必須是一個有效的 IP 地址。',
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
'json' => ':attribute 必須是一個有效的 JSON 字串。',
'lt' => [
'numeric' => ':attribute 必須小於 :value。',
'file' => ':attribute 必須小於 :value KB。',
'string' => ':attribute 必須少於 :value 個字元。',
'array' => ':attribute 必須少於 :value 個項目。',
],
'lte' => [
'numeric' => ':attribute 必須小於或等於 :value。',
'file' => ':attribute 必須小於或等於 :value KB。',
'string' => ':attribute 必須少於或等於 :value 個字元。',
'array' => ':attribute 必須少於或等於 :value 個項目。',
],
'max' => [
'numeric' => ':attribute 不能大於 :max。',
'file' => ':attribute 不能大於 :max KB。',
'string' => ':attribute 不能多於 :max 個字元。',
'array' => ':attribute 最多有 :max 個項目。',
],
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
'min' => [
'numeric' => ':attribute 不能小於 :min。',
'file' => ':attribute 不能小於 :min KB。',
'string' => ':attribute 不能少於 :min 個字元。',
'array' => ':attribute 至少有 :min 個項目。',
],
'multiple_of' => ':attribute 必須為 :value 的倍數。',
'not_in' => '所選的 :attribute 選項無效。',
'not_regex' => ':attribute 的格式錯誤。',
'numeric' => ':attribute 必須是一個數字。',
'password' => '密碼錯誤。',
'present' => ':attribute 必須存在。',
'regex' => ':attribute 的格式錯誤。',
'required' => ':attribute 欄位必填。',
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
'same' => ':attribute 與 :other 必須相同。',
'size' => [
'numeric' => ':attribute 的大小必須是 :size。',
'file' => ':attribute 的大小必須是 :size KB。',
'string' => ':attribute 必須是 :size 個字元。',
'array' => ':attribute 必須包含 :size 個項目。',
],
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
'string' => ':attribute 必須是一個字串。',
'timezone' => ':attribute 必須是一個有效的時區。',
'unique' => ':attribute 已經存在。',
'uploaded' => ':attribute 上傳失敗。',
'url' => ':attribute 的格式錯誤。',
'uuid' => ':attribute 必須是一個有效的 UUID。',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => '名稱',
'username' => '使用者名稱',
'email' => '電子郵件',
'first_name' => '名',
'last_name' => '姓',
'password' => '密碼',
'password_confirmation' => '確認密碼',
'city' => '城市',
'country' => '國家',
'address' => '地址',
'phone' => '電話',
'mobile' => '手機',
'age' => '年齡',
'sex' => '性別',
'gender' => '性別',
'day' => '天',
'month' => '月',
'year' => '年',
'hour' => '時',
'minute' => '分',
'second' => '秒',
'title' => '標題',
'content' => '內容',
'description' => '描述',
'excerpt' => '摘要',
'date' => '日期',
'time' => '時間',
'available' => '可用的',
'size' => '大小',
'product_id' => '商品',
'vendor_id' => '供應商',
'warehouse_id' => '倉庫',
'unit_id' => '單位',
'items' => '明細項目',
'quantity' => '數量',
'yield_quantity' => '標準產出量',
'production_date' => '生產日期',
'output_batch_number' => '成品批號',
'output_quantity' => '生產數量',
'source_warehouse_id' => '來源倉庫',
'target_warehouse_id' => '目標倉庫',
'expected_delivery_date' => '預計到貨日期',
'invoice_number' => '發票號碼',
'invoice_date' => '發票日期',
'invoice_amount' => '發票金額',
'tax_amount' => '稅額',
'remark' => '備註',
'code' => '代號',
],
];

225
lang/zh_TW/validation.php Normal file
View File

@@ -0,0 +1,225 @@
<?php
return [
'accepted' => ':attribute 必須接受。',
'active_url' => ':attribute 並非一個有效的網址。',
'after' => ':attribute 必須在 :date 之後。',
'after_or_equal' => ':attribute 必須在 :date 之後或相等。',
'alpha' => ':attribute 只能由字母組成。',
'alpha_dash' => ':attribute 只能由字母、數字、破折號與底線組成。',
'alpha_num' => ':attribute 只能由字母與數字組成。',
'array' => ':attribute 必須是一個陣列。',
'before' => ':attribute 必須在 :date 之前。',
'before_or_equal' => ':attribute 必須在 :date 之前或相等。',
'between' => [
'numeric' => ':attribute 必須介於 :min 至 :max 之間。',
'file' => ':attribute 必須介於 :min 至 :max KB 之間。',
'string' => ':attribute 必須介於 :min 至 :max 個字元之間。',
'array' => ':attribute 必須介於 :min 至 :max 個項目之間。',
],
'boolean' => ':attribute 必須為布林值。',
'confirmed' => ':attribute 確認欄位不一致。',
'date' => ':attribute 並非一個有效的日期。',
'date_equals' => ':attribute 必須等於 :date。',
'date_format' => ':attribute 不符合 :format 的格式。',
'different' => ':attribute 與 :other 必須不同。',
'digits' => ':attribute 必須是 :digits 位數字。',
'digits_between' => ':attribute 必須介於 :min 至 :max 位數字之間。',
'dimensions' => ':attribute 圖片尺寸不正確。',
'distinct' => ':attribute 已經存在。',
'email' => ':attribute 必須是一個有效的電子郵件地址。',
'ends_with' => ':attribute 結尾必須包含下列之一::values。',
'exists' => '所選的 :attribute 選項無效。',
'file' => ':attribute 必須是一個檔案。',
'filled' => ':attribute 屬性是必填的。',
'gt' => [
'numeric' => ':attribute 必須大於 :value。',
'file' => ':attribute 必須大於 :value KB。',
'string' => ':attribute 必須多於 :value 個字元。',
'array' => ':attribute 必須多於 :value 個項目。',
],
'gte' => [
'numeric' => ':attribute 必須大於或等於 :value。',
'file' => ':attribute 必須大於或等於 :value KB。',
'string' => ':attribute 必須多於或等於 :value 個字元。',
'array' => ':attribute 必須多於或等於 :value 個項目。',
],
'image' => ':attribute 必須是一張圖片。',
'in' => '所選的 :attribute 選項無效。',
'in_array' => ':attribute 沒有在 :other 中。',
'integer' => ':attribute 必須是一個整數。',
'ip' => ':attribute 必須是一個有效的 IP 地址。',
'ipv4' => ':attribute 必須是一個有效的 IPv4 地址。',
'ipv6' => ':attribute 必須是一個有效的 IPv6 地址。',
'json' => ':attribute 必須是一個有效的 JSON 字串。',
'lt' => [
'numeric' => ':attribute 必須小於 :value。',
'file' => ':attribute 必須小於 :value KB。',
'string' => ':attribute 必須少於 :value 個字元。',
'array' => ':attribute 必須少於 :value 個項目。',
],
'lte' => [
'numeric' => ':attribute 必須小於或等於 :value。',
'file' => ':attribute 必須小於或等於 :value KB。',
'string' => ':attribute 必須少於或等於 :value 個字元。',
'array' => ':attribute 必須少於或等於 :value 個項目。',
],
'max' => [
'numeric' => ':attribute 不能大於 :max。',
'file' => ':attribute 不能大於 :max KB。',
'string' => ':attribute 不能多於 :max 個字元。',
'array' => ':attribute 最多有 :max 個項目。',
],
'mimes' => ':attribute 必須是一個 :values 格式的檔案。',
'mimetypes' => ':attribute 必須是一個 :values 格式的檔案。',
'min' => [
'numeric' => ':attribute 不能小於 :min。',
'file' => ':attribute 不能小於 :min KB。',
'string' => ':attribute 不能少於 :min 個字元。',
'array' => ':attribute 至少有 :min 個項目。',
],
'multiple_of' => ':attribute 必須為 :value 的倍數。',
'not_in' => '所選的 :attribute 選項無效。',
'not_regex' => ':attribute 的格式錯誤。',
'numeric' => ':attribute 必須是一個數字。',
'password' => '密碼錯誤。',
'present' => ':attribute 必須存在。',
'regex' => ':attribute 的格式錯誤。',
'required' => ':attribute 欄位必填。',
'required_if' => '當 :other 是 :value 時,:attribute 欄位必填。',
'required_unless' => '當 :other 不是 :value 時,:attribute 欄位必填。',
'required_with' => '當 :values 出現時,:attribute 欄位必填。',
'required_with_all' => '當 :values 出現時,:attribute 欄位必填。',
'required_without' => '當 :values 留空時,:attribute 欄位必填。',
'required_without_all' => '當 :values 留空時,:attribute 欄位必填。',
'same' => ':attribute 與 :other 必須相同。',
'size' => [
'numeric' => ':attribute 的大小必須是 :size。',
'file' => ':attribute 的大小必須是 :size KB。',
'string' => ':attribute 必須是 :size 個字元。',
'array' => ':attribute 必須包含 :size 個項目。',
],
'starts_with' => ':attribute 開頭必須包含下列之一::values。',
'string' => ':attribute 必須是一個字串。',
'timezone' => ':attribute 必須是一個有效的時區。',
'unique' => ':attribute 已經存在。',
'uploaded' => ':attribute 上傳失敗。',
'url' => ':attribute 的格式錯誤。',
'uuid' => ':attribute 必須是一個有效的 UUID。',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => '名稱',
'username' => '使用者名稱',
'email' => '電子郵件',
'first_name' => '名',
'last_name' => '姓',
'password' => '密碼',
'password_confirmation' => '確認密碼',
'city' => '城市',
'country' => '國家',
'address' => '地址',
'phone' => '電話',
'mobile' => '手機',
'age' => '年齡',
'sex' => '性別',
'gender' => '性別',
'day' => '天',
'month' => '月',
'year' => '年',
'hour' => '時',
'minute' => '分',
'second' => '秒',
'title' => '標題',
'content' => '內容',
'description' => '描述',
'excerpt' => '摘要',
'date' => '日期',
'time' => '時間',
'available' => '可用的',
'size' => '大小',
'product_id' => '商品',
'productId' => '商品',
'vendor_id' => '供應商',
'vendorId' => '供應商',
'warehouse_id' => '倉庫',
'warehouseId' => '倉庫',
'unit_id' => '單位',
'unitId' => '單位',
'items' => '明細項目',
'quantity' => '數量',
'yield_quantity' => '標準產出量',
'yieldQuantity' => '標準產出量',
'production_date' => '生產日期',
'productionDate' => '生產日期',
'output_batch_number' => '成品批號',
'outputBatchNumber' => '成品批號',
'output_quantity' => '生產數量',
'outputQuantity' => '生產數量',
'output_box_count' => '生產箱數',
'outputBoxCount' => '生產箱數',
'source_warehouse_id' => '來源倉庫',
'sourceWarehouseId' => '來源倉庫',
'target_warehouse_id' => '目標倉庫',
'targetWarehouseId' => '目標倉庫',
'expected_delivery_date' => '預計到貨日期',
'expectedDeliveryDate' => '預計到貨日期',
'invoice_number' => '發票號碼',
'invoiceNumber' => '發票號碼',
'invoice_date' => '發票日期',
'invoiceDate' => '發票日期',
'invoice_amount' => '發票金額',
'invoiceAmount' => '發票金額',
'tax_amount' => '稅額',
'taxAmount' => '稅額',
'remark' => '備註',
'code' => '代號',
'short_name' => '簡稱',
'shortName' => '簡稱',
'tax_id' => '統編',
'taxId' => '統編',
'owner' => '負責人',
'contact_name' => '聯絡人',
'contactName' => '聯絡人',
'tel' => '電話',
'phone' => '手機',
'address' => '地址',
'brand' => '品牌',
'specification' => '規格',
'base_unit_id' => '基本單位',
'baseUnitId' => '基本單位',
'large_unit_id' => '大單位',
'largeUnitId' => '大單位',
'purchase_unit_id' => '採購單位',
'purchaseUnitId' => '採購單位',
'conversion_rate' => '換算率',
'conversionRate' => '換算率',
'category_id' => '分類',
'categoryId' => '分類',
'inventory_id' => '庫存項目',
'inventoryId' => '庫存項目',
'arrival_date' => '到貨日期',
'arrivalDate' => '到貨日期',
'expiry_date' => '效期',
'expiryDate' => '效期',
'quantity_used' => '使用數量',
'quantityUsed' => '使用數量',
'items.*.product_id' => '明細商品',
'items.*.productId' => '明細商品',
'items.*.quantity' => '明細數量',
'items.*.unit_id' => '明細單位',
'items.*.unitId' => '明細單位',
'items.*.remark' => '明細備註',
'items.*.inventory_id' => '明細批號',
'items.*.inventoryId' => '明細批號',
'items.*.quantity_used' => '明細用量',
'items.*.quantityUsed' => '明細用量',
'items.*.subtotal' => '明細小計',
'items.*.subtotalAmount' => '明細小計',
],
];

View File

@@ -41,7 +41,7 @@ interface Props {
activity: Activity | null;
}
// Field translation map
// 欄位翻譯對照表
const fieldLabels: Record<string, string> = {
name: '名稱',
code: '商品代號',
@@ -66,19 +66,19 @@ const fieldLabels: Record<string, string> = {
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<string, string> = {
quantity: '數量',
safety_stock: '安全庫存',
location: '儲位',
// Inventory fields
// 庫存欄位
batch_number: '批號',
box_number: '箱號',
origin_country: '來源國家',
@@ -95,7 +95,7 @@ const fieldLabels: Record<string, string> = {
source_purchase_order_id: '來源採購單',
quality_status: '品質狀態',
quality_remark: '品質備註',
// Purchase Order fields
// 採購單欄位
po_number: '採購單號',
vendor_id: '廠商',
vendor_name: '廠商名稱',
@@ -110,13 +110,13 @@ const fieldLabels: Record<string, string> = {
invoice_date: '發票日期',
invoice_amount: '發票金額',
last_price: '供貨價格',
// Utility Fee fields
// 公共事業費欄位
transaction_date: '費用日期',
category: '費用類別',
amount: '金額',
};
// Purchase Order Status Map
// 採購單狀態對照表
const statusMap: Record<string, string> = {
draft: '草稿',
pending: '待審核',
@@ -127,7 +127,7 @@ const statusMap: Record<string, string> = {
completed: '已完成',
};
// Inventory Quality Status Map
// 庫存品質狀態對照表
const qualityStatusMap: Record<string, string> = {
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
</Badge>
</div>
{/* Modern Metadata Strip */}
{/* 現代化元數據條 */}
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
@@ -293,7 +293,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
{activity.properties?.sub_subject || activity.subject_type}
</span>
</div>
{/* Only show 'description' if it differs from event name (unlikely but safe) */}
{/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */}
{activity.description !== getEventLabel(activity.event) &&
activity.description !== 'created' && activity.description !== 'updated' && (
<div className="flex items-center gap-2">
@@ -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
</Table>
</div>
)}
{/* Items Diff Section (Special for Purchase Orders) */}
{/* 項目差異區塊(採購單專用) */}
{activity.properties?.items_diff && (
<div className="mt-6 space-y-4">
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1">
@@ -417,7 +417,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
</TableHeader>
<TableBody>
{/* Updated Items */}
{/* 更新項目 */}
{activity.properties.items_diff.updated.map((item: any, idx: number) => (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
@@ -440,7 +440,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
))}
{/* Added Items */}
{/* 新增項目 */}
{activity.properties.items_diff.added.map((item: any, idx: number) => (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
@@ -453,7 +453,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
))}
{/* Removed Items */}
{/* 移除項目 */}
{activity.properties.items_diff.removed.map((item: any, idx: number) => (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>

View File

@@ -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({
<span className="text-gray-700">{activity.subject_type}</span>
)}
{/* Display reason/source if available (e.g., from Replenishment) */}
{/* 如果有原因/來源則顯示(例如:來自補貨) */}
{(attrs._reason || old._reason) && (
<span className="text-gray-500 text-xs">
( {attrs._reason || old._reason})

Some files were not shown because too many files have changed in this diff Show More