From e8da2ea1b1f35c69d87d642e3c7b3d20d36ecf8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 09:35:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[dashboard-ceo]=20CEO=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=84=B9=EC=85=98=EB=B3=84=20API?= =?UTF-8?q?=20=EB=B0=8F=20=EC=9D=BC=EC=9D=BC=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardCeoController/Service: 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태) - DailyReportController/Service: 엑셀 다운로드 API 추가 (GET /daily-report/export) - 라우트: dashboard 하위 6개 + daily-report/export 엔드포인트 추가 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/DailyReportController.php | 18 + .../Api/V1/DashboardCeoController.php | 92 +++ app/Services/DailyReportService.php | 70 ++ app/Services/DashboardCeoService.php | 736 ++++++++++++++++++ routes/api/v1/common.php | 9 + routes/api/v1/finance.php | 1 + 6 files changed, 926 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/DashboardCeoController.php create mode 100644 app/Services/DashboardCeoService.php diff --git a/app/Http/Controllers/Api/V1/DailyReportController.php b/app/Http/Controllers/Api/V1/DailyReportController.php index 41811ce..e2c1915 100644 --- a/app/Http/Controllers/Api/V1/DailyReportController.php +++ b/app/Http/Controllers/Api/V1/DailyReportController.php @@ -2,11 +2,14 @@ namespace App\Http\Controllers\Api\V1; +use App\Exports\DailyReportExport; use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Services\DailyReportService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Maatwebsite\Excel\Facades\Excel; +use Symfony\Component\HttpFoundation\BinaryFileResponse; /** * 일일 보고서 컨트롤러 @@ -58,4 +61,19 @@ public function summary(Request $request): JsonResponse return $this->service->summary($params); }, __('message.fetched')); } + + /** + * 일일 보고서 엑셀 다운로드 + */ + public function export(Request $request): BinaryFileResponse + { + $params = $request->validate([ + 'date' => 'nullable|date', + ]); + + $reportData = $this->service->exportData($params); + $filename = '일일일보_'.$reportData['date'].'.xlsx'; + + return Excel::download(new DailyReportExport($reportData), $filename); + } } diff --git a/app/Http/Controllers/Api/V1/DashboardCeoController.php b/app/Http/Controllers/Api/V1/DashboardCeoController.php new file mode 100644 index 0000000..e161b9b --- /dev/null +++ b/app/Http/Controllers/Api/V1/DashboardCeoController.php @@ -0,0 +1,92 @@ + $this->service->salesSummary(), + __('message.fetched') + ); + } + + /** + * 매입 현황 요약 + * GET /api/v1/dashboard/purchases/summary + */ + public function purchasesSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->purchasesSummary(), + __('message.fetched') + ); + } + + /** + * 생산 현황 요약 + * GET /api/v1/dashboard/production/summary + */ + public function productionSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->productionSummary(), + __('message.fetched') + ); + } + + /** + * 미출고 내역 요약 + * GET /api/v1/dashboard/unshipped/summary + */ + public function unshippedSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->unshippedSummary(), + __('message.fetched') + ); + } + + /** + * 시공 현황 요약 + * GET /api/v1/dashboard/construction/summary + */ + public function constructionSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->constructionSummary(), + __('message.fetched') + ); + } + + /** + * 근태 현황 요약 + * GET /api/v1/dashboard/attendance/summary + */ + public function attendanceSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->attendanceSummary(), + __('message.fetched') + ); + } +} diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index 5762d17..d2be5ca 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -182,6 +182,76 @@ public function summary(array $params): array ]; } + /** + * 엑셀 내보내기용 데이터 조합 + * DailyReportExport가 기대하는 구조로 변환 + */ + public function exportData(array $params): array + { + $tenantId = $this->tenantId(); + $date = isset($params['date']) ? Carbon::parse($params['date']) : Carbon::today(); + $dateStr = $date->format('Y-m-d'); + $startOfDay = $date->copy()->startOfDay(); + $endOfDay = $date->copy()->endOfDay(); + + // 전일 잔액 계산 (전일까지 입금 합계 - 전일까지 출금 합계) + $prevDeposits = Deposit::where('tenant_id', $tenantId) + ->where('deposit_date', '<', $startOfDay) + ->sum('amount'); + $prevWithdrawals = Withdrawal::where('tenant_id', $tenantId) + ->where('withdrawal_date', '<', $startOfDay) + ->sum('amount'); + $previousBalance = (float) ($prevDeposits - $prevWithdrawals); + + // 당일 입금 + $dailyDeposits = Deposit::where('tenant_id', $tenantId) + ->whereBetween('deposit_date', [$startOfDay, $endOfDay]) + ->get(); + $dailyDepositTotal = (float) $dailyDeposits->sum('amount'); + + // 당일 출금 + $dailyWithdrawals = Withdrawal::where('tenant_id', $tenantId) + ->whereBetween('withdrawal_date', [$startOfDay, $endOfDay]) + ->get(); + $dailyWithdrawalTotal = (float) $dailyWithdrawals->sum('amount'); + + $currentBalance = $previousBalance + $dailyDepositTotal - $dailyWithdrawalTotal; + + // 상세 내역 조합 + $details = []; + + foreach ($dailyDeposits as $d) { + $details[] = [ + 'type_label' => '입금', + 'client_name' => $d->client?->name ?? '-', + 'account_code' => $d->account_code ?? '-', + 'deposit_amount' => (float) $d->amount, + 'withdrawal_amount' => 0, + 'description' => $d->description ?? '', + ]; + } + + foreach ($dailyWithdrawals as $w) { + $details[] = [ + 'type_label' => '출금', + 'client_name' => $w->client?->name ?? '-', + 'account_code' => $w->account_code ?? '-', + 'deposit_amount' => 0, + 'withdrawal_amount' => (float) $w->amount, + 'description' => $w->description ?? '', + ]; + } + + return [ + 'date' => $dateStr, + 'previous_balance' => $previousBalance, + 'daily_deposit' => $dailyDepositTotal, + 'daily_withdrawal' => $dailyWithdrawalTotal, + 'current_balance' => $currentBalance, + 'details' => $details, + ]; + } + /** * 미수금 잔액 계산 * = 전체 매출 - 전체 입금 - 전체 수취어음 (기준일까지) diff --git a/app/Services/DashboardCeoService.php b/app/Services/DashboardCeoService.php new file mode 100644 index 0000000..d021e4c --- /dev/null +++ b/app/Services/DashboardCeoService.php @@ -0,0 +1,736 @@ +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.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.name') + ->orderBy('p.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('departments as d', 'u.department_id', '=', 'd.id') + ->select([ + 'a.id', + 'a.status', + 'u.name', + 'd.name as department', + 'u.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', + }; + } +} diff --git a/routes/api/v1/common.php b/routes/api/v1/common.php index ede81c7..c37c1ef 100644 --- a/routes/api/v1/common.php +++ b/routes/api/v1/common.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Api\V1\CategoryTemplateController; use App\Http\Controllers\Api\V1\ClassificationController; use App\Http\Controllers\Api\V1\CommonController; +use App\Http\Controllers\Api\V1\DashboardCeoController; use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\NotificationSettingController; @@ -225,4 +226,12 @@ Route::get('/summary', [DashboardController::class, 'summary'])->name('v1.dashboard.summary'); Route::get('/charts', [DashboardController::class, 'charts'])->name('v1.dashboard.charts'); Route::get('/approvals', [DashboardController::class, 'approvals'])->name('v1.dashboard.approvals'); + + // CEO 대시보드 섹션별 API + Route::get('/sales/summary', [DashboardCeoController::class, 'salesSummary'])->name('v1.dashboard.ceo.sales'); + Route::get('/purchases/summary', [DashboardCeoController::class, 'purchasesSummary'])->name('v1.dashboard.ceo.purchases'); + Route::get('/production/summary', [DashboardCeoController::class, 'productionSummary'])->name('v1.dashboard.ceo.production'); + Route::get('/unshipped/summary', [DashboardCeoController::class, 'unshippedSummary'])->name('v1.dashboard.ceo.unshipped'); + Route::get('/construction/summary', [DashboardCeoController::class, 'constructionSummary'])->name('v1.dashboard.ceo.construction'); + Route::get('/attendance/summary', [DashboardCeoController::class, 'attendanceSummary'])->name('v1.dashboard.ceo.attendance'); }); diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index aef25d2..0ce30b9 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -183,6 +183,7 @@ Route::get('/note-receivables', [DailyReportController::class, 'noteReceivables'])->name('v1.daily-report.note-receivables'); Route::get('/daily-accounts', [DailyReportController::class, 'dailyAccounts'])->name('v1.daily-report.daily-accounts'); Route::get('/summary', [DailyReportController::class, 'summary'])->name('v1.daily-report.summary'); + Route::get('/export', [DailyReportController::class, 'export'])->name('v1.daily-report.export'); }); // Comprehensive Analysis API (종합 분석 보고서)