chore: 完善模組化架構遷移與修復前端顯示錯誤

- 修正所有模組 Controller 的 Model 引用路徑 (App\Modules\...)
- 更新 ProductionOrder 與 ProductionOrderItem 模型結構以符合新版邏輯
- 修復 resources/js/utils/format.ts 在處理空值時導致 toLocaleString 崩潰的問題
- 清除全域路徑與 Controller 遷移殘留檔案
This commit is contained in:
2026-01-26 10:37:47 +08:00
parent db0c1ce3af
commit b0848a6bb8
70 changed files with 947 additions and 833 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

@@ -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

@@ -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,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

@@ -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' => '公共事業費',
];
}
@@ -101,7 +102,7 @@ class ActivityLogController extends Controller
})->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;

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;

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;

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

@@ -1,9 +1,11 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Finance\Controllers;
use App\Models\PurchaseOrder;
use App\Models\UtilityFee;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Procurement\Models\PurchaseOrder;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Carbon;

View File

@@ -1,8 +1,10 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\UtilityFee;
use App\Models\UtilityFee;
use Illuminate\Http\Request;
use Inertia\Inertia;

View File

@@ -0,0 +1,36 @@
<?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 = [
'type', // 'electricity', 'water', 'gas', etc.
'billing_period_start',
'billing_period_end',
'due_date',
'amount',
'usage_amount', // kWh, m3, etc.
'unit', // 度, 立方米
'status', // 'pending', 'paid', 'overdue'
'paid_at',
'payment_method',
'notes',
'receipt_image_path',
];
protected $casts = [
'billing_period_start' => 'date',
'billing_period_end' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
'amount' => 'decimal:2',
'usage_amount' => 'decimal:2',
];
}

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

