diff --git a/app/Console/Commands/TenantsBootstrap.php b/app/Console/Commands/TenantsBootstrap.php new file mode 100644 index 0000000..1fd05d2 --- /dev/null +++ b/app/Console/Commands/TenantsBootstrap.php @@ -0,0 +1,46 @@ +option('recipe'); + $tenantId = $this->option('tenant_id'); + + if ($this->option('all')) { + $ids = DB::table('tenants')->pluck('id')->all(); + } elseif ($tenantId) { + $ids = [(int) $tenantId]; + } else { + $this->error('Provide --tenant_id=ID or --all'); + return self::FAILURE; + } + + if (empty($ids)) { + $this->warn('No tenant to bootstrap.'); + return self::SUCCESS; + } + + foreach ($ids as $id) { + $this->line("Bootstrapping tenant {$id} with recipe {$recipe} …"); + $svc->bootstrap((int) $id, $recipe); + $this->info("Done tenant {$id}"); + } + + return self::SUCCESS; + } +} diff --git a/app/Observers/TenantObserver.php b/app/Observers/TenantObserver.php new file mode 100644 index 0000000..798c243 --- /dev/null +++ b/app/Observers/TenantObserver.php @@ -0,0 +1,14 @@ +bootstrap((int)$tenant->id, 'STANDARD'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bd2c6dc..fa4461e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\Tenants\Tenant; +use App\Observers\TenantObserver; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\DB; @@ -42,5 +44,8 @@ public function boot(): void // 메뉴 생성/수정/삭제 ↔ 권한 자동 동기화 Menu::observe(MenuObserver::class); + + // 테넌트 생성 시 자동 실행 + Tenant::observe(TenantObserver::class); } } diff --git a/app/Services/TenantBootstrap/Contracts/TenantBootstrapStep.php b/app/Services/TenantBootstrap/Contracts/TenantBootstrapStep.php new file mode 100644 index 0000000..1541b12 --- /dev/null +++ b/app/Services/TenantBootstrap/Contracts/TenantBootstrapStep.php @@ -0,0 +1,16 @@ + [ + new CapabilityProfilesStep(), + new CategoriesStep(), + ], + default => [ // STANDARD + new CapabilityProfilesStep(), + new CategoriesStep(), + new MenusStep(), + new SettingsStep(), + ], + }; + } + + public function version(string $recipe = 'STANDARD'): int + { + return match ($recipe) { + 'LITE' => 1, + default => 1, + }; + } +} diff --git a/app/Services/TenantBootstrap/Steps/CapabilityProfilesStep.php b/app/Services/TenantBootstrap/Steps/CapabilityProfilesStep.php new file mode 100644 index 0000000..e08cb0f --- /dev/null +++ b/app/Services/TenantBootstrap/Steps/CapabilityProfilesStep.php @@ -0,0 +1,32 @@ +1,'is_purchasable'=>0,'is_producible'=>1,'is_stock_managed'=>1]], + ['SUB_ASSEMBLY','서브어셈블리', ['is_sellable'=>0,'is_purchasable'=>0,'is_producible'=>1,'is_stock_managed'=>1]], + ['PURCHASED_PART','구매부품', ['is_sellable'=>0,'is_purchasable'=>1,'is_producible'=>0,'is_stock_managed'=>1]], + ['PHANTOM','팬텀(가상)', ['is_sellable'=>0,'is_purchasable'=>0,'is_producible'=>1,'is_stock_managed'=>0]], + ]; + foreach ($profiles as [$code,$name,$attrs]) { + DB::table('common_codes')->updateOrInsert( + ['tenant_id'=>$tenantId,'code_group'=>'capability_profile','code'=>$code], + [ + 'name'=>$name, + 'attributes'=>json_encode($attrs, JSON_UNESCAPED_UNICODE), + 'is_active'=>1, 'sort_order'=>0, + 'description'=>'기본 프로필' + ] + ); + } + } +} diff --git a/app/Services/TenantBootstrap/Steps/CategoriesStep.php b/app/Services/TenantBootstrap/Steps/CategoriesStep.php new file mode 100644 index 0000000..b8bdc8c --- /dev/null +++ b/app/Services/TenantBootstrap/Steps/CategoriesStep.php @@ -0,0 +1,33 @@ +where([ + 'tenant_id'=>$tenantId, 'code_group'=>'product', 'code'=>'DEFAULT' + ])->exists(); + + if (!$exists) { + DB::table('categories')->insert([ + 'tenant_id'=>$tenantId, + 'parent_id'=>null, + 'code_group'=>'product', + 'code'=>'DEFAULT', + 'name'=>'기본 카테고리', + 'profile_code'=>'FINISHED_GOOD', + 'is_active'=>1, + 'sort_order'=>1, + 'created_at'=>now(), + 'updated_at'=>now(), + ]); + } + } +} diff --git a/app/Services/TenantBootstrap/Steps/MenusStep.php b/app/Services/TenantBootstrap/Steps/MenusStep.php new file mode 100644 index 0000000..c196b4c --- /dev/null +++ b/app/Services/TenantBootstrap/Steps/MenusStep.php @@ -0,0 +1,28 @@ +hasTable('menus')) return; + + $exists = DB::table('menus')->where(['tenant_id'=>$tenantId, 'code'=>'ROOT'])->exists(); + if (!$exists) { + DB::table('menus')->insert([ + 'tenant_id'=>$tenantId, + 'name'=>'메인', + 'parent_id'=>null, 'sort_order'=>0, + 'is_active'=>1, 'created_at'=>now(), 'updated_at'=>now(), + ]); + // 필요 시 하위 기본 메뉴들 추가… + } + } +} diff --git a/app/Services/TenantBootstrap/Steps/SettingsStep.php b/app/Services/TenantBootstrap/Steps/SettingsStep.php new file mode 100644 index 0000000..95f8097 --- /dev/null +++ b/app/Services/TenantBootstrap/Steps/SettingsStep.php @@ -0,0 +1,28 @@ +hasTable('settings')) return; + + $pairs = [ + ['general.company_name', '회사명 미설정'], + ['ui.theme', 'light'], + ['inventory.auto_reserve', '0'], + ]; + foreach ($pairs as [$key,$val]) { + DB::table('settings')->updateOrInsert( + ['tenant_id'=>$tenantId, 'key'=>$key], + ['value'=>$val, 'updated_at'=>now(), 'created_at'=>now()] + ); + } + } +} diff --git a/app/Services/TenantBootstrap/Support/TenantBootstrapLogger.php b/app/Services/TenantBootstrap/Support/TenantBootstrapLogger.php new file mode 100644 index 0000000..1cc7857 --- /dev/null +++ b/app/Services/TenantBootstrap/Support/TenantBootstrapLogger.php @@ -0,0 +1,16 @@ +messages[] = ['level'=>'info', 'msg'=>$msg, 'ctx'=>$ctx, 'ts'=>now()->toDateTimeString()]; + } + public function error(string $msg, array $ctx = []): void { + $this->messages[] = ['level'=>'error', 'msg'=>$msg, 'ctx'=>$ctx, 'ts'=>now()->toDateTimeString()]; + } + public function dump(): array { return $this->messages; } +} diff --git a/app/Services/TenantBootstrapper.php b/app/Services/TenantBootstrapper.php new file mode 100644 index 0000000..c461d3d --- /dev/null +++ b/app/Services/TenantBootstrapper.php @@ -0,0 +1,56 @@ +registry->steps($recipe); + $ver = $this->registry->version($recipe); + + DB::transaction(function () use ($tenantId, $recipe, $steps, $ver, $logger) { + $runId = DB::table('tenant_bootstrap_runs')->insertGetId([ + 'tenant_id'=>$tenantId, 'recipe'=>$recipe, 'recipe_version'=>$ver, + 'status'=>'RUNNING', 'steps'=>json_encode([]), 'log'=>json_encode([]), + 'created_at'=>now(), 'updated_at'=>now(), + ]); + + $done = []; + try { + foreach ($steps as $step) { + /** @var \App\Services\TenantBootstrap\Contracts\TenantBootstrapStep $step */ + $logger->info('start step', ['key'=>$step->key()]); + $step->run($tenantId); + $done[] = $step->key(); + $logger->info('end step', ['key'=>$step->key()]); + DB::table('tenant_bootstrap_runs')->where('id',$runId)->update([ + 'steps'=>json_encode($done), + 'log'=>json_encode($logger->dump(), JSON_UNESCAPED_UNICODE), + 'updated_at'=>now(), + ]); + } + DB::table('tenant_bootstrap_runs')->where('id',$runId)->update([ + 'status'=>'SUCCESS', + 'log'=>json_encode($logger->dump(), JSON_UNESCAPED_UNICODE), + 'updated_at'=>now(), + ]); + } catch (\Throwable $e) { + $logger->error('step failed', ['exception'=>$e->getMessage()]); + DB::table('tenant_bootstrap_runs')->where('id',$runId)->update([ + 'status'=>'FAILED', + 'log'=>json_encode($logger->dump(), JSON_UNESCAPED_UNICODE), + 'updated_at'=>now(), + ]); + throw $e; + } + }); + } +} diff --git a/database/migrations/2025_08_22_001600_create_tenant_bootstrap_runs_table.php b/database/migrations/2025_08_22_001600_create_tenant_bootstrap_runs_table.php new file mode 100644 index 0000000..931fbf6 --- /dev/null +++ b/database/migrations/2025_08_22_001600_create_tenant_bootstrap_runs_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $t->unsignedBigInteger('tenant_id'); + $t->string('recipe', 50)->default('STANDARD'); + $t->string('status', 20)->default('PENDING'); // PENDING|RUNNING|SUCCESS|FAILED + $t->json('steps')->nullable(); // 실행된 스텝 목록 + $t->json('log')->nullable(); // 메시지/에러 요약 + $t->unsignedSmallInteger('recipe_version')->default(1); + $t->timestamps(); + + $t->index(['tenant_id', 'recipe']); + }); + } + public function down(): void { + Schema::dropIfExists('tenant_bootstrap_runs'); + } +};