feat: sam_stat P1 도메인 확장 (Phase 3)
- 차원 테이블: dim_client, dim_product 마이그레이션 + SCD Type 2 동기화 (DimensionSyncService) - 재고 통계: stat_inventory_daily + InventoryStatService (stocks, stock_transactions, inspections) - 견적/영업 통계: stat_quote_pipeline_daily + QuoteStatService (quotes, biddings, sales_prospects) - 인사/근태 통계: stat_hr_attendance_daily + HrStatService (attendances, leaves, user_tenants) - KPI/알림: stat_kpi_targets, stat_alerts + KpiAlertService + StatCheckKpiAlertsCommand - StatAggregatorService에 inventory, quote, hr 도메인 추가 (총 6개 도메인) - 스케줄러: stat:check-kpi-alerts 매일 09:00 등록 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
33
app/Console/Commands/StatCheckKpiAlertsCommand.php
Normal file
33
app/Console/Commands/StatCheckKpiAlertsCommand.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Stats\KpiAlertService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class StatCheckKpiAlertsCommand extends Command
|
||||
{
|
||||
protected $signature = 'stat:check-kpi-alerts';
|
||||
|
||||
protected $description = 'KPI 목표 대비 실적을 체크하고 미달 시 알림을 생성합니다';
|
||||
|
||||
public function handle(KpiAlertService $service): int
|
||||
{
|
||||
$this->info('KPI 알림 체크 시작...');
|
||||
|
||||
$result = $service->checkKpiAlerts();
|
||||
|
||||
$this->info("알림 생성: {$result['alerts_created']}건");
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
$this->warn('오류 발생:');
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('KPI 알림 체크 완료.');
|
||||
|
||||
return empty($result['errors']) ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
17
app/Models/Stats/Daily/StatHrAttendanceDaily.php
Normal file
17
app/Models/Stats/Daily/StatHrAttendanceDaily.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats\Daily;
|
||||
|
||||
use App\Models\Stats\BaseStatModel;
|
||||
|
||||
class StatHrAttendanceDaily extends BaseStatModel
|
||||
{
|
||||
protected $table = 'stat_hr_attendance_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'attendance_rate' => 'decimal:2',
|
||||
'overtime_hours' => 'decimal:2',
|
||||
'total_labor_cost' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
22
app/Models/Stats/Daily/StatInventoryDaily.php
Normal file
22
app/Models/Stats/Daily/StatInventoryDaily.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats\Daily;
|
||||
|
||||
use App\Models\Stats\BaseStatModel;
|
||||
|
||||
class StatInventoryDaily extends BaseStatModel
|
||||
{
|
||||
protected $table = 'stat_inventory_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'total_stock_qty' => 'decimal:2',
|
||||
'total_stock_value' => 'decimal:2',
|
||||
'receipt_qty' => 'decimal:2',
|
||||
'receipt_amount' => 'decimal:2',
|
||||
'issue_qty' => 'decimal:2',
|
||||
'issue_amount' => 'decimal:2',
|
||||
'inspection_pass_rate' => 'decimal:2',
|
||||
'turnover_rate' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
18
app/Models/Stats/Daily/StatQuotePipelineDaily.php
Normal file
18
app/Models/Stats/Daily/StatQuotePipelineDaily.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats\Daily;
|
||||
|
||||
use App\Models\Stats\BaseStatModel;
|
||||
|
||||
class StatQuotePipelineDaily extends BaseStatModel
|
||||
{
|
||||
protected $table = 'stat_quote_pipeline_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'quote_amount' => 'decimal:2',
|
||||
'quote_conversion_rate' => 'decimal:2',
|
||||
'prospect_amount' => 'decimal:2',
|
||||
'bidding_amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
18
app/Models/Stats/Dimensions/DimClient.php
Normal file
18
app/Models/Stats/Dimensions/DimClient.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats\Dimensions;
|
||||
|
||||
use App\Models\Stats\BaseStatModel;
|
||||
|
||||
class DimClient extends BaseStatModel
|
||||
{
|
||||
protected $table = 'dim_client';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $casts = [
|
||||
'valid_from' => 'date',
|
||||
'valid_to' => 'date',
|
||||
'is_current' => 'boolean',
|
||||
];
|
||||
}
|
||||
18
app/Models/Stats/Dimensions/DimProduct.php
Normal file
18
app/Models/Stats/Dimensions/DimProduct.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats\Dimensions;
|
||||
|
||||
use App\Models\Stats\BaseStatModel;
|
||||
|
||||
class DimProduct extends BaseStatModel
|
||||
{
|
||||
protected $table = 'dim_product';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $casts = [
|
||||
'valid_from' => 'date',
|
||||
'valid_to' => 'date',
|
||||
'is_current' => 'boolean',
|
||||
];
|
||||
}
|
||||
19
app/Models/Stats/StatAlert.php
Normal file
19
app/Models/Stats/StatAlert.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
class StatAlert extends BaseStatModel
|
||||
{
|
||||
protected $table = 'stat_alerts';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $casts = [
|
||||
'current_value' => 'decimal:2',
|
||||
'threshold_value' => 'decimal:2',
|
||||
'is_read' => 'boolean',
|
||||
'is_resolved' => 'boolean',
|
||||
'resolved_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
12
app/Models/Stats/StatKpiTarget.php
Normal file
12
app/Models/Stats/StatKpiTarget.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
class StatKpiTarget extends BaseStatModel
|
||||
{
|
||||
protected $table = 'stat_kpi_targets';
|
||||
|
||||
protected $casts = [
|
||||
'target_value' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
163
app/Services/Stats/DimensionSyncService.php
Normal file
163
app/Services/Stats/DimensionSyncService.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
use App\Models\Stats\Dimensions\DimClient;
|
||||
use App\Models\Stats\Dimensions\DimProduct;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DimensionSyncService
|
||||
{
|
||||
/**
|
||||
* 고객 차원 동기화 (SCD Type 2)
|
||||
*/
|
||||
public function syncClients(int $tenantId): int
|
||||
{
|
||||
$today = Carbon::today()->format('Y-m-d');
|
||||
$synced = 0;
|
||||
|
||||
$clients = DB::connection('mysql')
|
||||
->table('clients')
|
||||
->where('tenant_id', $tenantId)
|
||||
->select('id', 'tenant_id', 'name', 'client_group_id', 'client_type')
|
||||
->get();
|
||||
|
||||
foreach ($clients as $client) {
|
||||
$groupName = null;
|
||||
if ($client->client_group_id) {
|
||||
$groupName = DB::connection('mysql')
|
||||
->table('client_groups')
|
||||
->where('id', $client->client_group_id)
|
||||
->value('group_name');
|
||||
}
|
||||
|
||||
$current = DimClient::where('tenant_id', $tenantId)
|
||||
->where('client_id', $client->id)
|
||||
->where('is_current', true)
|
||||
->first();
|
||||
|
||||
if (! $current) {
|
||||
DimClient::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'client_id' => $client->id,
|
||||
'client_name' => $client->name,
|
||||
'client_group_id' => $client->client_group_id,
|
||||
'client_group_name' => $groupName,
|
||||
'client_type' => $client->client_type,
|
||||
'region' => null,
|
||||
'valid_from' => $today,
|
||||
'valid_to' => null,
|
||||
'is_current' => true,
|
||||
]);
|
||||
$synced++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed = $current->client_name !== $client->name
|
||||
|| $current->client_group_id != $client->client_group_id
|
||||
|| $current->client_type !== $client->client_type;
|
||||
|
||||
if ($changed) {
|
||||
$current->update([
|
||||
'valid_to' => $today,
|
||||
'is_current' => false,
|
||||
]);
|
||||
|
||||
DimClient::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'client_id' => $client->id,
|
||||
'client_name' => $client->name,
|
||||
'client_group_id' => $client->client_group_id,
|
||||
'client_group_name' => $groupName,
|
||||
'client_type' => $client->client_type,
|
||||
'region' => null,
|
||||
'valid_from' => $today,
|
||||
'valid_to' => null,
|
||||
'is_current' => true,
|
||||
]);
|
||||
$synced++;
|
||||
}
|
||||
}
|
||||
|
||||
return $synced;
|
||||
}
|
||||
|
||||
/**
|
||||
* 제품(품목) 차원 동기화 (SCD Type 2)
|
||||
*/
|
||||
public function syncProducts(int $tenantId): int
|
||||
{
|
||||
$today = Carbon::today()->format('Y-m-d');
|
||||
$synced = 0;
|
||||
|
||||
$items = DB::connection('mysql')
|
||||
->table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->select('id', 'tenant_id', 'code', 'name', 'item_type', 'category_id')
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$categoryName = null;
|
||||
if ($item->category_id) {
|
||||
$categoryName = DB::connection('mysql')
|
||||
->table('categories')
|
||||
->where('id', $item->category_id)
|
||||
->value('name');
|
||||
}
|
||||
|
||||
$current = DimProduct::where('tenant_id', $tenantId)
|
||||
->where('item_id', $item->id)
|
||||
->where('is_current', true)
|
||||
->first();
|
||||
|
||||
if (! $current) {
|
||||
DimProduct::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'item_id' => $item->id,
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
'item_type' => $item->item_type,
|
||||
'category_id' => $item->category_id,
|
||||
'category_name' => $categoryName,
|
||||
'valid_from' => $today,
|
||||
'valid_to' => null,
|
||||
'is_current' => true,
|
||||
]);
|
||||
$synced++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed = $current->item_name !== $item->name
|
||||
|| $current->item_code !== $item->code
|
||||
|| $current->item_type !== $item->item_type
|
||||
|| $current->category_id != $item->category_id;
|
||||
|
||||
if ($changed) {
|
||||
$current->update([
|
||||
'valid_to' => $today,
|
||||
'is_current' => false,
|
||||
]);
|
||||
|
||||
DimProduct::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'item_id' => $item->id,
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
'item_type' => $item->item_type,
|
||||
'category_id' => $item->category_id,
|
||||
'category_name' => $categoryName,
|
||||
'valid_from' => $today,
|
||||
'valid_to' => null,
|
||||
'is_current' => true,
|
||||
]);
|
||||
$synced++;
|
||||
}
|
||||
}
|
||||
|
||||
return $synced;
|
||||
}
|
||||
}
|
||||
87
app/Services/Stats/HrStatService.php
Normal file
87
app/Services/Stats/HrStatService.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
use App\Models\Stats\Daily\StatHrAttendanceDaily;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HrStatService implements StatDomainServiceInterface
|
||||
{
|
||||
public function aggregateDaily(int $tenantId, Carbon $date): int
|
||||
{
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
|
||||
// 전체 직원 수 (tenant_user_profiles 기준)
|
||||
$totalEmployees = DB::connection('mysql')
|
||||
->table('user_tenants')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
// 근태 (attendances)
|
||||
$attendanceStats = DB::connection('mysql')
|
||||
->table('attendances')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('base_date', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN status = 'onTime' THEN 1 ELSE 0 END) as on_time_count,
|
||||
SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_count,
|
||||
SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_count,
|
||||
SUM(CASE WHEN status = 'overtime' THEN 1 ELSE 0 END) as overtime_count
|
||||
")
|
||||
->first();
|
||||
|
||||
$attendanceCount = ($attendanceStats->on_time_count ?? 0)
|
||||
+ ($attendanceStats->late_count ?? 0)
|
||||
+ ($attendanceStats->overtime_count ?? 0);
|
||||
$attendanceRate = $totalEmployees > 0 ? ($attendanceCount / $totalEmployees) * 100 : 0;
|
||||
|
||||
// 휴가 (leaves)
|
||||
$leaveStats = DB::connection('mysql')
|
||||
->table('leaves')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('start_date', '<=', $dateStr)
|
||||
->where('end_date', '>=', $dateStr)
|
||||
->where('status', 'approved')
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN leave_type = 'annual' THEN 1 ELSE 0 END) as annual_count,
|
||||
SUM(CASE WHEN leave_type = 'sick' THEN 1 ELSE 0 END) as sick_count,
|
||||
SUM(CASE WHEN leave_type NOT IN ('annual', 'sick') THEN 1 ELSE 0 END) as other_count
|
||||
")
|
||||
->first();
|
||||
|
||||
// 초과근무 (attendances status = 'overtime')
|
||||
$overtimeCount = $attendanceStats->overtime_count ?? 0;
|
||||
|
||||
StatHrAttendanceDaily::updateOrCreate(
|
||||
['tenant_id' => $tenantId, 'stat_date' => $dateStr],
|
||||
[
|
||||
'total_employees' => $totalEmployees,
|
||||
'attendance_count' => $attendanceCount,
|
||||
'late_count' => $attendanceStats->late_count ?? 0,
|
||||
'absent_count' => $attendanceStats->absent_count ?? 0,
|
||||
'attendance_rate' => $attendanceRate,
|
||||
'leave_count' => $leaveStats->total_count ?? 0,
|
||||
'leave_annual_count' => $leaveStats->annual_count ?? 0,
|
||||
'leave_sick_count' => $leaveStats->sick_count ?? 0,
|
||||
'leave_other_count' => $leaveStats->other_count ?? 0,
|
||||
'overtime_hours' => 0, // attendances에 시간 정보 없음
|
||||
'overtime_employee_count' => $overtimeCount,
|
||||
'total_labor_cost' => 0, // 일간 인건비는 급여 정산 시 계산
|
||||
]
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function aggregateMonthly(int $tenantId, int $year, int $month): int
|
||||
{
|
||||
// 인사 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
97
app/Services/Stats/InventoryStatService.php
Normal file
97
app/Services/Stats/InventoryStatService.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
use App\Models\Stats\Daily\StatInventoryDaily;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InventoryStatService implements StatDomainServiceInterface
|
||||
{
|
||||
public function aggregateDaily(int $tenantId, Carbon $date): int
|
||||
{
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
|
||||
// 재고 현황 (stocks 테이블 - 현재 스냅샷)
|
||||
$stockSummary = DB::connection('mysql')
|
||||
->table('stocks')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('
|
||||
COUNT(*) as sku_count,
|
||||
COALESCE(SUM(stock_qty), 0) as total_qty,
|
||||
SUM(CASE WHEN stock_qty < safety_stock AND safety_stock > 0 THEN 1 ELSE 0 END) as below_safety,
|
||||
SUM(CASE WHEN stock_qty = 0 THEN 1 ELSE 0 END) as zero_stock,
|
||||
SUM(CASE WHEN stock_qty > safety_stock * 3 AND safety_stock > 0 THEN 1 ELSE 0 END) as excess_stock
|
||||
')
|
||||
->first();
|
||||
|
||||
// 입고 (stock_transactions type = 'receipt')
|
||||
$receiptStats = DB::connection('mysql')
|
||||
->table('stock_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereDate('created_at', $dateStr)
|
||||
->where('type', 'receipt')
|
||||
->selectRaw('COUNT(*) as cnt, COALESCE(SUM(qty), 0) as total_qty')
|
||||
->first();
|
||||
|
||||
// 출고 (stock_transactions type = 'issue')
|
||||
$issueStats = DB::connection('mysql')
|
||||
->table('stock_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereDate('created_at', $dateStr)
|
||||
->where('type', 'issue')
|
||||
->selectRaw('COUNT(*) as cnt, COALESCE(SUM(ABS(qty)), 0) as total_qty')
|
||||
->first();
|
||||
|
||||
// 품질검사 (inspections)
|
||||
$inspectionStats = DB::connection('mysql')
|
||||
->table('inspections')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('inspection_date', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as cnt,
|
||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) as pass_count,
|
||||
SUM(CASE WHEN result = 'fail' THEN 1 ELSE 0 END) as fail_count
|
||||
")
|
||||
->first();
|
||||
|
||||
$inspectionCount = $inspectionStats->cnt ?? 0;
|
||||
$passCount = $inspectionStats->pass_count ?? 0;
|
||||
$failCount = $inspectionStats->fail_count ?? 0;
|
||||
$passRate = $inspectionCount > 0 ? ($passCount / $inspectionCount) * 100 : 0;
|
||||
|
||||
StatInventoryDaily::updateOrCreate(
|
||||
['tenant_id' => $tenantId, 'stat_date' => $dateStr],
|
||||
[
|
||||
'total_sku_count' => $stockSummary->sku_count ?? 0,
|
||||
'total_stock_qty' => $stockSummary->total_qty ?? 0,
|
||||
'total_stock_value' => 0, // 단가 정보 없어 Phase 4에서 보완
|
||||
'receipt_count' => $receiptStats->cnt ?? 0,
|
||||
'receipt_qty' => $receiptStats->total_qty ?? 0,
|
||||
'receipt_amount' => 0,
|
||||
'issue_count' => $issueStats->cnt ?? 0,
|
||||
'issue_qty' => $issueStats->total_qty ?? 0,
|
||||
'issue_amount' => 0,
|
||||
'below_safety_count' => $stockSummary->below_safety ?? 0,
|
||||
'zero_stock_count' => $stockSummary->zero_stock ?? 0,
|
||||
'excess_stock_count' => $stockSummary->excess_stock ?? 0,
|
||||
'inspection_count' => $inspectionCount,
|
||||
'inspection_pass_count' => $passCount,
|
||||
'inspection_fail_count' => $failCount,
|
||||
'inspection_pass_rate' => $passRate,
|
||||
'turnover_rate' => 0, // 월간 집계에서 계산
|
||||
]
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function aggregateMonthly(int $tenantId, int $year, int $month): int
|
||||
{
|
||||
// 재고 도메인은 일간 스냅샷 기반이므로 별도 월간 테이블 없음
|
||||
// 필요시 Phase 4에서 stat_inventory_monthly 추가
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
148
app/Services/Stats/KpiAlertService.php
Normal file
148
app/Services/Stats/KpiAlertService.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
use App\Models\Stats\Daily\StatFinanceDaily;
|
||||
use App\Models\Stats\Daily\StatSalesDaily;
|
||||
use App\Models\Stats\Monthly\StatSalesMonthly;
|
||||
use App\Models\Stats\StatAlert;
|
||||
use App\Models\Stats\StatKpiTarget;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class KpiAlertService
|
||||
{
|
||||
/**
|
||||
* KPI 목표 대비 실적 체크 → 알림 생성
|
||||
*/
|
||||
public function checkKpiAlerts(): array
|
||||
{
|
||||
$errors = [];
|
||||
$alertsCreated = 0;
|
||||
|
||||
$tenants = Tenant::where('tenant_st_code', '!=', 'none')->get();
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
try {
|
||||
$alertsCreated += $this->checkTenantKpi($tenant->id);
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "tenant={$tenant->id}: {$e->getMessage()}";
|
||||
Log::error('stat:check-kpi-alerts 실패', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'alerts_created' => $alertsCreated,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
private function checkTenantKpi(int $tenantId): int
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$year = $now->year;
|
||||
$month = $now->month;
|
||||
$alertsCreated = 0;
|
||||
|
||||
$targets = StatKpiTarget::where('tenant_id', $tenantId)
|
||||
->where('stat_year', $year)
|
||||
->where(function ($q) use ($month) {
|
||||
$q->where('stat_month', $month)->orWhereNull('stat_month');
|
||||
})
|
||||
->get();
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$currentValue = $this->getCurrentValue($tenantId, $target->domain, $target->metric_code, $year, $month);
|
||||
|
||||
if ($currentValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ratio = $target->target_value > 0
|
||||
? ($currentValue / $target->target_value) * 100
|
||||
: 0;
|
||||
|
||||
if ($ratio < 50) {
|
||||
$severity = 'critical';
|
||||
} elseif ($ratio < 80) {
|
||||
$severity = 'warning';
|
||||
} else {
|
||||
continue; // 80% 이상이면 알림 불필요
|
||||
}
|
||||
|
||||
StatAlert::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'domain' => $target->domain,
|
||||
'alert_type' => 'below_target',
|
||||
'severity' => $severity,
|
||||
'title' => "{$target->description} 목표 미달 (".number_format($ratio, 0).'%)',
|
||||
'message' => "KPI '{$target->metric_code}' 현재값: {$currentValue}, 목표값: {$target->target_value} ({$target->unit})",
|
||||
'metric_code' => $target->metric_code,
|
||||
'current_value' => $currentValue,
|
||||
'threshold_value' => $target->target_value,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$alertsCreated++;
|
||||
}
|
||||
|
||||
return $alertsCreated;
|
||||
}
|
||||
|
||||
private function getCurrentValue(int $tenantId, string $domain, string $metricCode, int $year, int $month): ?float
|
||||
{
|
||||
return match ($domain) {
|
||||
'sales' => $this->getSalesMetric($tenantId, $metricCode, $year, $month),
|
||||
'finance' => $this->getFinanceMetric($tenantId, $metricCode, $year, $month),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function getSalesMetric(int $tenantId, string $metricCode, int $year, int $month): ?float
|
||||
{
|
||||
$monthly = StatSalesMonthly::where('tenant_id', $tenantId)
|
||||
->where('stat_year', $year)
|
||||
->where('stat_month', $month)
|
||||
->first();
|
||||
|
||||
if (! $monthly) {
|
||||
// 월간 미집계 시 일간 합산
|
||||
$daily = StatSalesDaily::where('tenant_id', $tenantId)
|
||||
->whereYear('stat_date', $year)
|
||||
->whereMonth('stat_date', $month)
|
||||
->selectRaw('SUM(order_amount) as order_total, SUM(sales_amount) as sales_total')
|
||||
->first();
|
||||
|
||||
return match ($metricCode) {
|
||||
'monthly_order_amount' => (float) ($daily->order_total ?? 0),
|
||||
'monthly_sales_amount' => (float) ($daily->sales_total ?? 0),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($metricCode) {
|
||||
'monthly_order_amount' => (float) $monthly->order_amount,
|
||||
'monthly_sales_amount' => (float) $monthly->sales_amount,
|
||||
'monthly_order_count' => (float) $monthly->order_count,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function getFinanceMetric(int $tenantId, string $metricCode, int $year, int $month): ?float
|
||||
{
|
||||
$daily = StatFinanceDaily::where('tenant_id', $tenantId)
|
||||
->whereYear('stat_date', $year)
|
||||
->whereMonth('stat_date', $month)
|
||||
->selectRaw('SUM(deposit_amount) as deposit_total, SUM(withdrawal_amount) as withdrawal_total')
|
||||
->first();
|
||||
|
||||
return match ($metricCode) {
|
||||
'monthly_deposit_amount' => (float) ($daily->deposit_total ?? 0),
|
||||
'monthly_withdrawal_amount' => (float) ($daily->withdrawal_total ?? 0),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
93
app/Services/Stats/QuoteStatService.php
Normal file
93
app/Services/Stats/QuoteStatService.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
use App\Models\Stats\Daily\StatQuotePipelineDaily;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QuoteStatService implements StatDomainServiceInterface
|
||||
{
|
||||
public function aggregateDaily(int $tenantId, Carbon $date): int
|
||||
{
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
|
||||
// 견적 (quotes)
|
||||
$quoteStats = DB::connection('mysql')
|
||||
->table('quotes')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('registration_date', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as created_count,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount,
|
||||
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
||||
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count,
|
||||
SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as conversion_count
|
||||
")
|
||||
->first();
|
||||
|
||||
$createdCount = $quoteStats->created_count ?? 0;
|
||||
$conversionCount = $quoteStats->conversion_count ?? 0;
|
||||
$conversionRate = $createdCount > 0 ? ($conversionCount / $createdCount) * 100 : 0;
|
||||
|
||||
// 입찰 (biddings)
|
||||
$biddingStats = DB::connection('mysql')
|
||||
->table('biddings')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('bidding_date', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as cnt,
|
||||
SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_count,
|
||||
COALESCE(SUM(bidding_amount), 0) as total_amount
|
||||
")
|
||||
->first();
|
||||
|
||||
// 상담 (sales_prospect_consultations)
|
||||
$consultationCount = DB::connection('mysql')
|
||||
->table('sales_prospect_consultations')
|
||||
->whereDate('created_at', $dateStr)
|
||||
->count();
|
||||
|
||||
// 영업 기회 (sales_prospects - tenant_id 없음, created_at 기반)
|
||||
$prospectStats = DB::connection('mysql')
|
||||
->table('sales_prospects')
|
||||
->whereDate('created_at', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as created_count,
|
||||
SUM(CASE WHEN status = 'contracted' THEN 1 ELSE 0 END) as won_count,
|
||||
SUM(CASE WHEN status = 'lost' THEN 1 ELSE 0 END) as lost_count
|
||||
")
|
||||
->first();
|
||||
|
||||
StatQuotePipelineDaily::updateOrCreate(
|
||||
['tenant_id' => $tenantId, 'stat_date' => $dateStr],
|
||||
[
|
||||
'quote_created_count' => $createdCount,
|
||||
'quote_amount' => $quoteStats->total_amount ?? 0,
|
||||
'quote_approved_count' => $quoteStats->approved_count ?? 0,
|
||||
'quote_rejected_count' => $quoteStats->rejected_count ?? 0,
|
||||
'quote_conversion_count' => $conversionCount,
|
||||
'quote_conversion_rate' => $conversionRate,
|
||||
'prospect_created_count' => $prospectStats->created_count ?? 0,
|
||||
'prospect_won_count' => $prospectStats->won_count ?? 0,
|
||||
'prospect_lost_count' => $prospectStats->lost_count ?? 0,
|
||||
'prospect_amount' => 0, // sales_prospects에 금액 컬럼 없음
|
||||
'bidding_count' => $biddingStats->cnt ?? 0,
|
||||
'bidding_won_count' => $biddingStats->won_count ?? 0,
|
||||
'bidding_amount' => $biddingStats->total_amount ?? 0,
|
||||
'consultation_count' => $consultationCount,
|
||||
]
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function aggregateMonthly(int $tenantId, int $year, int $month): int
|
||||
{
|
||||
// 견적 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ private function getDailyDomainServices(): array
|
||||
'sales' => SalesStatService::class,
|
||||
'finance' => FinanceStatService::class,
|
||||
'production' => ProductionStatService::class,
|
||||
'inventory' => InventoryStatService::class,
|
||||
'quote' => QuoteStatService::class,
|
||||
'hr' => HrStatService::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,6 +33,9 @@ private function getMonthlyDomainServices(): array
|
||||
'sales' => SalesStatService::class,
|
||||
'finance' => FinanceStatService::class,
|
||||
'production' => ProductionStatService::class,
|
||||
'inventory' => InventoryStatService::class,
|
||||
'quote' => QuoteStatService::class,
|
||||
'hr' => HrStatService::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user