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:
64
app/Http/Controllers/Api/V1/StatController.php
Normal file
64
app/Http/Controllers/Api/V1/StatController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/V1/Stat/StatAlertRequest.php
Normal file
21
app/Http/Requests/V1/Stat/StatAlertRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/V1/Stat/StatDailyRequest.php
Normal file
22
app/Http/Requests/V1/Stat/StatDailyRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/V1/Stat/StatMonthlyRequest.php
Normal file
22
app/Http/Requests/V1/Stat/StatMonthlyRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Models/Stats/Daily/StatSystemDaily.php
Normal file
16
app/Models/Stats/Daily/StatSystemDaily.php
Normal 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',
|
||||
];
|
||||
}
|
||||
20
app/Models/Stats/Monthly/StatProjectMonthly.php
Normal file
20
app/Models/Stats/Monthly/StatProjectMonthly.php
Normal 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',
|
||||
];
|
||||
}
|
||||
15
app/Models/Stats/StatEvent.php
Normal file
15
app/Models/Stats/StatEvent.php
Normal 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',
|
||||
];
|
||||
}
|
||||
16
app/Models/Stats/StatSnapshot.php
Normal file
16
app/Models/Stats/StatSnapshot.php
Normal 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',
|
||||
];
|
||||
}
|
||||
96
app/Observers/StatEventObserver.php
Normal file
96
app/Observers/StatEventObserver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
92
app/Services/Stats/ProjectStatService.php
Normal file
92
app/Services/Stats/ProjectStatService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
74
app/Services/Stats/StatEventService.php
Normal file
74
app/Services/Stats/StatEventService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
179
app/Services/Stats/StatQueryService.php
Normal file
179
app/Services/Stats/StatQueryService.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
135
app/Services/Stats/SystemStatService.php
Normal file
135
app/Services/Stats/SystemStatService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
19
routes/api/v1/stats.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user