[PROMOTE] 晉升 dev 變更至 demo 環境

1. [FEAT] 新增分析管理子項目的角色權限配置與路由選單防護,限制租戶管理員僅擁有「商品報表」權限。
2. [FIX] 補齊廣告、機台管理等翻譯文字,修復多語系標記換行問題。
3. [STYLE] 修復歷史 migration 的 SQLite 相容性,新增 AnalysisPermissionTest 自動化 Feature 測試並全數綠燈通過。
This commit is contained in:
sky121113 2026-05-18 17:26:05 +08:00
commit 8725c276cb
17 changed files with 186 additions and 35 deletions

View File

@ -577,6 +577,7 @@ class PermissionController extends Controller
'menu.machines',
'menu.warehouses',
'menu.sales',
'menu.analysis',
'menu.data-config',
'menu.remote',
'menu.special-permission',

View File

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

View File

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

View File

@ -1091,6 +1091,7 @@
"Order has been cancelled": "Order has been cancelled",
"Order is now in delivery": "Order is now in delivery",
"Order prepared successfully, stock deducted": "Order prepared successfully, stock deducted",
"Order updated successfully.": "Order updated successfully.",
"Orders": "Orders",
"Orders per hour for selected date": "Orders per hour for selected date",
"Original": "Original",
@ -1607,6 +1608,7 @@
"TapPay Integration Settings Description": "TapPay Integration Settings Description",
"Target": "Target",
"Target Machine": "Target Machine",
"Target Position": "Target Position",
"Target Warehouse": "Target Warehouse",
"Tax ID": "Tax ID",
"Tax ID (Optional)": "Tax ID (Optional)",
@ -1884,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",

View File

@ -1091,6 +1091,7 @@
"Order has been cancelled": "注文がキャンセルされました",
"Order is now in delivery": "注文は配送中です",
"Order prepared successfully, stock deducted": "注文の準備が成功し、在庫が差し引かれました",
"Order updated successfully.": "表示順を更新しました。",
"Orders": "購入注文",
"Orders per hour for selected date": "選択した日付の1時間あたりの注文数",
"Original": "元の",
@ -1607,6 +1608,7 @@
"TapPay Integration Settings Description": "TapPay 決済連携設定",
"Target": "対象",
"Target Machine": "対象機器",
"Target Position": "ターゲット位置",
"Target Warehouse": "対象倉庫",
"Tax ID": "統一企業番号",
"Tax ID (Optional)": "統一企業番号 (任意)",
@ -1884,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": "基本設定",

View File

@ -1110,6 +1110,7 @@
"Order has been cancelled": "訂單已取消",
"Order is now in delivery": "訂單配送中",
"Order prepared successfully, stock deducted": "訂單已成功備貨,庫存已扣除",
"Order updated successfully.": "順序更新成功。",
"Orders": "購買單",
"Orders per hour for selected date": "當日各小時訂單筆數",
"Original": "原始",
@ -1629,6 +1630,7 @@
"Target": "目標",
"Target Item": "操作目標",
"Target Machine": "目標機台",
"Target Position": "投放位置",
"Target Warehouse": "目標倉庫",
"Tax ID": "統一編號",
"Tax ID (Optional)": "統一編號 (選填)",
@ -1919,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": "基本設定",

View File

@ -98,7 +98,7 @@
<td class="px-6 py-6" width="30%">
<div class="flex flex-wrap gap-1 max-w-xs">
@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.')) {

View File

@ -422,8 +422,7 @@
<div
class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div class="md:col-span-1">
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Tax
System') }}</h4>
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Tax System') }}</h4>
</div>
<div class="md:col-span-3 flex flex-wrap gap-x-8 gap-y-4">
<label class="flex items-center gap-3 cursor-pointer group">
@ -446,8 +445,7 @@
<div
class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div class="md:col-span-1">
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Card
Machine System') }}</h4>
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Card Machine System') }}</h4>
</div>
<div class="md:col-span-3 flex flex-wrap gap-x-8 gap-y-4">
<label class="flex items-center gap-3 cursor-pointer group">
@ -968,8 +966,7 @@
<div
class="px-6 py-5 md:px-8 md:py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
<div>
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine
Images') }}</h2>
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine Images') }}</h2>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-1"
x-text="currentMachine?.name"></p>
</div>
@ -1124,8 +1121,7 @@
<div
class="px-8 py-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Maintenance
QR') }}</h3>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Maintenance QR') }}</h3>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1"
x-text="maintenanceQrMachineName"></p>
</div>
@ -1222,8 +1218,7 @@
</section>
</template>
<section class="space-y-6">
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware &
Network') }}</h3>
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware & Network') }}</h3>
<div class="grid grid-cols-1 gap-4">
<div
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">

