2025-10-13 21:52:34 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
2025-12-24 08:54:52 +09:00
|
|
|
use App\Models\BadDebts\BadDebt;
|
2025-10-13 21:52:34 +09:00
|
|
|
use App\Models\Orders\Client;
|
2025-12-24 08:54:52 +09:00
|
|
|
use App\Models\Tenants\Deposit;
|
|
|
|
|
use App\Models\Tenants\Sale;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2025-10-13 21:52:34 +09:00
|
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
|
|
|
|
|
|
class ClientService extends Service
|
|
|
|
|
{
|
|
|
|
|
/** 목록(검색/페이징) */
|
|
|
|
|
public function index(array $params)
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
2025-11-06 17:45:49 +09:00
|
|
|
$page = (int) ($params['page'] ?? 1);
|
|
|
|
|
$size = (int) ($params['size'] ?? 20);
|
|
|
|
|
$q = trim((string) ($params['q'] ?? ''));
|
2025-10-13 21:52:34 +09:00
|
|
|
$onlyActive = $params['only_active'] ?? null;
|
2026-01-29 15:04:55 +09:00
|
|
|
$clientType = $params['client_type'] ?? null;
|
2026-03-03 21:53:30 +09:00
|
|
|
$startDate = $params['start_date'] ?? null;
|
|
|
|
|
$endDate = $params['end_date'] ?? null;
|
2025-10-13 21:52:34 +09:00
|
|
|
|
|
|
|
|
$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) {
|
2025-12-08 20:25:38 +09:00
|
|
|
$query->where('is_active', (bool) $onlyActive);
|
2025-10-13 21:52:34 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 15:04:55 +09:00
|
|
|
// 거래처 유형 필터 (쉼표 구분 복수 값 지원: PURCHASE,BOTH)
|
|
|
|
|
if ($clientType !== null && $clientType !== '') {
|
|
|
|
|
$types = array_map('trim', explode(',', $clientType));
|
|
|
|
|
$query->whereIn('client_type', $types);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 21:53:30 +09:00
|
|
|
// 등록일 기간 필터
|
|
|
|
|
if ($startDate) {
|
|
|
|
|
$query->whereDate('created_at', '>=', $startDate);
|
|
|
|
|
}
|
|
|
|
|
if ($endDate) {
|
|
|
|
|
$query->whereDate('created_at', '<=', $endDate);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 21:52:34 +09:00
|
|
|
$query->orderBy('client_code')->orderBy('id');
|
|
|
|
|
|
2025-12-24 08:54:52 +09:00
|
|
|
$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');
|
|
|
|
|
|
2026-01-27 22:39:04 +09:00
|
|
|
// 활성 악성채권이 있는 거래처 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();
|
|
|
|
|
|
2025-12-24 08:54:52 +09:00
|
|
|
// 각 거래처에 미수금/악성채권 정보 추가
|
2026-01-27 22:39:04 +09:00
|
|
|
$paginator->getCollection()->transform(function ($client) use ($salesByClient, $depositsByClient, $badDebtsByClient, $clientsWithBadDebt) {
|
2025-12-24 08:54:52 +09:00
|
|
|
$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;
|
2026-01-27 22:39:04 +09:00
|
|
|
$client->has_bad_debt = isset($clientsWithBadDebt[$client->id]);
|
2025-12-24 08:54:52 +09:00
|
|
|
|
|
|
|
|
return $client;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $paginator;
|
2025-10-13 21:52:34 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 단건 */
|
|
|
|
|
public function show(int $id)
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
$client = Client::where('tenant_id', $tenantId)->find($id);
|
2025-11-06 17:45:49 +09:00
|
|
|
if (! $client) {
|
2025-10-13 21:52:34 +09:00
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
2025-11-06 17:45:49 +09:00
|
|
|
|
2025-12-24 08:54:52 +09:00
|
|
|
// 미수금 계산
|
|
|
|
|
$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);
|
|
|
|
|
|
|
|
|
|
// 악성채권 정보
|
2026-01-27 22:39:04 +09:00
|
|
|
$activeBadDebtQuery = BadDebt::where('tenant_id', $tenantId)
|
2025-12-24 08:54:52 +09:00
|
|
|
->where('client_id', $id)
|
|
|
|
|
->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION])
|
|
|
|
|
->where('is_active', true)
|
2026-01-27 22:39:04 +09:00
|
|
|
->whereNull('deleted_at');
|
2025-12-24 08:54:52 +09:00
|
|
|
|
2026-01-27 22:39:04 +09:00
|
|
|
$client->bad_debt_total = (clone $activeBadDebtQuery)->sum('debt_amount');
|
|
|
|
|
$client->has_bad_debt = (clone $activeBadDebtQuery)->exists();
|
2025-12-24 08:54:52 +09:00
|
|
|
|
2025-10-13 21:52:34 +09:00
|
|
|
return $client;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 생성 */
|
2025-12-08 20:25:38 +09:00
|
|
|
public function store(array $data)
|
2025-10-13 21:52:34 +09:00
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
2025-12-21 16:32:44 +09:00
|
|
|
// client_code 자동 생성 (프론트에서 보내도 무시)
|
|
|
|
|
$data['client_code'] = $this->generateClientCode($tenantId);
|
2025-10-13 21:52:34 +09:00
|
|
|
|
|
|
|
|
$data['tenant_id'] = $tenantId;
|
2025-12-08 20:25:38 +09:00
|
|
|
$data['is_active'] = $data['is_active'] ?? true;
|
2025-10-13 21:52:34 +09:00
|
|
|
|
2026-01-07 20:10:18 +09:00
|
|
|
$client = Client::create($data);
|
|
|
|
|
|
2026-01-23 10:05:50 +09:00
|
|
|
// 신규 거래처 등록 푸시는 ClientIssueObserver가 자동 처리
|
2026-01-07 20:10:18 +09:00
|
|
|
|
|
|
|
|
return $client;
|
2025-10-13 21:52:34 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:32:44 +09:00
|
|
|
/**
|
|
|
|
|
* 유니크한 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 21:52:34 +09:00
|
|
|
/** 수정 */
|
2025-12-08 20:25:38 +09:00
|
|
|
public function update(int $id, array $data)
|
2025-10-13 21:52:34 +09:00
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
2026-01-27 22:39:04 +09:00
|
|
|
$userId = $this->apiUserId();
|
2025-10-13 21:52:34 +09:00
|
|
|
|
|
|
|
|
$client = Client::where('tenant_id', $tenantId)->find($id);
|
2025-11-06 17:45:49 +09:00
|
|
|
if (! $client) {
|
2025-10-13 21:52:34 +09:00
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:32:44 +09:00
|
|
|
// client_code 변경 불가 (프론트에서 보내도 무시)
|
|
|
|
|
unset($data['client_code']);
|
2025-10-13 21:52:34 +09:00
|
|
|
|
2026-01-27 22:39:04 +09:00
|
|
|
// bad_debt 토글 처리 (bad_debts 테이블과 연동)
|
|
|
|
|
$badDebtToggle = $data['bad_debt'] ?? null;
|
|
|
|
|
unset($data['bad_debt']); // Client 모델에는 bad_debt 컬럼이 없으므로 제거
|
|
|
|
|
|
2025-12-08 20:25:38 +09:00
|
|
|
$client->update($data);
|
2025-11-06 17:45:49 +09:00
|
|
|
|
2026-01-27 22:39:04 +09:00
|
|
|
// bad_debt 토글이 명시적으로 전달된 경우에만 처리
|
|
|
|
|
if ($badDebtToggle !== null) {
|
|
|
|
|
$this->syncBadDebtStatus($client, (bool) $badDebtToggle, $userId);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 21:52:34 +09:00
|
|
|
return $client->refresh();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 22:39:04 +09:00
|
|
|
/**
|
|
|
|
|
* 악성채권 상태 동기화
|
|
|
|
|
* 프론트엔드의 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,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 21:52:34 +09:00
|
|
|
/** 삭제 */
|
|
|
|
|
public function destroy(int $id)
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
|
|
|
|
$client = Client::where('tenant_id', $tenantId)->find($id);
|
2025-11-06 17:45:49 +09:00
|
|
|
if (! $client) {
|
2025-10-13 21:52:34 +09:00
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 주문 존재 검사
|
|
|
|
|
if ($client->orders()->exists()) {
|
|
|
|
|
throw new BadRequestHttpException(__('error.has_orders'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$client->delete();
|
2025-11-06 17:45:49 +09:00
|
|
|
|
2025-10-13 21:52:34 +09:00
|
|
|
return 'success';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 활성/비활성 토글 */
|
|
|
|
|
public function toggle(int $id)
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
$client = Client::where('tenant_id', $tenantId)->find($id);
|
2025-11-06 17:45:49 +09:00
|
|
|
if (! $client) {
|
2025-10-13 21:52:34 +09:00
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:40:24 +09:00
|
|
|
$client->is_active = ! $client->is_active;
|
2025-10-13 21:52:34 +09:00
|
|
|
$client->save();
|
2025-11-06 17:45:49 +09:00
|
|
|
|
2025-10-13 21:52:34 +09:00
|
|
|
return $client->refresh();
|
|
|
|
|
}
|
2026-01-09 16:46:25 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 거래처 통계 조회
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-10-13 21:52:34 +09:00
|
|
|
}
|