From f7850e43a7d3d5d2cd11a77fec379002d7219488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 21 Jan 2026 10:25:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CEO=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20DB=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusBoardService: 현황판 8개 항목 집계 API - CalendarService: 캘린더 일정 조회 API (작업지시/계약/휴가) - TodayIssueService: 오늘의 이슈 리스트 API - VatService: 부가세 신고 현황 API - EntertainmentService: 접대비 현황 API - WelfareService: 복리후생 현황 API 버그 수정: - orders 테이블 status → status_code 컬럼명 수정 - users 테이블 department 관계 → tenantProfile.department로 수정 - Swagger 문서 및 라우트 추가 --- .../Controllers/Api/V1/CalendarController.php | 57 +++ .../Api/V1/EntertainmentController.php | 39 ++ .../Api/V1/StatusBoardController.php | 30 ++ .../Api/V1/TodayIssueController.php | 27 ++ app/Http/Controllers/Api/V1/VatController.php | 38 ++ .../Controllers/Api/V1/WelfareController.php | 52 +++ app/Services/CalendarService.php | 220 ++++++++++ app/Services/EntertainmentService.php | 258 ++++++++++++ app/Services/StatusBoardService.php | 231 +++++++++++ app/Services/TodayIssueService.php | 384 ++++++++++++++++++ app/Services/VatService.php | 261 ++++++++++++ app/Services/WelfareService.php | 252 ++++++++++++ app/Swagger/v1/CalendarApi.php | 146 +++++++ app/Swagger/v1/EntertainmentApi.php | 147 +++++++ app/Swagger/v1/StatusBoardApi.php | 94 +++++ app/Swagger/v1/TodayIssueApi.php | 89 ++++ app/Swagger/v1/VatApi.php | 138 +++++++ app/Swagger/v1/WelfareApi.php | 165 ++++++++ lang/ko/message.php | 60 +++ routes/api.php | 24 ++ 20 files changed, 2712 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/CalendarController.php create mode 100644 app/Http/Controllers/Api/V1/EntertainmentController.php create mode 100644 app/Http/Controllers/Api/V1/StatusBoardController.php create mode 100644 app/Http/Controllers/Api/V1/TodayIssueController.php create mode 100644 app/Http/Controllers/Api/V1/VatController.php create mode 100644 app/Http/Controllers/Api/V1/WelfareController.php create mode 100644 app/Services/CalendarService.php create mode 100644 app/Services/EntertainmentService.php create mode 100644 app/Services/StatusBoardService.php create mode 100644 app/Services/TodayIssueService.php create mode 100644 app/Services/VatService.php create mode 100644 app/Services/WelfareService.php create mode 100644 app/Swagger/v1/CalendarApi.php create mode 100644 app/Swagger/v1/EntertainmentApi.php create mode 100644 app/Swagger/v1/StatusBoardApi.php create mode 100644 app/Swagger/v1/TodayIssueApi.php create mode 100644 app/Swagger/v1/VatApi.php create mode 100644 app/Swagger/v1/WelfareApi.php diff --git a/app/Http/Controllers/Api/V1/CalendarController.php b/app/Http/Controllers/Api/V1/CalendarController.php new file mode 100644 index 0000000..f60837c --- /dev/null +++ b/app/Http/Controllers/Api/V1/CalendarController.php @@ -0,0 +1,57 @@ +validate([ + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date', + 'type' => 'nullable|in:schedule,order,construction,other', + 'department_filter' => 'nullable|in:all,department,personal', + ]); + + // 기본값 설정: 이번 달 전체 + $today = Carbon::today(); + $startDate = $validated['start_date'] ?? $today->copy()->startOfMonth()->format('Y-m-d'); + $endDate = $validated['end_date'] ?? $today->copy()->endOfMonth()->format('Y-m-d'); + $type = $validated['type'] ?? null; + $departmentFilter = $validated['department_filter'] ?? 'all'; + + $data = $this->calendarService->getSchedules( + $startDate, + $endDate, + $type, + $departmentFilter + ); + + return ApiResponse::handle( + data: $data, + message: __('message.fetched') + ); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/EntertainmentController.php b/app/Http/Controllers/Api/V1/EntertainmentController.php new file mode 100644 index 0000000..0b236a9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/EntertainmentController.php @@ -0,0 +1,39 @@ +query('limit_type', 'quarterly'); + $companyType = $request->query('company_type', 'medium'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; + + $data = $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter); + + return ApiResponse::handle($data, __('message.fetched')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/StatusBoardController.php b/app/Http/Controllers/Api/V1/StatusBoardController.php new file mode 100644 index 0000000..f4abd06 --- /dev/null +++ b/app/Http/Controllers/Api/V1/StatusBoardController.php @@ -0,0 +1,30 @@ +statusBoardService->summary(); + + return ApiResponse::handle( + data: $data, + message: __('message.fetched') + ); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/TodayIssueController.php b/app/Http/Controllers/Api/V1/TodayIssueController.php new file mode 100644 index 0000000..32810b8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TodayIssueController.php @@ -0,0 +1,27 @@ +input('limit', 30); + $data = $this->todayIssueService->summary((int) $limit); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/VatController.php b/app/Http/Controllers/Api/V1/VatController.php new file mode 100644 index 0000000..89f90a6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/VatController.php @@ -0,0 +1,38 @@ +query('period_type', 'quarter'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $period = $request->query('period') ? (int) $request->query('period') : null; + + $data = $this->vatService->getSummary($periodType, $year, $period); + + return ApiResponse::handle($data, __('message.fetched')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/WelfareController.php b/app/Http/Controllers/Api/V1/WelfareController.php new file mode 100644 index 0000000..8b7b244 --- /dev/null +++ b/app/Http/Controllers/Api/V1/WelfareController.php @@ -0,0 +1,52 @@ +query('limit_type', 'quarterly'); + $calculationType = $request->query('calculation_type', 'fixed'); + $fixedAmountPerMonth = $request->query('fixed_amount_per_month') + ? (int) $request->query('fixed_amount_per_month') + : 200000; + $ratio = $request->query('ratio') + ? (float) $request->query('ratio') + : 0.05; + $year = $request->query('year') ? (int) $request->query('year') : null; + $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; + + $data = $this->welfareService->getSummary( + $limitType, + $calculationType, + $fixedAmountPerMonth, + $ratio, + $year, + $quarter + ); + + return ApiResponse::handle($data, __('message.fetched')); + } +} \ No newline at end of file diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php new file mode 100644 index 0000000..dfa9a66 --- /dev/null +++ b/app/Services/CalendarService.php @@ -0,0 +1,220 @@ +tenantId(); + $userId = $this->apiUserId(); + + $schedules = collect(); + + // 타입 필터에 따라 데이터 수집 + if ($type === null || $type === 'order') { + $schedules = $schedules->merge( + $this->getWorkOrderSchedules($tenantId, $startDate, $endDate, $departmentFilter, $userId) + ); + } + + if ($type === null || $type === 'construction') { + $schedules = $schedules->merge( + $this->getContractSchedules($tenantId, $startDate, $endDate, $departmentFilter, $userId) + ); + } + + if ($type === null || $type === 'schedule') { + $schedules = $schedules->merge( + $this->getLeaveSchedules($tenantId, $startDate, $endDate, $departmentFilter, $userId) + ); + } + + // startDate 기준 정렬 + $sortedSchedules = $schedules + ->sortBy('startDate') + ->values() + ->toArray(); + + return [ + 'items' => $sortedSchedules, + 'total_count' => count($sortedSchedules), + ]; + } + + /** + * 작업지시(발주) 일정 조회 + */ + private function getWorkOrderSchedules( + int $tenantId, + string $startDate, + string $endDate, + string $departmentFilter, + int $userId + ): Collection { + $query = WorkOrder::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereNotNull('scheduled_date') + ->where('scheduled_date', '>=', $startDate) + ->where('scheduled_date', '<=', $endDate) + ->with(['assignee:id,name', 'assignee.tenantProfile:id,user_id,department_id', 'assignee.tenantProfile.department:id,name']); + + // 부서 필터 적용 + if ($departmentFilter === 'personal') { + $query->where('assignee_id', $userId); + } + // department 필터는 부서별 필터링 로직 추가 필요 (현재는 전체) + + $workOrders = $query->orderBy('scheduled_date')->limit(100)->get(); + + return $workOrders->map(function ($wo) { + $assigneeName = $wo->assignee?->name; + $departmentName = $wo->assignee?->tenantProfile?->department?->name; + + return [ + 'id' => 'wo_'.$wo->id, + 'title' => $wo->project_name ?? $wo->work_order_no, + 'startDate' => $wo->scheduled_date?->format('Y-m-d'), + 'endDate' => $wo->scheduled_date?->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'order', + 'department' => $departmentName, + 'personName' => $assigneeName, + 'color' => null, + ]; + }); + } + + /** + * 계약(시공) 일정 조회 + */ + private function getContractSchedules( + int $tenantId, + string $startDate, + string $endDate, + string $departmentFilter, + int $userId + ): Collection { + $query = Contract::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereNotNull('contract_start_date') + ->where(function ($q) use ($startDate, $endDate) { + // 기간이 겹치는 계약 조회 + $q->where(function ($sub) use ($startDate, $endDate) { + $sub->where('contract_start_date', '<=', $endDate) + ->where(function ($inner) use ($startDate) { + $inner->where('contract_end_date', '>=', $startDate) + ->orWhereNull('contract_end_date'); + }); + }); + }) + ->with(['constructionPm:id,name', 'constructionPm.tenantProfile:id,user_id,department_id', 'constructionPm.tenantProfile.department:id,name']); + + // 부서 필터 적용 + if ($departmentFilter === 'personal') { + $query->where('construction_pm_id', $userId); + } + + $contracts = $query->orderBy('contract_start_date')->limit(100)->get(); + + return $contracts->map(function ($contract) { + $pmName = $contract->constructionPm?->name; + $departmentName = $contract->constructionPm?->tenantProfile?->department?->name; + + return [ + 'id' => 'contract_'.$contract->id, + 'title' => $contract->project_name ?? $contract->contract_code, + 'startDate' => $contract->contract_start_date?->format('Y-m-d'), + 'endDate' => $contract->contract_end_date?->format('Y-m-d') ?? $contract->contract_start_date?->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'construction', + 'department' => $departmentName, + 'personName' => $pmName, + 'color' => null, + ]; + }); + } + + /** + * 휴가 일정 조회 + */ + private function getLeaveSchedules( + int $tenantId, + string $startDate, + string $endDate, + string $departmentFilter, + int $userId + ): Collection { + $query = Leave::query() + ->where('tenant_id', $tenantId) + ->where('status', 'approved') + ->where(function ($q) use ($startDate, $endDate) { + // 기간이 겹치는 휴가 조회 + $q->where('start_date', '<=', $endDate) + ->where('end_date', '>=', $startDate); + }) + ->with(['user:id,name', 'user.tenantProfile:id,user_id,department_id', 'user.tenantProfile.department:id,name']); + + // 부서 필터 적용 + if ($departmentFilter === 'personal') { + $query->where('user_id', $userId); + } + + $leaves = $query->orderBy('start_date')->limit(100)->get(); + + return $leaves->map(function ($leave) { + $userName = $leave->user?->name; + $departmentName = $leave->user?->tenantProfile?->department?->name; + $leaveType = $leave->type ?? 'leave'; + $title = $userName + ? __('message.calendar.leave_title', ['name' => $userName]) + : __('message.calendar.leave_default'); + + return [ + 'id' => 'leave_'.$leave->id, + 'title' => $title, + 'startDate' => $leave->start_date?->format('Y-m-d'), + 'endDate' => $leave->end_date?->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'schedule', + 'department' => $departmentName, + 'personName' => $userName, + 'color' => null, + ]; + }); + } +} \ No newline at end of file diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php new file mode 100644 index 0000000..893154e --- /dev/null +++ b/app/Services/EntertainmentService.php @@ -0,0 +1,258 @@ + 36000000, // 대기업: 연 3,600만원 + 'medium' => 36000000, // 중견기업: 연 3,600만원 + 'small' => 24000000, // 중소기업: 연 2,400만원 + ]; + + /** + * 접대비 현황 요약 조회 + * + * @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly) + * @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium) + * @param int|null $year 연도 (기본: 현재 연도) + * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) + * @return array{cards: array, check_points: array} + */ + public function getSummary( + ?string $limitType = 'quarterly', + ?string $companyType = 'medium', + ?int $year = null, + ?int $quarter = null + ): array { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + + // 기본값 설정 + $year = $year ?? $now->year; + $limitType = $limitType ?? 'quarterly'; + $companyType = $companyType ?? 'medium'; + $quarter = $quarter ?? $now->quarter; + + // 기간 범위 계산 + if ($limitType === 'annual') { + $startDate = Carbon::create($year, 1, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, 12, 31)->format('Y-m-d'); + $periodLabel = "{$year}년"; + } else { + $startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); + $periodLabel = "{$quarter}사분기"; + } + + // 연간 시작일 (매출 계산용) + $yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); + $yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); + + // 매출액 조회 (연간) + $annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate); + + // 접대비 한도 계산 + $annualLimit = $this->calculateLimit($annualSales, $companyType); + $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); + + // 접대비 사용액 조회 + $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); + + // 잔여 한도 + $remainingLimit = max(0, $periodLimit - $usedAmount); + + // 카드 데이터 구성 + $cards = [ + [ + 'id' => 'et_sales', + 'label' => '매출', + 'amount' => (int) $annualSales, + ], + [ + 'id' => 'et_limit', + 'label' => "{{$periodLabel}} 접대비 총 한도", + 'amount' => (int) $periodLimit, + ], + [ + 'id' => 'et_remaining', + 'label' => "{{$periodLabel}} 접대비 잔여한도", + 'amount' => (int) $remainingLimit, + ], + [ + 'id' => 'et_used', + 'label' => "{{$periodLabel}} 접대비 사용금액", + 'amount' => (int) $usedAmount, + ], + ]; + + // 체크포인트 생성 + $checkPoints = $this->generateCheckPoints( + $periodLabel, + $periodLimit, + $usedAmount, + $remainingLimit, + $tenantId, + $startDate, + $endDate + ); + + return [ + 'cards' => $cards, + 'check_points' => $checkPoints, + ]; + } + + /** + * 연간 매출액 조회 + */ + private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float + { + // TODO: 실제 매출 테이블에서 조회 + // 현재는 임시로 고정값 반환 (orders 테이블 또는 invoices 테이블에서 합계) + return DB::table('orders') + ->where('tenant_id', $tenantId) + ->whereBetween('order_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 30530000000; + } + + /** + * 접대비 한도 계산 + */ + private function calculateLimit(float $annualSales, string $companyType): float + { + // 기본 한도 (기업 규모별) + $baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium']; + + // 매출 기반 한도 (0.3%) + $salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE; + + // 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화) + return $baseLimit + $salesBasedLimit; + } + + /** + * 접대비 사용액 조회 + */ + private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float + { + // TODO: 실제 접대비 계정과목에서 조회 + // expense_accounts 또는 card_transactions에서 접대비 항목 합계 + $amount = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + return $amount ?: 10000000; // 임시 기본값 + } + + /** + * 거래처 누락 건수 조회 + */ + private function getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array + { + // TODO: 거래처 정보 누락 건수 조회 + $result = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('vendor_id') + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + return [ + 'count' => $result->count ?? 0, + 'total' => $result->total ?? 0, + ]; + } + + /** + * 체크포인트 생성 + */ + private function generateCheckPoints( + string $periodLabel, + float $limit, + float $used, + float $remaining, + int $tenantId, + string $startDate, + string $endDate + ): array { + $checkPoints = []; + $usageRate = $limit > 0 ? ($used / $limit) * 100 : 0; + $usedFormatted = number_format($used / 10000); + $limitFormatted = number_format($limit / 10000); + $remainingFormatted = number_format($remaining / 10000); + + // 사용률에 따른 체크포인트 + if ($usageRate <= 75) { + // 정상 운영 + $remainingRate = 100 - $usageRate; + $checkPoints[] = [ + 'id' => 'et_cp_normal', + 'type' => 'success', + 'message' => "{{$periodLabel}} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate:.0f}%). 여유 있게 운영 중입니다.", + 'highlights' => [ + ['text' => "{$usedFormatted}만원", 'color' => 'green'], + ['text' => "{$limitFormatted}만원 ({$remainingRate:.0f}%)", 'color' => 'green'], + ], + ]; + } elseif ($usageRate <= 100) { + // 주의 (85% 이상) + $checkPoints[] = [ + 'id' => 'et_cp_warning', + 'type' => 'warning', + 'message' => "접대비 한도 {$usageRate:.0f}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.", + 'highlights' => [ + ['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'], + ], + ]; + } else { + // 한도 초과 + $overAmount = $used - $limit; + $overFormatted = number_format($overAmount / 10000); + $checkPoints[] = [ + 'id' => 'et_cp_over', + 'type' => 'error', + 'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.", + 'highlights' => [ + ['text' => "{$overFormatted}만원 발생", 'color' => 'red'], + ], + ]; + } + + // 거래처 정보 누락 체크 + $missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate); + if ($missingVendor['count'] > 0) { + $missingTotal = number_format($missingVendor['total'] / 10000); + $checkPoints[] = [ + 'id' => 'et_cp_missing', + 'type' => 'error', + 'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.", + 'highlights' => [ + ['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'], + ['text' => '거래처 정보가 누락', 'color' => 'red'], + ], + ]; + } + + return $checkPoints; + } +} \ No newline at end of file diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php new file mode 100644 index 0000000..4a9a74a --- /dev/null +++ b/app/Services/StatusBoardService.php @@ -0,0 +1,231 @@ +tenantId(); + $userId = $this->apiUserId(); + $today = Carbon::today(); + + return [ + 'items' => [ + $this->getOrdersStatus($tenantId, $today), + $this->getBadDebtStatus($tenantId), + $this->getSafetyStockStatus($tenantId), + $this->getTaxDeadlineStatus($tenantId, $today), + $this->getNewClientStatus($tenantId, $today), + $this->getLeaveStatus($tenantId, $today), + $this->getPurchaseStatus($tenantId), + $this->getApprovalStatus($tenantId, $userId), + ], + ]; + } + + /** + * 수주 현황 (오늘 신규 수주 건수) + */ + private function getOrdersStatus(int $tenantId, Carbon $today): array + { + $count = Order::query() + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $today) + ->where('status_code', 'confirmed') // 확정된 수주만 + ->count(); + + return [ + 'id' => 'orders', + 'label' => __('message.status_board.orders'), + 'count' => $count, + 'path' => '/sales/order-management-sales', + 'isHighlighted' => false, + ]; + } + + /** + * 채권 추심 현황 (추심 진행 중인 건수) + */ + private function getBadDebtStatus(int $tenantId): array + { + $count = BadDebt::query() + ->where('tenant_id', $tenantId) + ->where('status', 'in_progress') // 추심 진행 중 + ->count(); + + return [ + 'id' => 'bad_debts', + 'label' => __('message.status_board.bad_debts'), + 'count' => $count, + 'path' => '/accounting/bad-debt-collection', + 'isHighlighted' => false, + ]; + } + + /** + * 안전 재고 현황 (안전재고 미달 품목 수) + */ + private function getSafetyStockStatus(int $tenantId): array + { + $count = Stock::query() + ->where('tenant_id', $tenantId) + ->where('safety_stock', '>', 0) // 안전재고 설정된 품목만 + ->whereColumn('stock_qty', '<', 'safety_stock') + ->count(); + + $isHighlighted = $count > 0; // 미달 품목 있으면 강조 + + return [ + 'id' => 'safety_stock', + 'label' => __('message.status_board.safety_stock'), + 'count' => $count, + 'path' => '/material/stock-status', + 'isHighlighted' => $isHighlighted, + ]; + } + + /** + * 세금 신고 현황 (부가세 신고 D-day) + */ + private function getTaxDeadlineStatus(int $tenantId, Carbon $today): array + { + // 부가세 신고 마감일 계산 (분기별: 1/25, 4/25, 7/25, 10/25) + $quarter = $today->quarter; + $deadlineMonth = match ($quarter) { + 1 => 1, // 1분기 → 1월 25일 + 2 => 4, // 2분기 → 4월 25일 + 3 => 7, // 3분기 → 7월 25일 + 4 => 10, // 4분기 → 10월 25일 + }; + + $deadlineYear = $today->year; + // 1분기 마감일이 지났으면 다음 분기 마감일 + if ($today->month > $deadlineMonth || ($today->month == $deadlineMonth && $today->day > 25)) { + $deadlineMonth = match ($quarter) { + 1 => 4, + 2 => 7, + 3 => 10, + 4 => 1, // 다음 해 + }; + if ($deadlineMonth == 1) { + $deadlineYear++; + } + } + + $deadline = Carbon::create($deadlineYear, $deadlineMonth, 25); + $daysUntil = $today->diffInDays($deadline, false); + + $countText = $daysUntil >= 0 + ? __('message.status_board.tax_d_day', ['days' => $daysUntil]) + : __('message.status_board.tax_overdue', ['days' => abs($daysUntil)]); + + return [ + 'id' => 'tax_deadline', + 'label' => __('message.status_board.tax_deadline'), + 'count' => $countText, + 'path' => '/accounting/tax', + 'isHighlighted' => $daysUntil <= 7 && $daysUntil >= 0, + ]; + } + + /** + * 신규 업체 등록 현황 (최근 7일 신규 거래처) + */ + private function getNewClientStatus(int $tenantId, Carbon $today): array + { + $count = Client::query() + ->where('tenant_id', $tenantId) + ->where('created_at', '>=', $today->copy()->subDays(7)) + ->count(); + + return [ + 'id' => 'new_clients', + 'label' => __('message.status_board.new_clients'), + 'count' => $count, + 'path' => '/accounting/vendors', + 'isHighlighted' => false, + ]; + } + + /** + * 연차 현황 (오늘 휴가 중인 인원) + */ + private function getLeaveStatus(int $tenantId, Carbon $today): array + { + $count = Leave::query() + ->where('tenant_id', $tenantId) + ->where('status', 'approved') + ->whereDate('start_date', '<=', $today) + ->whereDate('end_date', '>=', $today) + ->count(); + + return [ + 'id' => 'leaves', + 'label' => __('message.status_board.leaves'), + 'count' => $count, + 'path' => '/hr/vacation-management', + 'isHighlighted' => false, + ]; + } + + /** + * 발주 현황 (발주 대기 건수) + */ + private function getPurchaseStatus(int $tenantId): array + { + $count = Purchase::query() + ->where('tenant_id', $tenantId) + ->where('status', 'pending') // 대기 중인 발주 + ->count(); + + return [ + 'id' => 'purchases', + 'label' => __('message.status_board.purchases'), + 'count' => $count, + 'path' => '/construction/order/order-management', + 'isHighlighted' => false, + ]; + } + + /** + * 결재 요청 현황 (나의 결재 대기 건수) + */ + private function getApprovalStatus(int $tenantId, int $userId): array + { + $count = ApprovalStep::query() + ->whereHas('approval', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId) + ->where('status', 'pending'); + }) + ->where('approver_id', $userId) + ->where('status', 'pending') + ->count(); + + return [ + 'id' => 'approvals', + 'label' => __('message.status_board.approvals'), + 'count' => $count, + 'path' => '/approval/inbox', + 'isHighlighted' => $count > 0, + ]; + } +} \ No newline at end of file diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php new file mode 100644 index 0000000..8e4ce4a --- /dev/null +++ b/app/Services/TodayIssueService.php @@ -0,0 +1,384 @@ +tenantId(); + $userId = $this->apiUserId(); + $today = Carbon::today(); + + // 각 카테고리별 이슈 수집 + $issues = collect(); + + // 1. 수주 성공 (최근 7일) + $issues = $issues->merge($this->getOrderSuccessIssues($tenantId, $today)); + + // 2. 미수금 이슈 (주식 이슈 - 연체 미수금) + $issues = $issues->merge($this->getReceivableIssues($tenantId)); + + // 3. 재고 이슈 (직정 제고 - 안전재고 미달) + $issues = $issues->merge($this->getStockIssues($tenantId)); + + // 4. 지출예상내역서 (승인 대기 건) + $issues = $issues->merge($this->getExpectedExpenseIssues($tenantId)); + + // 5. 세금 신고 (부가세 D-day) + $issues = $issues->merge($this->getTaxIssues($tenantId, $today)); + + // 6. 결재 요청 (내 결재 대기 건) + $issues = $issues->merge($this->getApprovalIssues($tenantId, $userId)); + + // 7. 기타 (신규 거래처 등록) + $issues = $issues->merge($this->getOtherIssues($tenantId, $today)); + + // 날짜 기준 내림차순 정렬 후 limit 적용 + $sortedIssues = $issues + ->sortByDesc('created_at') + ->take($limit) + ->values() + ->map(function ($item) { + // created_at 필드 제거 (정렬용으로만 사용) + unset($item['created_at']); + + return $item; + }) + ->toArray(); + + return [ + 'items' => $sortedIssues, + 'total_count' => $issues->count(), + ]; + } + + /** + * 수주 성공 이슈 (최근 7일 확정 수주) + */ + private function getOrderSuccessIssues(int $tenantId, Carbon $today): array + { + $orders = Order::query() + ->where('tenant_id', $tenantId) + ->where('status_code', 'confirmed') + ->where('created_at', '>=', $today->copy()->subDays(7)) + ->with('client:id,name') + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + return $orders->map(function ($order) { + $clientName = $order->client?->name ?? __('message.today_issue.unknown_client'); + $amount = number_format($order->total_amount ?? 0); + + return [ + 'id' => 'order_'.$order->id, + 'badge' => '수주 성공', + 'content' => __('message.today_issue.order_success', [ + 'client' => $clientName, + 'amount' => $amount, + ]), + 'time' => $this->formatRelativeTime($order->created_at), + 'date' => $order->created_at?->toDateString(), + 'needsApproval' => false, + 'path' => '/sales/order-management-sales', + 'created_at' => $order->created_at, + ]; + })->toArray(); + } + + /** + * 미수금 이슈 (주식 이슈 - 연체 미수금) + */ + private function getReceivableIssues(int $tenantId): array + { + // BadDebt 모델에서 추심 진행 중인 건 조회 + $badDebts = BadDebt::query() + ->where('tenant_id', $tenantId) + ->whereIn('status', ['in_progress', 'legal_action']) + ->with('client:id,name') + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + return $badDebts->map(function ($debt) { + $clientName = $debt->client?->name ?? __('message.today_issue.unknown_client'); + $amount = number_format($debt->total_amount ?? 0); + $days = $debt->overdue_days ?? 0; + + return [ + 'id' => 'receivable_'.$debt->id, + 'badge' => '주식 이슈', + 'content' => __('message.today_issue.receivable_overdue', [ + 'client' => $clientName, + 'amount' => $amount, + 'days' => $days, + ]), + 'time' => $this->formatRelativeTime($debt->created_at), + 'date' => $debt->created_at?->toDateString(), + 'needsApproval' => false, + 'path' => '/accounting/receivables-status', + 'created_at' => $debt->created_at, + ]; + })->toArray(); + } + + /** + * 재고 이슈 (직정 제고 - 안전재고 미달) + */ + private function getStockIssues(int $tenantId): array + { + $stocks = Stock::query() + ->where('tenant_id', $tenantId) + ->where('safety_stock', '>', 0) + ->whereColumn('stock_qty', '<', 'safety_stock') + ->with('item:id,name,code') + ->orderByDesc('updated_at') + ->limit(10) + ->get(); + + return $stocks->map(function ($stock) { + $itemName = $stock->item?->name ?? $stock->item?->code ?? __('message.today_issue.unknown_item'); + + return [ + 'id' => 'stock_'.$stock->id, + 'badge' => '직정 제고', + 'content' => __('message.today_issue.stock_below_safety', [ + 'item' => $itemName, + ]), + 'time' => $this->formatRelativeTime($stock->updated_at), + 'date' => $stock->updated_at?->toDateString(), + 'needsApproval' => false, + 'path' => '/material/stock-status', + 'created_at' => $stock->updated_at, + ]; + })->toArray(); + } + + /** + * 지출예상내역서 이슈 (승인 대기) + */ + private function getExpectedExpenseIssues(int $tenantId): array + { + $expenses = ExpectedExpense::query() + ->where('tenant_id', $tenantId) + ->where('payment_status', 'pending') + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + // 그룹화: 같은 날짜의 품의서들을 묶어서 표시 + if ($expenses->isEmpty()) { + return []; + } + + $totalCount = $expenses->count(); + $totalAmount = $expenses->sum('amount'); + $firstExpense = $expenses->first(); + $title = $firstExpense->description ?? __('message.today_issue.expense_item'); + + $content = $totalCount > 1 + ? __('message.today_issue.expense_pending_multiple', [ + 'title' => $title, + 'count' => $totalCount - 1, + 'amount' => number_format($totalAmount), + ]) + : __('message.today_issue.expense_pending_single', [ + 'title' => $title, + 'amount' => number_format($totalAmount), + ]); + + return [ + [ + 'id' => 'expense_summary', + 'badge' => '지출예상내역서', + 'content' => $content, + 'time' => $this->formatRelativeTime($firstExpense->created_at), + 'date' => $firstExpense->created_at?->toDateString(), + 'needsApproval' => true, + 'path' => '/approval/inbox', + 'created_at' => $firstExpense->created_at, + ], + ]; + } + + /** + * 세금 신고 이슈 (부가세 D-day) + */ + private function getTaxIssues(int $tenantId, Carbon $today): array + { + // 부가세 신고 마감일 계산 (분기별: 1/25, 4/25, 7/25, 10/25) + $quarter = $today->quarter; + $deadlineMonth = match ($quarter) { + 1 => 1, + 2 => 4, + 3 => 7, + 4 => 10, + }; + + $deadlineYear = $today->year; + if ($today->month > $deadlineMonth || ($today->month == $deadlineMonth && $today->day > 25)) { + $deadlineMonth = match ($quarter) { + 1 => 4, + 2 => 7, + 3 => 10, + 4 => 1, + }; + if ($deadlineMonth == 1) { + $deadlineYear++; + } + } + + $deadline = Carbon::create($deadlineYear, $deadlineMonth, 25); + $daysUntil = $today->diffInDays($deadline, false); + + // D-30 이내인 경우에만 표시 + if ($daysUntil > 30 || $daysUntil < 0) { + return []; + } + + $quarterName = match ($deadlineMonth) { + 1 => '4', + 4 => '1', + 7 => '2', + 10 => '3', + }; + + return [ + [ + 'id' => 'tax_vat_'.$deadlineYear.'_'.$deadlineMonth, + 'badge' => '세금 신고', + 'content' => __('message.today_issue.tax_vat_deadline', [ + 'quarter' => $quarterName, + 'days' => $daysUntil, + ]), + 'time' => $this->formatRelativeTime($today), + 'date' => $today->toDateString(), + 'needsApproval' => false, + 'path' => '/accounting/tax', + 'created_at' => $today, + ], + ]; + } + + /** + * 결재 요청 이슈 (내 결재 대기 건) + */ + private function getApprovalIssues(int $tenantId, int $userId): array + { + $steps = ApprovalStep::query() + ->whereHas('approval', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId) + ->where('status', 'pending'); + }) + ->where('approver_id', $userId) + ->where('status', 'pending') + ->with(['approval' => function ($query) { + $query->with('drafter:id,name'); + }]) + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + return $steps->map(function ($step) { + $drafterName = $step->approval->drafter?->name ?? __('message.today_issue.unknown_user'); + $title = $step->approval->title ?? __('message.today_issue.approval_request'); + + return [ + 'id' => 'approval_'.$step->approval->id, + 'badge' => '결재 요청', + 'content' => __('message.today_issue.approval_pending', [ + 'title' => $title, + 'drafter' => $drafterName, + ]), + 'time' => $this->formatRelativeTime($step->approval->created_at), + 'date' => $step->approval->created_at?->toDateString(), + 'needsApproval' => true, + 'path' => '/approval/inbox', + 'created_at' => $step->approval->created_at, + ]; + })->toArray(); + } + + /** + * 기타 이슈 (신규 거래처 등록 등) + */ + private function getOtherIssues(int $tenantId, Carbon $today): array + { + // 최근 7일 신규 거래처 + $clients = Client::query() + ->where('tenant_id', $tenantId) + ->where('created_at', '>=', $today->copy()->subDays(7)) + ->orderByDesc('created_at') + ->limit(5) + ->get(); + + return $clients->map(function ($client) { + return [ + 'id' => 'client_'.$client->id, + 'badge' => '기타', + 'content' => __('message.today_issue.new_client', [ + 'name' => $client->name, + ]), + 'time' => $this->formatRelativeTime($client->created_at), + 'date' => $client->created_at?->toDateString(), + 'needsApproval' => false, + 'path' => '/accounting/vendors', + 'created_at' => $client->created_at, + ]; + })->toArray(); + } + + /** + * 상대 시간 포맷팅 + */ + private function formatRelativeTime(?Carbon $datetime): string + { + if (! $datetime) { + return ''; + } + + $now = Carbon::now(); + $diffInMinutes = $now->diffInMinutes($datetime); + $diffInHours = $now->diffInHours($datetime); + $diffInDays = $now->diffInDays($datetime); + + if ($diffInMinutes < 60) { + return __('message.today_issue.time_minutes_ago', ['minutes' => max(1, $diffInMinutes)]); + } + + if ($diffInHours < 24) { + return __('message.today_issue.time_hours_ago', ['hours' => $diffInHours]); + } + + if ($diffInDays == 1) { + return __('message.today_issue.time_yesterday'); + } + + if ($diffInDays < 7) { + return __('message.today_issue.time_days_ago', ['days' => $diffInDays]); + } + + return $datetime->format('Y-m-d'); + } +} \ No newline at end of file diff --git a/app/Services/VatService.php b/app/Services/VatService.php new file mode 100644 index 0000000..8613be3 --- /dev/null +++ b/app/Services/VatService.php @@ -0,0 +1,261 @@ +tenantId(); + $now = Carbon::now(); + + // 기본값 설정 + $year = $year ?? $now->year; + $periodType = $periodType ?? 'quarter'; + $period = $period ?? $this->getCurrentPeriod($periodType, $now); + + // 기간 범위 계산 + [$startDate, $endDate] = $this->getPeriodDateRange($year, $periodType, $period); + + // 발행 완료된 세금계산서만 계산 (status: issued, sent) + $validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT]; + + // 매출세액 (sales) + $salesTaxAmount = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_SALES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->sum('tax_amount'); + + // 매입세액 (purchases) + $purchasesTaxAmount = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_PURCHASES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->sum('tax_amount'); + + // 예상 납부세액 (매출세액 - 매입세액) + $estimatedPayment = $salesTaxAmount - $purchasesTaxAmount; + + // 미발행 세금계산서 건수 (전체 기간, status: draft) + $unissuedCount = TaxInvoice::where('tenant_id', $tenantId) + ->where('status', TaxInvoice::STATUS_DRAFT) + ->count(); + + // 카드 데이터 구성 + $cards = [ + [ + 'id' => 'vat_sales_tax', + 'label' => '매출세액', + 'amount' => (int) $salesTaxAmount, + ], + [ + 'id' => 'vat_purchases_tax', + 'label' => '매입세액', + 'amount' => (int) $purchasesTaxAmount, + ], + [ + 'id' => 'vat_estimated_payment', + 'label' => '예상 납부세액', + 'amount' => (int) abs($estimatedPayment), + 'subLabel' => $estimatedPayment < 0 ? '환급' : null, + ], + [ + 'id' => 'vat_unissued', + 'label' => '세금계산서 미발행', + 'amount' => $unissuedCount, + 'unit' => '건', + ], + ]; + + // 체크포인트 생성 + $checkPoints = $this->generateCheckPoints( + $year, + $periodType, + $period, + $salesTaxAmount, + $purchasesTaxAmount, + $estimatedPayment, + $tenantId + ); + + return [ + 'cards' => $cards, + 'check_points' => $checkPoints, + ]; + } + + /** + * 현재 기간 계산 + */ + private function getCurrentPeriod(string $periodType, Carbon $date): int + { + return match ($periodType) { + 'quarter' => $date->quarter, + 'half' => $date->month <= 6 ? 1 : 2, + 'year' => 1, + default => $date->quarter, + }; + } + + /** + * 기간 범위 날짜 계산 + * + * @return array{0: string, 1: string} [startDate, endDate] + */ + private function getPeriodDateRange(int $year, string $periodType, int $period): array + { + return match ($periodType) { + 'quarter' => [ + Carbon::create($year, ($period - 1) * 3 + 1, 1)->format('Y-m-d'), + Carbon::create($year, $period * 3, 1)->endOfMonth()->format('Y-m-d'), + ], + 'half' => [ + Carbon::create($year, ($period - 1) * 6 + 1, 1)->format('Y-m-d'), + Carbon::create($year, $period * 6, 1)->endOfMonth()->format('Y-m-d'), + ], + 'year' => [ + Carbon::create($year, 1, 1)->format('Y-m-d'), + Carbon::create($year, 12, 31)->format('Y-m-d'), + ], + default => [ + Carbon::create($year, ($period - 1) * 3 + 1, 1)->format('Y-m-d'), + Carbon::create($year, $period * 3, 1)->endOfMonth()->format('Y-m-d'), + ], + }; + } + + /** + * 체크포인트 생성 + */ + private function generateCheckPoints( + int $year, + string $periodType, + int $period, + float $salesTaxAmount, + float $purchasesTaxAmount, + float $estimatedPayment, + int $tenantId + ): array { + $checkPoints = []; + $periodLabel = $this->getPeriodLabel($year, $periodType, $period); + + // 이전 기간 데이터 조회 (전기 대비 비교용) + $previousPeriod = $this->getPreviousPeriod($year, $periodType, $period); + [$prevStartDate, $prevEndDate] = $this->getPeriodDateRange( + $previousPeriod['year'], + $periodType, + $previousPeriod['period'] + ); + + $validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT]; + + $prevSalesTax = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_SALES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$prevStartDate, $prevEndDate]) + ->sum('tax_amount'); + + $prevPurchasesTax = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_PURCHASES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$prevStartDate, $prevEndDate]) + ->sum('tax_amount'); + + $prevEstimatedPayment = $prevSalesTax - $prevPurchasesTax; + + // 납부/환급 여부에 따른 메시지 생성 + if ($estimatedPayment < 0) { + // 환급 + $refundAmount = number_format(abs($estimatedPayment)); + $message = "{$periodLabel} 기준, 예상 환급세액은 {$refundAmount}원입니다."; + + // 원인 분석 추가 + if ($purchasesTaxAmount > $salesTaxAmount) { + $message .= ' 매입세액이 매출세액을 초과하여 환급이 예상됩니다.'; + } + + $checkPoints[] = [ + 'id' => 'vat_cp_refund', + 'type' => 'success', + 'message' => $message, + 'highlights' => [ + ['text' => "{$periodLabel} 기준, 예상 환급세액은 {$refundAmount}원입니다.", 'color' => 'blue'], + ], + ]; + } else { + // 납부 + $paymentAmount = number_format($estimatedPayment); + $message = "{$periodLabel} 기준, 예상 납부세액은 {$paymentAmount}원입니다."; + + // 전기 대비 변동률 계산 + if ($prevEstimatedPayment > 0) { + $changeRate = (($estimatedPayment - $prevEstimatedPayment) / $prevEstimatedPayment) * 100; + $changeDirection = $changeRate >= 0 ? '증가' : '감소'; + $message .= sprintf(' 전기 대비 %.1f%% %s했습니다.', abs($changeRate), $changeDirection); + } + + $checkPoints[] = [ + 'id' => 'vat_cp_payment', + 'type' => 'success', + 'message' => $message, + 'highlights' => [ + ['text' => "{$periodLabel} 기준, 예상 납부세액은 {$paymentAmount}원입니다.", 'color' => 'red'], + ], + ]; + } + + return $checkPoints; + } + + /** + * 기간 라벨 생성 + */ + private function getPeriodLabel(int $year, string $periodType, int $period): string + { + return match ($periodType) { + 'quarter' => "{$year}년 {$period}기 예정신고", + 'half' => "{$year}년 " . ($period === 1 ? '상반기' : '하반기') . ' 확정신고', + 'year' => "{$year}년 연간", + default => "{$year}년 {$period}기", + }; + } + + /** + * 이전 기간 계산 + * + * @return array{year: int, period: int} + */ + private function getPreviousPeriod(int $year, string $periodType, int $period): array + { + return match ($periodType) { + 'quarter' => $period === 1 + ? ['year' => $year - 1, 'period' => 4] + : ['year' => $year, 'period' => $period - 1], + 'half' => $period === 1 + ? ['year' => $year - 1, 'period' => 2] + : ['year' => $year, 'period' => 1], + 'year' => ['year' => $year - 1, 'period' => 1], + default => $period === 1 + ? ['year' => $year - 1, 'period' => 4] + : ['year' => $year, 'period' => $period - 1], + }; + } +} \ No newline at end of file diff --git a/app/Services/WelfareService.php b/app/Services/WelfareService.php new file mode 100644 index 0000000..155e25f --- /dev/null +++ b/app/Services/WelfareService.php @@ -0,0 +1,252 @@ +tenantId(); + $now = Carbon::now(); + + // 기본값 설정 + $year = $year ?? $now->year; + $limitType = $limitType ?? 'quarterly'; + $calculationType = $calculationType ?? 'fixed'; + $fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000; + $ratio = $ratio ?? 0.05; + $quarter = $quarter ?? $now->quarter; + + // 기간 범위 계산 + if ($limitType === 'annual') { + $startDate = Carbon::create($year, 1, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, 12, 31)->format('Y-m-d'); + $periodLabel = "{$year}년"; + $monthCount = 12; + } else { + $startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); + $periodLabel = "{$quarter}사분기"; + $monthCount = 3; + } + + // 직원 수 조회 + $employeeCount = $this->getEmployeeCount($tenantId); + + // 한도 계산 + if ($calculationType === 'fixed') { + $annualLimit = $fixedAmountPerMonth * 12 * $employeeCount; + } else { + // 급여 총액 기반 비율 계산 + $totalSalary = $this->getTotalSalary($tenantId, $year); + $annualLimit = $totalSalary * $ratio; + } + + $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); + + // 복리후생비 사용액 조회 + $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); + + // 잔여 한도 + $remainingLimit = max(0, $periodLimit - $usedAmount); + + // 카드 데이터 구성 + $cards = [ + [ + 'id' => 'wf_annual_limit', + 'label' => '당해년도 복리후생비 한도', + 'amount' => (int) $annualLimit, + ], + [ + 'id' => 'wf_period_limit', + 'label' => "{{$periodLabel}} 복리후생비 총 한도", + 'amount' => (int) $periodLimit, + ], + [ + 'id' => 'wf_remaining', + 'label' => "{{$periodLabel}} 복리후생비 잔여한도", + 'amount' => (int) $remainingLimit, + ], + [ + 'id' => 'wf_used', + 'label' => "{{$periodLabel}} 복리후생비 사용금액", + 'amount' => (int) $usedAmount, + ], + ]; + + // 체크포인트 생성 + $checkPoints = $this->generateCheckPoints( + $tenantId, + $employeeCount, + $usedAmount, + $monthCount, + $startDate, + $endDate + ); + + return [ + 'cards' => $cards, + 'check_points' => $checkPoints, + ]; + } + + /** + * 직원 수 조회 + */ + private function getEmployeeCount(int $tenantId): int + { + $count = DB::table('users') + ->join('user_tenants', 'users.id', '=', 'user_tenants.user_id') + ->where('user_tenants.tenant_id', $tenantId) + ->where('user_tenants.is_active', true) + ->whereNull('users.deleted_at') + ->count(); + + return $count ?: 50; // 임시 기본값 + } + + /** + * 연간 급여 총액 조회 + */ + private function getTotalSalary(int $tenantId, int $year): float + { + // TODO: 실제 급여 테이블에서 조회 + // payroll 또는 salary_histories에서 연간 급여 합계 + return 2000000000; // 임시 기본값 (20억) + } + + /** + * 복리후생비 사용액 조회 + */ + private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float + { + // TODO: 실제 복리후생비 계정과목에서 조회 + $amount = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + return $amount ?: 5123000; // 임시 기본값 + } + + /** + * 월 식대 조회 + */ + private function getMonthlyMealAmount(int $tenantId, string $startDate, string $endDate): float + { + // TODO: 식대 항목 조회 + $amount = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->where('sub_type', 'meal') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + return $amount ?: 0; + } + + /** + * 체크포인트 생성 + */ + private function generateCheckPoints( + int $tenantId, + int $employeeCount, + float $usedAmount, + int $monthCount, + string $startDate, + string $endDate + ): array { + $checkPoints = []; + + // 1인당 월 복리후생비 계산 + $perPersonMonthly = $employeeCount > 0 && $monthCount > 0 + ? $usedAmount / $employeeCount / $monthCount + : 0; + $perPersonFormatted = number_format($perPersonMonthly / 10000); + + // 업계 평균 비교 + if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) { + $checkPoints[] = [ + 'id' => 'wf_cp_normal', + 'type' => 'success', + 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.", + 'highlights' => [ + ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'green'], + ], + ]; + } elseif ($perPersonMonthly < self::INDUSTRY_AVG_MIN) { + $checkPoints[] = [ + 'id' => 'wf_cp_low', + 'type' => 'warning', + 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 낮습니다.", + 'highlights' => [ + ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], + ], + ]; + } else { + $checkPoints[] = [ + 'id' => 'wf_cp_high', + 'type' => 'warning', + 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.", + 'highlights' => [ + ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], + ], + ]; + } + + // 식대 비과세 한도 체크 + $mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate); + $perPersonMeal = $employeeCount > 0 ? $mealAmount / $employeeCount : 0; + + if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) { + $mealFormatted = number_format($perPersonMeal / 10000); + $limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000); + $checkPoints[] = [ + 'id' => 'wf_cp_meal', + 'type' => 'error', + 'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.", + 'highlights' => [ + ['text' => "식대가 월 {$mealFormatted}만원으로", 'color' => 'red'], + ['text' => '초과', 'color' => 'red'], + ], + ]; + } + + return $checkPoints; + } +} \ No newline at end of file diff --git a/app/Swagger/v1/CalendarApi.php b/app/Swagger/v1/CalendarApi.php new file mode 100644 index 0000000..30dd660 --- /dev/null +++ b/app/Swagger/v1/CalendarApi.php @@ -0,0 +1,146 @@ + '입찰 상태가 변경되었습니다.', 'converted' => '견적이 입찰로 변환되었습니다.', ], + + // CEO 대시보드 현황판 + 'status_board' => [ + 'orders' => '수주', + 'bad_debts' => '채권 추심', + 'safety_stock' => '안전 재고', + 'tax_deadline' => '세금 신고', + 'tax_d_day' => '부가세 신고 D-:days', + 'tax_overdue' => '부가세 신고 :days일 초과', + 'new_clients' => '신규 업체 등록', + 'leaves' => '연차', + 'purchases' => '발주', + 'approvals' => '결재 요청', + ], + + // CEO 대시보드 오늘의 이슈 리스트 + 'today_issue' => [ + 'unknown_client' => '미확인 거래처', + 'unknown_item' => '미확인 품목', + 'unknown_user' => '미확인 사용자', + 'expense_item' => '품의서', + 'approval_request' => '결재 요청', + + // 이슈 내용 메시지 + 'order_success' => ':client 신규 수주 :amount원 확정', + 'receivable_overdue' => ':client 미수금 :amount원 연체 :days일', + 'stock_below_safety' => ':item 재고 부족 경고', + 'expense_pending_multiple' => ':title 외 :count건 (:amount원)', + 'expense_pending_single' => ':title (:amount원)', + 'tax_vat_deadline' => ':quarter분기 부가세 신고 D-:days', + 'approval_pending' => ':title 승인 요청 (:drafter)', + 'new_client' => '신규 거래처 :name 등록 완료', + + // 상대 시간 + 'time_minutes_ago' => ':minutes분 전', + 'time_hours_ago' => ':hours시간 전', + 'time_yesterday' => '어제', + 'time_days_ago' => ':days일 전', + ], + + // CEO 대시보드 캘린더 + 'calendar' => [ + 'leave_title' => ':name 휴가', + 'leave_default' => '휴가', + ], + + // CEO 대시보드 부가세 현황 + 'vat' => [ + 'sales_tax' => '매출세액', + 'purchases_tax' => '매입세액', + 'estimated_payment' => '예상 납부세액', + 'unissued_invoices' => '세금계산서 미발행', + 'refund' => '환급', + 'payment' => '납부', + 'period_quarter' => ':year년 :period기 예정신고', + 'period_half' => ':year년 :half 확정신고', + 'period_year' => ':year년 연간', + 'first_half' => '상반기', + 'second_half' => '하반기', + ], ]; diff --git a/routes/api.php b/routes/api.php index 961cd58..86bc2d0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,6 +17,7 @@ use App\Http\Controllers\Api\V1\BiddingController; use App\Http\Controllers\Api\V1\BillController; use App\Http\Controllers\Api\V1\BoardController; +use App\Http\Controllers\Api\V1\CalendarController; use App\Http\Controllers\Api\V1\CardController; use App\Http\Controllers\Api\V1\CardTransactionController; use App\Http\Controllers\Api\V1\CategoryController; @@ -42,6 +43,7 @@ use App\Http\Controllers\Api\V1\Design\DesignModelController; use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController; use App\Http\Controllers\Api\V1\EmployeeController; +use App\Http\Controllers\Api\V1\EntertainmentController; use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\ExpectedExpenseController; use App\Http\Controllers\Api\V1\FileStorageController; @@ -94,7 +96,9 @@ use App\Http\Controllers\Api\V1\ShipmentController; use App\Http\Controllers\Api\V1\SiteBriefingController; use App\Http\Controllers\Api\V1\SiteController; +use App\Http\Controllers\Api\V1\StatusBoardController; use App\Http\Controllers\Api\V1\StockController; +use App\Http\Controllers\Api\V1\TodayIssueController; use App\Http\Controllers\Api\V1\SubscriptionController; use App\Http\Controllers\Api\V1\SystemBoardController; use App\Http\Controllers\Api\V1\SystemPostController; @@ -110,7 +114,9 @@ use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserInvitationController; use App\Http\Controllers\Api\V1\UserRoleController; +use App\Http\Controllers\Api\V1\VatController; use App\Http\Controllers\Api\V1\VendorLedgerController; +use App\Http\Controllers\Api\V1\WelfareController; use App\Http\Controllers\Api\V1\WithdrawalController; use App\Http\Controllers\Api\V1\WorkOrderController; use App\Http\Controllers\Api\V1\WorkResultController; @@ -617,6 +623,24 @@ // Comprehensive Analysis API (종합 분석 보고서) Route::get('/comprehensive-analysis', [ComprehensiveAnalysisController::class, 'index'])->name('v1.comprehensive-analysis.index'); + // Status Board API (CEO 대시보드 현황판) + Route::get('/status-board/summary', [StatusBoardController::class, 'summary'])->name('v1.status-board.summary'); + + // Today Issue API (CEO 대시보드 오늘의 이슈 리스트) + Route::get('/today-issues/summary', [TodayIssueController::class, 'summary'])->name('v1.today-issues.summary'); + + // Calendar API (CEO 대시보드 캘린더) + Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules'); + + // Vat API (CEO 대시보드 부가세 현황) + Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary'); + + // Entertainment API (CEO 대시보드 접대비 현황) + Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary'); + + // Welfare API (CEO 대시보드 복리후생비 현황) + Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary'); + // Plan API (요금제 관리) Route::prefix('plans')->group(function () { Route::get('', [PlanController::class, 'index'])->name('v1.plans.index');