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

0
app/Modules/.gitkeep Normal file
View File

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Activitylog\Models\Activity;
class ActivityLogController extends Controller
{
private function getSubjectMap()
{
return [
'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' => '公共事業費',
];
}
public function index(Request $request)
{
$perPage = $request->input('per_page', 10);
$sortBy = $request->input('sort_by', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
$search = $request->input('search');
$dateStart = $request->input('date_start');
$dateEnd = $request->input('date_end');
$event = $request->input('event');
$subjectType = $request->input('subject_type');
$causerId = $request->input('causer_id');
$query = Activity::with('causer');
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('log_name', 'like', "%{$search}%")
->orWhere('properties', 'like', "%{$search}%");
});
}
if ($dateStart) {
$query->whereDate('created_at', '>=', $dateStart);
}
if ($dateEnd) {
$query->whereDate('created_at', '<=', $dateEnd);
}
if ($event) {
$query->where('event', $event);
}
if ($subjectType) {
$query->where('subject_type', $subjectType);
}
if ($causerId) {
$query->where('causer_id', $causerId);
}
if ($sortBy === 'created_at') {
$query->orderBy($sortBy, $sortOrder);
} else {
$query->latest();
}
$activities = $query->paginate($perPage)
->through(function ($activity) {
$subjectMap = $this->getSubjectMap();
$eventMap = [
'created' => '新增',
'updated' => '更新',
'deleted' => '刪除',
];
return [
'id' => $activity->id,
'description' => $eventMap[$activity->event] ?? $activity->event,
'subject_type' => $subjectMap[$activity->subject_type] ?? class_basename($activity->subject_type),
'event' => $activity->event,
'causer' => $activity->causer ? $activity->causer->name : 'System',
'created_at' => $activity->created_at->format('Y-m-d H:i:s'),
'properties' => $activity->properties,
];
});
// 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\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
->map(function ($user) {
return ['label' => $user->name, 'value' => (string) $user->id];
});
return Inertia::render('Admin/ActivityLog/Index', [
'activities' => $activities,
'filters' => [
'per_page' => $request->input('per_page', '10'),
'sort_by' => $request->input('sort_by'),
'sort_order' => $request->input('sort_order'),
'search' => $request->input('search'),
'date_start' => $request->input('date_start'),
'date_end' => $request->input('date_end'),
'event' => $request->input('event'),
'subject_type' => $request->input('subject_type'),
'causer_id' => $request->input('causer_id'),
],
'subject_types' => $subjectTypes,
'users' => $users,
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Modules\Core\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
/**
* Display the login view.
*/
public function show()
{
$centralDomains = config('tenancy.central_domains', []);
// [Hack] Demo 環境特殊規則
$demoPort = config('tenancy.demo_tenant_port');
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
return Inertia::render('Landlord/Auth/Login');
}
return Inertia::render('Auth/Login');
}
/**
* Handle an incoming authentication request.
*/
public function store(Request $request)
{
$request->validate([
'username' => ['required', 'string'],
'password' => ['required', 'string'],
], [
'username.required' => '請輸入帳號',
'password.required' => '請輸入密碼',
]);
$credentials = $request->only('username', 'password');
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
$centralDomains = config('tenancy.central_domains', []);
$centralDomains = config('tenancy.central_domains', []);
// [Hack] Demo 環境特殊規則
$demoPort = config('tenancy.demo_tenant_port');
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
return redirect()->intended(route('landlord.dashboard'));
}
return redirect()->intended(route('dashboard'));
}
throw ValidationException::withMessages([
'username' => '帳號或密碼錯誤。',
]);
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Modules\Core\Controllers;
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;
class DashboardController extends Controller
{
public function index()
{
$centralDomains = config('tenancy.central_domains', []);
$demoPort = config('tenancy.demo_tenant_port');
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
return redirect()->route('landlord.dashboard');
}
// 計算低庫存數量:各商品在各倉庫的總量 < 安全庫存
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
function ($join) {
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
->on('ss.product_id', '=', 'inv.product_id');
})
->whereRaw('inv.total_qty <= ss.safety_stock')
->count();
$stats = [
'productsCount' => Product::count(),
'vendorsCount' => Vendor::count(),
'purchaseOrdersCount' => PurchaseOrder::count(),
'warehousesCount' => Warehouse::count(),
'totalInventoryValue' => Inventory::join('products', 'inventories.product_id', '=', 'products.id')
->sum('inventories.quantity'),
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
'lowStockCount' => $lowStockCount,
];
return Inertia::render('Dashboard', [
'stats' => $stats,
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
class ProfileController extends Controller
{
/**
* 顯示使用者設定頁面
*/
public function edit(Request $request)
{
return Inertia::render('Profile/Edit', [
'user' => $request->user(),
]);
}
/**
* 更新使用者基本資料
*/
public function update(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
]);
$request->user()->update($validated);
return back()->with('success', '個人資料已更新');
}
/**
* 更新密碼
*/
public function updatePassword(Request $request)
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('success', '密碼已更新');
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Inertia\Inertia;
use Illuminate\Validation\Rule;
class RoleController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$sortBy = $request->input('sort_by', 'id');
$sortOrder = $request->input('sort_order', 'asc');
$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 {
$query->orderBy('id', 'asc');
}
$roles = $query->get();
return Inertia::render('Admin/Role/Index', [
'roles' => $roles,
'filters' => $request->only(['sort_by', 'sort_order']),
]);
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
$permissions = $this->getGroupedPermissions();
return Inertia::render('Admin/Role/Create', [
'groupedPermissions' => $permissions
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
'display_name' => ['required', 'string', 'max:255'],
'permissions' => ['array'],
'permissions.*' => ['exists:permissions,name']
]);
$role = Role::create([
'name' => $validated['name'],
'display_name' => $validated['display_name']
]);
if (!empty($validated['permissions'])) {
$role->syncPermissions($validated['permissions']);
}
return redirect()->route('roles.index')->with('success', '角色建立成功');
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id)
{
$role = Role::with('permissions')->findOrFail($id);
// 禁止編輯超級管理員角色
if ($role->name === 'super-admin') {
return redirect()->route('roles.index')->with('error', '超級管理員角色不可編輯');
}
$groupedPermissions = $this->getGroupedPermissions();
$currentPermissions = $role->permissions->pluck('name')->toArray();
return Inertia::render('Admin/Role/Edit', [
'role' => $role,
'groupedPermissions' => $groupedPermissions,
'currentPermissions' => $currentPermissions
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
$role = Role::findOrFail($id);
if ($role->name === 'super-admin') {
return redirect()->route('roles.index')->with('error', '超級管理員角色不可變更');
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($role->id)],
'display_name' => ['required', 'string', 'max:255'],
'permissions' => ['array'],
'permissions.*' => ['exists:permissions,name']
]);
$role->update([
'name' => $validated['name'],
'display_name' => $validated['display_name']
]);
if (isset($validated['permissions'])) {
$role->syncPermissions($validated['permissions']);
}
return redirect()->route('roles.index')->with('success', '角色更新成功');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
$role = Role::withCount('users')->findOrFail($id);
if ($role->name === 'super-admin') {
return back()->with('error', '超級管理員角色不可刪除');
}
if ($role->users_count > 0) {
return back()->with('error', "尚有 {$role->users_count} 位使用者屬於此角色,無法刪除");
}
$role->delete();
return redirect()->route('roles.index')->with('success', '角色已刪除');
}
/**
* 取得並分組權限
*/
private function getGroupedPermissions()
{
$allPermissions = Permission::orderBy('name')->get();
$grouped = [];
foreach ($allPermissions as $permission) {
$parts = explode('.', $permission->name);
$group = $parts[0];
$action = $parts[1] ?? '';
// 特定權限遷移邏輯
if ($permission->name === 'inventory.transfer') {
$group = 'warehouses'; // 調撥功能移至倉庫管理下
}
if (!isset($grouped[$group])) {
$grouped[$group] = [];
}
$grouped[$group][] = $permission;
}
// 依照側邊欄順序定義
$groupDefinitions = [
'products' => '商品資料管理',
'warehouses' => '倉庫管理',
'inventory' => '庫存管理',
'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理',
'users' => '使用者管理',
'roles' => '角色與權限',
'utility_fees' => '公共事業費管理',
'accounting' => '會計報表',
];
$result = [];
foreach ($groupDefinitions as $key => $displayName) {
if (isset($grouped[$key])) {
$result[] = [
'key' => $key,
'name' => $displayName,
'permissions' => $grouped[$key]
];
unset($grouped[$key]); // 從待處理中移除
}
}
// 處理剩餘未定義在 groupDefinitions 中的群組 (安全機制)
foreach ($grouped as $key => $permissions) {
$result[] = [
'key' => $key,
'name' => ucfirst($key),
'permissions' => $permissions
];
}
return $result;
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Core\Models\User;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Inertia\Inertia;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Hash;
class UserController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$perPage = $request->input('per_page', 10);
$sortBy = $request->input('sort_by', 'id');
$sortOrder = $request->input('sort_order', 'asc');
$search = $request->input('search');
$roleId = $request->input('role');
$query = User::with(['roles:id,name,display_name']);
// Handle Search
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%");
});
}
// 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 {
$query->orderBy('id', 'asc');
}
$users = $query->paginate($perPage)->withQueryString();
$roles = Role::select('id', 'name', 'display_name')->get();
return Inertia::render('Admin/User/Index', [
'users' => $users,
'roles' => $roles,
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role']),
]);
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
$roles = Role::pluck('display_name', 'name');
return Inertia::render('Admin/User/Create', [
'roles' => $roles
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'],
'username' => ['required', 'string', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'roles' => ['array'],
], [
'password.required' => '請輸入密碼',
'password.min' => '密碼長度至少需 :min 個字元',
'password.confirmed' => '密碼確認不符',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'username' => $validated['username'],
'password' => Hash::make($validated['password']),
]);
if (!empty($validated['roles'])) {
$user->syncRoles($validated['roles']);
// Update the 'created' log to include roles
$activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user))
->where('subject_id', $user->id)
->where('event', 'created')
->latest()
->first();
if ($activity) {
$roleNames = $user->roles()->pluck('display_name')->join(', ');
$properties = $activity->properties->toArray();
$properties['attributes']['role_id'] = $roleNames;
$activity->properties = $properties;
$activity->save();
}
}
return redirect()->route('users.index')->with('success', '使用者建立成功');
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id)
{
$user = User::with('roles')->findOrFail($id);
$roles = Role::get(['id', 'name', 'display_name']);
return Inertia::render('Admin/User/Edit', [
'user' => $user,
'roles' => $roles,
'currentRoles' => $user->getRoleNames()
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
$user = User::findOrFail($id);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
'roles' => ['array'],
], [
'password.min' => '密碼長度至少需 :min 個字元',
'password.confirmed' => '密碼確認不符',
]);
// 1. Prepare data and detect changes
$userData = [
'name' => $validated['name'],
'email' => $validated['email'],
'username' => $validated['username'],
];
if (!empty($validated['password'])) {
$userData['password'] = Hash::make($validated['password']);
}
$user->fill($userData);
// Capture dirty attributes for manual logging
$dirty = $user->getDirty();
$oldAttributes = [];
$newAttributes = [];
foreach ($dirty as $key => $value) {
$oldAttributes[$key] = $user->getOriginal($key);
$newAttributes[$key] = $value;
}
// Save without triggering events (prevents duplicate log)
$user->saveQuietly();
// 2. Handle Roles
$roleChanges = null;
if (isset($validated['roles'])) {
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
$user->syncRoles($validated['roles']);
$newRoles = $user->roles()->pluck('display_name')->join(', ');
if ($oldRoles !== $newRoles) {
$roleChanges = [
'old' => $oldRoles,
'new' => $newRoles
];
}
}
// 3. Manually Log activity (Single Consolidated Log)
if (!empty($newAttributes) || $roleChanges) {
$properties = [
'attributes' => $newAttributes,
'old' => $oldAttributes,
];
if ($roleChanges) {
$properties['attributes']['role_id'] = $roleChanges['new'];
$properties['old']['role_id'] = $roleChanges['old'];
}
activity()
->performedOn($user)
->causedBy(auth()->user())
->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
$activity->properties = $activity->properties->merge([
'snapshot' => [
'name' => $user->name,
'username' => $user->username,
]
]);
})
->log('updated');
}
return redirect()->route('users.index')->with('success', '使用者更新成功');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
$user = User::findOrFail($id);
if ($user->hasRole('super-admin')) {
return back()->with('error', '無法刪除超級管理員帳號');
}
if ($user->id === auth()->id()) {
return back()->with('error', '無法刪除自己');
}
$user->delete();
return redirect()->route('users.index')->with('success', '使用者已刪除');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Core\Models;
use Spatie\Permission\Models\Role as SpatieRole;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Role extends SpatieRole
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Modules\Core\Models;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
/**
* 租戶 Model
*
* 代表 ERP 系統中的每一個客戶公司 (如:小小冰室、酒水客戶等)
*
* 自訂屬性 (存在 data JSON 欄位中,可透過 $tenant->name 存取):
* - name: 租戶名稱 (: 小小冰室)
* - email: 聯絡信箱
* - is_active: 是否啟用
*/
class Tenant extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains;
/**
* 定義獨立欄位 ( data JSON)
* 只有 id 是獨立欄位,其他自訂屬性都存在 data JSON
*/
public static function getCustomColumns(): array
{
return [
'id',
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Modules\Core\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasRoles, LogsActivity;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'username',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$activity->properties = $activity->properties->merge([
'snapshot' => [
'name' => $this->name,
'username' => $this->username,
]
]);
}
}

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,173 @@
<?php
namespace App\Modules\Finance\Controllers;
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;
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

