tenantId(); $now = Carbon::now(); $year = $now->year; $month = $now->month; $today = $now->format('Y-m-d'); // 누적 매출 (연초~오늘) $cumulativeSales = DB::table('sales') ->where('tenant_id', $tenantId) ->whereYear('sale_date', $year) ->where('sale_date', '<=', $today) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; // 당월 매출 $monthlySales = DB::table('sales') ->where('tenant_id', $tenantId) ->whereYear('sale_date', $year) ->whereMonth('sale_date', $month) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; // 전년 동월 매출 (YoY) $lastYearMonthlySales = DB::table('sales') ->where('tenant_id', $tenantId) ->whereYear('sale_date', $year - 1) ->whereMonth('sale_date', $month) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; $yoyChange = $lastYearMonthlySales > 0 ? round((($monthlySales - $lastYearMonthlySales) / $lastYearMonthlySales) * 100, 1) : 0; // 달성률 (당월 매출 / 전년 동월 매출 * 100) $achievementRate = $lastYearMonthlySales > 0 ? round(($monthlySales / $lastYearMonthlySales) * 100, 0) : 0; // 월별 추이 (1~12월) $monthlyTrend = $this->getSalesMonthlyTrend($tenantId, $year); // 거래처별 매출 (상위 5개) $clientSales = $this->getSalesClientRanking($tenantId, $year); // 일별 매출 내역 (최근 10건) $dailyItems = $this->getSalesDailyItems($tenantId, $today); // 일별 합계 $dailyTotal = DB::table('sales') ->where('tenant_id', $tenantId) ->where('sale_date', $today) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; return [ 'cumulative_sales' => (int) $cumulativeSales, 'achievement_rate' => (int) $achievementRate, 'yoy_change' => $yoyChange, 'monthly_sales' => (int) $monthlySales, 'monthly_trend' => $monthlyTrend, 'client_sales' => $clientSales, 'daily_items' => $dailyItems, 'daily_total' => (int) $dailyTotal, ]; } private function getSalesMonthlyTrend(int $tenantId, int $year): array { $monthlyData = DB::table('sales') ->select(DB::raw('MONTH(sale_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount')) ->where('tenant_id', $tenantId) ->whereYear('sale_date', $year) ->whereNull('deleted_at') ->groupBy(DB::raw('MONTH(sale_date)')) ->orderBy('month') ->get(); $result = []; for ($i = 1; $i <= 12; $i++) { $found = $monthlyData->firstWhere('month', $i); $result[] = [ 'month' => sprintf('%d-%02d', $year, $i), 'label' => $i.'월', 'amount' => $found ? (int) $found->amount : 0, ]; } return $result; } private function getSalesClientRanking(int $tenantId, int $year): array { $clients = DB::table('sales as s') ->leftJoin('clients as c', 's.client_id', '=', 'c.id') ->select('c.name', DB::raw('SUM(s.total_amount) as amount')) ->where('s.tenant_id', $tenantId) ->whereYear('s.sale_date', $year) ->whereNull('s.deleted_at') ->groupBy('s.client_id', 'c.name') ->orderByDesc('amount') ->limit(5) ->get(); return $clients->map(fn ($item) => [ 'name' => $item->name ?? '미지정', 'amount' => (int) $item->amount, ])->toArray(); } private function getSalesDailyItems(int $tenantId, string $today): array { $items = DB::table('sales as s') ->leftJoin('clients as c', 's.client_id', '=', 'c.id') ->select([ 's.sale_date as date', 'c.name as client', 's.description as item', 's.total_amount as amount', 's.status', 's.deposit_id', ]) ->where('s.tenant_id', $tenantId) ->where('s.sale_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d')) ->whereNull('s.deleted_at') ->orderByDesc('s.sale_date') ->limit(10) ->get(); return $items->map(fn ($item) => [ 'date' => $item->date, 'client' => $item->client ?? '미지정', 'item' => $item->item ?? '-', 'amount' => (int) $item->amount, 'status' => $item->deposit_id ? 'deposited' : 'unpaid', ])->toArray(); } // ─── 2. 매입 현황 ─────────────────────────────── /** * 매입 현황 요약 */ public function purchasesSummary(): array { $tenantId = $this->tenantId(); $now = Carbon::now(); $year = $now->year; $month = $now->month; $today = $now->format('Y-m-d'); // 누적 매입 $cumulativePurchase = DB::table('purchases') ->where('tenant_id', $tenantId) ->whereYear('purchase_date', $year) ->where('purchase_date', '<=', $today) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; // 미결제 금액 (withdrawal_id가 없는 것) $unpaidAmount = DB::table('purchases') ->where('tenant_id', $tenantId) ->whereYear('purchase_date', $year) ->whereNull('withdrawal_id') ->whereNull('deleted_at') ->sum('total_amount') ?: 0; // 전년 동월 대비 $thisMonthPurchase = DB::table('purchases') ->where('tenant_id', $tenantId) ->whereYear('purchase_date', $year) ->whereMonth('purchase_date', $month) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; $lastYearMonthPurchase = DB::table('purchases') ->where('tenant_id', $tenantId) ->whereYear('purchase_date', $year - 1) ->whereMonth('purchase_date', $month) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; $yoyChange = $lastYearMonthPurchase > 0 ? round((($thisMonthPurchase - $lastYearMonthPurchase) / $lastYearMonthPurchase) * 100, 1) : 0; // 월별 추이 $monthlyTrend = $this->getPurchaseMonthlyTrend($tenantId, $year); // 자재 구성 비율 (purchase_type별) $materialRatio = $this->getPurchaseMaterialRatio($tenantId, $year); // 일별 매입 내역 $dailyItems = $this->getPurchaseDailyItems($tenantId, $today); // 일별 합계 $dailyTotal = DB::table('purchases') ->where('tenant_id', $tenantId) ->where('purchase_date', $today) ->whereNull('deleted_at') ->sum('total_amount') ?: 0; return [ 'cumulative_purchase' => (int) $cumulativePurchase, 'unpaid_amount' => (int) $unpaidAmount, 'yoy_change' => $yoyChange, 'monthly_trend' => $monthlyTrend, 'material_ratio' => $materialRatio, 'daily_items' => $dailyItems, 'daily_total' => (int) $dailyTotal, ]; } private function getPurchaseMonthlyTrend(int $tenantId, int $year): array { $monthlyData = DB::table('purchases') ->select(DB::raw('MONTH(purchase_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount')) ->where('tenant_id', $tenantId) ->whereYear('purchase_date', $year) ->whereNull('deleted_at') ->groupBy(DB::raw('MONTH(purchase_date)')) ->orderBy('month') ->get(); $result = []; for ($i = 1; $i <= 12; $i++) { $found = $monthlyData->firstWhere('month', $i); $result[] = [ 'month' => sprintf('%d-%02d', $year, $i), 'label' => $i.'월', 'amount' => $found ? (int) $found->amount : 0, ]; } return $result; } private function getPurchaseMaterialRatio(int $tenantId, int $year): array { $colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; $ratioData = DB::table('purchases') ->select('purchase_type', DB::raw('SUM(total_amount) as value')) ->where('tenant_id', $tenantId) ->whereYear('purchase_date', $year) ->whereNull('deleted_at') ->groupBy('purchase_type') ->orderByDesc('value') ->limit(6) ->get(); $total = $ratioData->sum('value'); $idx = 0; return $ratioData->map(function ($item) use ($total, $colors, &$idx) { $name = $this->getPurchaseTypeName($item->purchase_type); $result = [ 'name' => $name, 'value' => (int) $item->value, 'percentage' => $total > 0 ? round(($item->value / $total) * 100, 1) : 0, 'color' => $colors[$idx % count($colors)], ]; $idx++; return $result; })->toArray(); } private function getPurchaseTypeName(?string $type): string { $map = [ '원재료매입' => '원자재', '부재료매입' => '부자재', '소모품매입' => '소모품', '외주가공비' => '외주가공', '접대비' => '접대비', '복리후생비' => '복리후생', ]; return $map[$type] ?? ($type ?? '기타'); } private function getPurchaseDailyItems(int $tenantId, string $today): array { $items = DB::table('purchases as p') ->leftJoin('clients as c', 'p.client_id', '=', 'c.id') ->select([ 'p.purchase_date as date', 'c.name as supplier', 'p.description as item', 'p.total_amount as amount', 'p.withdrawal_id', ]) ->where('p.tenant_id', $tenantId) ->where('p.purchase_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d')) ->whereNull('p.deleted_at') ->orderByDesc('p.purchase_date') ->limit(10) ->get(); return $items->map(fn ($item) => [ 'date' => $item->date, 'supplier' => $item->supplier ?? '미지정', 'item' => $item->item ?? '-', 'amount' => (int) $item->amount, 'status' => $item->withdrawal_id ? 'paid' : 'unpaid', ])->toArray(); } // ─── 3. 생산 현황 ─────────────────────────────── /** * 생산 현황 요약 */ public function productionSummary(): array { $tenantId = $this->tenantId(); $today = Carbon::now(); $todayStr = $today->format('Y-m-d'); $dayOfWeekMap = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; $dayOfWeek = $dayOfWeekMap[$today->dayOfWeek]; // 공정별 작업 현황 $processes = $this->getProductionProcesses($tenantId, $todayStr); // 출고 현황 $shipment = $this->getShipmentSummary($tenantId, $todayStr); return [ 'date' => $todayStr, 'day_of_week' => $dayOfWeek, 'processes' => $processes, 'shipment' => $shipment, ]; } private function getProductionProcesses(int $tenantId, string $today): array { // 공정별 작업 지시 집계 $processData = DB::table('work_orders as wo') ->leftJoin('processes as p', 'wo.process_id', '=', 'p.id') ->select( 'p.id as process_id', 'p.process_name as process_name', DB::raw('COUNT(*) as total_work'), DB::raw("SUM(CASE WHEN wo.status = 'pending' OR wo.status = 'unassigned' OR wo.status = 'waiting' THEN 1 ELSE 0 END) as todo"), DB::raw("SUM(CASE WHEN wo.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress"), DB::raw("SUM(CASE WHEN wo.status = 'completed' OR wo.status = 'shipped' THEN 1 ELSE 0 END) as completed"), DB::raw("SUM(CASE WHEN wo.priority = 'urgent' THEN 1 ELSE 0 END) as urgent"), ) ->where('wo.tenant_id', $tenantId) ->where('wo.scheduled_date', $today) ->where('wo.is_active', true) ->whereNull('wo.deleted_at') ->whereNotNull('wo.process_id') ->groupBy('p.id', 'p.process_name') ->orderBy('p.process_name') ->get(); return $processData->map(function ($process) use ($tenantId, $today) { $totalWork = (int) $process->total_work; $todo = (int) $process->todo; $inProgress = (int) $process->in_progress; $completed = (int) $process->completed; // 작업 아이템 (최대 5건) $workItems = DB::table('work_orders as wo') ->leftJoin('orders as o', 'wo.sales_order_id', '=', 'o.id') ->leftJoin('clients as c', 'o.client_id', '=', 'c.id') ->select([ 'wo.id', 'wo.work_order_no as order_no', 'c.name as client', 'wo.project_name as product', 'wo.status', ]) ->where('wo.tenant_id', $tenantId) ->where('wo.process_id', $process->process_id) ->where('wo.scheduled_date', $today) ->where('wo.is_active', true) ->whereNull('wo.deleted_at') ->orderByRaw("FIELD(wo.priority, 'urgent', 'normal', 'low')") ->limit(5) ->get(); // 작업자별 현황 $workers = DB::table('work_order_assignees as woa') ->join('work_orders as wo', 'woa.work_order_id', '=', 'wo.id') ->leftJoin('users as u', 'woa.user_id', '=', 'u.id') ->select( 'u.name', DB::raw('COUNT(*) as assigned'), DB::raw("SUM(CASE WHEN wo.status IN ('completed', 'shipped') THEN 1 ELSE 0 END) as completed"), ) ->where('wo.tenant_id', $tenantId) ->where('wo.process_id', $process->process_id) ->where('wo.scheduled_date', $today) ->where('wo.is_active', true) ->whereNull('wo.deleted_at') ->groupBy('woa.user_id', 'u.name') ->get(); return [ 'process_name' => $process->process_name ?? '미지정', 'total_work' => $totalWork, 'todo' => $todo, 'in_progress' => $inProgress, 'completed' => $completed, 'urgent' => (int) $process->urgent, 'sub_line' => 0, 'regular' => max(0, $totalWork - (int) $process->urgent), 'worker_count' => $workers->count(), 'work_items' => $workItems->map(fn ($wi) => [ 'id' => 'wo_'.$wi->id, 'order_no' => $wi->order_no ?? '-', 'client' => $wi->client ?? '미지정', 'product' => $wi->product ?? '-', 'quantity' => 0, 'status' => $this->mapWorkOrderStatus($wi->status), ])->toArray(), 'workers' => $workers->map(fn ($w) => [ 'name' => $w->name ?? '미지정', 'assigned' => (int) $w->assigned, 'completed' => (int) $w->completed, 'rate' => $w->assigned > 0 ? round(($w->completed / $w->assigned) * 100, 0) : 0, ])->toArray(), ]; })->toArray(); } private function mapWorkOrderStatus(string $status): string { return match ($status) { 'completed', 'shipped' => 'completed', 'in_progress' => 'in_progress', default => 'pending', }; } private function getShipmentSummary(int $tenantId, string $today): array { $thisMonth = Carbon::parse($today); $monthStart = $thisMonth->copy()->startOfMonth()->format('Y-m-d'); $monthEnd = $thisMonth->copy()->endOfMonth()->format('Y-m-d'); // 예정 출고 $expected = DB::table('shipments') ->where('tenant_id', $tenantId) ->whereBetween('scheduled_date', [$monthStart, $monthEnd]) ->whereIn('status', ['scheduled', 'ready']) ->whereNull('deleted_at') ->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount') ->first(); // 실제 출고 $actual = DB::table('shipments') ->where('tenant_id', $tenantId) ->whereBetween('scheduled_date', [$monthStart, $monthEnd]) ->whereIn('status', ['shipping', 'completed']) ->whereNull('deleted_at') ->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount') ->first(); return [ 'expected_amount' => (int) ($expected->amount ?? 0), 'expected_count' => (int) ($expected->count ?? 0), 'actual_amount' => (int) ($actual->amount ?? 0), 'actual_count' => (int) ($actual->count ?? 0), ]; } // ─── 4. 미출고 내역 ────────────────────────────── /** * 미출고 내역 요약 */ public function unshippedSummary(): array { $tenantId = $this->tenantId(); $today = Carbon::now()->format('Y-m-d'); $items = DB::table('shipments as s') ->leftJoin('orders as o', 's.order_id', '=', 'o.id') ->leftJoin('clients as c', 's.client_id', '=', 'c.id') ->select([ 's.id', 's.lot_no as port_no', 's.site_name', 'c.name as order_client', 's.scheduled_date as due_date', ]) ->where('s.tenant_id', $tenantId) ->whereIn('s.status', ['scheduled', 'ready']) ->whereNull('s.deleted_at') ->orderBy('s.scheduled_date') ->limit(50) ->get(); $result = $items->map(function ($item) use ($today) { $dueDate = Carbon::parse($item->due_date); $daysLeft = Carbon::parse($today)->diffInDays($dueDate, false); return [ 'id' => 'us_'.$item->id, 'port_no' => $item->port_no ?? '-', 'site_name' => $item->site_name ?? '-', 'order_client' => $item->order_client ?? '미지정', 'due_date' => $item->due_date, 'days_left' => (int) $daysLeft, ]; })->toArray(); return [ 'items' => $result, 'total_count' => count($result), ]; } // ─── 5. 시공 현황 ─────────────────────────────── /** * 시공 현황 요약 */ public function constructionSummary(): array { $tenantId = $this->tenantId(); $now = Carbon::now(); $monthStart = $now->copy()->startOfMonth()->format('Y-m-d'); $monthEnd = $now->copy()->endOfMonth()->format('Y-m-d'); // 이번 달 시공 건수 $thisMonthCount = DB::table('contracts') ->where('tenant_id', $tenantId) ->where(function ($q) use ($monthStart, $monthEnd) { $q->whereBetween('contract_start_date', [$monthStart, $monthEnd]) ->orWhereBetween('contract_end_date', [$monthStart, $monthEnd]) ->orWhere(function ($q2) use ($monthStart, $monthEnd) { $q2->where('contract_start_date', '<=', $monthStart) ->where('contract_end_date', '>=', $monthEnd); }); }) ->where('is_active', true) ->whereNull('deleted_at') ->count(); // 완료 건수 $completedCount = DB::table('contracts') ->where('tenant_id', $tenantId) ->where('status', 'completed') ->where(function ($q) use ($monthStart, $monthEnd) { $q->whereBetween('contract_end_date', [$monthStart, $monthEnd]); }) ->where('is_active', true) ->whereNull('deleted_at') ->count(); // 시공 아이템 목록 $items = DB::table('contracts as ct') ->leftJoin('users as u', 'ct.construction_pm_id', '=', 'u.id') ->select([ 'ct.id', 'ct.project_name as site_name', 'ct.partner_name as client', 'ct.contract_start_date as start_date', 'ct.contract_end_date as end_date', 'ct.status', 'ct.stage', ]) ->where('ct.tenant_id', $tenantId) ->where('ct.is_active', true) ->whereNull('ct.deleted_at') ->where(function ($q) use ($monthStart, $monthEnd) { $q->whereBetween('ct.contract_start_date', [$monthStart, $monthEnd]) ->orWhereBetween('ct.contract_end_date', [$monthStart, $monthEnd]) ->orWhere(function ($q2) use ($monthStart, $monthEnd) { $q2->where('ct.contract_start_date', '<=', $monthStart) ->where('ct.contract_end_date', '>=', $monthEnd); }); }) ->orderBy('ct.contract_start_date') ->limit(20) ->get(); $today = $now->format('Y-m-d'); return [ 'this_month' => $thisMonthCount, 'completed' => $completedCount, 'items' => $items->map(function ($item) use ($today) { $progress = $this->calculateContractProgress($item, $today); return [ 'id' => 'c_'.$item->id, 'site_name' => $item->site_name ?? '-', 'client' => $item->client ?? '미지정', 'start_date' => $item->start_date, 'end_date' => $item->end_date, 'progress' => $progress, 'status' => $this->mapContractStatus($item->status, $item->start_date, $today), ]; })->toArray(), ]; } private function calculateContractProgress(object $contract, string $today): int { if ($contract->status === 'completed') { return 100; } $start = Carbon::parse($contract->start_date); $end = Carbon::parse($contract->end_date); $now = Carbon::parse($today); if ($now->lt($start)) { return 0; } $totalDays = $start->diffInDays($end); if ($totalDays <= 0) { return 0; } $elapsedDays = $start->diffInDays($now); $progress = min(99, round(($elapsedDays / $totalDays) * 100)); return (int) $progress; } private function mapContractStatus(string $status, ?string $startDate, string $today): string { if ($status === 'completed') { return 'completed'; } if ($startDate && Carbon::parse($startDate)->gt(Carbon::parse($today))) { return 'scheduled'; } return 'in_progress'; } // ─── 6. 근태 현황 ─────────────────────────────── /** * 근태 현황 요약 */ public function attendanceSummary(): array { $tenantId = $this->tenantId(); $today = Carbon::now()->format('Y-m-d'); // 오늘 근태 기록 $attendances = DB::table('attendances as a') ->leftJoin('users as u', 'a.user_id', '=', 'u.id') ->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) { $join->on('tup.user_id', '=', 'u.id') ->where('tup.tenant_id', '=', $tenantId); }) ->leftJoin('departments as d', 'tup.department_id', '=', 'd.id') ->select([ 'a.id', 'a.status', 'u.name', 'd.name as department', 'tup.position_key as position', ]) ->where('a.tenant_id', $tenantId) ->where('a.base_date', $today) ->whereNull('a.deleted_at') ->get(); $present = 0; $onLeave = 0; $late = 0; $absent = 0; $employees = $attendances->map(function ($att) use (&$present, &$onLeave, &$late, &$absent) { $mappedStatus = $this->mapAttendanceStatus($att->status); match ($mappedStatus) { 'present' => $present++, 'on_leave' => $onLeave++, 'late' => $late++, 'absent' => $absent++, default => null, }; return [ 'id' => 'emp_'.$att->id, 'department' => $att->department ?? '-', 'position' => $att->position ?? '-', 'name' => $att->name ?? '-', 'status' => $mappedStatus, ]; })->toArray(); return [ 'present' => $present, 'on_leave' => $onLeave, 'late' => $late, 'absent' => $absent, 'employees' => $employees, ]; } private function mapAttendanceStatus(?string $status): string { return match ($status) { 'onTime', 'normal', 'overtime', 'earlyLeave' => 'present', 'late', 'lateEarlyLeave' => 'late', 'vacation', 'halfDayVacation', 'sickLeave' => 'on_leave', 'absent', 'noRecord' => 'absent', default => 'present', }; } }