tenantId(); $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $onlyActive = $params['only_active'] ?? null; $clientType = $params['client_type'] ?? null; $startDate = $params['start_date'] ?? null; $endDate = $params['end_date'] ?? null; $query = Client::query()->where('tenant_id', $tenantId); if ($q !== '') { $query->where(function ($qq) use ($q) { $qq->where('name', 'like', "%{$q}%") ->orWhere('client_code', 'like', "%{$q}%") ->orWhere('contact_person', 'like', "%{$q}%"); }); } if ($onlyActive !== null) { $query->where('is_active', (bool) $onlyActive); } // 거래처 유형 필터 (쉼표 구분 복수 값 지원: PURCHASE,BOTH) if ($clientType !== null && $clientType !== '') { $types = array_map('trim', explode(',', $clientType)); $query->whereIn('client_type', $types); } // 등록일 기간 필터 if ($startDate) { $query->whereDate('created_at', '>=', $startDate); } if ($endDate) { $query->whereDate('created_at', '<=', $endDate); } $query->orderBy('client_code')->orderBy('id'); $paginator = $query->paginate($size, ['*'], 'page', $page); // 미수금 계산: 매출 합계 - 입금 합계 $clientIds = $paginator->pluck('id')->toArray(); if (! empty($clientIds)) { // 거래처별 매출 합계 $salesByClient = Sale::where('tenant_id', $tenantId) ->whereIn('client_id', $clientIds) ->whereNull('deleted_at') ->groupBy('client_id') ->select('client_id', DB::raw('SUM(total_amount) as total_sales')) ->pluck('total_sales', 'client_id'); // 거래처별 입금 합계 $depositsByClient = Deposit::where('tenant_id', $tenantId) ->whereIn('client_id', $clientIds) ->whereNull('deleted_at') ->groupBy('client_id') ->select('client_id', DB::raw('SUM(amount) as total_deposits')) ->pluck('total_deposits', 'client_id'); // 거래처별 활성 악성채권 합계 (추심중, 법적조치) $badDebtsByClient = BadDebt::where('tenant_id', $tenantId) ->whereIn('client_id', $clientIds) ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) ->where('is_active', true) ->whereNull('deleted_at') ->groupBy('client_id') ->select('client_id', DB::raw('SUM(debt_amount) as total_bad_debt')) ->pluck('total_bad_debt', 'client_id'); // 활성 악성채권이 있는 거래처 ID 목록 (금액과 무관하게 레코드 존재 여부) $clientsWithBadDebt = BadDebt::where('tenant_id', $tenantId) ->whereIn('client_id', $clientIds) ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) ->where('is_active', true) ->whereNull('deleted_at') ->distinct() ->pluck('client_id') ->flip() ->toArray(); // 각 거래처에 미수금/악성채권 정보 추가 $paginator->getCollection()->transform(function ($client) use ($salesByClient, $depositsByClient, $badDebtsByClient, $clientsWithBadDebt) { $totalSales = $salesByClient[$client->id] ?? 0; $totalDeposits = $depositsByClient[$client->id] ?? 0; $client->outstanding_amount = max(0, $totalSales - $totalDeposits); $client->bad_debt_total = $badDebtsByClient[$client->id] ?? 0; $client->has_bad_debt = isset($clientsWithBadDebt[$client->id]); return $client; }); } return $paginator; } /** 단건 */ public function show(int $id) { $tenantId = $this->tenantId(); $client = Client::where('tenant_id', $tenantId)->find($id); if (! $client) { throw new NotFoundHttpException(__('error.not_found')); } // 미수금 계산 $totalSales = Sale::where('tenant_id', $tenantId) ->where('client_id', $id) ->whereNull('deleted_at') ->sum('total_amount'); $totalDeposits = Deposit::where('tenant_id', $tenantId) ->where('client_id', $id) ->whereNull('deleted_at') ->sum('amount'); $client->outstanding_amount = max(0, $totalSales - $totalDeposits); // 악성채권 정보 $activeBadDebtQuery = BadDebt::where('tenant_id', $tenantId) ->where('client_id', $id) ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) ->where('is_active', true) ->whereNull('deleted_at'); $client->bad_debt_total = (clone $activeBadDebtQuery)->sum('debt_amount'); $client->has_bad_debt = (clone $activeBadDebtQuery)->exists(); return $client; } /** 생성 */ public function store(array $data) { $tenantId = $this->tenantId(); // client_code 자동 생성 (프론트에서 보내도 무시) $data['client_code'] = $this->generateClientCode($tenantId); $data['tenant_id'] = $tenantId; $data['is_active'] = $data['is_active'] ?? true; $client = Client::create($data); // 신규 거래처 등록 푸시는 ClientIssueObserver가 자동 처리 return $client; } /** * 유니크한 client_code 자동 생성 * 형식: 8자리 영숫자 (예: A3B7X9K2) */ private function generateClientCode(int $tenantId): string { $maxAttempts = 10; $attempt = 0; do { // 8자리 영숫자 코드 생성 (대문자 + 숫자) $code = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8)); // 중복 검사 $exists = Client::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('client_code', $code) ->exists(); $attempt++; } while ($exists && $attempt < $maxAttempts); if ($exists) { // 극히 드문 경우: timestamp 추가하여 유니크성 보장 $code = strtoupper(substr(bin2hex(random_bytes(2)), 0, 4).dechex(time() % 0xFFFF)); } return $code; } /** 수정 */ public function update(int $id, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $client = Client::where('tenant_id', $tenantId)->find($id); if (! $client) { throw new NotFoundHttpException(__('error.not_found')); } // client_code 변경 불가 (프론트에서 보내도 무시) unset($data['client_code']); // bad_debt 토글 처리 (bad_debts 테이블과 연동) $badDebtToggle = $data['bad_debt'] ?? null; unset($data['bad_debt']); // Client 모델에는 bad_debt 컬럼이 없으므로 제거 $client->update($data); // bad_debt 토글이 명시적으로 전달된 경우에만 처리 if ($badDebtToggle !== null) { $this->syncBadDebtStatus($client, (bool) $badDebtToggle, $userId); } return $client->refresh(); } /** * 악성채권 상태 동기화 * 프론트엔드의 bad_debt 토글에 따라 bad_debts 테이블 연동 */ private function syncBadDebtStatus(Client $client, bool $hasBadDebt, ?int $userId): void { $tenantId = $client->tenant_id; // 현재 활성 악성채권 조회 $activeBadDebt = BadDebt::where('tenant_id', $tenantId) ->where('client_id', $client->id) ->where('is_active', true) ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) ->first(); if ($hasBadDebt && ! $activeBadDebt) { // 악성채권 활성화: 새 레코드 생성 BadDebt::create([ 'tenant_id' => $tenantId, 'client_id' => $client->id, 'debt_amount' => $client->outstanding_balance ?? 0, 'status' => BadDebt::STATUS_COLLECTING, 'overdue_days' => 0, 'occurred_at' => now(), 'is_active' => true, 'created_by' => $userId, ]); } elseif (! $hasBadDebt && $activeBadDebt) { // 악성채권 비활성화: 기존 레코드 종료 처리 $activeBadDebt->update([ 'is_active' => false, 'status' => BadDebt::STATUS_RECOVERED, 'closed_at' => now(), 'updated_by' => $userId, ]); } } /** 삭제 */ public function destroy(int $id) { $tenantId = $this->tenantId(); $client = Client::where('tenant_id', $tenantId)->find($id); if (! $client) { throw new NotFoundHttpException(__('error.not_found')); } // 주문 존재 검사 if ($client->orders()->exists()) { throw new BadRequestHttpException(__('error.has_orders')); } $client->delete(); return 'success'; } /** 활성/비활성 토글 */ public function toggle(int $id) { $tenantId = $this->tenantId(); $client = Client::where('tenant_id', $tenantId)->find($id); if (! $client) { throw new NotFoundHttpException(__('error.not_found')); } $client->is_active = ! $client->is_active; $client->save(); return $client->refresh(); } /** * 거래처 통계 조회 */ public function stats(): array { $tenantId = $this->tenantId(); $total = Client::where('tenant_id', $tenantId)->count(); // 거래처 유형별 통계 $typeCounts = Client::where('tenant_id', $tenantId) ->selectRaw('client_type, COUNT(*) as count') ->groupBy('client_type') ->pluck('count', 'client_type') ->toArray(); // 악성채권 보유 거래처 수 (활성 악성채권이 있는 거래처) $badDebtClientIds = BadDebt::where('tenant_id', $tenantId) ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) ->where('is_active', true) ->whereNull('deleted_at') ->distinct('client_id') ->pluck('client_id'); $badDebtCount = Client::where('tenant_id', $tenantId) ->whereIn('id', $badDebtClientIds) ->count(); return [ 'total' => $total, 'sales' => $typeCounts['SALES'] ?? 0, 'purchase' => $typeCounts['PURCHASE'] ?? 0, 'both' => $typeCounts['BOTH'] ?? 0, 'badDebt' => $badDebtCount, 'normal' => $total - $badDebtCount, ]; } /** * 거래처 일괄 삭제 */ public function bulkDestroy(array $ids): int { $tenantId = $this->tenantId(); $clients = Client::where('tenant_id', $tenantId) ->whereIn('id', $ids) ->get(); $deletedCount = 0; foreach ($clients as $client) { // 주문 존재 검사 - 주문 있으면 건너뛰기 if ($client->orders()->exists()) { continue; } $client->delete(); $deletedCount++; } return $deletedCount; } }