feat: [demo] Phase 4 고도화 — 분석 API, 비활성 알림, 통계 시딩
- DemoAnalyticsService: 전환율 퍼널, 파트너 성과, 활동 현황, 대시보드 요약 - DemoAnalyticsController: 분석 API 4개 엔드포인트 - CheckDemoInactiveCommand: 7일 비활성 데모 테넌트 탐지 및 로그 알림 - ManufacturingPresetSeeder: sam_stat DB에 90일 매출/생산 통계 시딩 - 라우트: demo-analytics prefix 4개 GET 엔드포인트 등록 - 스케줄러: demo:check-inactive 매일 09:30 실행
This commit is contained in:
103
app/Console/Commands/CheckDemoInactiveCommand.php
Normal file
103
app/Console/Commands/CheckDemoInactiveCommand.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 비활성 알림 커맨드
|
||||
*
|
||||
* - 7일 이상 활동 없는 데모 테넌트 탐지
|
||||
* - 파트너에게 후속 조치 알림 로그
|
||||
*
|
||||
* 기존 코드 영향 없음: DEMO 테넌트만 대상
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class CheckDemoInactiveCommand extends Command
|
||||
{
|
||||
protected $signature = 'demo:check-inactive
|
||||
{--days=7 : 비활성 기준 일수}';
|
||||
|
||||
protected $description = '데모 테넌트 비활성 알림 (활동 없는 테넌트 탐지)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$thresholdDays = (int) $this->option('days');
|
||||
|
||||
$demos = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES)
|
||||
->where('tenant_st_code', '!=', 'expired')
|
||||
->get();
|
||||
|
||||
if ($demos->isEmpty()) {
|
||||
$this->info('활성 데모 테넌트 없음');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$inactiveCount = 0;
|
||||
|
||||
foreach ($demos as $tenant) {
|
||||
$lastActivity = $this->getLastActivity($tenant->id);
|
||||
|
||||
if (! $lastActivity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$daysSince = (int) now()->diffInDays($lastActivity);
|
||||
|
||||
if ($daysSince < $thresholdDays) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$inactiveCount++;
|
||||
$this->line(" - [{$tenant->id}] {$tenant->company_name} ({$daysSince}일 비활성)");
|
||||
|
||||
Log::warning('데모 테넌트 비활성 알림', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
'tenant_type' => $tenant->tenant_type,
|
||||
'days_inactive' => $daysSince,
|
||||
'last_activity' => $lastActivity->toDateString(),
|
||||
'partner_id' => $tenant->demo_source_partner_id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($inactiveCount === 0) {
|
||||
$this->info("비활성 테넌트 없음 (기준: {$thresholdDays}일)");
|
||||
} else {
|
||||
$this->info("비활성 테넌트: {$inactiveCount}건 (기준: {$thresholdDays}일)");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getLastActivity(int $tenantId): ?\Carbon\Carbon
|
||||
{
|
||||
$tables = ['orders', 'quotes', 'items', 'clients'];
|
||||
$latest = null;
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = DB::table($table)
|
||||
->where('tenant_id', $tenantId)
|
||||
->max('updated_at');
|
||||
|
||||
if ($date) {
|
||||
$parsed = \Carbon\Carbon::parse($date);
|
||||
if (! $latest || $parsed->gt($latest)) {
|
||||
$latest = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Api/V1/DemoAnalyticsController.php
Normal file
62
app/Http/Controllers/Api/V1/DemoAnalyticsController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Demo\DemoAnalyticsService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 분석 API 컨트롤러
|
||||
*
|
||||
* 전환율, 파트너 성과, 활동 현황 등 데모 분석 엔드포인트
|
||||
*
|
||||
* 기존 코드 영향 없음: 데모 전용 라우트에서만 사용
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private DemoAnalyticsService $service) {}
|
||||
|
||||
/**
|
||||
* 대시보드 요약
|
||||
*/
|
||||
public function summary()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->summary();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전환율 퍼널 분석
|
||||
*/
|
||||
public function conversionFunnel(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->conversionFunnel($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트너별 성과 분석
|
||||
*/
|
||||
public function partnerPerformance(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->partnerPerformance($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 테넌트 활동 현황
|
||||
*/
|
||||
public function activityReport(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->activityReport($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
271
app/Services/Demo/DemoAnalyticsService.php
Normal file
271
app/Services/Demo/DemoAnalyticsService.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Demo;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 분석 서비스
|
||||
*
|
||||
* - 전환율 분석 (데모 → 정식)
|
||||
* - 활동 모니터링 (데모 테넌트별 사용량)
|
||||
* - 파트너별 영업 성과 분석
|
||||
*
|
||||
* 기존 코드 영향 없음: 데모 전용 분석 로직만 포함
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoAnalyticsService extends Service
|
||||
{
|
||||
/**
|
||||
* 전환율 분석 (전체 또는 파트너별)
|
||||
*/
|
||||
public function conversionFunnel(array $params = []): array
|
||||
{
|
||||
$partnerId = $params['partner_id'] ?? null;
|
||||
$period = $params['period'] ?? 'all'; // all, monthly, quarterly
|
||||
|
||||
$baseQuery = Tenant::withoutGlobalScopes();
|
||||
|
||||
if ($partnerId) {
|
||||
$baseQuery->where('demo_source_partner_id', $partnerId);
|
||||
}
|
||||
|
||||
// Tier 3 체험 테넌트 전체 (현재 + 전환 완료)
|
||||
$totalTrials = (clone $baseQuery)
|
||||
->where(function ($q) {
|
||||
$q->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->orWhere(function ($q2) {
|
||||
// 정식 전환된 테넌트 (demo_source_partner_id가 있으면서 STD)
|
||||
$q2->where('tenant_type', Tenant::TYPE_STD)
|
||||
->whereNotNull('demo_source_partner_id');
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
// 활성 체험 중
|
||||
$activeTrials = (clone $baseQuery)
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('demo_expires_at')
|
||||
->orWhere('demo_expires_at', '>', now());
|
||||
})
|
||||
->count();
|
||||
|
||||
// 만료된 체험
|
||||
$expiredTrials = (clone $baseQuery)
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->whereNotNull('demo_expires_at')
|
||||
->where('demo_expires_at', '<', now())
|
||||
->count();
|
||||
|
||||
// 정식 전환 완료
|
||||
$converted = (clone $baseQuery)
|
||||
->where('tenant_type', Tenant::TYPE_STD)
|
||||
->whereNotNull('demo_source_partner_id')
|
||||
->count();
|
||||
|
||||
$conversionRate = $totalTrials > 0
|
||||
? round($converted / $totalTrials * 100, 1) : 0;
|
||||
|
||||
// 평균 전환 기간 (정식 전환된 건의 생성일 → 전환일 차이)
|
||||
$avgConversionDays = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_STD)
|
||||
->whereNotNull('demo_source_partner_id')
|
||||
->when($partnerId, fn ($q) => $q->where('demo_source_partner_id', $partnerId))
|
||||
->selectRaw('AVG(DATEDIFF(updated_at, created_at)) as avg_days')
|
||||
->value('avg_days');
|
||||
|
||||
return [
|
||||
'funnel' => [
|
||||
'total_trials' => $totalTrials,
|
||||
'active_trials' => $activeTrials,
|
||||
'expired_trials' => $expiredTrials,
|
||||
'converted' => $converted,
|
||||
],
|
||||
'conversion_rate' => $conversionRate,
|
||||
'avg_conversion_days' => $avgConversionDays ? (int) round($avgConversionDays) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트너별 성과 분석
|
||||
*/
|
||||
public function partnerPerformance(array $params = []): array
|
||||
{
|
||||
$partners = DB::table('sales_partners as sp')
|
||||
->leftJoin('users as u', 'sp.user_id', '=', 'u.id')
|
||||
->where('sp.status', 'active')
|
||||
->select('sp.id', 'sp.partner_code', 'u.name as partner_name')
|
||||
->get();
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($partners as $partner) {
|
||||
$demos = Tenant::withoutGlobalScopes()
|
||||
->where('demo_source_partner_id', $partner->id)
|
||||
->get();
|
||||
|
||||
$trials = $demos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL);
|
||||
$convertedCount = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_STD)
|
||||
->where('demo_source_partner_id', $partner->id)
|
||||
->count();
|
||||
|
||||
$totalTrials = $trials->count() + $convertedCount;
|
||||
$conversionRate = $totalTrials > 0
|
||||
? round($convertedCount / $totalTrials * 100, 1) : 0;
|
||||
|
||||
$results[] = [
|
||||
'partner_id' => $partner->id,
|
||||
'partner_code' => $partner->partner_code,
|
||||
'partner_name' => $partner->partner_name,
|
||||
'demo_count' => $demos->count(),
|
||||
'active_trials' => $trials->filter(fn ($t) => ! $t->isDemoExpired())->count(),
|
||||
'expired_trials' => $trials->filter(fn ($t) => $t->isDemoExpired())->count(),
|
||||
'converted' => $convertedCount,
|
||||
'conversion_rate' => $conversionRate,
|
||||
];
|
||||
}
|
||||
|
||||
// 전환율 내림차순 정렬
|
||||
usort($results, fn ($a, $b) => $b['conversion_rate'] <=> $a['conversion_rate']);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 테넌트 활동 현황
|
||||
*/
|
||||
public function activityReport(array $params = []): array
|
||||
{
|
||||
$demos = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES)
|
||||
->when(
|
||||
! empty($params['partner_id']),
|
||||
fn ($q) => $q->where('demo_source_partner_id', $params['partner_id'])
|
||||
)
|
||||
->get();
|
||||
|
||||
$report = [];
|
||||
|
||||
foreach ($demos as $tenant) {
|
||||
// 각 데모 테넌트의 데이터 입력량 조회
|
||||
$dataCounts = $this->getDataCounts($tenant->id);
|
||||
$totalRecords = array_sum($dataCounts);
|
||||
|
||||
// 최근 활동 시점 (가장 최근 레코드의 updated_at)
|
||||
$lastActivity = $this->getLastActivity($tenant->id);
|
||||
|
||||
$daysSinceActivity = $lastActivity
|
||||
? (int) now()->diffInDays($lastActivity) : null;
|
||||
|
||||
$report[] = [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
'tenant_type' => $tenant->tenant_type,
|
||||
'demo_expires_at' => $tenant->demo_expires_at?->toDateString(),
|
||||
'is_expired' => $tenant->isDemoExpired(),
|
||||
'data_counts' => $dataCounts,
|
||||
'total_records' => $totalRecords,
|
||||
'last_activity' => $lastActivity?->toDateString(),
|
||||
'days_since_activity' => $daysSinceActivity,
|
||||
'activity_status' => $this->classifyActivity($daysSinceActivity),
|
||||
];
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 요약 (대시보드용)
|
||||
*/
|
||||
public function summary(): array
|
||||
{
|
||||
$funnel = $this->conversionFunnel();
|
||||
|
||||
$allDemos = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES)
|
||||
->get();
|
||||
|
||||
$inactiveCount = 0;
|
||||
foreach ($allDemos as $tenant) {
|
||||
$lastActivity = $this->getLastActivity($tenant->id);
|
||||
if ($lastActivity && now()->diffInDays($lastActivity) >= 7) {
|
||||
$inactiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'funnel' => $funnel['funnel'],
|
||||
'conversion_rate' => $funnel['conversion_rate'],
|
||||
'avg_conversion_days' => $funnel['avg_conversion_days'],
|
||||
'total_demos' => $allDemos->count(),
|
||||
'inactive_count' => $inactiveCount,
|
||||
'by_type' => [
|
||||
'showcase' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)->count(),
|
||||
'partner' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_PARTNER)->count(),
|
||||
'trial' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Private Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private function getDataCounts(int $tenantId): array
|
||||
{
|
||||
$tables = ['departments', 'clients', 'items', 'quotes', 'orders'];
|
||||
$counts = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (\Schema::hasTable($table) && \Schema::hasColumn($table, 'tenant_id')) {
|
||||
$counts[$table] = DB::table($table)->where('tenant_id', $tenantId)->count();
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function getLastActivity(int $tenantId): ?\Carbon\Carbon
|
||||
{
|
||||
$tables = ['orders', 'quotes', 'items', 'clients'];
|
||||
$latest = null;
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = DB::table($table)
|
||||
->where('tenant_id', $tenantId)
|
||||
->max('updated_at');
|
||||
|
||||
if ($date) {
|
||||
$parsed = \Carbon\Carbon::parse($date);
|
||||
if (! $latest || $parsed->gt($latest)) {
|
||||
$latest = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
|
||||
private function classifyActivity(?int $daysSinceActivity): string
|
||||
{
|
||||
if ($daysSinceActivity === null) {
|
||||
return 'no_data';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$daysSinceActivity <= 1 => 'active',
|
||||
$daysSinceActivity <= 3 => 'normal',
|
||||
$daysSinceActivity <= 7 => 'low',
|
||||
default => 'inactive',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ public function run(int $tenantId): void
|
||||
|
||||
// 5. 수주
|
||||
$this->seedOrders($tenantId, $clientIds, $now);
|
||||
|
||||
// 6. 통계 데이터 (대시보드 차트용, 최근 90일)
|
||||
$this->seedStatData($tenantId);
|
||||
}
|
||||
|
||||
private function seedDepartments(int $tenantId, $now): void
|
||||
@@ -245,4 +248,141 @@ private function seedOrders(int $tenantId, array $clientIds, $now): void
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function seedStatData(int $tenantId): void
|
||||
{
|
||||
// sam_stat DB에 통계 데이터가 있어야 대시보드 차트가 의미있게 보인다.
|
||||
// 최근 90일치 일간 매출/생산 통계를 시드한다.
|
||||
$statConnection = 'sam_stat';
|
||||
|
||||
// sam_stat 연결이 설정되어 있지 않으면 스킵
|
||||
if (! config("database.connections.{$statConnection}")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블 존재 여부 확인
|
||||
try {
|
||||
$hasSalesTable = \Schema::connection($statConnection)->hasTable('stat_sales_daily');
|
||||
$hasProductionTable = \Schema::connection($statConnection)->hasTable('stat_production_daily');
|
||||
} catch (\Exception $e) {
|
||||
return; // sam_stat DB 접속 불가 시 스킵
|
||||
}
|
||||
|
||||
$today = now()->startOfDay();
|
||||
|
||||
// 매출 통계 (90일)
|
||||
if ($hasSalesTable) {
|
||||
$salesData = [];
|
||||
for ($i = 89; $i >= 0; $i--) {
|
||||
$date = $today->copy()->subDays($i);
|
||||
|
||||
// 주말은 거래 없음
|
||||
if ($date->isWeekend()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 계절성 반영 — 월초에 주문 많고, 중반에 매출 확정
|
||||
$dayOfMonth = $date->day;
|
||||
$orderBase = ($dayOfMonth <= 10) ? rand(2, 5) : rand(0, 3);
|
||||
$salesBase = ($dayOfMonth >= 10 && $dayOfMonth <= 25) ? rand(1, 4) : rand(0, 2);
|
||||
|
||||
// 성장 트렌드 (최근일수록 약간 증가)
|
||||
$growthFactor = 1.0 + (90 - $i) * 0.003;
|
||||
|
||||
$orderCount = (int) round($orderBase * $growthFactor);
|
||||
$orderAmount = $orderCount * rand(15000000, 65000000);
|
||||
$salesCount = (int) round($salesBase * $growthFactor);
|
||||
$salesAmount = $salesCount * rand(20000000, 70000000);
|
||||
|
||||
$salesData[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'stat_date' => $date->toDateString(),
|
||||
'order_count' => $orderCount,
|
||||
'order_amount' => $orderAmount,
|
||||
'order_item_count' => $orderCount * rand(2, 8),
|
||||
'sales_count' => $salesCount,
|
||||
'sales_amount' => $salesAmount,
|
||||
'sales_tax_amount' => (int) round($salesAmount * 0.1),
|
||||
'new_client_count' => rand(0, 1),
|
||||
'active_client_count' => rand(5, 10),
|
||||
'order_draft_count' => rand(0, 2),
|
||||
'order_confirmed_count' => rand(1, 3),
|
||||
'order_in_progress_count' => rand(1, 4),
|
||||
'order_completed_count' => rand(0, 2),
|
||||
'order_cancelled_count' => rand(0, 1) > 0 ? 0 : rand(0, 1),
|
||||
'shipment_count' => $salesCount,
|
||||
'shipment_amount' => $salesAmount,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
// 기존 데이터 삭제 후 삽입
|
||||
DB::connection($statConnection)->table('stat_sales_daily')
|
||||
->where('tenant_id', $tenantId)
|
||||
->delete();
|
||||
|
||||
// 청크 단위 삽입
|
||||
foreach (array_chunk($salesData, 30) as $chunk) {
|
||||
DB::connection($statConnection)->table('stat_sales_daily')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// 생산 통계 (90일)
|
||||
if ($hasProductionTable) {
|
||||
$prodData = [];
|
||||
for ($i = 89; $i >= 0; $i--) {
|
||||
$date = $today->copy()->subDays($i);
|
||||
|
||||
if ($date->isWeekend()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$growthFactor = 1.0 + (90 - $i) * 0.002;
|
||||
|
||||
$productionQty = (int) round(rand(50, 150) * $growthFactor);
|
||||
$defectQty = (int) round($productionQty * rand(10, 40) / 1000); // 1~4% 불량률
|
||||
$defectRate = $productionQty > 0 ? round($defectQty / $productionQty * 100, 2) : 0;
|
||||
|
||||
$plannedHours = rand(40, 80);
|
||||
$actualHours = $plannedHours + rand(-10, 5);
|
||||
$efficiencyRate = $actualHours > 0 ? round($plannedHours / $actualHours * 100, 2) : 0;
|
||||
|
||||
$onTime = rand(3, 8);
|
||||
$late = rand(0, 2);
|
||||
$deliveryRate = ($onTime + $late) > 0
|
||||
? round($onTime / ($onTime + $late) * 100, 2) : 100;
|
||||
|
||||
$prodData[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'stat_date' => $date->toDateString(),
|
||||
'wo_created_count' => rand(2, 6),
|
||||
'wo_completed_count' => rand(1, 5),
|
||||
'wo_in_progress_count' => rand(3, 10),
|
||||
'wo_overdue_count' => rand(0, 2),
|
||||
'production_qty' => $productionQty,
|
||||
'defect_qty' => $defectQty,
|
||||
'defect_rate' => $defectRate,
|
||||
'planned_hours' => $plannedHours,
|
||||
'actual_hours' => $actualHours,
|
||||
'efficiency_rate' => min($efficiencyRate, 120),
|
||||
'active_worker_count' => rand(8, 20),
|
||||
'issue_count' => rand(0, 3),
|
||||
'on_time_delivery_count' => $onTime,
|
||||
'late_delivery_count' => $late,
|
||||
'delivery_rate' => $deliveryRate,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
DB::connection($statConnection)->table('stat_production_daily')
|
||||
->where('tenant_id', $tenantId)
|
||||
->delete();
|
||||
|
||||
foreach (array_chunk($prodData, 30) as $chunk) {
|
||||
DB::connection($statConnection)->table('stat_production_daily')->insert($chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
use App\Http\Controllers\Api\V1\ClientGroupController;
|
||||
use App\Http\Controllers\Api\V1\CompanyController;
|
||||
use App\Http\Controllers\Api\V1\DemoAnalyticsController;
|
||||
use App\Http\Controllers\Api\V1\DemoTenantController;
|
||||
use App\Http\Controllers\Api\V1\EstimateController;
|
||||
use App\Http\Controllers\Api\V1\OrderController;
|
||||
@@ -205,6 +206,14 @@
|
||||
Route::post('/{id}/convert', [DemoTenantController::class, 'convert'])->whereNumber('id')->name('v1.demo-tenants.convert');
|
||||
});
|
||||
|
||||
// Demo Analytics API (데모 분석)
|
||||
Route::prefix('demo-analytics')->group(function () {
|
||||
Route::get('/summary', [DemoAnalyticsController::class, 'summary'])->name('v1.demo-analytics.summary');
|
||||
Route::get('/conversion-funnel', [DemoAnalyticsController::class, 'conversionFunnel'])->name('v1.demo-analytics.conversion-funnel');
|
||||
Route::get('/partner-performance', [DemoAnalyticsController::class, 'partnerPerformance'])->name('v1.demo-analytics.partner-performance');
|
||||
Route::get('/activity-report', [DemoAnalyticsController::class, 'activityReport'])->name('v1.demo-analytics.activity-report');
|
||||
});
|
||||
|
||||
// Company API (회사 추가 관리)
|
||||
Route::prefix('companies')->group(function () {
|
||||
Route::post('/check', [CompanyController::class, 'check'])->name('v1.companies.check'); // 사업자등록번호 검증
|
||||
|
||||
@@ -170,6 +170,17 @@
|
||||
\Illuminate\Support\Facades\Log::error('❌ demo:check-expired 스케줄러 실행 실패', ['time' => now()]);
|
||||
});
|
||||
|
||||
// 매일 오전 09:30에 데모 테넌트 비활성 알림 (7일 이상 활동 없음)
|
||||
Schedule::command('demo:check-inactive')
|
||||
->dailyAt('09:30')
|
||||
->appendOutputTo(storage_path('logs/scheduler.log'))
|
||||
->onSuccess(function () {
|
||||
\Illuminate\Support\Facades\Log::info('✅ demo:check-inactive 스케줄러 실행 성공', ['time' => now()]);
|
||||
})
|
||||
->onFailure(function () {
|
||||
\Illuminate\Support\Facades\Log::error('❌ demo:check-inactive 스케줄러 실행 실패', ['time' => now()]);
|
||||
});
|
||||
|
||||
// 매일 자정 00:00에 쇼케이스 테넌트 데이터 리셋 + 샘플 재시드
|
||||
Schedule::command('demo:reset-showcase --seed')
|
||||
->dailyAt('00:00')
|
||||
|
||||
Reference in New Issue
Block a user