View File

@ -30,7 +30,7 @@
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Permissions') }}</p>
@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.')) {

View File

@ -10,8 +10,7 @@
@csrf
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-400">{{ __('Machine
Name') }}</label>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-400">{{ __('Machine Name') }}</label>
<input type="text" name="name" id="name"
class="mt-1 block w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-2 px-3 text-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
required>

View File

@ -390,8 +390,7 @@
<path d="M15 15l6 6m-6-6l-6-6m6 6l6-6m-6 6l-6 6" />
</svg>
</div>
<p class="text-xs font-bold text-slate-500 uppercase tracking-widest">{{ __('No matching
machines') }}</p>
<p class="text-xs font-bold text-slate-500 uppercase tracking-widest">{{ __('No matching machines') }}</p>
</div>
</template>
</div>
@ -505,10 +504,8 @@
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.5l7 7V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-xl font-black text-slate-800 dark:text-slate-200 tracking-tight">{{ __('No Machine
Selected') }}</h3>
<p class="text-sm font-bold text-slate-400 uppercase tracking-widest mt-2">{{ __('Select an asset
from the left to start analysis') }}</p>
<h3 class="text-xl font-black text-slate-800 dark:text-slate-200 tracking-tight">{{ __('No Machine Selected') }}</h3>
<p class="text-sm font-bold text-slate-400 uppercase tracking-widest mt-2">{{ __('Select an asset from the left to start analysis') }}</p>
</div>
</template>
</div>

View File

@ -169,8 +169,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
<!-- Will be hydrated by JS -->
</div>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest px-1">{{ __('Type
to search or leave blank for system defaults.') }}</p>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest px-1">{{ __('Type to search or leave blank for system defaults.') }}</p>
</div>
@endif

View File

@ -288,8 +288,7 @@
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Creation
Time') }}</p>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Creation Time') }}</p>
<div class="flex flex-col">
<span class="text-sm font-bold text-slate-700 dark:text-slate-200">{{
$item->created_at->format('Y-m-d') }}</span>

View File

@ -536,8 +536,7 @@
</div>
<div
class="px-7 py-4 rounded-[1.75rem] bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[120px]">
<span class="text-[10px] font-black text-rose-500 uppercase tracking-widest mb-1">{{ __('Low
Stock') }}</span>
<span class="text-[10px] font-black text-rose-500 uppercase tracking-widest mb-1">{{ __('Low Stock') }}</span>
<span class="text-3xl font-black text-rose-600"
x-text="slots.filter(s => s != null && s.stock <= 5).length"></span>
</div>

View File

@ -234,22 +234,33 @@
</button>
<div x-show="open && !sidebarCollapsed" x-collapse>
<ul class="luxury-submenu" data-sidebar-sub>
@can('menu.analysis.change-stock')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.change-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.change-stock') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="16" cy="16" r="6"/></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Change Stock') }}</span>
</a></li>
@endcan
@can('menu.analysis.machine-reports')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.machine-reports') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.machine-reports') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /><path d="M6 12l3-3 3 3 6-6" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Machine Reports') }}</span>
</a></li>
@endcan
@can('menu.analysis.product-reports')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.product-reports') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.product-reports') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" /><line x1="3" y1="6" x2="21" y2="6" /><path d="M16 10a4 4 0 0 1-8 0" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Product Reports') }}</span>
</a></li>
@endcan
@can('menu.analysis.survey-analysis')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.survey-analysis') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.survey-analysis') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" /><rect x="9" y="3" width="6" height="4" rx="1" ry="1" /><path d="M9 14l2 2 4-4" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Survey Analysis') }}</span>
</a></li>
@endcan
</ul>
</div>
</li>

View File

@ -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. 稽核管理

View File

@ -0,0 +1,129 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\User;
use App\Models\System\Role;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AnalysisPermissionTest extends TestCase
{
use RefreshDatabase;
protected $company;
protected $superAdmin;
protected $tenantAdmin;
protected function setUp(): void
{
parent::setUp();
// 1. 運行 RoleSeeder 以確保所有權限與角色都被播種
$this->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);
}
}
}