@@ -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,15 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\WarehouseProductSafetyStock;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
class InventoryController extends Controller
{
public function index(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
public function index(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{
$warehouse->load([
'inventories.product.category',
@@ -15,7 +17,7 @@ class InventoryController extends Controller
'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction'
]);
$allProducts = \App\Models\Product::with('category')->get();
$allProducts = \App\Modules\Inventory\Models\Product::with('category')->get();
// 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) {
@@ -104,10 +106,10 @@ class InventoryController extends Controller
]);
}
public function create(\App\Models\Warehouse $warehouse)
public function create(\App\Modules\Inventory\Models\Warehouse $warehouse)
{
// 取得所有商品供前端選單使用
$products = \App\Models\Product::with(['baseUnit', 'largeUnit'])
$products = \App\Modules\Inventory\Models\Product::with(['baseUnit', 'largeUnit'])
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
->get()
->map(function ($product) {
@@ -127,7 +129,7 @@ class InventoryController extends Controller
]);
}
public function store(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
public function store(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{
$validated = $request->validate([
'inboundDate' => 'required|date',
@@ -148,16 +150,16 @@ class InventoryController extends Controller
if ($item['batchMode'] === 'existing') {
// 模式 A選擇現有批號 (包含已刪除的也要能找回來累加)
$inventory = \App\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']);
$inventory = \App\Modules\Inventory\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']);
if ($inventory->trashed()) {
$inventory->restore();
}
} else {
// 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW';
$product = \App\Models\Product::find($item['productId']);
$product = \App\Modules\Inventory\Models\Product::find($item['productId']);
$batchNumber = \App\Models\Inventory::generateBatchNumber(
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$validated['inboundDate']
@@ -208,12 +210,12 @@ class InventoryController extends Controller
/**
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
*/
public function getBatches(\App\Models\Warehouse $warehouse, $productId, \Illuminate\Http\Request $request)
public function getBatches(\App\Modules\Inventory\Models\Warehouse $warehouse, $productId, \Illuminate\Http\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 = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->get()
->map(function ($inventory) {
@@ -227,10 +229,10 @@ class InventoryController extends Controller
});
// 計算下一個流水號
$product = \App\Models\Product::find($productId);
$product = \App\Modules\Inventory\Models\Product::find($productId);
$nextSequence = '01';
if ($product) {
$batchNumber = \App\Models\Inventory::generateBatchNumber(
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$arrivalDate
@@ -244,7 +246,7 @@ class InventoryController extends Controller
]);
}
public function edit(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId)
public function edit(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
{
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
@@ -252,7 +254,7 @@ class InventoryController extends Controller
return redirect()->back()->with('error', '無法編輯範例資料');
}
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
@@ -289,13 +291,13 @@ class InventoryController extends Controller
]);
}
public function update(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId)
public function update(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
{
// 若是 product ID (舊邏輯),先轉為 inventory
// 但新路由我們傳的是 inventory ID
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$inventory = \App\Models\Inventory::find($inventoryId);
$inventory = \App\Modules\Inventory\Models\Inventory::find($inventoryId);
// 如果找不到 (可能是舊路由傳 product ID)
if (!$inventory) {
@@ -393,9 +395,9 @@ class InventoryController extends Controller
});
}
public function destroy(\App\Models\Warehouse $warehouse, $inventoryId)
public function destroy(\App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
{
$inventory = \App\Models\Inventory::findOrFail($inventoryId);
$inventory = \App\Modules\Inventory\Models\Inventory::findOrFail($inventoryId);
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
if ($inventory->quantity > 0) {
@@ -421,14 +423,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 = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
@@ -503,7 +505,7 @@ class InventoryController extends Controller
if ($inventoryId) {
// 單一批號查詢
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);

View File

@@ -1,9 +1,11 @@
<?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 Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -59,7 +61,7 @@ class ProductController extends Controller
$products = $query->paginate($perPage)->withQueryString();
$categories = \App\Models\Category::where('is_active', true)->get();
$categories = \App\Modules\Inventory\Models\Category::where('is_active', true)->get();
return Inertia::render('Product/Index', [
'products' => $products,

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;

View File

@@ -1,9 +1,11 @@
<?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

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;

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,9 +1,10 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency
class Inventory extends Model
{

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; // Cross-module Core dependency
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;
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Procurement\Models\Vendor; // Cross-module dependency (Procurement)
class Product extends Model
{

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,9 +1,10 @@
<?php
namespace App\Models;
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency (Procurement)
class Warehouse extends Model
{

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

@@ -1,10 +1,12 @@
<?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;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
@@ -146,9 +148,9 @@ class PurchaseOrderController extends Controller
// 確保有一個有效的使用者 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'),

View File

@@ -1,8 +1,10 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\Vendor;
use App\Models\Vendor;
use Illuminate\Http\Request;
class VendorController extends Controller
@@ -56,7 +58,7 @@ class VendorController extends Controller
$vendor->load(['products.baseUnit', 'products.largeUnit']);
return \Inertia\Inertia::render('Vendor/Show', [
'vendor' => $vendor,
'products' => \App\Models\Product::with('baseUnit')->get(),
'products' => \App\Modules\Inventory\Models\Product::with('baseUnit')->get(),
]);
}

View File

@@ -1,8 +1,10 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\Vendor;
use App\Models\Vendor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -28,7 +30,7 @@ class VendorProductController extends Controller
]);
// 記錄操作
$product = \App\Models\Product::find($validated['product_id']);
$product = \App\Modules\Inventory\Models\Product::find($validated['product_id']);
activity()
->performedOn($vendor)
->withProperties([
@@ -66,7 +68,7 @@ class VendorProductController extends Controller
]);
// 記錄操作
$product = \App\Models\Product::find($productId);
$product = \App\Modules\Inventory\Models\Product::find($productId);
activity()
->performedOn($vendor)
->withProperties([
@@ -95,7 +97,7 @@ class VendorProductController extends Controller
public function destroy(Vendor $vendor, $productId)
{
// 記錄操作 (需在 detach 前獲取資訊)
$product = \App\Models\Product::find($productId);
$product = \App\Modules\Inventory\Models\Product::find($productId);
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
$vendor->products()->detach($productId);

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Core\Models\User;
class PurchaseOrder extends Model
{
/** @use HasFactory<\Database\Factories\PurchaseOrderFactory> */
use HasFactory;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'po_number',
'vendor_id',
'warehouse_id',
'user_id',
'order_date',
'expected_delivery_date',
'status',
'total_amount',
'notes',
];
protected $casts = [
'order_date' => 'date',
'expected_delivery_date' => 'date',
'total_amount' => 'decimal:2',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$snapshot = $activity->properties['snapshot'] ?? [];
$snapshot['po_number'] = $this->po_number;
if ($this->vendor) {
$snapshot['vendor_name'] = $this->vendor->name;
}
if ($this->warehouse) {
$snapshot['warehouse_name'] = $this->warehouse->name;
}
$activity->properties = $activity->properties->merge([
'snapshot' => $snapshot
]);
}
public function vendor(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Vendor::class);
}
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrderItem::class);
}
}

View File

@@ -0,0 +1,44 @@
<?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);
}
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -1,38 +1,35 @@
<?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',
'name',
'short_name',
'tax_id',
'owner',
'contact_name',
'tel',
'phone',
'contact_person',
'email',
'phone',
'address',
'remark'
'tax_id',
'payment_terms',
];
public function products(): BelongsToMany
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_vendor')
->withPivot('last_price')
->withTimestamps();
return $this->belongsToMany(Product::class)->withPivot('last_price')->withTimestamps();
}
public function purchaseOrders(): HasMany
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrder::class);
}
@@ -49,12 +46,8 @@ class Vendor extends Model
{
$properties = $activity->properties;
// Store name in 'snapshot' for context, keeping 'attributes' clean
$snapshot = $properties['snapshot'] ?? [];
// Only set name if it's not already set (e.g. by controller for specific context like supply product)
if (!isset($snapshot['name'])) {
$snapshot['name'] = $this->name;
}
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;

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

@@ -1,13 +1,14 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Production\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 App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Product;
use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
use App\Modules\Inventory\Models\Unit;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Core\Models\User;
class ProductionOrder extends Model
{
/** @use HasFactory<\Database\Factories\ProductionOrderFactory> */
use HasFactory;
protected $fillable = [
'code',
'product_id',
'warehouse_id',
'output_quantity',
'output_batch_number',
'output_box_count',
'production_date',
'expiry_date',
'user_id',
'status',
'remark',
];
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;
}
protected $casts = [
'order_date' => 'date',
'start_date' => 'datetime',
'completion_date' => 'datetime',
'quantity' => 'decimal:2',
'produced_quantity' => 'decimal:2',
];
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Product::class);
}
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ProductionOrderItem::class);
}
}

