diff --git a/app/Exports/DailyReportExport.php b/app/Exports/DailyReportExport.php index f8c4eee..3c7fcaa 100644 --- a/app/Exports/DailyReportExport.php +++ b/app/Exports/DailyReportExport.php @@ -7,6 +7,7 @@ use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithStyles; use Maatwebsite\Excel\Concerns\WithTitle; +use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle @@ -31,10 +32,10 @@ public function headings(): array return [ ['일일 일보 - '.$this->report['date']], [], - ['전일 잔액', number_format($this->report['previous_balance']).'원'], - ['당일 입금액', number_format($this->report['daily_deposit']).'원'], - ['당일 출금액', number_format($this->report['daily_withdrawal']).'원'], - ['당일 잔액', number_format($this->report['current_balance']).'원'], + ['전월 이월', number_format($this->report['previous_balance']).'원'], + ['당월 입금', number_format($this->report['daily_deposit']).'원'], + ['당월 출금', number_format($this->report['daily_withdrawal']).'원'], + ['잔액', number_format($this->report['current_balance']).'원'], [], ['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'], ]; @@ -47,6 +48,7 @@ public function array(): array { $rows = []; + // ── 예금 입출금 내역 ── foreach ($this->report['details'] as $detail) { $rows[] = [ $detail['type_label'], @@ -58,7 +60,7 @@ public function array(): array ]; } - // 합계 행 추가 + // 합계 행 $rows[] = []; $rows[] = [ '합계', @@ -69,6 +71,37 @@ public function array(): array '', ]; + // ── 어음 및 외상매출채권 현황 ── + $noteReceivables = $this->report['note_receivables'] ?? []; + + $rows[] = []; + $rows[] = []; + $rows[] = ['어음 및 외상매출채권 현황']; + $rows[] = ['No.', '내용', '금액', '발행일', '만기일']; + + $noteTotal = 0; + $no = 1; + foreach ($noteReceivables as $item) { + $amount = $item['current_balance'] ?? 0; + $noteTotal += $amount; + $rows[] = [ + $no++, + $item['content'] ?? '-', + $amount > 0 ? number_format($amount) : '', + $item['issue_date'] ?? '-', + $item['due_date'] ?? '-', + ]; + } + + // 어음 합계 + $rows[] = [ + '합계', + '', + number_format($noteTotal), + '', + '', + ]; + return $rows; } @@ -77,7 +110,7 @@ public function array(): array */ public function styles(Worksheet $sheet): array { - return [ + $styles = [ 1 => ['font' => ['bold' => true, 'size' => 14]], 3 => ['font' => ['bold' => true]], 4 => ['font' => ['bold' => true]], @@ -86,10 +119,32 @@ public function styles(Worksheet $sheet): array 8 => [ 'font' => ['bold' => true], 'fill' => [ - 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'E0E0E0'], ], ], ]; + + // 어음 섹션 헤더 스타일 (동적 행 번호) + // headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행 + $detailCount = count($this->report['details']); + $noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행 + $noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행 + + $styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]]; + $styles[$noteHeaderRow] = [ + 'font' => ['bold' => true], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'E0E0E0'], + ], + ]; + + // 어음 합계 행 + $noteCount = count($this->report['note_receivables'] ?? []); + $noteTotalRow = $noteHeaderRow + $noteCount + 1; + $styles[$noteTotalRow] = ['font' => ['bold' => true]]; + + return $styles; } } 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/AiReportService.php b/app/Services/AiReportService.php index 8a65a0e..cc87567 100644 --- a/app/Services/AiReportService.php +++ b/app/Services/AiReportService.php @@ -323,7 +323,7 @@ private function getReceivableData(int $tenantId, Carbon $reportDate): array private function callGeminiApi(array $inputData): array { $apiKey = config('services.gemini.api_key'); - $model = config('services.gemini.model', 'gemini-2.0-flash'); + $model = config('services.gemini.model', 'gemini-2.5-flash'); $baseUrl = config('services.gemini.base_url'); if (empty($apiKey)) { diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index fbabb97..188ea79 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -5,6 +5,9 @@ use App\Models\Tenants\BankAccount; use App\Models\Tenants\Bill; use App\Models\Tenants\Deposit; +use App\Models\Tenants\ExpectedExpense; +use App\Models\Tenants\Purchase; +use App\Models\Tenants\Sale; use App\Models\Tenants\Withdrawal; use Carbon\Carbon; @@ -155,6 +158,11 @@ public function summary(array $params): array : null; $operatingStability = $this->getOperatingStability($operatingMonths); + // 기획서 D1.7 자금현황 카드용 필드 + $receivableBalance = $this->calculateReceivableBalance($tenantId, $date); + $payableBalance = $this->calculatePayableBalance($tenantId); + $monthlyExpenseTotal = $this->calculateMonthlyExpenseTotal($tenantId, $date); + return [ 'date' => $date->format('Y-m-d'), 'day_of_week' => $date->locale('ko')->dayName, @@ -167,9 +175,138 @@ public function summary(array $params): array 'monthly_operating_expense' => $monthlyOperatingExpense, 'operating_months' => $operatingMonths, 'operating_stability' => $operatingStability, + // 자금현황 카드용 + 'receivable_balance' => $receivableBalance, + 'payable_balance' => $payableBalance, + 'monthly_expense_total' => $monthlyExpenseTotal, ]; } + /** + * 엑셀 내보내기용 데이터 조합 + * DailyReportExport가 기대하는 구조로 변환 + */ + public function exportData(array $params): array + { + $date = isset($params['date']) ? Carbon::parse($params['date']) : Carbon::today(); + $dateStr = $date->format('Y-m-d'); + + // 화면과 동일한 계좌별 현황 데이터 재사용 + $dailyAccounts = $this->dailyAccounts($params); + + // KRW 계좌 합산 (화면 합계와 동일) + $carryover = 0; + $totalIncome = 0; + $totalExpense = 0; + $totalBalance = 0; + + $details = []; + + foreach ($dailyAccounts as $account) { + $carryover += $account['carryover']; + $totalIncome += $account['income']; + $totalExpense += $account['expense']; + $totalBalance += $account['balance']; + + // 계좌별 상세 내역 + if ($account['income'] > 0) { + $details[] = [ + 'type_label' => '입금', + 'client_name' => $account['category'], + 'account_code' => '-', + 'deposit_amount' => $account['income'], + 'withdrawal_amount' => 0, + 'description' => '', + ]; + } + + if ($account['expense'] > 0) { + $details[] = [ + 'type_label' => '출금', + 'client_name' => $account['category'], + 'account_code' => '-', + 'deposit_amount' => 0, + 'withdrawal_amount' => $account['expense'], + 'description' => '', + ]; + } + } + + // 어음 및 외상매출채권 현황 + $noteReceivables = $this->noteReceivables($params); + + return [ + 'date' => $dateStr, + 'previous_balance' => $carryover, + 'daily_deposit' => $totalIncome, + 'daily_withdrawal' => $totalExpense, + 'current_balance' => $totalBalance, + 'details' => $details, + 'note_receivables' => $noteReceivables, + ]; + } + + /** + * 미수금 잔액 계산 + * = 전체 매출 - 전체 입금 - 전체 수취어음 (기준일까지) + * ReceivablesService.getTotalCarryForwardBalance() 동일 로직 + */ + private function calculateReceivableBalance(int $tenantId, Carbon $date): float + { + $endDate = $date->format('Y-m-d'); + + $totalSales = Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('sale_date', '<=', $endDate) + ->sum('total_amount'); + + $totalDeposits = Deposit::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('deposit_date', '<=', $endDate) + ->sum('amount'); + + $totalBills = Bill::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('bill_type', 'received') + ->where('issue_date', '<=', $endDate) + ->sum('amount'); + + return (float) ($totalSales - $totalDeposits - $totalBills); + } + + /** + * 미지급금 잔액 계산 + * = 미지급 상태(pending, partial, overdue)인 ExpectedExpense 합계 + */ + private function calculatePayableBalance(int $tenantId): float + { + return (float) ExpectedExpense::where('tenant_id', $tenantId) + ->whereIn('payment_status', ['pending', 'partial', 'overdue']) + ->sum('amount'); + } + + /** + * 당월 예상 지출 합계 계산 + * = 당월 매입(Purchase) + 당월 예상지출(ExpectedExpense) + */ + private function calculateMonthlyExpenseTotal(int $tenantId, Carbon $date): float + { + $startOfMonth = $date->copy()->startOfMonth()->format('Y-m-d'); + $endOfMonth = $date->copy()->endOfMonth()->format('Y-m-d'); + + // 당월 매입 합계 + $purchaseTotal = Purchase::where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$startOfMonth, $endOfMonth]) + ->sum('total_amount'); + + // 당월 예상 지출 합계 (매입 외: 카드, 어음, 급여, 임대료 등) + $expectedExpenseTotal = ExpectedExpense::where('tenant_id', $tenantId) + ->whereBetween('expected_payment_date', [$startOfMonth, $endOfMonth]) + ->sum('amount'); + + return (float) ($purchaseTotal + $expectedExpenseTotal); + } + /** * 직전 3개월 평균 월 운영비 계산 * diff --git a/app/Services/DashboardCeoService.php b/app/Services/DashboardCeoService.php new file mode 100644 index 0000000..1dccd32 --- /dev/null +++ b/app/Services/DashboardCeoService.php @@ -0,0 +1,740 @@ +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', + }; + } +}