feat: I-4 거래통장 조회 API 구현

- BankTransactionController: 은행 거래내역 조회 API
- BankTransactionService: 은행 거래 조회 로직
- Swagger 문서화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 15:47:03 +09:00
parent e7862ed6e6
commit 2aa1d78e62
3 changed files with 543 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\BankTransactionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 은행 거래 (입출금 통합) 조회 컨트롤러
*/
class BankTransactionController extends Controller
{
public function __construct(
protected BankTransactionService $service
) {}
/**
* 입출금 통합 목록 조회
*/
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'bank_account_id' => 'nullable|integer',
'transaction_type' => 'nullable|string|max:50',
'search' => 'nullable|string|max:100',
'sort_by' => 'nullable|in:transaction_date,amount',
'sort_dir' => 'nullable|in:asc,desc',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->index($params);
}, __('message.fetched'));
}
/**
* 입출금 요약 통계
*/
public function summary(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
return $this->service->summary($params);
}, __('message.fetched'));
}
/**
* 계좌 목록 조회 (필터용)
*/
public function accounts(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->getAccountOptions();
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace App\Services;
use App\Models\Tenants\BankAccount;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\Withdrawal;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* 은행 거래 (입출금 통합) 조회 서비스
*/
class BankTransactionService extends Service
{
/**
* 입출금 통합 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$perPage = $params['per_page'] ?? 20;
$page = $params['page'] ?? 1;
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
$bankAccountId = $params['bank_account_id'] ?? null;
$transactionType = $params['transaction_type'] ?? null;
$search = $params['search'] ?? null;
$sortBy = $params['sort_by'] ?? 'transaction_date';
$sortDir = $params['sort_dir'] ?? 'desc';
$tenantId = $this->tenantId();
// 입금 쿼리 (payment_method = 'transfer')
$depositsQuery = DB::table('deposits')
->join('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id')
->leftJoin('clients', 'deposits.client_id', '=', 'clients.id')
->where('deposits.tenant_id', $tenantId)
->where('deposits.payment_method', 'transfer')
->whereNull('deposits.deleted_at')
->select([
'deposits.id',
DB::raw("'deposit' as type"),
'deposits.deposit_date as transaction_date',
'bank_accounts.id as bank_account_id',
'bank_accounts.bank_name',
'bank_accounts.account_name',
'deposits.description as note',
'deposits.client_id as vendor_id',
DB::raw('COALESCE(clients.name, deposits.client_name) as vendor_name'),
'deposits.client_name as depositor_name',
'deposits.amount as deposit_amount',
DB::raw('0 as withdrawal_amount'),
'deposits.account_code as transaction_type',
DB::raw("CONCAT('deposit-', deposits.id) as source_id"),
'deposits.created_at',
'deposits.updated_at',
]);
// 출금 쿼리 (payment_method = 'transfer')
$withdrawalsQuery = DB::table('withdrawals')
->join('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id')
->leftJoin('clients', 'withdrawals.client_id', '=', 'clients.id')
->where('withdrawals.tenant_id', $tenantId)
->where('withdrawals.payment_method', 'transfer')
->whereNull('withdrawals.deleted_at')
->select([
'withdrawals.id',
DB::raw("'withdrawal' as type"),
'withdrawals.withdrawal_date as transaction_date',
'bank_accounts.id as bank_account_id',
'bank_accounts.bank_name',
'bank_accounts.account_name',
'withdrawals.description as note',
'withdrawals.client_id as vendor_id',
DB::raw('COALESCE(clients.name, withdrawals.client_name) as vendor_name'),
'withdrawals.client_name as depositor_name',
DB::raw('0 as deposit_amount'),
'withdrawals.amount as withdrawal_amount',
'withdrawals.account_code as transaction_type',
DB::raw("CONCAT('withdrawal-', withdrawals.id) as source_id"),
'withdrawals.created_at',
'withdrawals.updated_at',
]);
// 필터 적용: 날짜 범위
if ($startDate) {
$depositsQuery->whereDate('deposits.deposit_date', '>=', $startDate);
$withdrawalsQuery->whereDate('withdrawals.withdrawal_date', '>=', $startDate);
}
if ($endDate) {
$depositsQuery->whereDate('deposits.deposit_date', '<=', $endDate);
$withdrawalsQuery->whereDate('withdrawals.withdrawal_date', '<=', $endDate);
}
// 필터 적용: 계좌
if ($bankAccountId) {
$depositsQuery->where('deposits.bank_account_id', $bankAccountId);
$withdrawalsQuery->where('withdrawals.bank_account_id', $bankAccountId);
}
// 필터 적용: 입출금유형 (account_code)
if ($transactionType) {
if ($transactionType === 'unset') {
$depositsQuery->whereNull('deposits.account_code');
$withdrawalsQuery->whereNull('withdrawals.account_code');
} else {
$depositsQuery->where('deposits.account_code', $transactionType);
$withdrawalsQuery->where('withdrawals.account_code', $transactionType);
}
}
// 필터 적용: 검색어
if ($search) {
$depositsQuery->where(function ($q) use ($search) {
$q->where('bank_accounts.bank_name', 'like', "%{$search}%")
->orWhere('bank_accounts.account_name', 'like', "%{$search}%")
->orWhere('deposits.client_name', 'like', "%{$search}%")
->orWhere('clients.name', 'like', "%{$search}%")
->orWhere('deposits.description', 'like', "%{$search}%");
});
$withdrawalsQuery->where(function ($q) use ($search) {
$q->where('bank_accounts.bank_name', 'like', "%{$search}%")
->orWhere('bank_accounts.account_name', 'like', "%{$search}%")
->orWhere('withdrawals.client_name', 'like', "%{$search}%")
->orWhere('clients.name', 'like', "%{$search}%")
->orWhere('withdrawals.description', 'like', "%{$search}%");
});
}
// UNION
$unionQuery = $depositsQuery->union($withdrawalsQuery);
// 정렬 컬럼 매핑
$sortColumn = match ($sortBy) {
'transaction_date' => 'transaction_date',
'deposit_amount', 'withdrawal_amount', 'amount' => DB::raw('(deposit_amount + withdrawal_amount)'),
default => 'transaction_date',
};
// 전체 조회하여 정렬 및 잔액 계산
$allItems = DB::query()
->fromSub($unionQuery, 'transactions')
->orderBy($sortColumn, $sortDir)
->get();
// 잔액 계산 (날짜순 정렬 기준)
$balance = 0;
$itemsWithBalance = $allItems->map(function ($item) use (&$balance) {
$balance += $item->deposit_amount - $item->withdrawal_amount;
$item->balance = $balance;
return $item;
});
// 수동 페이지네이션
$total = $itemsWithBalance->count();
$offset = ($page - 1) * $perPage;
$paginatedItems = $itemsWithBalance->slice($offset, $perPage)->values();
return new LengthAwarePaginator(
$paginatedItems,
$total,
$perPage,
$page,
['path' => request()->url()]
);
}
/**
* 입출금 요약 통계
*/
public function summary(array $params): array
{
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
$tenantId = $this->tenantId();
// 입금 합계 (계좌이체)
$depositQuery = Deposit::where('tenant_id', $tenantId)
->where('payment_method', 'transfer');
if ($startDate) {
$depositQuery->whereDate('deposit_date', '>=', $startDate);
}
if ($endDate) {
$depositQuery->whereDate('deposit_date', '<=', $endDate);
}
$totalDeposit = $depositQuery->sum('amount');
$depositUnsetCount = (clone $depositQuery)->whereNull('account_code')->count();
// 출금 합계 (계좌이체)
$withdrawalQuery = Withdrawal::where('tenant_id', $tenantId)
->where('payment_method', 'transfer');
if ($startDate) {
$withdrawalQuery->whereDate('withdrawal_date', '>=', $startDate);
}
if ($endDate) {
$withdrawalQuery->whereDate('withdrawal_date', '<=', $endDate);
}
$totalWithdrawal = $withdrawalQuery->sum('amount');
$withdrawalUnsetCount = (clone $withdrawalQuery)->whereNull('account_code')->count();
return [
'total_deposit' => (float) $totalDeposit,
'total_withdrawal' => (float) $totalWithdrawal,
'deposit_unset_count' => (int) $depositUnsetCount,
'withdrawal_unset_count' => (int) $withdrawalUnsetCount,
];
}
/**
* 계좌 목록 조회 (필터용)
*/
public function getAccountOptions(): array
{
$tenantId = $this->tenantId();
return BankAccount::where('tenant_id', $tenantId)
->where('status', 'active')
->orderBy('bank_name')
->orderBy('account_name')
->get()
->map(fn ($acc) => [
'id' => $acc->id,
'label' => "{$acc->bank_name}|{$acc->account_name}",
])
->toArray();
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="BankTransaction",
* description="은행 거래 조회 API - 입출금 통합 조회 및 통계"
* )
*/
/**
* @OA\Schema(
* schema="BankTransactionItem",
* description="은행 거래 항목",
*
* @OA\Property(property="id", type="integer", description="거래 ID", example=1),
* @OA\Property(property="type", type="string", enum={"deposit", "withdrawal"}, description="구분", example="deposit"),
* @OA\Property(property="transaction_date", type="string", format="date", description="거래일", example="2025-01-15"),
* @OA\Property(property="bank_account_id", type="integer", description="계좌 ID", example=1),
* @OA\Property(property="bank_name", type="string", description="은행명", example="국민은행"),
* @OA\Property(property="account_name", type="string", description="계좌명", example="영업계좌"),
* @OA\Property(property="note", type="string", description="적요", example="거래 메모", nullable=true),
* @OA\Property(property="vendor_id", type="integer", description="거래처 ID", example=1, nullable=true),
* @OA\Property(property="vendor_name", type="string", description="거래처명", example="(주)삼성전자", nullable=true),
* @OA\Property(property="depositor_name", type="string", description="입금자/수취인", example="홍길동", nullable=true),
* @OA\Property(property="deposit_amount", type="number", format="float", description="입금액", example=1000000),
* @OA\Property(property="withdrawal_amount", type="number", format="float", description="출금액", example=0),
* @OA\Property(property="balance", type="number", format="float", description="잔액", example=50000000),
* @OA\Property(property="transaction_type", type="string", description="입출금 유형", example="salesRevenue", nullable=true),
* @OA\Property(property="source_id", type="string", description="원본 ID", example="deposit-1"),
* @OA\Property(property="created_at", type="string", format="date-time", description="생성일시"),
* @OA\Property(property="updated_at", type="string", format="date-time", description="수정일시")
* )
*
* @OA\Schema(
* schema="BankTransactionSummary",
* description="은행 거래 요약 통계",
*
* @OA\Property(property="total_deposit", type="number", format="float", description="총 입금액", example=15000000),
* @OA\Property(property="total_withdrawal", type="number", format="float", description="총 출금액", example=8500000),
* @OA\Property(property="deposit_unset_count", type="integer", description="입금 유형 미설정 건수", example=5),
* @OA\Property(property="withdrawal_unset_count", type="integer", description="출금 유형 미설정 건수", example=3)
* )
*
* @OA\Schema(
* schema="BankTransactionPagination",
* description="은행 거래 목록 페이지네이션",
* type="object",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/BankTransactionItem")),
* @OA\Property(property="first_page_url", type="string"),
* @OA\Property(property="from", type="integer"),
* @OA\Property(property="last_page", type="integer"),
* @OA\Property(property="last_page_url", type="string"),
* @OA\Property(property="next_page_url", type="string", nullable=true),
* @OA\Property(property="path", type="string"),
* @OA\Property(property="per_page", type="integer"),
* @OA\Property(property="prev_page_url", type="string", nullable=true),
* @OA\Property(property="to", type="integer"),
* @OA\Property(property="total", type="integer")
* )
*
* @OA\Schema(
* schema="BankAccountOption",
* description="계좌 선택 옵션",
*
* @OA\Property(property="id", type="integer", description="계좌 ID", example=1),
* @OA\Property(property="label", type="string", description="표시명", example="국민은행|영업계좌")
* )
*/
class BankTransactionApi
{
/**
* @OA\Get(
* path="/api/v1/bank-transactions",
* operationId="getBankTransactionList",
* tags={"BankTransaction"},
* summary="입출금 통합 목록 조회",
* description="은행 계좌의 입출금 내역을 통합 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="start_date",
* in="query",
* description="조회 시작일",
*
* @OA\Schema(type="string", format="date", example="2025-01-01")
* ),
*
* @OA\Parameter(
* name="end_date",
* in="query",
* description="조회 종료일",
*
* @OA\Schema(type="string", format="date", example="2025-12-31")
* ),
*
* @OA\Parameter(
* name="bank_account_id",
* in="query",
* description="계좌 ID 필터",
*
* @OA\Schema(type="integer", example=1)
* ),
*
* @OA\Parameter(
* name="transaction_type",
* in="query",
* description="입출금 유형 필터 (unset: 미설정)",
*
* @OA\Schema(type="string", example="salesRevenue")
* ),
*
* @OA\Parameter(
* name="search",
* in="query",
* description="검색어 (은행명, 계좌명, 거래처, 적요)",
*
* @OA\Schema(type="string", example="국민")
* ),
*
* @OA\Parameter(
* name="sort_by",
* in="query",
* description="정렬 기준",
*
* @OA\Schema(type="string", enum={"transaction_date", "amount"}, default="transaction_date")
* ),
*
* @OA\Parameter(
* name="sort_dir",
* in="query",
* description="정렬 방향",
*
* @OA\Schema(type="string", enum={"asc", "desc"}, default="desc")
* ),
*
* @OA\Parameter(
* name="per_page",
* in="query",
* description="페이지당 항목 수",
*
* @OA\Schema(type="integer", default=20)
* ),
*
* @OA\Parameter(
* name="page",
* in="query",
* description="페이지 번호",
*
* @OA\Schema(type="integer", default=1)
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/BankTransactionPagination")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음")
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/bank-transactions/summary",
* operationId="getBankTransactionSummary",
* tags={"BankTransaction"},
* summary="입출금 요약 통계",
* description="입금/출금 합계 및 유형 미설정 건수를 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="start_date",
* in="query",
* description="조회 시작일",
*
* @OA\Schema(type="string", format="date", example="2025-01-01")
* ),
*
* @OA\Parameter(
* name="end_date",
* in="query",
* description="조회 종료일",
*
* @OA\Schema(type="string", format="date", example="2025-12-31")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/BankTransactionSummary")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음")
* )
*/
public function summary() {}
/**
* @OA\Get(
* path="/api/v1/bank-transactions/accounts",
* operationId="getBankTransactionAccounts",
* tags={"BankTransaction"},
* summary="계좌 목록 조회 (필터용)",
* description="입출금 조회 필터에 사용할 계좌 목록을 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BankAccountOption")
* )
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음")
* )
*/
public function accounts() {}
}