diff --git a/app/Http/Controllers/Api/V1/ReceivablesController.php b/app/Http/Controllers/Api/V1/ReceivablesController.php new file mode 100644 index 0000000..7affea0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ReceivablesController.php @@ -0,0 +1,49 @@ +validate([ + 'year' => 'nullable|integer|min:2000|max:2100', + 'search' => 'nullable|string|max:100', + 'has_receivable' => 'nullable|boolean', + ]); + + return $this->service->index($params); + }, __('message.fetched')); + } + + /** + * 채권 현황 요약 통계 + */ + public function summary(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'year' => 'nullable|integer|min:2000|max:2100', + ]); + + return $this->service->summary($params); + }, __('message.fetched')); + } +} diff --git a/app/Services/ReceivablesService.php b/app/Services/ReceivablesService.php new file mode 100644 index 0000000..8c5e7f6 --- /dev/null +++ b/app/Services/ReceivablesService.php @@ -0,0 +1,301 @@ +tenantId(); + $year = $params['year'] ?? date('Y'); + $search = $params['search'] ?? null; + + // 거래처 목록 조회 + $clientsQuery = Client::where('tenant_id', $tenantId) + ->where('is_active', true); + + if ($search) { + $clientsQuery->where('name', 'like', "%{$search}%"); + } + + $clients = $clientsQuery->orderBy('name')->get(); + + $result = []; + + foreach ($clients as $client) { + // 월별 데이터 수집 + $salesByMonth = $this->getSalesByMonth($tenantId, $client->id, $year); + $depositsByMonth = $this->getDepositsByMonth($tenantId, $client->id, $year); + $billsByMonth = $this->getBillsByMonth($tenantId, $client->id, $year); + + // 미수금 계산 (매출 - 입금 - 어음) + $receivablesByMonth = $this->calculateReceivables($salesByMonth, $depositsByMonth, $billsByMonth); + + // 카테고리별 데이터 생성 + $categories = [ + [ + 'category' => 'sales', + 'amounts' => $this->formatMonthlyAmounts($salesByMonth), + ], + [ + 'category' => 'deposit', + 'amounts' => $this->formatMonthlyAmounts($depositsByMonth), + ], + [ + 'category' => 'bill', + 'amounts' => $this->formatMonthlyAmounts($billsByMonth), + ], + [ + 'category' => 'receivable', + 'amounts' => $this->formatMonthlyAmounts($receivablesByMonth), + ], + [ + 'category' => 'memo', + 'amounts' => $this->getEmptyMonthlyAmounts(), + ], + ]; + + // 미수금이 있는 월 확인 (연체 표시용) + $overdueMonths = []; + foreach ($receivablesByMonth as $month => $amount) { + if ($amount > 0) { + $overdueMonths[] = $month; + } + } + + $result[] = [ + 'id' => (string) $client->id, + 'vendor_id' => $client->id, + 'vendor_name' => $client->name, + 'is_overdue' => count($overdueMonths) > 0, + 'overdue_months' => $overdueMonths, + 'categories' => $categories, + ]; + } + + // 미수금이 있는 거래처만 필터링 (선택적) + if (! empty($params['has_receivable'])) { + $result = array_filter($result, function ($item) { + $receivableCat = collect($item['categories'])->firstWhere('category', 'receivable'); + + return $receivableCat && $receivableCat['amounts']['total'] > 0; + }); + $result = array_values($result); + } + + return $result; + } + + /** + * 요약 통계 조회 + */ + public function summary(array $params): array + { + $tenantId = $this->tenantId(); + $year = $params['year'] ?? date('Y'); + + $startDate = "{$year}-01-01"; + $endDate = "{$year}-12-31"; + + // 총 매출 + $totalSales = Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('sale_date', [$startDate, $endDate]) + ->sum('total_amount'); + + // 총 입금 + $totalDeposits = Deposit::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('deposit_date', [$startDate, $endDate]) + ->sum('amount'); + + // 총 어음 + $totalBills = Bill::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('bill_type', 'received') + ->whereBetween('issue_date', [$startDate, $endDate]) + ->sum('amount'); + + // 총 미수금 + $totalReceivables = $totalSales - $totalDeposits - $totalBills; + if ($totalReceivables < 0) { + $totalReceivables = 0; + } + + // 거래처 수 + $vendorCount = Client::where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereHas('orders') + ->count(); + + // 연체 거래처 수 (미수금이 있는 거래처) + $overdueVendorCount = DB::table('sales') + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('sale_date', [$startDate, $endDate]) + ->select('client_id') + ->groupBy('client_id') + ->havingRaw('SUM(total_amount) > ( + SELECT COALESCE(SUM(d.amount), 0) FROM deposits d + WHERE d.tenant_id = ? AND d.client_id = sales.client_id + AND d.deposit_date BETWEEN ? AND ? + ) + ( + SELECT COALESCE(SUM(b.amount), 0) FROM bills b + WHERE b.tenant_id = ? AND b.client_id = sales.client_id + AND b.bill_type = "received" + AND b.issue_date BETWEEN ? AND ? + )', [$tenantId, $startDate, $endDate, $tenantId, $startDate, $endDate]) + ->count(); + + return [ + 'total_sales' => (float) $totalSales, + 'total_deposits' => (float) $totalDeposits, + 'total_bills' => (float) $totalBills, + 'total_receivables' => (float) $totalReceivables, + 'vendor_count' => $vendorCount, + 'overdue_vendor_count' => $overdueVendorCount, + ]; + } + + /** + * 월별 매출 조회 + */ + private function getSalesByMonth(int $tenantId, int $clientId, string $year): array + { + $result = []; + + $sales = Sale::where('tenant_id', $tenantId) + ->where('client_id', $clientId) + ->whereYear('sale_date', $year) + ->select( + DB::raw('MONTH(sale_date) as month'), + DB::raw('SUM(total_amount) as total') + ) + ->groupBy(DB::raw('MONTH(sale_date)')) + ->get(); + + foreach ($sales as $sale) { + $result[(int) $sale->month] = (float) $sale->total; + } + + return $result; + } + + /** + * 월별 입금 조회 + */ + private function getDepositsByMonth(int $tenantId, int $clientId, string $year): array + { + $result = []; + + $deposits = Deposit::where('tenant_id', $tenantId) + ->where('client_id', $clientId) + ->whereYear('deposit_date', $year) + ->select( + DB::raw('MONTH(deposit_date) as month'), + DB::raw('SUM(amount) as total') + ) + ->groupBy(DB::raw('MONTH(deposit_date)')) + ->get(); + + foreach ($deposits as $deposit) { + $result[(int) $deposit->month] = (float) $deposit->total; + } + + return $result; + } + + /** + * 월별 어음 조회 + */ + private function getBillsByMonth(int $tenantId, int $clientId, string $year): array + { + $result = []; + + $bills = Bill::where('tenant_id', $tenantId) + ->where('client_id', $clientId) + ->where('bill_type', 'received') + ->whereYear('issue_date', $year) + ->select( + DB::raw('MONTH(issue_date) as month'), + DB::raw('SUM(amount) as total') + ) + ->groupBy(DB::raw('MONTH(issue_date)')) + ->get(); + + foreach ($bills as $bill) { + $result[(int) $bill->month] = (float) $bill->total; + } + + return $result; + } + + /** + * 미수금 계산 + */ + private function calculateReceivables(array $sales, array $deposits, array $bills): array + { + $result = []; + + for ($month = 1; $month <= 12; $month++) { + $salesAmount = $sales[$month] ?? 0; + $depositAmount = $deposits[$month] ?? 0; + $billAmount = $bills[$month] ?? 0; + + $receivable = $salesAmount - $depositAmount - $billAmount; + $result[$month] = max(0, $receivable); + } + + return $result; + } + + /** + * 월별 금액을 프론트엔드 형식으로 변환 + */ + private function formatMonthlyAmounts(array $monthlyData): array + { + $amounts = []; + $total = 0; + + for ($month = 1; $month <= 12; $month++) { + $key = "month{$month}"; + $amount = $monthlyData[$month] ?? 0; + $amounts[$key] = $amount; + $total += $amount; + } + + $amounts['total'] = $total; + + return $amounts; + } + + /** + * 빈 월별 금액 데이터 + */ + private function getEmptyMonthlyAmounts(): array + { + $amounts = []; + + for ($month = 1; $month <= 12; $month++) { + $amounts["month{$month}"] = 0; + } + + $amounts['total'] = 0; + + return $amounts; + } +} diff --git a/app/Swagger/v1/ReceivablesApi.php b/app/Swagger/v1/ReceivablesApi.php new file mode 100644 index 0000000..40da9ca --- /dev/null +++ b/app/Swagger/v1/ReceivablesApi.php @@ -0,0 +1,168 @@ +