@@ -0,0 +1,178 @@
<?php
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\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

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

@@ -0,0 +1,56 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:categories,name',
]);
Category::create([
'name' => $validated['name'],
'is_active' => true,
]);
return redirect()->back()->with('success', '分類已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Category $category)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:categories,name,' . $category->id,
]);
$category->update($validated);
return redirect()->back()->with('success', '分類已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Category $category)
{
if ($category->products()->count() > 0) {
return redirect()->back()->with('error', '該分類下尚有商品,無法刪除');
}
$category->delete();
return redirect()->back()->with('success', '分類已刪除');
}
}

View File

@@ -0,0 +1,539 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
class InventoryController extends Controller
{
public function index(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{
$warehouse->load([
'inventories.product.category',
'inventories.product.baseUnit',
'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction'
]);
$allProducts = \App\Modules\Inventory\Models\Product::with('category')->get();
// 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'type' => $product->category?->name ?? '其他',
];
});
// 2. 從新表格讀取安全庫存設定 (商品-倉庫層級)
$safetyStockMap = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
->pluck('safety_stock', 'product_id')
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
// 3. 準備 inventories (批號分組)
$items = $warehouse->inventories()
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction'])
->get();
$inventories = $items->groupBy('product_id')->map(function ($batchItems) use ($safetyStockMap) {
$firstItem = $batchItems->first();
$product = $firstItem->product;
$totalQuantity = $batchItems->sum('quantity');
// 從獨立表格讀取安全庫存
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
// 計算狀態
$status = '正常';
if (!is_null($safetyStock)) {
if ($totalQuantity < $safetyStock) {
$status = '低於';
}
}
return [
'productId' => (string) $firstItem->product_id,
'productName' => $product?->name ?? '未知商品',
'productCode' => $product?->code ?? 'N/A',
'baseUnit' => $product?->baseUnit?->name ?? '個',
'totalQuantity' => (float) $totalQuantity,
'safetyStock' => $safetyStock,
'status' => $status,
'batches' => $batchItems->map(function ($inv) {
return [
'id' => (string) $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product?->name ?? '未知商品',
'productCode' => $inv->product?->code ?? 'N/A',
'unit' => $inv->product?->baseUnit?->name ?? '個',
'quantity' => (float) $inv->quantity,
'safetyStock' => null, // 批號層級不再有安全庫存
'status' => '正常',
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
];
})->values(),
];
})->values();
// 4. 準備 safetyStockSettings (從新表格讀取)
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
->with(['product.category'])
->get()
->map(function ($setting) {
return [
'id' => (string) $setting->id,
'warehouseId' => (string) $setting->warehouse_id,
'productId' => (string) $setting->product_id,
'productName' => $setting->product?->name ?? '未知商品',
'productType' => $setting->product?->category?->name ?? '其他',
'safetyStock' => (float) $setting->safety_stock,
'createdAt' => $setting->created_at->toIso8601String(),
'updatedAt' => $setting->updated_at->toIso8601String(),
];
});
return \Inertia\Inertia::render('Warehouse/Inventory', [
'warehouse' => $warehouse,
'inventories' => $inventories,
'safetyStockSettings' => $safetyStockSettings,
'availableProducts' => $availableProducts,
]);
}
public function create(\App\Modules\Inventory\Models\Warehouse $warehouse)
{
// 取得所有商品供前端選單使用
$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) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'code' => $product->code,
'baseUnit' => $product->baseUnit?->name ?? '個',
'largeUnit' => $product->largeUnit?->name, // 可能為 null
'conversionRate' => (float) $product->conversion_rate,
];
});
return \Inertia\Inertia::render('Warehouse/AddInventory', [
'warehouse' => $warehouse,
'products' => $products,
]);
}
public function store(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{
$validated = $request->validate([
'inboundDate' => 'required|date',
'reason' => 'required|string',
'notes' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.batchMode' => 'required|in:existing,new',
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
'items.*.expiryDate' => 'nullable|date',
]);
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) {
$inventory = null;
if ($item['batchMode'] === 'existing') {
// 模式 A選擇現有批號 (包含已刪除的也要能找回來累加)
$inventory = \App\Modules\Inventory\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']);
if ($inventory->trashed()) {
$inventory->restore();
}
} else {
// 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW';
$product = \App\Modules\Inventory\Models\Product::find($item['productId']);
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$validated['inboundDate']
);
// 同樣要檢查此批號是否已經存在 (即使模式是 new, 但可能撞到同一天同產地手動建立的)
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => $batchNumber
],
[
'quantity' => 0,
'arrival_date' => $validated['inboundDate'],
'expiry_date' => $item['expiryDate'] ?? null,
'origin_country' => $originCountry,
]
);
if ($inventory->trashed()) {
$inventory->restore();
}
}
$currentQty = $inventory->quantity;
$newQty = $currentQty + $item['quantity'];
$inventory->quantity = $newQty;
$inventory->save();
// 寫入異動紀錄
$inventory->transactions()->create([
'type' => '手動入庫',
'quantity' => $item['quantity'],
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
'actual_time' => $validated['inboundDate'],
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存記錄已儲存成功');
});
}
/**
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
*/
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\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->get()
->map(function ($inventory) {
return [
'inventoryId' => (string) $inventory->id,
'batchNumber' => $inventory->batch_number,
'originCountry' => $inventory->origin_country,
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
'quantity' => (float) $inventory->quantity,
];
});
// 計算下一個流水號
$product = \App\Modules\Inventory\Models\Product::find($productId);
$nextSequence = '01';
if ($product) {
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$arrivalDate
);
$nextSequence = substr($batchNumber, -2);
}
return response()->json([
'batches' => $batches,
'nextSequence' => $nextSequence
]);
}
public function edit(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
{
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
if (str_starts_with($inventoryId, 'mock-inv-')) {
return redirect()->back()->with('error', '無法編輯範例資料');
}
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
// 轉換為前端需要的格式
$inventoryData = [
'id' => (string) $inventory->id,
'warehouseId' => (string) $inventory->warehouse_id,
'productId' => (string) $inventory->product_id,
'productName' => $inventory->product?->name ?? '未知商品',
'quantity' => (float) $inventory->quantity,
'batchNumber' => $inventory->batch_number ?? '-',
'expiryDate' => $inventory->expiry_date ?? null,
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
'lastOutboundDate' => null,
];
// 整理異動紀錄
$transactions = $inventory->transactions->map(function ($tx) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
];
});
return \Inertia\Inertia::render('Warehouse/EditInventory', [
'warehouse' => $warehouse,
'inventory' => $inventoryData,
'transactions' => $transactions,
]);
}
public function update(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
{
// 若是 product ID (舊邏輯),先轉為 inventory
// 但新路由我們傳的是 inventory ID
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$inventory = \App\Modules\Inventory\Models\Inventory::find($inventoryId);
// 如果找不到 (可能是舊路由傳 product ID)
if (!$inventory) {
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
}
if (!$inventory) {
return redirect()->back()->with('error', '找不到庫存紀錄');
}
$validated = $request->validate([
'quantity' => 'required|numeric|min:0',
// 以下欄位改為 nullable支援新表單
'type' => 'nullable|string',
'operation' => 'nullable|in:add,subtract,set',
'reason' => 'nullable|string',
'notes' => 'nullable|string',
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
'batchNumber' => 'nullable|string',
'expiryDate' => 'nullable|date',
'lastInboundDate' => 'nullable|date',
'lastOutboundDate' => 'nullable|date',
]);
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
$currentQty = (float) $inventory->quantity;
$newQty = (float) $validated['quantity'];
// 判斷是否來自調整彈窗 (包含 operation 參數)
$isAdjustment = isset($validated['operation']);
$changeQty = 0;
if ($isAdjustment) {
switch ($validated['operation']) {
case 'add':
$changeQty = (float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'subtract':
$changeQty = -(float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'set':
$changeQty = $newQty - $currentQty;
break;
}
} else {
// 來自編輯頁面,直接 Set
$changeQty = $newQty - $currentQty;
}
// 更新庫存
$inventory->update(['quantity' => $newQty]);
// 異動類型映射
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
$typeMapping = [
'manual_adjustment' => '手動調整庫存',
'adjustment' => '盤點調整',
'purchase_in' => '採購進貨',
'sales_out' => '銷售出庫',
'return_in' => '退貨入庫',
'return_out' => '退貨出庫',
'transfer_in' => '撥補入庫',
'transfer_out' => '撥補出庫',
];
$chineseType = $typeMapping[$type] ?? $type;
// 如果是編輯頁面來的,且沒傳 type設為手動編輯
if (!$isAdjustment && !isset($validated['type'])) {
$chineseType = '手動編輯';
}
// 整理原因
$reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
if (isset($validated['notes'])) {
$reason .= ' - ' . $validated['notes'];
}
// 寫入異動紀錄
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
'type' => $chineseType,
'quantity' => $changeQty,
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $reason,
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
->with('success', '庫存資料已更新');
});
}
public function destroy(\App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
{
$inventory = \App\Modules\Inventory\Models\Inventory::findOrFail($inventoryId);
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
if ($inventory->quantity > 0) {
return redirect()->back()->with('error', '庫存數量大於 0無法刪除。請先進行出庫或調整。');
}
// 歸零異動 (因為已經限制為 0 才能刪,這段邏輯可以簡化,但為了保險起見,若有微小殘值仍可記錄歸零)
if (abs($inventory->quantity) > 0.0001) {
$inventory->transactions()->create([
'type' => '手動編輯',
'quantity' => -$inventory->quantity,
'balance_before' => $inventory->quantity,
'balance_after' => 0,
'reason' => '刪除庫存品項',
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
$inventory->delete();
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存品項已刪除');
}
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{
$inventoryId = $request->query('inventoryId');
$productId = $request->query('productId');
if ($productId) {
// 商品層級查詢
$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');
}, 'transactions.user'])
->get();
if ($inventories->isEmpty()) {
return redirect()->back()->with('error', '找不到該商品的庫存紀錄');
}
$firstInventory = $inventories->first();
$productName = $firstInventory->product?->name ?? '未知商品';
$productCode = $firstInventory->product?->code ?? 'N/A';
$currentTotalQuantity = $inventories->sum('quantity');
// 合併所有批號的交易紀錄
$allTransactions = collect();
foreach ($inventories as $inv) {
foreach ($inv->transactions as $tx) {
$allTransactions->push([
'raw_tx' => $tx,
'batchNumber' => $inv->batch_number ?? '-',
'sort_time' => $tx->actual_time ?? $tx->created_at,
]);
}
}
// 依時間倒序排序 (最新的在前面)
$sortedTransactions = $allTransactions->sort(function ($a, $b) {
// 先比時間 (Desc)
if ($a['sort_time'] != $b['sort_time']) {
return $a['sort_time'] > $b['sort_time'] ? -1 : 1;
}
// 再比 ID (Desc)
return $a['raw_tx']->id > $b['raw_tx']->id ? -1 : 1;
});
// 回推計算結餘
$runningBalance = $currentTotalQuantity;
$transactions = $sortedTransactions->map(function ($item) use (&$runningBalance) {
$tx = $item['raw_tx'];
// 本次異動後的結餘 = 當前推算的結餘
$balanceAfter = $runningBalance;
// 推算前一次的結餘 (減去本次的異動量:如果是入庫+10前一次就是-10)
$runningBalance = $runningBalance - $tx->quantity;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $balanceAfter, // 使用即時計算的商品總結餘
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
'batchNumber' => $item['batchNumber'],
];
})->values();
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => 'product-' . $productId,
'productName' => $productName,
'productCode' => $productCode,
'quantity' => (float) $currentTotalQuantity,
],
'transactions' => $transactions
]);
}
if ($inventoryId) {
// 單一批號查詢
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
$transactions = $inventory->transactions->map(function ($tx) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
];
});
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => (string) $inventory->id,
'productName' => $inventory->product?->name ?? '未知商品',
'productCode' => $inventory->product?->code ?? 'N/A',
'batchNumber' => $inventory->batch_number ?? '-',
'quantity' => (float) $inventory->quantity,
],
'transactions' => $transactions
]);
}
return redirect()->back()->with('error', '未提供查詢參數');
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Modules\Inventory\Controllers;
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;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request): Response
{
$query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']);
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('brand', 'like', "%{$search}%");
});
}
if ($request->filled('category_id') && $request->category_id !== 'all') {
$query->where('category_id', $request->category_id);
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 10;
}
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
// Define allowed sort fields to prevent SQL injection
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
}
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
$sortDirection = 'desc';
}
// Handle relation sorting (category name) separately if needed, or simple 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.
$query->orderBy('category_id', $sortDirection);
} else {
$query->orderBy($sortField, $sortDirection);
}
$products = $query->paginate($perPage)->withQueryString();
$categories = \App\Modules\Inventory\Models\Category::where('is_active', true)->get();
return Inertia::render('Product/Index', [
'products' => $products,
'categories' => $categories,
'units' => Unit::all(),
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code',
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id',
], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
'base_unit_id.required' => '基本庫存單位為必填',
'base_unit_id.exists' => '所選基本單位不存在',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001',
]);
$product = Product::create($validated);
return redirect()->back()->with('success', '商品已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id',
], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
'base_unit_id.required' => '基本庫存單位為必填',
'base_unit_id.exists' => '所選基本單位不存在',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001',
]);
$product->update($validated);
return redirect()->back()->with('success', '商品已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Product $product)
{
$product->delete();
return redirect()->back()->with('success', '商品已刪除');
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Modules\Inventory\Controllers;
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;
class SafetyStockController extends Controller
{
/**
* 顯示安全庫存設定頁面
*/
public function index(Warehouse $warehouse)
{
$allProducts = Product::with(['category', 'baseUnit'])->get();
// 準備可選商品列表
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'type' => $product->category ? $product->category->name : '其他',
'unit' => $product->baseUnit?->name ?? '個',
];
});
// 準備現有庫存列表 (用於庫存量對比)
$inventories = Inventory::where('warehouse_id', $warehouse->id)
->select('product_id', DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_id')
->get()
->map(function ($inv) {
return [
'productId' => (string) $inv->product_id,
'quantity' => (float) $inv->total_quantity,
];
});
// 準備安全庫存設定列表 (從新表格讀取)
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
->with(['product.category', 'product.baseUnit'])
->get()
->map(function ($setting) {
return [
'id' => (string) $setting->id,
'warehouseId' => (string) $setting->warehouse_id,
'productId' => (string) $setting->product_id,
'productName' => $setting->product->name,
'productType' => $setting->product->category ? $setting->product->category->name : '其他',
'safetyStock' => (float) $setting->safety_stock,
'unit' => $setting->product->baseUnit?->name ?? '個',
'updatedAt' => $setting->updated_at->toIso8601String(),
];
});
return Inertia::render('Warehouse/SafetyStockSettings', [
'warehouse' => $warehouse,
'safetyStockSettings' => $safetyStockSettings,
'inventories' => $inventories,
'availableProducts' => $availableProducts,
]);
}
/**
* 批量儲存安全庫存設定
*/
public function store(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'settings' => 'required|array|min:1',
'settings.*.productId' => 'required|exists:products,id',
'settings.*.quantity' => 'required|numeric|min:0',
]);
DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['settings'] as $item) {
WarehouseProductSafetyStock::updateOrCreate(
[
'warehouse_id' => $warehouse->id,
'product_id' => $item['productId'],
],
[
'safety_stock' => $item['quantity'],
]
);
}
});
return redirect()->back()->with('success', '安全庫存設定已更新');
}
/**
* 更新單筆安全庫存設定
*/
public function update(Request $request, Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
{
$validated = $request->validate([
'safetyStock' => 'required|numeric|min:0',
]);
$safetyStock->update([
'safety_stock' => $validated['safetyStock'],
]);
return redirect()->back()->with('success', '安全庫存已更新');
}
/**
* 刪除安全庫存設定
*/
public function destroy(Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
{
$safetyStock->delete();
return redirect()->back()->with('success', '安全庫存設定已移除');
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Modules\Inventory\Controllers;
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;
class TransferOrderController extends Controller
{
/**
* 儲存撥補單(建立調撥單並執行庫存轉移)
*/
public function store(Request $request)
{
$validated = $request->validate([
'sourceWarehouseId' => 'required|exists:warehouses,id',
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
'productId' => 'required|exists:products,id',
'quantity' => 'required|numeric|min:0.01',
'transferDate' => 'required|date',
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
'notes' => 'nullable|string',
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
]);
return DB::transaction(function () use ($validated) {
// 1. 檢查來源倉庫庫存
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
->where('product_id', $validated['productId'])
->first();
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
throw ValidationException::withMessages([
'quantity' => ['來源倉庫庫存不足'],
]);
}
// 2. 獲取或建立目標倉庫庫存
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $validated['targetWarehouseId'],
'product_id' => $validated['productId'],
],
[
'quantity' => 0,
]
);
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
// 3. 執行庫存轉移 (扣除來源)
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $validated['quantity'];
// 設定活動紀錄原因
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
$sourceInventory->update(['quantity' => $newSourceQty]);
// 記錄來源異動
$sourceInventory->transactions()->create([
'type' => '撥補出庫',
'quantity' => -$validated['quantity'],
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
// 4. 執行庫存轉移 (增加目標)
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $validated['quantity'];
// 設定活動紀錄原因
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
$targetInventory->update(['quantity' => $newTargetQty]);
// 記錄目標異動
$targetInventory->transactions()->create([
'type' => '撥補入庫',
'quantity' => $validated['quantity'],
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
});
}
/**
* 獲取特定倉庫的庫存列表 (API)
*/
public function getWarehouseInventories(Warehouse $warehouse)
{
$inventories = $warehouse->inventories()
->with(['product.baseUnit', 'product.category'])
->where('quantity', '>', 0) // 只回傳有庫存的
->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 ?? '個',
];
});
return response()->json($inventories);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Modules\Inventory\Controllers;
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)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:units,name',
'code' => 'nullable|string|max:50',
], [
'name.required' => '單位名稱為必填項目',
'name.unique' => '該單位名稱已存在',
'name.max' => '單位名稱不能超過 255 個字元',
'code.max' => '單位代碼不能超過 50 個字元',
]);
Unit::create($validated);
return redirect()->back()->with('success', '單位已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Unit $unit)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:units,name,' . $unit->id,
'code' => 'nullable|string|max:50',
], [
'name.required' => '單位名稱為必填項目',
'name.unique' => '該單位名稱已存在',
'name.max' => '單位名稱不能超過 255 個字元',
'code.max' => '單位代碼不能超過 50 個字元',
]);
$unit->update($validated);
return redirect()->back()->with('success', '單位已更新');
}
/**
* 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)
->exists();
if ($isUsed) {
return redirect()->back()->with('error', '該單位已被商品使用,無法刪除');
}
$unit->delete();
return redirect()->back()->with('success', '單位已刪除');
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Modules\Inventory\Models\Warehouse;
use Inertia\Inertia;
class WarehouseController extends Controller
{
public function index(Request $request)
{
$query = Warehouse::query();
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
}
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
->orderBy('created_at', 'desc')
->paginate(10)
->withQueryString();
return Inertia::render('Warehouse/Index', [
'warehouses' => $warehouses,
'filters' => $request->only(['search']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
// Auto-generate code
$prefix = 'WH';
$lastWarehouse = Warehouse::latest('id')->first();
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
$validated['code'] = $code;
Warehouse::create($validated);
return redirect()->back()->with('success', '倉庫已建立');
}
public function update(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$warehouse->update($validated);
return redirect()->back()->with('success', '倉庫資訊已更新');
}
public function destroy(Warehouse $warehouse)
{
// 檢查是否有相關聯的採購單
if ($warehouse->purchaseOrders()->exists()) {
return redirect()->back()->with('error', '無法刪除:該倉庫有相關聯的採購單,請先處理採購單。');
}
\Illuminate\Support\Facades\DB::transaction(function () use ($warehouse) {
// 刪除庫存異動紀錄 (透過庫存關聯)
foreach ($warehouse->inventories as $inventory) {
// 刪除該庫存的所有異動紀錄
$inventory->transactions()->delete();
}
// 刪除庫存項目
$warehouse->inventories()->delete();
// 刪除倉庫
$warehouse->delete();
});
return redirect()->back()->with('success', '倉庫及其庫存與紀錄已刪除');
}
}

View File

@@ -0,0 +1,40 @@
<?php
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'];
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
}

View File

@@ -0,0 +1,140 @@
<?php
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
{
/** @use HasFactory<\Database\Factories\InventoryFactory> */
use HasFactory;
use \Illuminate\Database\Eloquent\SoftDeletes;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'warehouse_id',
'product_id',
'quantity',
'location',
// 批號追溯欄位
'batch_number',
'box_number',
'origin_country',
'arrival_date',
'expiry_date',
'source_purchase_order_id',
'quality_status',
'quality_remark',
];
protected $casts = [
'arrival_date' => 'date:Y-m-d',
'expiry_date' => 'date:Y-m-d',
];
/**
* 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.
* @var string|null
*/
public $activityLogReason;
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$attributes = $properties['attributes'] ?? [];
$snapshot = $properties['snapshot'] ?? [];
// Always snapshot names for context, even if IDs didn't change
// $this refers to the Inventory model instance
$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;
}
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Product::class);
}
public function transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(InventoryTransaction::class);
}
public function lastOutgoingTransaction()
{
return $this->hasOne(InventoryTransaction::class)->ofMany([
'actual_time' => 'max',
'id' => 'max',
], function ($query) {
$query->where('quantity', '<', 0);
});
}
public function lastIncomingTransaction()
{
return $this->hasOne(InventoryTransaction::class)->ofMany([
'actual_time' => 'max',
'id' => 'max',
], function ($query) {
$query->where('quantity', '>', 0);
});
}
/**
* 來源採購單
*/
public function sourcePurchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(PurchaseOrder::class, 'source_purchase_order_id');
}
/**
* 產生批號
* 格式:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
*/
public static function generateBatchNumber(string $productCode, string $originCountry, string $arrivalDate): string
{
$dateFormatted = date('Ymd', strtotime($arrivalDate));
$prefix = "{$productCode}-{$originCountry}-{$dateFormatted}-";
// 加入 withTrashed() 確保流水號不會撞到已刪除的紀錄
$lastBatch = static::withTrashed()
->where('batch_number', 'like', "{$prefix}%")
->orderByDesc('batch_number')
->first();
if ($lastBatch) {
$lastNumber = (int) substr($lastBatch->batch_number, -2);
$nextNumber = str_pad($lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
return $prefix . $nextNumber;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Core\Models\User; // Cross-module Core dependency
class InventoryTransaction extends Model
{
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
use HasFactory;
protected $fillable = [
'inventory_id',
'type',
'quantity',
'balance_before',
'balance_after',
'reason',
'reference_type',
'reference_id',
'user_id',
'actual_time',
];
protected $casts = [
'actual_time' => 'datetime',
];
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Inventory::class);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
{
return $this->morphTo();
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
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
{
use HasFactory, LogsActivity, SoftDeletes;
protected $fillable = [
'code',
'name',
'category_id',
'brand',
'specification',
'base_unit_id',
'large_unit_id',
'conversion_rate',
'purchase_unit_id',
];
protected $casts = [
'conversion_rate' => 'decimal:4',
];
/**
* Get the category that owns the product.
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function baseUnit(): BelongsTo
{
return $this->belongsTo(Unit::class, 'base_unit_id');
}
public function largeUnit(): BelongsTo
{
return $this->belongsTo(Unit::class, 'large_unit_id');
}
public function purchaseUnit(): BelongsTo
{
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
{
return $this->hasMany(Inventory::class);
}
public function transactions(): HasMany
{
return $this->hasMany(InventoryTransaction::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'] ?? [];
// 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])) {
$unit = Unit::find($attributes[$field]);
$nameKey = str_replace('_id', '_name', $field);
$snapshot[$nameKey] = $unit ? $unit->name : null;
}
}
// Always snapshot self name for context (so logs always show "Cola")
$snapshot['name'] = $this->name;
$properties['attributes'] = $attributes;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Warehouse::class, 'inventories')
->withPivot(['quantity', 'safety_stock', 'location'])
->withTimestamps();
}
}

View File

@@ -0,0 +1,45 @@
<?php
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, LogsActivity;
protected $fillable = ['name', 'abbreviation'];
public function productsAsBase(): HasMany
{
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();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
}

View File

@@ -0,0 +1,57 @@
<?php
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
{
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
use HasFactory;
use \Spatie\Activitylog\Traits\LogsActivity;
protected $fillable = [
'code',
'name',
'address',
'description',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
{
return \Spatie\Activitylog\LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
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
{
return $this->belongsToMany(Product::class, 'inventories')
->withPivot(['quantity', 'safety_stock', 'location'])
->withTimestamps();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 倉庫-商品安全庫存設定
* 每個倉庫-商品組合只有一筆安全庫存設定
*/
class WarehouseProductSafetyStock extends Model
{
protected $table = 'warehouse_product_safety_stocks';
protected $fillable = [
'warehouse_id',
'product_id',
'safety_stock',
];
protected $casts = [
'safety_stock' => 'decimal:2',
];
/**
* 所屬倉庫
*/
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
/**
* 所屬商品
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

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,556 @@
<?php
namespace App\Modules\Procurement\Controllers;
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;
class PurchaseOrderController extends Controller
{
public function index(Request $request)
{
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
// Search
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhereHas('vendor', function($vq) use ($request) {
$vq->where('name', 'like', "%{$request->search}%");
});
});
}
// Filters
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
$query->where('warehouse_id', $request->warehouse_id);
}
// Date Range
if ($request->date_start) {
$query->whereDate('created_at', '>=', $request->date_start);
}
if ($request->date_end) {
$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'];
if (in_array($sortField, $allowedSortFields)) {
$query->orderBy($sortField, $sortDirection);
}
$perPage = $request->input('per_page', 10);
$orders = $query->paginate($perPage)->withQueryString();
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']),
]);
}
public function create()
{
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
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),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
return Inertia::render('PurchaseOrder/Create', [
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
'invoice_date' => 'nullable|date',
'invoice_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
'items.*.unitId' => 'nullable|exists:units,id',
'tax_amount' => 'nullable|numeric|min:0',
]);
try {
DB::beginTransaction();
// 生成單號YYYYMMDD001
$today = now()->format('Ymd');
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 3 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -3));
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
} else {
$sequence = '001';
}
$code = $today . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$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\Modules\Core\Models\User::first();
if (!$user) {
$user = \App\Modules\Core\Models\User::create([
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
]);
}
$userId = $user->id;
}
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
]);
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
}
}
public function show($id)
{
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product.baseUnit', 'items.product.largeUnit'])->findOrFail($id);
$order->items->transform(function ($item) use ($order) {
$product = $item->product;
if ($product) {
// 手動附加所有必要的屬性
$item->productId = (string) $product->id;
$item->productName = $product->name;
$item->base_unit_id = $product->base_unit_id;
$item->base_unit_name = $product->baseUnit?->name;
$item->large_unit_id = $product->large_unit_id;
$item->large_unit_name = $product->largeUnit?->name;
$item->purchase_unit_id = $product->purchase_unit_id;
$item->conversion_rate = (float) $product->conversion_rate;
// Fetch last price
$lastPrice = DB::table('product_vendor')
->where('vendor_id', $order->vendor_id)
->where('product_id', $product->id)
->value('last_price');
$item->previousPrice = (float) ($lastPrice ?? 0);
// 設定當前選中的單位 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;
});
return Inertia::render('PurchaseOrder/Show', [
'order' => $order
]);
}
public function edit($id)
{
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
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),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
// Transform items for frontend form
// Transform items for frontend form
$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;
});
return Inertia::render('PurchaseOrder/Create', [
'order' => $order,
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function update(Request $request, $id)
{
$order = PurchaseOrder::findOrFail($id);
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
'invoice_date' => 'nullable|date',
'invoice_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'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' => 'nullable|numeric|min:0',
'taxAmount' => 'nullable|numeric|min:0',
]);
try {
DB::beginTransaction();
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$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
$order->fill([
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'status' => $validated['status'],
'invoice_number' => $validated['invoice_number'] ?? null,
'invoice_date' => $validated['invoice_date'] ?? null,
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
// Capture attribute changes for manual logging
$dirty = $order->getDirty();
$oldAttributes = [];
$newAttributes = [];
foreach ($dirty as $key => $value) {
$oldAttributes[$key] = $order->getOriginal($key);
$newAttributes[$key] = $value;
}
// Save without triggering events (prevents duplicate log)
$order->saveQuietly();
// 2. Capture old items with product names for diffing
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
return [
'id' => $item->id,
'product_id' => $item->product_id,
'product_name' => $item->product?->name,
'quantity' => (float) $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $item->unit?->name,
'subtotal' => (float) $item->subtotal,
];
})->keyBy('product_id');
// Sync items (Original logic)
$order->items()->delete();
$newItemsData = [];
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$newItem = $order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
]);
$newItemsData[] = $newItem;
}
// 3. Calculate Item Diffs
$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,
'product_name' => $item->product?->name,
'quantity' => (float) $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $item->unit?->name,
'subtotal' => (float) $item->subtotal,
];
})->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'] ||
$oldItem['subtotal'] != $newItem['subtotal']
) {
$itemDiffs['updated'][] = [
'product_name' => $newItem['product_name'],
'old' => [
'quantity' => $oldItem['quantity'],
'unit_name' => $oldItem['unit_name'],
'subtotal' => $oldItem['subtotal'],
],
'new' => [
'quantity' => $newItem['quantity'],
'unit_name' => $newItem['unit_name'],
'subtotal' => $newItem['subtotal'],
]
];
}
}
}
// 4. Manually Log activity (Single Consolidated Log)
// Log if there are attribute changes OR item changes
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => $newAttributes,
'old' => $oldAttributes,
'items_diff' => $itemDiffs,
'snapshot' => [
'po_number' => $order->code,
'vendor_name' => $order->vendor?->name,
'warehouse_name' => $order->warehouse?->name,
'user_name' => $order->user?->name,
]
])
->log('updated');
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
}
}
public function destroy($id)
{
try {
DB::beginTransaction();
$order = PurchaseOrder::with(['items.product', 'items.unit'])->findOrFail($id);
// Capture items for logging
$items = $order->items->map(function ($item) {
return [
'product_name' => $item->product_name,
'quantity' => floatval($item->quantity),
'unit_name' => $item->unit_name,
'subtotal' => floatval($item->subtotal),
];
})->toArray();
// Manually log the deletion with items
activity()
->performedOn($order)
->causedBy(auth()->user())
->event('deleted')
->withProperties([
'attributes' => $order->getAttributes(),
'items_diff' => [
'added' => [],
'removed' => $items,
'updated' => [],
],
'snapshot' => [
'po_number' => $order->code,
'vendor_name' => $order->vendor?->name,
'warehouse_name' => $order->warehouse?->name,
'user_name' => $order->user?->name,
]
])
->log('deleted');
// Disable automatic logging for this operation
$order->disableLogging();
// Delete associated items first
$order->items()->delete();
$order->delete();
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\Vendor;
use Illuminate\Http\Request;
class VendorController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(\Illuminate\Http\Request $request): \Inertia\Response
{
$query = Vendor::query();
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('tax_id', 'like', "%{$search}%")
->orWhere('owner', 'like', "%{$search}%")
->orWhere('contact_name', 'like', "%{$search}%");
});
}
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
$allowedSorts = ['id', 'code', 'name', 'owner', 'contact_name', 'phone'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
}
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
$sortDirection = 'desc';
}
$perPage = $request->input('per_page', 10);
$vendors = $query->orderBy($sortField, $sortDirection)
->paginate($perPage)
->withQueryString();
return \Inertia\Inertia::render('Vendor/Index', [
'vendors' => $vendors,
'filters' => $request->only(['search', 'sort_field', 'sort_direction', 'per_page']),
]);
}
/**
* Display the specified resource.
*/
public function show(Vendor $vendor): \Inertia\Response
{
$vendor->load(['products.baseUnit', 'products.largeUnit']);
return \Inertia\Inertia::render('Vendor/Show', [
'vendor' => $vendor,
'products' => \App\Modules\Inventory\Models\Product::with('baseUnit')->get(),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(\Illuminate\Http\Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'short_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:8',
'owner' => 'nullable|string|max:255',
'contact_name' => 'nullable|string|max:255',
'tel' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'address' => 'nullable|string',
'remark' => 'nullable|string',
]);
// Auto-generate code
$prefix = 'V';
$lastVendor = Vendor::latest('id')->first();
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
$validated['code'] = $code;
Vendor::create($validated);
return redirect()->back()->with('success', '廠商已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(\Illuminate\Http\Request $request, Vendor $vendor)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'short_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:8',
'owner' => 'nullable|string|max:255',
'contact_name' => 'nullable|string|max:255',
'tel' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'address' => 'nullable|string',
'remark' => 'nullable|string',
]);
$vendor->update($validated);
return redirect()->back()->with('success', '廠商資料已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Vendor $vendor)
{
$vendor->delete();
return redirect()->back()->with('success', '廠商已刪除');
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\Vendor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class VendorProductController extends Controller
{
/**
* 新增供貨商品 (Attach)
*/
public function store(Request $request, Vendor $vendor)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'last_price' => 'nullable|numeric|min:0',
]);
// 檢查是否已存在
if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) {
return redirect()->back()->with('error', '該商品已在供貨清單中');
}
$vendor->products()->attach($validated['product_id'], [
'last_price' => $validated['last_price'] ?? null
]);
// 記錄操作
$product = \App\Modules\Inventory\Models\Product::find($validated['product_id']);
activity()
->performedOn($vendor)
->withProperties([
'attributes' => [
'product_name' => $product->name,
'last_price' => $validated['last_price'] ?? null,
],
'sub_subject' => '供貨商品',
'snapshot' => [
'name' => "{$vendor->name}-{$product->name}", // 顯示例如:台積電-紅糖
'vendor_name' => $vendor->name,
'product_name' => $product->name,
]
])
->event('created')
->log('新增供貨商品');
return redirect()->back()->with('success', '供貨商品已新增');
}
/**
* 更新供貨商品資訊 (Update Pivot)
*/
public function update(Request $request, Vendor $vendor, $productId)
{
$validated = $request->validate([
'last_price' => 'nullable|numeric|min:0',
]);
// 獲取舊價格
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
$vendor->products()->updateExistingPivot($productId, [
'last_price' => $validated['last_price'] ?? null
]);
// 記錄操作
$product = \App\Modules\Inventory\Models\Product::find($productId);
activity()
->performedOn($vendor)
->withProperties([
'old' => [
'last_price' => $old_price,
],
'attributes' => [
'last_price' => $validated['last_price'] ?? null,
],
'sub_subject' => '供貨商品',
'snapshot' => [
'name' => "{$vendor->name}-{$product->name}",
'vendor_name' => $vendor->name,
'product_name' => $product->name,
]
])
->event('updated')
->log('更新供貨商品價格');
return redirect()->back()->with('success', '供貨資訊已更新');
}
/**
* 移除供貨商品 (Detach)
*/
public function destroy(Vendor $vendor, $productId)
{
// 記錄操作 (需在 detach 前獲取資訊)
$product = \App\Modules\Inventory\Models\Product::find($productId);
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
$vendor->products()->detach($productId);
if ($product) {
activity()
->performedOn($vendor)
->withProperties([
'old' => [
'product_name' => $product->name,
'last_price' => $old_price,
],
'sub_subject' => '供貨商品',
'snapshot' => [
'name' => "{$vendor->name}-{$product->name}",
'vendor_name' => $vendor->name,
'product_name' => $product->name,
]
])
->event('deleted')
->log('移除供貨商品');
}
return redirect()->back()->with('success', '供貨商品已移除');
}
}

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

@@ -0,0 +1,55 @@
<?php
namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Inventory\Models\Product;
class Vendor extends Model
{
/** @use HasFactory<\Database\Factories\VendorFactory> */
use HasFactory, LogsActivity;
protected $fillable = [
'code',
'name',
'contact_person',
'email',
'phone',
'address',
'tax_id',
'payment_terms',
];
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Product::class)->withPivot('last_price')->withTimestamps();
}
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrder::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
$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

@@ -0,0 +1,386 @@
<?php
namespace App\Modules\Production\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Product;
use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
use App\Modules\Inventory\Models\Unit;
use App\Modules\Inventory\Models\Warehouse;
use 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

@@ -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');
});