diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php index 24f1b59..e2660d8 100644 --- a/app/Http/Controllers/Admin/PermissionController.php +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -577,6 +577,7 @@ class PermissionController extends Controller 'menu.machines', 'menu.warehouses', 'menu.sales', + 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.special-permission', diff --git a/database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php b/database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php index a3e8cf9..96fc430 100644 --- a/database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php +++ b/database/migrations/2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php @@ -12,10 +12,9 @@ return new class extends Migration */ public function up(): void { - Schema::table('machine_stock_movements', function (Blueprint $table) { - // 在 MySQL 中,更新 ENUM 建議使用 DB::statement 以確保相容性 + if (Schema::getConnection()->getDriverName() !== 'sqlite') { DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback', 'sale', 'decommission') NOT NULL"); - }); + } } /** @@ -23,8 +22,8 @@ return new class extends Migration */ public function down(): void { - Schema::table('machine_stock_movements', function (Blueprint $table) { + if (Schema::getConnection()->getDriverName() !== 'sqlite') { DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback') NOT NULL"); - }); + } } }; diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index 6e81443..b5b04ba 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -40,6 +40,10 @@ class RoleSeeder extends Seeder 'menu.sales.pass-codes', 'menu.sales.store-gifts', 'menu.analysis', + 'menu.analysis.change-stock', + 'menu.analysis.machine-reports', + 'menu.analysis.product-reports', + 'menu.analysis.survey-analysis', 'menu.audit', 'menu.data-config', 'menu.data-config.products', @@ -102,6 +106,7 @@ class RoleSeeder extends Seeder 'menu.sales.pass-codes', 'menu.sales.store-gifts', 'menu.analysis', + 'menu.analysis.product-reports', 'menu.audit', 'menu.data-config', 'menu.data-config.products', diff --git a/lang/en.json b/lang/en.json index 9a6f994..73f108a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1886,6 +1886,10 @@ "machines": "Machine Management", "members": "Member Management", "menu.analysis": "Data Analysis", + "menu.analysis.change-stock": "Change Stock", + "menu.analysis.machine-reports": "Machine Reports", + "menu.analysis.product-reports": "Product Reports", + "menu.analysis.survey-analysis": "Survey Analysis", "menu.app": "APP Maintenance", "menu.audit": "Audit Management", "menu.basic": "Basic Settings", diff --git a/lang/ja.json b/lang/ja.json index 7423027..9ecc431 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -1886,6 +1886,10 @@ "machines": "機器管理", "members": "会員管理", "menu.analysis": "データ分析", + "menu.analysis.change-stock": "両替在庫", + "menu.analysis.machine-reports": "機台レポート", + "menu.analysis.product-reports": "商品レポート", + "menu.analysis.survey-analysis": "アンケート分析", "menu.app": "APP 運用保守", "menu.audit": "監査管理", "menu.basic": "基本設定", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index e89ff1b..30da24e 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -1921,6 +1921,10 @@ "member_price": "會員價", "members": "會員管理", "menu.analysis": "數據分析", + "menu.analysis.change-stock": "零錢庫存", + "menu.analysis.machine-reports": "機台報表", + "menu.analysis.product-reports": "商品報表", + "menu.analysis.survey-analysis": "問卷分析", "menu.app": "APP 運維", "menu.audit": "審核管理", "menu.basic": "基本設定", diff --git a/resources/views/admin/_shared/role-list.blade.php b/resources/views/admin/_shared/role-list.blade.php index fa0fdae..e085182 100644 --- a/resources/views/admin/_shared/role-list.blade.php +++ b/resources/views/admin/_shared/role-list.blade.php @@ -98,7 +98,7 @@
@php - $activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions']; + $activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions']; $displayPermissions = $role->permissions->filter(function($p) use ($activeModules) { if ($p->name === 'menu.data-config.sub-account-roles') return false; if (str_starts_with($p->name, 'menu.')) { diff --git a/resources/views/admin/data-config/partials/role-card.blade.php b/resources/views/admin/data-config/partials/role-card.blade.php index f89dcff..24ca5df 100644 --- a/resources/views/admin/data-config/partials/role-card.blade.php +++ b/resources/views/admin/data-config/partials/role-card.blade.php @@ -30,7 +30,7 @@

{{ __('Permissions') }}

@php - $activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions']; + $activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions']; $displayPermissions = $role->permissions->filter(function($p) use ($activeModules) { if ($p->name === 'menu.data-config.sub-account-roles') return false; if (str_starts_with($p->name, 'menu.')) { diff --git a/resources/views/layouts/partials/sidebar-menu.blade.php b/resources/views/layouts/partials/sidebar-menu.blade.php index 8bf6fec..b487760 100644 --- a/resources/views/layouts/partials/sidebar-menu.blade.php +++ b/resources/views/layouts/partials/sidebar-menu.blade.php @@ -234,22 +234,33 @@
diff --git a/routes/web.php b/routes/web.php index 3a2d420..d844b06 100644 --- a/routes/web.php +++ b/routes/web.php @@ -146,10 +146,10 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name( // 7. 分析管理 Route::prefix('analysis')->name('analysis.')->group(function () { - Route::get('/change-stock', [App\Http\Controllers\Admin\AnalysisController::class, 'changeStock'])->name('change-stock'); - Route::get('/machine-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'machineReports'])->name('machine-reports'); - Route::get('/product-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'productReports'])->name('product-reports'); - Route::get('/survey-analysis', [App\Http\Controllers\Admin\AnalysisController::class, 'surveyAnalysis'])->name('survey-analysis'); + Route::get('/change-stock', [App\Http\Controllers\Admin\AnalysisController::class, 'changeStock'])->name('change-stock')->middleware('can:menu.analysis.change-stock'); + Route::get('/machine-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'machineReports'])->name('machine-reports')->middleware('can:menu.analysis.machine-reports'); + Route::get('/product-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'productReports'])->name('product-reports')->middleware('can:menu.analysis.product-reports'); + Route::get('/survey-analysis', [App\Http\Controllers\Admin\AnalysisController::class, 'surveyAnalysis'])->name('survey-analysis')->middleware('can:menu.analysis.survey-analysis'); }); // 8. 稽核管理 diff --git a/tests/Feature/Admin/AnalysisPermissionTest.php b/tests/Feature/Admin/AnalysisPermissionTest.php new file mode 100644 index 0000000..b52eaa7 --- /dev/null +++ b/tests/Feature/Admin/AnalysisPermissionTest.php @@ -0,0 +1,129 @@ +artisan('db:seed', ['--class' => 'RoleSeeder']); + + // 2. 建立測試公司 + $this->company = Company::create([ + 'name' => 'Test Company', + 'code' => 'TEST', + 'status' => 1, + ]); + + // 註冊 SQLite 相容的 DATE_FORMAT 函數 + if (\Illuminate\Support\Facades\Schema::getConnection()->getDriverName() === 'sqlite') { + $pdo = \Illuminate\Support\Facades\Schema::getConnection()->getPdo(); + $pdo->sqliteCreateFunction('DATE_FORMAT', function ($date, $format) { + if (empty($date)) return null; + $phpFormat = str_replace( + ['%Y', '%m', '%d', '%H', '%i', '%s'], + ['Y', 'm', 'd', 'H', 'i', 's'], + $format + ); + return date($phpFormat, strtotime($date)); + }, 2); + } + + // 3. 建立 Super Admin 帳號 + $this->superAdmin = User::create([ + 'name' => 'Super Admin User', + 'username' => 'superadmin_test', + 'email' => 'superadmin@example.com', + 'password' => bcrypt('password'), + 'company_id' => null, + ]); + $this->superAdmin->assignRole('super-admin'); + + // 4. 建立租戶管理員帳號,並克隆其「管理員」角色 + // 我們的 RoleSeeder 會將 'menu.analysis.product-reports' 賦予 tenantAdmin 角色範本 + // 租戶的「管理員」將繼承該範本的權限。 + $this->tenantAdmin = User::create([ + 'name' => 'Tenant Admin User', + 'username' => 'tenantadmin_test', + 'email' => 'tenantadmin@example.com', + 'password' => bcrypt('password'), + 'company_id' => $this->company->id, + ]); + + // 克隆 RoleSeeder 建立的 tenantAdmin 角色權限至該租戶下命名為「管理員」 + $tenantAdminTemplate = Role::where('name', '客戶管理員角色模板')->first(); + $this->assertNotNull($tenantAdminTemplate); + + $tenantRole = Role::create([ + 'name' => '管理員', + 'company_id' => $this->company->id, + 'guard_name' => 'web', + 'is_system' => false, + ]); + + // 同步權限 + $tenantRole->syncPermissions($tenantAdminTemplate->permissions); + $this->tenantAdmin->assignRole($tenantRole); + } + + /** + * 測試超級管理員可以訪問所有的分析管理子路由 + */ + public function test_super_admin_can_access_all_analysis_routes() + { + $this->actingAs($this->superAdmin); + + $routes = [ + 'admin.analysis.change-stock', + 'admin.analysis.machine-reports', + 'admin.analysis.product-reports', + 'admin.analysis.survey-analysis', + ]; + + foreach ($routes as $route) { + $response = $this->get(route($route)); + // 應成功訪問 (由於 Controller 實作可能回傳檢視或 200) + $response->assertStatus(200); + } + } + + /** + * 測試租戶管理員只能訪問商品報表,其他 3 個路由會回傳 403 越權錯誤 + */ + public function test_tenant_admin_can_only_access_product_reports() + { + $this->actingAs($this->tenantAdmin); + + // 1. 可以正常訪問商品報表 + $response = $this->get(route('admin.analysis.product-reports')); + $response->assertStatus(200); + + // 2. 其他 3 個路由皆應回傳 403 Forbidden 越權錯誤 + $forbiddenRoutes = [ + 'admin.analysis.change-stock', + 'admin.analysis.machine-reports', + 'admin.analysis.survey-analysis', + ]; + + foreach ($forbiddenRoutes as $route) { + $response = $this->get(route($route)); + $response->assertStatus(403); + } + } +}