View File

@@ -0,0 +1,44 @@
<?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',
];
public function inventory()
{
return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class);
}
public function unit()
{
return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class);
}
public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(ProductionOrder::class);
}
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Production\Controllers\ProductionOrderController;
Route::middleware('auth')->group(function () {
// 生產管理
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,40 @@
<?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);
$routesPath = $module . '/Routes/web.php';
if (File::exists($routesPath)) {
Route::middleware('web')
->group($routesPath);
}
}
}
}
}

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,7 +7,7 @@ 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
{

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

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**| 化妝品 | 成分分析、過敏原管理 |

View File

@@ -5,14 +5,16 @@
/**
* 格式化數字為千分位格式
*/
export const formatNumber = (num: number): string => {
export const formatNumber = (num: number | null | undefined): string => {
if (num === null || num === undefined) return "0";
return num.toLocaleString();
};
/**
* 格式化貨幣NT$
*/
export const formatCurrency = (num: number): string => {
export const formatCurrency = (num: number | null | undefined): string => {
if (num === null || num === undefined) return "NT$ 0";
return `NT$ ${num.toLocaleString()}`;
};

View File

@@ -2,211 +2,37 @@
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\VendorController;
use App\Http\Controllers\VendorProductController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\PurchaseOrderController;
use App\Http\Controllers\WarehouseController;
use App\Http\Controllers\InventoryController;
use App\Http\Controllers\SafetyStockController;
use App\Http\Controllers\TransferOrderController;
use App\Http\Controllers\UnitController;
use App\Http\Controllers\Admin\RoleController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\ActivityLogController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\UtilityFeeController;
use App\Http\Controllers\AccountingReportController;
use App\Http\Controllers\ProductionOrderController;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
// 登入/登出路由
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::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: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: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: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');
});
// 公共事業費管理 (TODO: 添加權限控制)
// 公共事業費
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: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');
// 系統管理
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');
});
// 生產管理
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');
// 系統管理
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');
});
});
}); // End of auth middleware group