feat: sam_stat P2 도메인 + 통계 API + 대시보드 전환 (Phase 4)

- 4.1: stat_project_monthly + ProjectStatService (건설/프로젝트 월간)
- 4.2: stat_system_daily + SystemStatService (API/감사/FCM/파일/결재)
- 4.3: stat_events, stat_snapshots + StatEventService + StatEventObserver
- 4.4: StatController (summary/daily/monthly/alerts) + StatQueryService + FormRequest 3개 + routes/stats.php
- 4.5: DashboardService sam_stat 우선 조회 + 원본 DB 폴백 패턴

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 21:56:53 +09:00
parent 595e3d59b4
commit 4d8dac1091
22 changed files with 1011 additions and 7 deletions

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Stat\StatAlertRequest;
use App\Http\Requests\V1\Stat\StatDailyRequest;
use App\Http\Requests\V1\Stat\StatMonthlyRequest;
use App\Services\Stats\StatQueryService;
use Illuminate\Http\JsonResponse;
class StatController extends Controller
{
public function __construct(
private readonly StatQueryService $statQueryService
) {}
/**
* 대시보드 요약 통계 (sam_stat 기반)
*/
public function summary(): JsonResponse
{
$data = $this->statQueryService->getDashboardSummary();
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 일간 통계 조회
*/
public function daily(StatDailyRequest $request): JsonResponse
{
$data = $this->statQueryService->getDailyStat(
$request->validated('domain'),
$request->validated()
);
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 월간 통계 조회
*/
public function monthly(StatMonthlyRequest $request): JsonResponse
{
$data = $this->statQueryService->getMonthlyStat(
$request->validated('domain'),
$request->validated()
);
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 알림 목록 조회
*/
public function alerts(StatAlertRequest $request): JsonResponse
{
$data = $this->statQueryService->getAlerts($request->validated());
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\V1\Stat;
use Illuminate\Foundation\Http\FormRequest;
class StatAlertRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'limit' => 'nullable|integer|min:1|max:100',
'unread_only' => 'nullable|boolean',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\V1\Stat;
use Illuminate\Foundation\Http\FormRequest;
class StatDailyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'domain' => 'required|string|in:sales,finance,production,inventory,quote,hr,system',
'start_date' => 'required|date|date_format:Y-m-d',
'end_date' => 'required|date|date_format:Y-m-d|after_or_equal:start_date',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\V1\Stat;
use Illuminate\Foundation\Http\FormRequest;
class StatMonthlyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'domain' => 'required|string|in:sales,finance,production,project',
'year' => 'required|integer|min:2020|max:2099',
'month' => 'nullable|integer|min:1|max:12',
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatSystemDaily extends BaseStatModel
{
protected $table = 'stat_system_daily';
protected $casts = [
'stat_date' => 'date',
'file_upload_size_mb' => 'decimal:2',
'approval_avg_hours' => 'decimal:2',
];
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Models\Stats\Monthly;
use App\Models\Stats\BaseStatModel;
class StatProjectMonthly extends BaseStatModel
{
protected $table = 'stat_project_monthly';
protected $casts = [
'contract_total_amount' => 'decimal:2',
'expected_expense_total' => 'decimal:2',
'actual_expense_total' => 'decimal:2',
'labor_cost_total' => 'decimal:2',
'material_cost_total' => 'decimal:2',
'gross_profit' => 'decimal:2',
'gross_profit_rate' => 'decimal:2',
];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\Stats;
class StatEvent extends BaseStatModel
{
protected $table = 'stat_events';
public $timestamps = false;
protected $casts = [
'payload' => 'array',
'occurred_at' => 'datetime',
];
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models\Stats;
class StatSnapshot extends BaseStatModel
{
protected $table = 'stat_snapshots';
public $timestamps = false;
protected $casts = [
'snapshot_date' => 'date',
'data' => 'array',
'created_at' => 'datetime',
];
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Observers;
use App\Services\Stats\StatEventService;
use Illuminate\Database\Eloquent\Model;
class StatEventObserver
{
private static array $domainMap = [
'App\Models\Tenants\Order' => 'sales',
'App\Models\Tenants\Sale' => 'sales',
'App\Models\Tenants\Deposit' => 'finance',
'App\Models\Tenants\Withdrawal' => 'finance',
'App\Models\Tenants\Purchase' => 'finance',
'App\Models\Production\WorkOrder' => 'production',
'App\Models\Tenants\Approval' => 'system',
];
public function created(Model $model): void
{
$this->recordEvent($model, 'created');
}
public function updated(Model $model): void
{
$this->recordEvent($model, 'updated');
}
public function deleted(Model $model): void
{
$this->recordEvent($model, 'deleted');
}
private function recordEvent(Model $model, string $action): void
{
$tenantId = $model->getAttribute('tenant_id');
if (! $tenantId) {
return;
}
$className = get_class($model);
$domain = self::$domainMap[$className] ?? 'other';
$entityType = class_basename($model);
$eventType = strtolower($entityType).'_'.$action;
$payload = match ($action) {
'created' => $this->getCreatedPayload($model),
'updated' => $this->getUpdatedPayload($model),
'deleted' => ['id' => $model->getKey()],
default => null,
};
app(StatEventService::class)->recordEvent(
$tenantId,
$domain,
$eventType,
$entityType,
$model->getKey(),
$payload
);
}
private function getCreatedPayload(Model $model): array
{
$payload = ['id' => $model->getKey()];
if ($model->getAttribute('total_amount') !== null) {
$payload['amount'] = $model->getAttribute('total_amount');
}
if ($model->getAttribute('amount') !== null) {
$payload['amount'] = $model->getAttribute('amount');
}
if ($model->getAttribute('status') !== null) {
$payload['status'] = $model->getAttribute('status');
}
return $payload;
}
private function getUpdatedPayload(Model $model): array
{
$changed = $model->getChanges();
$payload = ['id' => $model->getKey()];
if (isset($changed['status'])) {
$payload['old_status'] = $model->getOriginal('status');
$payload['new_status'] = $changed['status'];
}
if (isset($changed['total_amount']) || isset($changed['amount'])) {
$payload['amount'] = $changed['total_amount'] ?? $changed['amount'];
}
return $payload;
}
}

View File

@@ -14,6 +14,7 @@
use App\Models\Tenants\Deposit;
use App\Models\Tenants\ExpectedExpense;
use App\Models\Tenants\Purchase;
use App\Models\Tenants\Sale;
use App\Models\Tenants\Stock;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\Withdrawal;
@@ -21,6 +22,7 @@
use App\Observers\ExpenseSync\PurchaseExpenseSyncObserver;
use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver;
use App\Observers\MenuObserver;
use App\Observers\StatEventObserver;
use App\Observers\TenantObserver;
use App\Observers\TodayIssue\ApprovalIssueObserver;
use App\Observers\TodayIssue\ApprovalStepIssueObserver;
@@ -96,5 +98,13 @@ public function boot(): void
Purchase::observe(PurchaseExpenseSyncObserver::class);
Withdrawal::observe(WithdrawalExpenseSyncObserver::class);
Bill::observe(BillExpenseSyncObserver::class);
// 통계 이벤트 기록 (stat_events 테이블)
Order::observe(StatEventObserver::class);
Sale::observe(StatEventObserver::class);
Deposit::observe(StatEventObserver::class);
Withdrawal::observe(StatEventObserver::class);
Purchase::observe(StatEventObserver::class);
Approval::observe(StatEventObserver::class);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Services;
use App\Models\Stats\Monthly\StatFinanceMonthly;
use App\Models\Stats\Monthly\StatSalesMonthly;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\Attendance;
@@ -114,23 +116,36 @@ private function getTodaySummary(int $tenantId, Carbon $today): array
}
/**
* 재무 요약 데이터
* 재무 요약 데이터 (sam_stat 우선, 폴백: 원본 DB)
*/
private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array
{
// 월간 입금 합계
// sam_stat 월간 데이터 시도
$monthly = StatFinanceMonthly::where('tenant_id', $tenantId)
->where('stat_year', $startOfMonth->year)
->where('stat_month', $startOfMonth->month)
->first();
if ($monthly) {
return [
'monthly_deposit' => (float) $monthly->deposit_amount,
'monthly_withdrawal' => (float) $monthly->withdrawal_amount,
'balance' => (float) ($monthly->deposit_amount - $monthly->withdrawal_amount),
'source' => 'sam_stat',
];
}
// 폴백: 원본 DB 실시간 집계
$monthlyDeposit = Deposit::query()
->where('tenant_id', $tenantId)
->whereBetween('deposit_date', [$startOfMonth, $endOfMonth])
->sum('amount');
// 월간 출금 합계
$monthlyWithdrawal = Withdrawal::query()
->where('tenant_id', $tenantId)
->whereBetween('withdrawal_date', [$startOfMonth, $endOfMonth])
->sum('amount');
// 현재 잔액 (전체 입금 - 전체 출금)
$totalDeposits = Deposit::query()
->where('tenant_id', $tenantId)
->sum('amount');
@@ -145,21 +160,35 @@ private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $
'monthly_deposit' => (float) $monthlyDeposit,
'monthly_withdrawal' => (float) $monthlyWithdrawal,
'balance' => (float) $balance,
'source' => 'samdb',
];
}
/**
* 매출/매입 요약 데이터
* 매출/매입 요약 데이터 (sam_stat 우선, 폴백: 원본 DB)
*/
private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array
{
// 월간 매출 합계
// sam_stat 월간 데이터 시도
$monthly = StatSalesMonthly::where('tenant_id', $tenantId)
->where('stat_year', $startOfMonth->year)
->where('stat_month', $startOfMonth->month)
->first();
if ($monthly) {
return [
'monthly_sales' => (float) $monthly->sales_amount,
'monthly_purchases' => 0,
'source' => 'sam_stat',
];
}
// 폴백: 원본 DB 실시간 집계
$monthlySales = Sale::query()
->where('tenant_id', $tenantId)
->whereBetween('sale_date', [$startOfMonth, $endOfMonth])
->sum('total_amount');
// 월간 매입 합계
$monthlyPurchases = Purchase::query()
->where('tenant_id', $tenantId)
->whereBetween('purchase_date', [$startOfMonth, $endOfMonth])
@@ -168,6 +197,7 @@ private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $en
return [
'monthly_sales' => (float) $monthlySales,
'monthly_purchases' => (float) $monthlyPurchases,
'source' => 'samdb',
];
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Monthly\StatProjectMonthly;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class ProjectStatService implements StatDomainServiceInterface
{
public function aggregateDaily(int $tenantId, Carbon $date): int
{
// 건설/프로젝트는 월간 전용 (일간 집계 없음)
return 0;
}
public function aggregateMonthly(int $tenantId, int $year, int $month): int
{
$startDate = Carbon::create($year, $month, 1)->startOfDay();
$endDate = $startDate->copy()->endOfMonth();
// 현장 현황
$activeSites = DB::connection('mysql')
->table('sites')
->where('tenant_id', $tenantId)
->where('status', 'active')
->whereNull('deleted_at')
->count();
$completedSites = DB::connection('mysql')
->table('sites')
->where('tenant_id', $tenantId)
->where('status', 'completed')
->whereNull('deleted_at')
->count();
// 계약 현황 (해당 월 신규 계약)
$contractStats = DB::connection('mysql')
->table('contracts')
->where('tenant_id', $tenantId)
->whereBetween('created_at', [$startDate, $endDate])
->whereNull('deleted_at')
->selectRaw('
COUNT(*) as new_count,
COALESCE(SUM(contract_amount), 0) as total_amount
')
->first();
// 지출예상 (해당 월)
$expenseStats = DB::connection('mysql')
->table('expected_expenses')
->where('tenant_id', $tenantId)
->whereYear('expected_payment_date', $year)
->whereMonth('expected_payment_date', $month)
->whereNull('deleted_at')
->selectRaw('
COALESCE(SUM(amount), 0) as expected_total,
COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN amount ELSE 0 END), 0) as actual_total
')
->first();
// 수익률 계산
$contractTotal = (float) ($contractStats->total_amount ?? 0);
$actualExpense = (float) ($expenseStats->actual_total ?? 0);
$grossProfit = $contractTotal - $actualExpense;
$grossProfitRate = $contractTotal > 0 ? ($grossProfit / $contractTotal) * 100 : 0;
StatProjectMonthly::updateOrCreate(
[
'tenant_id' => $tenantId,
'stat_year' => $year,
'stat_month' => $month,
],
[
'active_site_count' => $activeSites,
'completed_site_count' => $completedSites,
'new_contract_count' => $contractStats->new_count ?? 0,
'contract_total_amount' => $contractTotal,
'expected_expense_total' => $expenseStats->expected_total ?? 0,
'actual_expense_total' => $actualExpense,
'labor_cost_total' => 0,
'material_cost_total' => 0,
'gross_profit' => $grossProfit,
'gross_profit_rate' => $grossProfitRate,
'handover_report_count' => 0,
'structure_review_count' => 0,
]
);
return 1;
}
}

View File

@@ -21,6 +21,7 @@ private function getDailyDomainServices(): array
'inventory' => InventoryStatService::class,
'quote' => QuoteStatService::class,
'hr' => HrStatService::class,
'system' => SystemStatService::class,
];
}
@@ -36,6 +37,7 @@ private function getMonthlyDomainServices(): array
'inventory' => InventoryStatService::class,
'quote' => QuoteStatService::class,
'hr' => HrStatService::class,
'project' => ProjectStatService::class,
];
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\StatEvent;
use App\Models\Stats\StatSnapshot;
use Illuminate\Support\Facades\Log;
class StatEventService
{
/**
* 통계 이벤트 기록
*/
public function recordEvent(
int $tenantId,
string $domain,
string $eventType,
string $entityType,
int $entityId,
?array $payload = null
): void {
try {
StatEvent::create([
'tenant_id' => $tenantId,
'domain' => $domain,
'event_type' => $eventType,
'entity_type' => $entityType,
'entity_id' => $entityId,
'payload' => $payload,
'occurred_at' => now(),
]);
} catch (\Throwable $e) {
Log::warning('stat_event 기록 실패', [
'tenant_id' => $tenantId,
'domain' => $domain,
'event_type' => $eventType,
'error' => $e->getMessage(),
]);
}
}
/**
* 스냅샷 저장
*/
public function saveSnapshot(
int $tenantId,
string $domain,
string $snapshotDate,
array $data,
string $snapshotType = 'daily'
): void {
try {
StatSnapshot::updateOrCreate(
[
'tenant_id' => $tenantId,
'snapshot_date' => $snapshotDate,
'domain' => $domain,
'snapshot_type' => $snapshotType,
],
[
'data' => $data,
'created_at' => now(),
]
);
} catch (\Throwable $e) {
Log::warning('stat_snapshot 저장 실패', [
'tenant_id' => $tenantId,
'domain' => $domain,
'snapshot_date' => $snapshotDate,
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Daily\StatFinanceDaily;
use App\Models\Stats\Daily\StatHrAttendanceDaily;
use App\Models\Stats\Daily\StatInventoryDaily;
use App\Models\Stats\Daily\StatProductionDaily;
use App\Models\Stats\Daily\StatQuotePipelineDaily;
use App\Models\Stats\Daily\StatSalesDaily;
use App\Models\Stats\Daily\StatSystemDaily;
use App\Models\Stats\Monthly\StatFinanceMonthly;
use App\Models\Stats\Monthly\StatProductionMonthly;
use App\Models\Stats\Monthly\StatProjectMonthly;
use App\Models\Stats\Monthly\StatSalesMonthly;
use App\Models\Stats\StatAlert;
use App\Services\Service;
use Carbon\Carbon;
class StatQueryService extends Service
{
/**
* 도메인별 일간 통계 조회
*/
public function getDailyStat(string $domain, array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'];
$endDate = $params['end_date'];
$model = $this->getDailyModel($domain);
if (! $model) {
return [];
}
return $model::where('tenant_id', $tenantId)
->whereBetween('stat_date', [$startDate, $endDate])
->orderBy('stat_date')
->get()
->toArray();
}
/**
* 도메인별 월간 통계 조회
*/
public function getMonthlyStat(string $domain, array $params): array
{
$tenantId = $this->tenantId();
$year = (int) $params['year'];
$month = isset($params['month']) ? (int) $params['month'] : null;
$model = $this->getMonthlyModel($domain);
if (! $model) {
return [];
}
$query = $model::where('tenant_id', $tenantId)
->where('stat_year', $year);
if ($month) {
$query->where('stat_month', $month);
}
return $query->orderBy('stat_month')
->get()
->toArray();
}
/**
* 대시보드 요약 통계 (sam_stat 기반)
*/
public function getDashboardSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::today()->format('Y-m-d');
$year = Carbon::now()->year;
$month = Carbon::now()->month;
return [
'sales_today' => $this->getTodaySales($tenantId, $today),
'finance_today' => $this->getTodayFinance($tenantId, $today),
'production_today' => $this->getTodayProduction($tenantId, $today),
'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month),
'alerts' => $this->getActiveAlerts($tenantId),
];
}
/**
* 알림 목록 조회
*/
public function getAlerts(array $params): array
{
$tenantId = $this->tenantId();
$limit = $params['limit'] ?? 20;
$unreadOnly = $params['unread_only'] ?? false;
$query = StatAlert::where('tenant_id', $tenantId)
->orderByDesc('created_at');
if ($unreadOnly) {
$query->where('is_read', false);
}
return $query->limit($limit)->get()->toArray();
}
private function getTodaySales(int $tenantId, string $today): ?array
{
$stat = StatSalesDaily::where('tenant_id', $tenantId)
->where('stat_date', $today)
->first();
return $stat?->toArray();
}
private function getTodayFinance(int $tenantId, string $today): ?array
{
$stat = StatFinanceDaily::where('tenant_id', $tenantId)
->where('stat_date', $today)
->first();
return $stat?->toArray();
}
private function getTodayProduction(int $tenantId, string $today): ?array
{
$stat = StatProductionDaily::where('tenant_id', $tenantId)
->where('stat_date', $today)
->first();
return $stat?->toArray();
}
private function getMonthlySalesOverview(int $tenantId, int $year, int $month): ?array
{
$stat = StatSalesMonthly::where('tenant_id', $tenantId)
->where('stat_year', $year)
->where('stat_month', $month)
->first();
return $stat?->toArray();
}
private function getActiveAlerts(int $tenantId): array
{
return StatAlert::where('tenant_id', $tenantId)
->where('is_read', false)
->where('is_resolved', false)
->orderByDesc('created_at')
->limit(10)
->get()
->toArray();
}
private function getDailyModel(string $domain): ?string
{
return match ($domain) {
'sales' => StatSalesDaily::class,
'finance' => StatFinanceDaily::class,
'production' => StatProductionDaily::class,
'inventory' => StatInventoryDaily::class,
'quote' => StatQuotePipelineDaily::class,
'hr' => StatHrAttendanceDaily::class,
'system' => StatSystemDaily::class,
default => null,
};
}
private function getMonthlyModel(string $domain): ?string
{
return match ($domain) {
'sales' => StatSalesMonthly::class,
'finance' => StatFinanceMonthly::class,
'production' => StatProductionMonthly::class,
'project' => StatProjectMonthly::class,
default => null,
};
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Daily\StatSystemDaily;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class SystemStatService implements StatDomainServiceInterface
{
public function aggregateDaily(int $tenantId, Carbon $date): int
{
$dateStr = $date->format('Y-m-d');
// API 사용량
$apiStats = DB::connection('mysql')
->table('api_request_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->selectRaw('
COUNT(*) as request_count,
SUM(CASE WHEN response_status >= 400 THEN 1 ELSE 0 END) as error_count,
COALESCE(AVG(duration_ms), 0) as avg_response_ms
')
->first();
// 사용자 활동 (고유 사용자 수, 로그인 수)
$activeUsers = DB::connection('mysql')
->table('api_request_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
// personal_access_tokens에 tenant_id 없으므로 user_tenants 조인으로 로그인 수 집계
$loginCount = DB::connection('mysql')
->table('personal_access_tokens as pat')
->join('user_tenants as ut', function ($join) use ($tenantId) {
$join->on('pat.tokenable_id', '=', 'ut.user_id')
->where('pat.tokenable_type', '=', 'App\\Models\\Members\\User')
->where('ut.tenant_id', '=', $tenantId);
})
->whereDate('pat.created_at', $dateStr)
->count();
// 감사 로그
$auditStats = DB::connection('mysql')
->table('audit_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->selectRaw("
SUM(CASE WHEN action = 'created' THEN 1 ELSE 0 END) as create_count,
SUM(CASE WHEN action = 'updated' THEN 1 ELSE 0 END) as update_count,
SUM(CASE WHEN action = 'deleted' THEN 1 ELSE 0 END) as delete_count
")
->first();
// FCM 알림
$fcmStats = DB::connection('mysql')
->table('fcm_send_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->selectRaw('
COALESCE(SUM(success_count), 0) as sent_count,
COALESCE(SUM(failure_count), 0) as failed_count
')
->first();
// 파일 업로드
$fileStats = DB::connection('mysql')
->table('files')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->selectRaw('
COUNT(*) as upload_count,
COALESCE(SUM(file_size), 0) / 1048576 as upload_size_mb
')
->first();
// 결재
$approvalSubmitted = DB::connection('mysql')
->table('approvals')
->where('tenant_id', $tenantId)
->whereDate('drafted_at', $dateStr)
->whereNull('deleted_at')
->count();
$approvalCompleted = DB::connection('mysql')
->table('approvals')
->where('tenant_id', $tenantId)
->whereDate('completed_at', $dateStr)
->whereNull('deleted_at')
->count();
$approvalAvgHours = DB::connection('mysql')
->table('approvals')
->where('tenant_id', $tenantId)
->whereDate('completed_at', $dateStr)
->whereNotNull('drafted_at')
->whereNotNull('completed_at')
->whereNull('deleted_at')
->selectRaw('AVG(TIMESTAMPDIFF(HOUR, drafted_at, completed_at)) as avg_hours')
->value('avg_hours');
StatSystemDaily::updateOrCreate(
['tenant_id' => $tenantId, 'stat_date' => $dateStr],
[
'api_request_count' => $apiStats->request_count ?? 0,
'api_error_count' => $apiStats->error_count ?? 0,
'api_avg_response_ms' => (int) ($apiStats->avg_response_ms ?? 0),
'active_user_count' => $activeUsers,
'login_count' => $loginCount,
'audit_create_count' => $auditStats->create_count ?? 0,
'audit_update_count' => $auditStats->update_count ?? 0,
'audit_delete_count' => $auditStats->delete_count ?? 0,
'fcm_sent_count' => $fcmStats->sent_count ?? 0,
'fcm_failed_count' => $fcmStats->failed_count ?? 0,
'file_upload_count' => $fileStats->upload_count ?? 0,
'file_upload_size_mb' => $fileStats->upload_size_mb ?? 0,
'approval_submitted_count' => $approvalSubmitted,
'approval_completed_count' => $approvalCompleted,
'approval_avg_hours' => (float) ($approvalAvgHours ?? 0),
]
);
return 1;
}
public function aggregateMonthly(int $tenantId, int $year, int $month): int
{
// 시스템 도메인은 일간 테이블만 운영
return 0;
}
}

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_project_monthly', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->smallInteger('stat_year');
$table->tinyInteger('stat_month');
// 프로젝트 현황
$table->unsignedInteger('active_site_count')->default(0);
$table->unsignedInteger('completed_site_count')->default(0);
$table->unsignedInteger('new_contract_count')->default(0);
$table->decimal('contract_total_amount', 18, 2)->default(0);
// 원가
$table->decimal('expected_expense_total', 18, 2)->default(0);
$table->decimal('actual_expense_total', 18, 2)->default(0);
$table->decimal('labor_cost_total', 18, 2)->default(0);
$table->decimal('material_cost_total', 18, 2)->default(0);
// 수익률
$table->decimal('gross_profit', 18, 2)->default(0);
$table->decimal('gross_profit_rate', 5, 2)->default(0);
// 이슈
$table->unsignedInteger('handover_report_count')->default(0);
$table->unsignedInteger('structure_review_count')->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month');
$table->index(['stat_year', 'stat_month'], 'idx_year_month');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_project_monthly');
}
};

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_system_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// API 사용량
$table->unsignedInteger('api_request_count')->default(0);
$table->unsignedInteger('api_error_count')->default(0);
$table->unsignedInteger('api_avg_response_ms')->default(0);
// 사용자 활동
$table->unsignedInteger('active_user_count')->default(0);
$table->unsignedInteger('login_count')->default(0);
// 감사
$table->unsignedInteger('audit_create_count')->default(0);
$table->unsignedInteger('audit_update_count')->default(0);
$table->unsignedInteger('audit_delete_count')->default(0);
// 알림
$table->unsignedInteger('fcm_sent_count')->default(0);
$table->unsignedInteger('fcm_failed_count')->default(0);
// 파일
$table->unsignedInteger('file_upload_count')->default(0);
$table->decimal('file_upload_size_mb', 10, 2)->default(0);
// 결재
$table->unsignedInteger('approval_submitted_count')->default(0);
$table->unsignedInteger('approval_completed_count')->default(0);
$table->decimal('approval_avg_hours', 8, 2)->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date', 'idx_date');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_system_daily');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_events', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->string('domain', 50);
$table->string('event_type', 100);
$table->string('entity_type', 100);
$table->unsignedBigInteger('entity_id');
$table->json('payload')->nullable();
$table->timestamp('occurred_at');
$table->index(['tenant_id', 'domain'], 'idx_tenant_domain');
$table->index('occurred_at', 'idx_occurred');
$table->index(['entity_type', 'entity_id'], 'idx_entity');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_events');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_snapshots', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('snapshot_date');
$table->string('domain', 50);
$table->string('snapshot_type', 50)->default('daily');
$table->json('data');
$table->timestamp('created_at')->nullable();
$table->unique(['tenant_id', 'snapshot_date', 'domain', 'snapshot_type'], 'uk_tenant_date_domain');
$table->index('snapshot_date', 'idx_date');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_snapshots');
}
};

View File

@@ -37,6 +37,7 @@
require __DIR__.'/api/v1/boards.php';
require __DIR__.'/api/v1/documents.php';
require __DIR__.'/api/v1/common.php';
require __DIR__.'/api/v1/stats.php';
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');

19
routes/api/v1/stats.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
/**
* 통계 API 라우트 (v1)
*
* - 대시보드 요약 (sam_stat 기반)
* - 도메인별 일간/월간 통계
* - 알림 목록
*/
use App\Http\Controllers\Api\V1\StatController;
use Illuminate\Support\Facades\Route;
Route::prefix('stats')->group(function () {
Route::get('/summary', [StatController::class, 'summary'])->name('v1.stats.summary');
Route::get('/daily', [StatController::class, 'daily'])->name('v1.stats.daily');
Route::get('/monthly', [StatController::class, 'monthly'])->name('v1.stats.monthly');
Route::get('/alerts', [StatController::class, 'alerts'])->name('v1.stats.alerts');
});