From 4fa38e39e6385cbb87ba9b71c17db61c2df63383 Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 2 Jan 2026 14:47:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(API):=20=EC=B1=84=EA=B6=8C=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EB=8F=99=EC=A0=81=EC=9B=94=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EB=B0=8F=20year=3D0=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReceivablesController: boolean 유효성 검사를 string|in:true,false,1,0으로 변경 - 쿼리 문자열의 "true" 문자열을 올바르게 처리 - 디버깅용 Log::info 추가 - ReceivablesService: 동적 월 기간 지원 - recent_year=true 시 최근 12개월 동적 계산 - 월별 레이블 동적 생성 (예: 25.02, 25.03...) - 이월잔액(carry_forward_balance) 계산 추가 - Client 모델: is_overdue, memo 필드 추가 - 마이그레이션: clients 테이블에 is_overdue 컬럼 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Api/V1/ReceivablesController.php | 60 ++- app/Models/Orders/Client.php | 2 + app/Services/ReceivablesService.php | 417 ++++++++++++------ ...113722_add_is_overdue_to_clients_table.php | 31 ++ routes/api.php | 1 + 5 files changed, 375 insertions(+), 136 deletions(-) create mode 100644 database/migrations/2026_01_02_113722_add_is_overdue_to_clients_table.php diff --git a/app/Http/Controllers/Api/V1/ReceivablesController.php b/app/Http/Controllers/Api/V1/ReceivablesController.php index 7affea0..bc6c941 100644 --- a/app/Http/Controllers/Api/V1/ReceivablesController.php +++ b/app/Http/Controllers/Api/V1/ReceivablesController.php @@ -24,11 +24,21 @@ public function index(Request $request): JsonResponse { return ApiResponse::handle(function () use ($request) { $params = $request->validate([ - 'year' => 'nullable|integer|min:2000|max:2100', + 'year' => 'nullable|integer|min:2000|max:2100', 'recent_year' => 'nullable|string|in:true,false,1,0', 'search' => 'nullable|string|max:100', - 'has_receivable' => 'nullable|boolean', + 'has_receivable' => 'nullable|string|in:true,false,1,0', ]); + // 문자열 boolean을 실제 boolean으로 변환 + if (isset($params['recent_year'])) { + $params['recent_year'] = filter_var($params['recent_year'], FILTER_VALIDATE_BOOLEAN); + } + if (isset($params['has_receivable'])) { + $params['has_receivable'] = filter_var($params['has_receivable'], FILTER_VALIDATE_BOOLEAN); + } + + \Log::info('[Receivables] index params', $params); + return $this->service->index($params); }, __('message.fetched')); } @@ -41,9 +51,53 @@ public function summary(Request $request): JsonResponse return ApiResponse::handle(function () use ($request) { $params = $request->validate([ 'year' => 'nullable|integer|min:2000|max:2100', + 'recent_year' => 'nullable|string|in:true,false,1,0', ]); + // 문자열 boolean을 실제 boolean으로 변환 + if (isset($params['recent_year'])) { + $params['recent_year'] = filter_var($params['recent_year'], FILTER_VALIDATE_BOOLEAN); + } + + \Log::info('[Receivables] summary params', $params); + return $this->service->summary($params); }, __('message.fetched')); } -} + + /** + * 연체 상태 일괄 업데이트 + */ + public function updateOverdueStatus(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'updates' => 'required|array|min:1', + 'updates.*.id' => 'required|integer|exists:clients,id', + 'updates.*.is_overdue' => 'required|boolean', + ]); + + $updatedCount = $this->service->updateOverdueStatus($validated['updates']); + + return ['updated_count' => $updatedCount]; + }, __('message.updated')); + } + + /** + * 거래처 메모 일괄 업데이트 + */ + public function updateMemos(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'memos' => 'required|array|min:1', + 'memos.*.id' => 'required|integer|exists:clients,id', + 'memos.*.memo' => 'nullable|string|max:1000', + ]); + + $updatedCount = $this->service->updateMemos($validated['memos']); + + return ['updated_count' => $updatedCount]; + }, __('message.updated')); + } +} \ No newline at end of file diff --git a/app/Models/Orders/Client.php b/app/Models/Orders/Client.php index fe9db06..bd9bea2 100644 --- a/app/Models/Orders/Client.php +++ b/app/Models/Orders/Client.php @@ -38,6 +38,7 @@ class Client extends Model 'outstanding_balance', 'credit_limit', 'is_active', + 'is_overdue', 'client_type', 'manager_name', 'manager_tel', @@ -46,6 +47,7 @@ class Client extends Model protected $casts = [ 'is_active' => 'boolean', + 'is_overdue' => 'boolean', 'tax_agreement' => 'boolean', 'tax_amount' => 'decimal:2', 'outstanding_balance' => 'decimal:2', diff --git a/app/Services/ReceivablesService.php b/app/Services/ReceivablesService.php index 8c5e7f6..bef1ab5 100644 --- a/app/Services/ReceivablesService.php +++ b/app/Services/ReceivablesService.php @@ -6,12 +6,14 @@ use App\Models\Tenants\Bill; use App\Models\Tenants\Deposit; use App\Models\Tenants\Sale; -use Illuminate\Support\Collection; +use Carbon\Carbon; use Illuminate\Support\Facades\DB; /** * 채권 현황 서비스 * 거래처별 월별 매출, 입금, 어음, 미수금 현황 조회 + * - 동적 월 표시 지원 (최근 1년: 동적 12개월) + * - 이월잔액 + 누적 미수금 계산 */ class ReceivablesService extends Service { @@ -21,9 +23,17 @@ class ReceivablesService extends Service public function index(array $params): array { $tenantId = $this->tenantId(); + $recentYear = $params['recent_year'] ?? false; $year = $params['year'] ?? date('Y'); $search = $params['search'] ?? null; + // 월 기간 생성 (동적 월 지원) + $periods = $this->generateMonthPeriods($recentYear, $year); + $monthLabels = array_map(fn ($p) => $p['label'], $periods); + + // 이월잔액 기준일 (첫번째 월의 시작일 전날) + $carryForwardDate = Carbon::parse($periods[0]['start'])->subDay()->format('Y-m-d'); + // 거래처 목록 조회 $clientsQuery = Client::where('tenant_id', $tenantId) ->where('is_active', true); @@ -37,52 +47,55 @@ public function index(array $params): array $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); + // 이월잔액 계산 (기준일 이전까지의 누적 미수금) + $carryForwardBalance = $this->getCarryForwardBalance($tenantId, $client->id, $carryForwardDate); - // 미수금 계산 (매출 - 입금 - 어음) - $receivablesByMonth = $this->calculateReceivables($salesByMonth, $depositsByMonth, $billsByMonth); + // 월별 데이터 수집 (년-월 키 기반) + $salesByPeriod = $this->getSalesByPeriods($tenantId, $client->id, $periods); + $depositsByPeriod = $this->getDepositsByPeriods($tenantId, $client->id, $periods); + $billsByPeriod = $this->getBillsByPeriods($tenantId, $client->id, $periods); - // 카테고리별 데이터 생성 + // 누적 미수금 계산 + $receivablesByPeriod = $this->calculateCumulativeReceivables( + $carryForwardBalance, + $salesByPeriod, + $depositsByPeriod, + $billsByPeriod, + count($periods) + ); + + // 카테고리별 데이터 생성 (배열 형태) $categories = [ [ 'category' => 'sales', - 'amounts' => $this->formatMonthlyAmounts($salesByMonth), + 'amounts' => $this->formatPeriodAmounts($salesByPeriod, count($periods)), ], [ 'category' => 'deposit', - 'amounts' => $this->formatMonthlyAmounts($depositsByMonth), + 'amounts' => $this->formatPeriodAmounts($depositsByPeriod, count($periods)), ], [ 'category' => 'bill', - 'amounts' => $this->formatMonthlyAmounts($billsByMonth), + 'amounts' => $this->formatPeriodAmounts($billsByPeriod, count($periods)), ], [ 'category' => 'receivable', - 'amounts' => $this->formatMonthlyAmounts($receivablesByMonth), - ], - [ - 'category' => 'memo', - 'amounts' => $this->getEmptyMonthlyAmounts(), + 'amounts' => $this->formatReceivableAmounts($receivablesByPeriod), ], ]; - // 미수금이 있는 월 확인 (연체 표시용) - $overdueMonths = []; - foreach ($receivablesByMonth as $month => $amount) { - if ($amount > 0) { - $overdueMonths[] = $month; - } - } + // 연체 여부: 최종 미수금이 양수인 경우 + $finalReceivable = end($receivablesByPeriod); + $isOverdue = $client->is_overdue ?? ($finalReceivable > 0); $result[] = [ 'id' => (string) $client->id, 'vendor_id' => $client->id, 'vendor_name' => $client->name, - 'is_overdue' => count($overdueMonths) > 0, - 'overdue_months' => $overdueMonths, + 'is_overdue' => $isOverdue, + 'memo' => $client->memo ?? '', + 'carry_forward_balance' => $carryForwardBalance, + 'month_labels' => $monthLabels, 'categories' => $categories, ]; } @@ -97,7 +110,11 @@ public function index(array $params): array $result = array_values($result); } - return $result; + // 공통 월 레이블 추가 (프론트엔드에서 헤더로 사용) + return [ + 'month_labels' => $monthLabels, + 'items' => $result, + ]; } /** @@ -106,62 +123,55 @@ public function index(array $params): array public function summary(array $params): array { $tenantId = $this->tenantId(); + $recentYear = $params['recent_year'] ?? false; $year = $params['year'] ?? date('Y'); - $startDate = "{$year}-01-01"; - $endDate = "{$year}-12-31"; + // 월 기간 생성 + $periods = $this->generateMonthPeriods($recentYear, $year); + $startDate = $periods[0]['start']; + $endDate = end($periods)['end']; - // 총 매출 + // 이월잔액 기준일 + $carryForwardDate = Carbon::parse($startDate)->subDay()->format('Y-m-d'); + + // 전체 이월잔액 (모든 거래처) + $totalCarryForward = $this->getTotalCarryForwardBalance($tenantId, $carryForwardDate); + + // 기간 내 총 매출 $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; - } + // 총 미수금 (이월잔액 + 매출 - 입금 - 어음) + $totalReceivables = $totalCarryForward + $totalSales - $totalDeposits - $totalBills; // 거래처 수 $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]) + // 연체 거래처 수 (미수금이 양수인 거래처) + $overdueVendorCount = Client::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('is_overdue', true) ->count(); return [ + 'total_carry_forward' => (float) $totalCarryForward, 'total_sales' => (float) $totalSales, 'total_deposits' => (float) $totalDeposits, 'total_bills' => (float) $totalBills, @@ -172,130 +182,271 @@ public function summary(array $params): array } /** - * 월별 매출 조회 + * 월 기간 배열 생성 + * @return array [['start' => 'Y-m-d', 'end' => 'Y-m-d', 'label' => 'YY.MM', 'year' => Y, 'month' => M], ...] */ - private function getSalesByMonth(int $tenantId, int $clientId, string $year): array + private function generateMonthPeriods(bool $recentYear, string $year): array { - $result = []; + $periods = []; - $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(); + if ($recentYear) { + // 최근 1년: 현재 월 기준으로 12개월 전부터 + $current = Carbon::now()->startOfMonth(); + $start = $current->copy()->subMonths(11); - foreach ($sales as $sale) { - $result[(int) $sale->month] = (float) $sale->total; + for ($i = 0; $i < 12; $i++) { + $month = $start->copy()->addMonths($i); + $periods[] = [ + 'start' => $month->format('Y-m-01'), + 'end' => $month->endOfMonth()->format('Y-m-d'), + 'label' => $month->format('y.m'), + 'year' => (int) $month->format('Y'), + 'month' => (int) $month->format('n'), + ]; + } + } else { + // 특정 연도: 1월~12월 + for ($month = 1; $month <= 12; $month++) { + $date = Carbon::createFromDate($year, $month, 1); + $periods[] = [ + 'start' => $date->format('Y-m-01'), + 'end' => $date->endOfMonth()->format('Y-m-d'), + 'label' => "{$month}월", + 'year' => (int) $year, + 'month' => $month, + ]; + } } - return $result; + return $periods; } /** - * 월별 입금 조회 + * 이월잔액 계산 (기준일 이전까지의 누적 미수금) */ - private function getDepositsByMonth(int $tenantId, int $clientId, string $year): array + private function getCarryForwardBalance(int $tenantId, int $clientId, string $beforeDate): float { - $result = []; - - $deposits = Deposit::where('tenant_id', $tenantId) + // 기준일 이전 총 매출 + $totalSales = Sale::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(); + ->where('sale_date', '<=', $beforeDate) + ->sum('total_amount'); - foreach ($deposits as $deposit) { - $result[(int) $deposit->month] = (float) $deposit->total; - } + // 기준일 이전 총 입금 + $totalDeposits = Deposit::where('tenant_id', $tenantId) + ->where('client_id', $clientId) + ->where('deposit_date', '<=', $beforeDate) + ->sum('amount'); - return $result; - } - - /** - * 월별 어음 조회 - */ - private function getBillsByMonth(int $tenantId, int $clientId, string $year): array - { - $result = []; - - $bills = Bill::where('tenant_id', $tenantId) + // 기준일 이전 총 어음 + $totalBills = 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(); + ->where('issue_date', '<=', $beforeDate) + ->sum('amount'); - foreach ($bills as $bill) { - $result[(int) $bill->month] = (float) $bill->total; - } - - return $result; + return (float) ($totalSales - $totalDeposits - $totalBills); } /** - * 미수금 계산 + * 전체 거래처 이월잔액 합계 */ - private function calculateReceivables(array $sales, array $deposits, array $bills): array + private function getTotalCarryForwardBalance(int $tenantId, string $beforeDate): float + { + $totalSales = Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('sale_date', '<=', $beforeDate) + ->sum('total_amount'); + + $totalDeposits = Deposit::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('deposit_date', '<=', $beforeDate) + ->sum('amount'); + + $totalBills = Bill::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('bill_type', 'received') + ->where('issue_date', '<=', $beforeDate) + ->sum('amount'); + + return (float) ($totalSales - $totalDeposits - $totalBills); + } + + /** + * 기간별 매출 조회 + */ + private function getSalesByPeriods(int $tenantId, int $clientId, array $periods): array { $result = []; - for ($month = 1; $month <= 12; $month++) { - $salesAmount = $sales[$month] ?? 0; - $depositAmount = $deposits[$month] ?? 0; - $billAmount = $bills[$month] ?? 0; + foreach ($periods as $index => $period) { + $total = Sale::where('tenant_id', $tenantId) + ->where('client_id', $clientId) + ->whereBetween('sale_date', [$period['start'], $period['end']]) + ->sum('total_amount'); - $receivable = $salesAmount - $depositAmount - $billAmount; - $result[$month] = max(0, $receivable); + $result[$index] = (float) $total; } return $result; } /** - * 월별 금액을 프론트엔드 형식으로 변환 + * 기간별 입금 조회 */ - private function formatMonthlyAmounts(array $monthlyData): array + private function getDepositsByPeriods(int $tenantId, int $clientId, array $periods): array + { + $result = []; + + foreach ($periods as $index => $period) { + $total = Deposit::where('tenant_id', $tenantId) + ->where('client_id', $clientId) + ->whereBetween('deposit_date', [$period['start'], $period['end']]) + ->sum('amount'); + + $result[$index] = (float) $total; + } + + return $result; + } + + /** + * 기간별 어음 조회 + */ + private function getBillsByPeriods(int $tenantId, int $clientId, array $periods): array + { + $result = []; + + foreach ($periods as $index => $period) { + $total = Bill::where('tenant_id', $tenantId) + ->where('client_id', $clientId) + ->where('bill_type', 'received') + ->whereBetween('issue_date', [$period['start'], $period['end']]) + ->sum('amount'); + + $result[$index] = (float) $total; + } + + return $result; + } + + /** + * 누적 미수금 계산 + * 1월: 이월잔액 + 1월 매출 - 1월 입금 - 1월 어음 + * 2월: 1월 미수금 + 2월 매출 - 2월 입금 - 2월 어음 + * ... + */ + private function calculateCumulativeReceivables( + float $carryForward, + array $sales, + array $deposits, + array $bills, + int $periodCount + ): array { + $result = []; + $cumulative = $carryForward; + + for ($i = 0; $i < $periodCount; $i++) { + $monthSales = $sales[$i] ?? 0; + $monthDeposits = $deposits[$i] ?? 0; + $monthBills = $bills[$i] ?? 0; + + $cumulative = $cumulative + $monthSales - $monthDeposits - $monthBills; + $result[$i] = $cumulative; + } + + return $result; + } + + /** + * 기간별 금액을 프론트엔드 형식으로 변환 (매출, 입금, 어음용) + */ + private function formatPeriodAmounts(array $periodData, int $periodCount): array { $amounts = []; $total = 0; - for ($month = 1; $month <= 12; $month++) { - $key = "month{$month}"; - $amount = $monthlyData[$month] ?? 0; - $amounts[$key] = $amount; + for ($i = 0; $i < $periodCount; $i++) { + $amount = $periodData[$i] ?? 0; + $amounts[] = $amount; $total += $amount; } - $amounts['total'] = $total; - - return $amounts; + return [ + 'values' => $amounts, + 'total' => $total, + ]; } /** - * 빈 월별 금액 데이터 + * 미수금 금액을 프론트엔드 형식으로 변환 (누적이므로 total = 마지막 값) */ - private function getEmptyMonthlyAmounts(): array + private function formatReceivableAmounts(array $receivables): array { - $amounts = []; + $values = array_values($receivables); + $total = ! empty($values) ? end($values) : 0; - for ($month = 1; $month <= 12; $month++) { - $amounts["month{$month}"] = 0; + return [ + 'values' => $values, + 'total' => $total, + ]; + } + + /** + * 연체 상태 일괄 업데이트 + */ + public function updateOverdueStatus(array $updates): int + { + $tenantId = $this->tenantId(); + $updatedCount = 0; + + foreach ($updates as $update) { + $clientId = (int) $update['id']; + $isOverdue = (bool) $update['is_overdue']; + + $affected = Client::where('tenant_id', $tenantId) + ->where('id', $clientId) + ->update(['is_overdue' => $isOverdue]); + + $updatedCount += $affected; } - $amounts['total'] = 0; - - return $amounts; + return $updatedCount; } -} + + /** + * 거래처 메모 업데이트 + */ + public function updateMemo(int $clientId, string $memo): bool + { + $tenantId = $this->tenantId(); + + $affected = Client::where('tenant_id', $tenantId) + ->where('id', $clientId) + ->update(['memo' => $memo]); + + return $affected > 0; + } + + /** + * 거래처 메모 일괄 업데이트 + */ + public function updateMemos(array $memos): int + { + $tenantId = $this->tenantId(); + $updatedCount = 0; + + foreach ($memos as $item) { + $clientId = (int) $item['id']; + $memo = $item['memo'] ?? ''; + + $affected = Client::where('tenant_id', $tenantId) + ->where('id', $clientId) + ->update(['memo' => $memo]); + + $updatedCount += $affected; + } + + return $updatedCount; + } +} \ No newline at end of file diff --git a/database/migrations/2026_01_02_113722_add_is_overdue_to_clients_table.php b/database/migrations/2026_01_02_113722_add_is_overdue_to_clients_table.php new file mode 100644 index 0000000..2d571c1 --- /dev/null +++ b/database/migrations/2026_01_02_113722_add_is_overdue_to_clients_table.php @@ -0,0 +1,31 @@ +boolean('is_overdue') + ->default(false) + ->after('is_active') + ->comment('연체 여부 (수동 설정)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('clients', function (Blueprint $table) { + $table->dropColumn('is_overdue'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 9c27eca..01dc329 100644 --- a/routes/api.php +++ b/routes/api.php @@ -540,6 +540,7 @@ Route::get('', [ReceivablesController::class, 'index'])->name('v1.receivables.index'); Route::get('/summary', [ReceivablesController::class, 'summary'])->name('v1.receivables.summary'); Route::put('/overdue-status', [ReceivablesController::class, 'updateOverdueStatus'])->name('v1.receivables.update-overdue-status'); + Route::put('/memos', [ReceivablesController::class, 'updateMemos'])->name('v1.receivables.update-memos'); }); // Daily Report API (일일 보고서)