From 961ab47bac6f8e1f8b3a37fc6aa4ff09ac5a4959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 20 Feb 2026 23:29:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[corporate-card]=20=EB=B2=95=EC=9D=B8?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B4=80=EB=A6=AC=20API=207=EA=B0=9C=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CorporateCard 모델 (corporate_cards 테이블) - CorporateCardService (CRUD + 토글 + 활성 목록) - CorporateCardController (ApiResponse 패턴) - Store/Update FormRequest 검증 - 라우트: /api/v1/corporate-cards (index, store, show, update, destroy, toggle, active) --- .../Api/V1/CorporateCardController.php | 97 ++++++++++ .../StoreCorporateCardRequest.php | 31 ++++ .../UpdateCorporateCardRequest.php | 31 ++++ app/Models/Tenants/CorporateCard.php | 110 +++++++++++ app/Services/CorporateCardService.php | 171 ++++++++++++++++++ routes/api/v1/finance.php | 12 ++ 6 files changed, 452 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/CorporateCardController.php create mode 100644 app/Http/Requests/V1/CorporateCard/StoreCorporateCardRequest.php create mode 100644 app/Http/Requests/V1/CorporateCard/UpdateCorporateCardRequest.php create mode 100644 app/Models/Tenants/CorporateCard.php create mode 100644 app/Services/CorporateCardService.php diff --git a/app/Http/Controllers/Api/V1/CorporateCardController.php b/app/Http/Controllers/Api/V1/CorporateCardController.php new file mode 100644 index 0000000..d7e035c --- /dev/null +++ b/app/Http/Controllers/Api/V1/CorporateCardController.php @@ -0,0 +1,97 @@ +only([ + 'search', + 'status', + 'card_type', + 'sort_by', + 'sort_dir', + 'per_page', + 'page', + ]); + + $cards = $this->service->index($params); + + return ApiResponse::success($cards, __('message.fetched')); + } + + /** + * 법인카드 등록 + */ + public function store(StoreCorporateCardRequest $request) + { + $card = $this->service->store($request->validated()); + + return ApiResponse::success($card, __('message.created'), [], 201); + } + + /** + * 법인카드 상세 + */ + public function show(int $id) + { + $card = $this->service->show($id); + + return ApiResponse::success($card, __('message.fetched')); + } + + /** + * 법인카드 수정 + */ + public function update(int $id, UpdateCorporateCardRequest $request) + { + $card = $this->service->update($id, $request->validated()); + + return ApiResponse::success($card, __('message.updated')); + } + + /** + * 법인카드 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::success(null, __('message.deleted')); + } + + /** + * 법인카드 상태 토글 (사용/정지) + */ + public function toggle(int $id) + { + $card = $this->service->toggleStatus($id); + + return ApiResponse::success($card, __('message.updated')); + } + + /** + * 활성 법인카드 목록 (셀렉트박스용) + */ + public function active() + { + $cards = $this->service->getActiveCards(); + + return ApiResponse::success($cards, __('message.fetched')); + } +} diff --git a/app/Http/Requests/V1/CorporateCard/StoreCorporateCardRequest.php b/app/Http/Requests/V1/CorporateCard/StoreCorporateCardRequest.php new file mode 100644 index 0000000..d8f4293 --- /dev/null +++ b/app/Http/Requests/V1/CorporateCard/StoreCorporateCardRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:100'], + 'card_company' => ['required', 'string', 'max:50'], + 'card_number' => ['required', 'string', 'max:30'], + 'card_type' => ['required', 'string', 'in:credit,debit'], + 'payment_day' => ['nullable', 'integer', 'min:1', 'max:31'], + 'credit_limit' => ['nullable', 'numeric', 'min:0'], + 'card_holder_name' => ['required', 'string', 'max:100'], + 'actual_user' => ['required', 'string', 'max:100'], + 'expiry_date' => ['nullable', 'string', 'max:10'], + 'cvc' => ['nullable', 'string', 'max:4'], + 'status' => ['nullable', 'string', 'in:active,inactive'], + 'memo' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/V1/CorporateCard/UpdateCorporateCardRequest.php b/app/Http/Requests/V1/CorporateCard/UpdateCorporateCardRequest.php new file mode 100644 index 0000000..4b6c25b --- /dev/null +++ b/app/Http/Requests/V1/CorporateCard/UpdateCorporateCardRequest.php @@ -0,0 +1,31 @@ + ['sometimes', 'string', 'max:100'], + 'card_company' => ['sometimes', 'string', 'max:50'], + 'card_number' => ['sometimes', 'string', 'max:30'], + 'card_type' => ['sometimes', 'string', 'in:credit,debit'], + 'payment_day' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:31'], + 'credit_limit' => ['sometimes', 'nullable', 'numeric', 'min:0'], + 'card_holder_name' => ['sometimes', 'string', 'max:100'], + 'actual_user' => ['sometimes', 'string', 'max:100'], + 'expiry_date' => ['sometimes', 'nullable', 'string', 'max:10'], + 'cvc' => ['sometimes', 'nullable', 'string', 'max:4'], + 'status' => ['sometimes', 'string', 'in:active,inactive'], + 'memo' => ['sometimes', 'nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Models/Tenants/CorporateCard.php b/app/Models/Tenants/CorporateCard.php new file mode 100644 index 0000000..aafeddb --- /dev/null +++ b/app/Models/Tenants/CorporateCard.php @@ -0,0 +1,110 @@ + 'integer', + 'credit_limit' => 'decimal:2', + 'current_usage' => 'decimal:2', + ]; + + protected $attributes = [ + 'status' => 'active', + 'payment_day' => 15, + 'credit_limit' => 0, + 'current_usage' => 0, + ]; + + // ========================================================================= + // Scopes + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + public function scopeByType($query, string $cardType) + { + return $query->where('card_type', $cardType); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function toggleStatus(): void + { + $this->status = $this->status === 'active' ? 'inactive' : 'active'; + } + + /** + * 마스킹된 카드번호 + */ + public function getMaskedCardNumber(): string + { + $number = preg_replace('/[^0-9]/', '', $this->card_number); + if (strlen($number) <= 4) { + return $this->card_number; + } + + return '****-****-****-'.substr($number, -4); + } +} diff --git a/app/Services/CorporateCardService.php b/app/Services/CorporateCardService.php new file mode 100644 index 0000000..43068f3 --- /dev/null +++ b/app/Services/CorporateCardService.php @@ -0,0 +1,171 @@ +tenantId(); + + $query = CorporateCard::query() + ->where('tenant_id', $tenantId); + + // 검색 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('card_name', 'like', "%{$search}%") + ->orWhere('card_company', 'like', "%{$search}%") + ->orWhere('card_number', 'like', "%{$search}%") + ->orWhere('card_holder_name', 'like', "%{$search}%") + ->orWhere('actual_user', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 카드 유형 필터 + if (! empty($params['card_type'])) { + $query->where('card_type', $params['card_type']); + } + + // 정렬 + $query->orderBy($params['sort_by'] ?? 'created_at', $params['sort_dir'] ?? 'desc'); + + return $query->paginate($params['per_page'] ?? 20); + } + + /** + * 법인카드 상세 조회 + */ + public function show(int $id): CorporateCard + { + return CorporateCard::query() + ->where('tenant_id', $this->tenantId()) + ->findOrFail($id); + } + + /** + * 법인카드 등록 + */ + public function store(array $data): CorporateCard + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($data, $tenantId) { + return CorporateCard::create([ + 'tenant_id' => $tenantId, + 'card_name' => $data['card_name'], + 'card_company' => $data['card_company'], + 'card_number' => $data['card_number'], + 'card_type' => $data['card_type'], + 'payment_day' => $data['payment_day'] ?? 15, + 'credit_limit' => $data['credit_limit'] ?? 0, + 'current_usage' => 0, + 'card_holder_name' => $data['card_holder_name'], + 'actual_user' => $data['actual_user'], + 'expiry_date' => $data['expiry_date'] ?? null, + 'cvc' => $data['cvc'] ?? null, + 'status' => $data['status'] ?? 'active', + 'memo' => $data['memo'] ?? null, + ]); + }); + } + + /** + * 법인카드 수정 + */ + public function update(int $id, array $data): CorporateCard + { + return DB::transaction(function () use ($id, $data) { + $card = CorporateCard::query() + ->where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $card->fill([ + 'card_name' => $data['card_name'] ?? $card->card_name, + 'card_company' => $data['card_company'] ?? $card->card_company, + 'card_number' => $data['card_number'] ?? $card->card_number, + 'card_type' => $data['card_type'] ?? $card->card_type, + 'payment_day' => $data['payment_day'] ?? $card->payment_day, + 'credit_limit' => $data['credit_limit'] ?? $card->credit_limit, + 'card_holder_name' => $data['card_holder_name'] ?? $card->card_holder_name, + 'actual_user' => $data['actual_user'] ?? $card->actual_user, + 'expiry_date' => $data['expiry_date'] ?? $card->expiry_date, + 'cvc' => $data['cvc'] ?? $card->cvc, + 'status' => $data['status'] ?? $card->status, + 'memo' => $data['memo'] ?? $card->memo, + ]); + + $card->save(); + + return $card->fresh(); + }); + } + + /** + * 법인카드 삭제 + */ + public function destroy(int $id): bool + { + return DB::transaction(function () use ($id) { + $card = CorporateCard::query() + ->where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $card->delete(); + + return true; + }); + } + + /** + * 법인카드 상태 토글 (사용/정지) + */ + public function toggleStatus(int $id): CorporateCard + { + return DB::transaction(function () use ($id) { + $card = CorporateCard::query() + ->where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $card->toggleStatus(); + $card->save(); + + return $card; + }); + } + + /** + * 활성 법인카드 목록 (셀렉트박스용) + */ + public function getActiveCards(): array + { + return CorporateCard::query() + ->where('tenant_id', $this->tenantId()) + ->where('status', 'active') + ->orderBy('card_name') + ->get(['id', 'card_name', 'card_company', 'card_number', 'card_type']) + ->map(function ($card) { + return [ + 'id' => $card->id, + 'card_name' => $card->card_name, + 'card_company' => $card->card_company, + 'display_number' => $card->getMaskedCardNumber(), + 'card_type' => $card->card_type, + ]; + }) + ->toArray(); + } +} diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 9490c2a..1654646 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -22,6 +22,7 @@ use App\Http\Controllers\Api\V1\CardController; use App\Http\Controllers\Api\V1\CardTransactionController; use App\Http\Controllers\Api\V1\ComprehensiveAnalysisController; +use App\Http\Controllers\Api\V1\CorporateCardController; use App\Http\Controllers\Api\V1\DailyReportController; use App\Http\Controllers\Api\V1\DepositController; use App\Http\Controllers\Api\V1\EntertainmentController; @@ -65,6 +66,17 @@ Route::patch('/{id}/set-primary', [BankAccountController::class, 'setPrimary'])->whereNumber('id')->name('v1.bank-accounts.set-primary'); }); +// CorporateCard API (법인카드 관리) +Route::prefix('corporate-cards')->group(function () { + Route::get('', [CorporateCardController::class, 'index'])->name('v1.corporate-cards.index'); + Route::post('', [CorporateCardController::class, 'store'])->name('v1.corporate-cards.store'); + Route::get('/active', [CorporateCardController::class, 'active'])->name('v1.corporate-cards.active'); + Route::get('/{id}', [CorporateCardController::class, 'show'])->whereNumber('id')->name('v1.corporate-cards.show'); + Route::put('/{id}', [CorporateCardController::class, 'update'])->whereNumber('id')->name('v1.corporate-cards.update'); + Route::delete('/{id}', [CorporateCardController::class, 'destroy'])->whereNumber('id')->name('v1.corporate-cards.destroy'); + Route::patch('/{id}/toggle', [CorporateCardController::class, 'toggle'])->whereNumber('id')->name('v1.corporate-cards.toggle'); +}); + // Deposit API (입금 관리) Route::prefix('deposits')->group(function () { Route::get('', [DepositController::class, 'index'])->name('v1.deposits.index');