feat: I-2 거래처 원장 API 구현

- VendorLedgerController: 거래처별 원장 조회 API
- VendorLedgerService: 원장 조회 비즈니스 로직
- 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:46:31 +09:00
parent aca0902c26
commit dced7b7fd3
3 changed files with 662 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\VendorLedgerService;
use Illuminate\Http\Request;
class VendorLedgerController extends Controller
{
public function __construct(
private readonly VendorLedgerService $service
) {}
/**
* 거래처원장 목록 (거래처별 매출/수금 집계)
*/
public function index(Request $request)
{
$params = $request->only([
'search',
'start_date',
'end_date',
'sort_by',
'sort_dir',
'per_page',
'page',
]);
$ledger = $this->service->index($params);
return ApiResponse::success($ledger, __('message.fetched'));
}
/**
* 거래처원장 요약 통계
*/
public function summary(Request $request)
{
$params = $request->only([
'start_date',
'end_date',
]);
$summary = $this->service->summary($params);
return ApiResponse::success($summary, __('message.fetched'));
}
/**
* 거래처원장 상세 (거래처별 거래 내역)
*/
public function show(int $clientId, Request $request)
{
$params = $request->only([
'start_date',
'end_date',
]);
$detail = $this->service->show($clientId, $params);
return ApiResponse::success($detail, __('message.fetched'));
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace App\Services;
use App\Models\Orders\Client;
use App\Models\Tenants\Bill;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\Sale;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class VendorLedgerService extends Service
{
/**
* 거래처원장 목록 조회 (거래처별 매출/수금 집계)
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
$search = $params['search'] ?? null;
$perPage = $params['per_page'] ?? 20;
// 거래처 목록 조회
$query = Client::query()
->where('tenant_id', $tenantId)
->where('is_active', true);
// 거래처명 검색
if (! empty($search)) {
$query->where('name', 'like', "%{$search}%");
}
// 거래처 ID 목록
$clientsQuery = clone $query;
$clients = $clientsQuery->select('id', 'name', 'sales_payment_day')->get();
// 매출 집계 서브쿼리
$salesSubquery = Sale::query()
->select('client_id', DB::raw('SUM(total_amount) as total_sales'))
->where('tenant_id', $tenantId)
->when($startDate, fn ($q) => $q->where('sale_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('sale_date', '<=', $endDate))
->groupBy('client_id');
// 수금 집계 서브쿼리
$depositsSubquery = Deposit::query()
->select('client_id', DB::raw('SUM(amount) as total_collection'))
->where('tenant_id', $tenantId)
->when($startDate, fn ($q) => $q->where('deposit_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('deposit_date', '<=', $endDate))
->groupBy('client_id');
// 이월잔액 서브쿼리 (기간 시작 전 매출 - 수금)
$carryoverSalesSubquery = null;
$carryoverDepositsSubquery = null;
if ($startDate) {
$carryoverSalesSubquery = Sale::query()
->select('client_id', DB::raw('COALESCE(SUM(total_amount), 0) as carryover_sales'))
->where('tenant_id', $tenantId)
->where('sale_date', '<', $startDate)
->groupBy('client_id');
$carryoverDepositsSubquery = Deposit::query()
->select('client_id', DB::raw('COALESCE(SUM(amount), 0) as carryover_deposits'))
->where('tenant_id', $tenantId)
->where('deposit_date', '<', $startDate)
->groupBy('client_id');
}
// 메인 쿼리
$mainQuery = Client::query()
->select([
'clients.id',
'clients.name',
'clients.sales_payment_day',
DB::raw('COALESCE(sales_agg.total_sales, 0) as period_sales'),
DB::raw('COALESCE(deposits_agg.total_collection, 0) as period_collection'),
])
->where('clients.tenant_id', $tenantId)
->where('clients.is_active', true)
->leftJoinSub($salesSubquery, 'sales_agg', 'clients.id', '=', 'sales_agg.client_id')
->leftJoinSub($depositsSubquery, 'deposits_agg', 'clients.id', '=', 'deposits_agg.client_id');
if ($carryoverSalesSubquery && $carryoverDepositsSubquery) {
$mainQuery->addSelect([
DB::raw('COALESCE(carryover_sales_agg.carryover_sales, 0) as carryover_sales'),
DB::raw('COALESCE(carryover_deposits_agg.carryover_deposits, 0) as carryover_deposits'),
])
->leftJoinSub($carryoverSalesSubquery, 'carryover_sales_agg', 'clients.id', '=', 'carryover_sales_agg.client_id')
->leftJoinSub($carryoverDepositsSubquery, 'carryover_deposits_agg', 'clients.id', '=', 'carryover_deposits_agg.client_id');
} else {
$mainQuery->addSelect([
DB::raw('0 as carryover_sales'),
DB::raw('0 as carryover_deposits'),
]);
}
// 검색
if (! empty($search)) {
$mainQuery->where('clients.name', 'like', "%{$search}%");
}
// 정렬
$sortBy = $params['sort_by'] ?? 'name';
$sortDir = $params['sort_dir'] ?? 'asc';
$mainQuery->orderBy("clients.{$sortBy}", $sortDir);
return $mainQuery->paginate($perPage);
}
/**
* 거래처원장 요약 통계
*/
public function summary(array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
// 기간 내 매출 합계
$totalSales = Sale::query()
->where('tenant_id', $tenantId)
->when($startDate, fn ($q) => $q->where('sale_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('sale_date', '<=', $endDate))
->sum('total_amount');
// 기간 내 수금 합계
$totalCollection = Deposit::query()
->where('tenant_id', $tenantId)
->when($startDate, fn ($q) => $q->where('deposit_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('deposit_date', '<=', $endDate))
->sum('amount');
// 이월잔액 (기간 시작 전 매출 - 수금)
$carryoverBalance = 0;
if ($startDate) {
$carryoverSales = Sale::query()
->where('tenant_id', $tenantId)
->where('sale_date', '<', $startDate)
->sum('total_amount');
$carryoverDeposits = Deposit::query()
->where('tenant_id', $tenantId)
->where('deposit_date', '<', $startDate)
->sum('amount');
$carryoverBalance = (float) $carryoverSales - (float) $carryoverDeposits;
}
// 현재 잔액 = 이월잔액 + 매출 - 수금
$balance = $carryoverBalance + (float) $totalSales - (float) $totalCollection;
return [
'carryover_balance' => $carryoverBalance,
'total_sales' => (float) $totalSales,
'total_collection' => (float) $totalCollection,
'balance' => $balance,
];
}
/**
* 거래처원장 상세 (거래처별 거래 내역)
*/
public function show(int $clientId, array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
// 거래처 정보
$client = Client::query()
->where('tenant_id', $tenantId)
->findOrFail($clientId);
// 이월잔액 계산
$carryoverBalance = 0;
if ($startDate) {
$carryoverSales = Sale::query()
->where('tenant_id', $tenantId)
->where('client_id', $clientId)
->where('sale_date', '<', $startDate)
->sum('total_amount');
$carryoverDeposits = Deposit::query()
->where('tenant_id', $tenantId)
->where('client_id', $clientId)
->where('deposit_date', '<', $startDate)
->sum('amount');
$carryoverBalance = (float) $carryoverSales - (float) $carryoverDeposits;
}
// 거래 내역 조회 (매출 + 수금)
$transactions = collect();
// 매출 내역
$sales = Sale::query()
->where('tenant_id', $tenantId)
->where('client_id', $clientId)
->when($startDate, fn ($q) => $q->where('sale_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('sale_date', '<=', $endDate))
->orderBy('sale_date')
->get()
->map(fn ($sale) => [
'id' => $sale->id,
'date' => $sale->sale_date->format('Y-m-d'),
'type' => 'sales',
'description' => $sale->description ?? '매출',
'sales_amount' => (float) $sale->total_amount,
'collection_amount' => 0,
'reference_id' => $sale->id,
'reference_type' => 'sale',
'has_action' => false,
'is_highlighted' => ! $sale->tax_invoice_issued,
]);
// 수금 내역
$deposits = Deposit::query()
->where('tenant_id', $tenantId)
->where('client_id', $clientId)
->when($startDate, fn ($q) => $q->where('deposit_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('deposit_date', '<=', $endDate))
->orderBy('deposit_date')
->get()
->map(fn ($deposit) => [
'id' => $deposit->id,
'date' => $deposit->deposit_date->format('Y-m-d'),
'type' => 'collection',
'description' => $deposit->description ?? '입금',
'sales_amount' => 0,
'collection_amount' => (float) $deposit->amount,
'reference_id' => $deposit->id,
'reference_type' => 'deposit',
'has_action' => false,
'is_highlighted' => false,
'is_parenthesis' => $deposit->payment_method === 'check',
]);
// 어음 내역
$bills = Bill::query()
->where('tenant_id', $tenantId)
->where('client_id', $clientId)
->when($startDate, fn ($q) => $q->where('issue_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('issue_date', '<=', $endDate))
->orderBy('issue_date')
->get()
->map(fn ($bill) => [
'id' => $bill->id,
'date' => $bill->issue_date,
'type' => 'note',
'description' => "수취 어음 (만기 {$bill->maturity_date})",
'sales_amount' => 0,
'collection_amount' => (float) $bill->amount,
'reference_id' => $bill->id,
'reference_type' => 'bill',
'has_action' => true,
'is_highlighted' => false,
'is_parenthesis' => true,
'note_info' => $bill->maturity_date,
]);
// 전체 거래 내역 합치기 및 정렬
$allTransactions = $sales->merge($deposits)->merge($bills)
->sortBy('date')
->values();
// 잔액 계산
$runningBalance = $carryoverBalance;
$transactions = $allTransactions->map(function ($item) use (&$runningBalance) {
$runningBalance = $runningBalance + $item['sales_amount'] - $item['collection_amount'];
$item['balance'] = $runningBalance;
return $item;
});
// 합계 계산
$totalSales = $transactions->sum('sales_amount');
$totalCollection = $transactions->sum('collection_amount');
return [
'client' => [
'id' => $client->id,
'name' => $client->name,
'business_number' => $client->business_no,
'representative_name' => $client->contact_person,
'phone' => $client->phone,
'mobile' => $client->mobile,
'fax' => $client->fax,
'email' => $client->email,
'address' => $client->address,
],
'period' => [
'start_date' => $startDate,
'end_date' => $endDate,
],
'carryover_balance' => $carryoverBalance,
'total_sales' => $totalSales,
'total_collection' => $totalCollection,
'balance' => $runningBalance,
'transactions' => $transactions->toArray(),
];
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="VendorLedger",
* description="거래처원장 API - 거래처별 매출/수금 내역 조회"
* )
*/
/**
* @OA\Schema(
* schema="VendorLedgerItem",
* description="거래처원장 리스트 항목",
*
* @OA\Property(property="id", type="integer", description="거래처 ID", example=1),
* @OA\Property(property="name", type="string", description="거래처명", example="ABC 주식회사"),
* @OA\Property(property="sales_payment_day", type="string", description="결제일", example="25일", nullable=true),
* @OA\Property(property="period_sales", type="number", format="float", description="기간 내 매출", example=10000000),
* @OA\Property(property="period_collection", type="number", format="float", description="기간 내 수금", example=8000000),
* @OA\Property(property="carryover_sales", type="number", format="float", description="이월 매출", example=5000000),
* @OA\Property(property="carryover_deposits", type="number", format="float", description="이월 수금", example=3000000)
* )
*
* @OA\Schema(
* schema="VendorLedgerSummary",
* description="거래처원장 요약 통계",
*
* @OA\Property(property="carryover_balance", type="number", format="float", description="이월잔액", example=2000000),
* @OA\Property(property="total_sales", type="number", format="float", description="총 매출", example=10000000),
* @OA\Property(property="total_collection", type="number", format="float", description="총 수금", example=8000000),
* @OA\Property(property="balance", type="number", format="float", description="현재 잔액", example=4000000)
* )
*
* @OA\Schema(
* schema="VendorLedgerTransaction",
* description="거래처원장 거래 내역",
*
* @OA\Property(property="id", type="integer", description="거래 ID", example=1),
* @OA\Property(property="date", type="string", format="date", description="거래일", example="2025-01-15"),
* @OA\Property(property="type", type="string", description="거래 유형", example="sales", enum={"sales", "collection", "note"}),
* @OA\Property(property="description", type="string", description="적요", example="제품 납품"),
* @OA\Property(property="sales_amount", type="number", format="float", description="매출 금액", example=1000000),
* @OA\Property(property="collection_amount", type="number", format="float", description="수금 금액", example=0),
* @OA\Property(property="balance", type="number", format="float", description="잔액", example=1000000),
* @OA\Property(property="reference_id", type="integer", description="참조 ID", example=1),
* @OA\Property(property="reference_type", type="string", description="참조 타입", example="sale", enum={"sale", "deposit", "bill"}),
* @OA\Property(property="has_action", type="boolean", description="작업 버튼 표시 여부", example=false),
* @OA\Property(property="is_highlighted", type="boolean", description="하이라이트 여부", example=true),
* @OA\Property(property="is_parenthesis", type="boolean", description="괄호 표시 여부", example=false),
* @OA\Property(property="note_info", type="string", description="어음 정보", example="2025-02-15", nullable=true)
* )
*
* @OA\Schema(
* schema="VendorLedgerClient",
* description="거래처 정보",
*
* @OA\Property(property="id", type="integer", description="거래처 ID", example=1),
* @OA\Property(property="name", type="string", description="거래처명", example="ABC 주식회사"),
* @OA\Property(property="business_number", type="string", description="사업자등록번호", example="123-45-67890", nullable=true),
* @OA\Property(property="representative_name", type="string", description="대표자", example="홍길동", nullable=true),
* @OA\Property(property="phone", type="string", description="전화번호", example="02-1234-5678", nullable=true),
* @OA\Property(property="mobile", type="string", description="휴대전화", example="010-1234-5678", nullable=true),
* @OA\Property(property="fax", type="string", description="팩스", example="02-1234-5679", nullable=true),
* @OA\Property(property="email", type="string", description="이메일", example="contact@example.com", nullable=true),
* @OA\Property(property="address", type="string", description="주소", example="서울시 강남구", nullable=true)
* )
*
* @OA\Schema(
* schema="VendorLedgerDetail",
* description="거래처원장 상세",
*
* @OA\Property(property="client", ref="#/components/schemas/VendorLedgerClient"),
* @OA\Property(
* property="period",
* type="object",
* @OA\Property(property="start_date", type="string", format="date", example="2025-01-01"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-12-31")
* ),
* @OA\Property(property="carryover_balance", type="number", format="float", description="이월잔액", example=2000000),
* @OA\Property(property="total_sales", type="number", format="float", description="총 매출", example=10000000),
* @OA\Property(property="total_collection", type="number", format="float", description="총 수금", example=8000000),
* @OA\Property(property="balance", type="number", format="float", description="현재 잔액", example=4000000),
* @OA\Property(
* property="transactions",
* type="array",
*
* @OA\Items(ref="#/components/schemas/VendorLedgerTransaction")
* )
* )
*
* @OA\Schema(
* schema="VendorLedgerPagination",
* description="거래처원장 목록 페이지네이션",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/VendorLedgerItem")),
* @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")
* )
*/
class VendorLedgerApi
{
/**
* @OA\Get(
* path="/api/v1/vendor-ledger",
* operationId="getVendorLedgerList",
* tags={"VendorLedger"},
* summary="거래처원장 목록 조회",
* description="거래처별 매출/수금 집계 목록을 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="search",
* in="query",
* description="거래처명 검색",
*
* @OA\Schema(type="string", example="ABC")
* ),
*
* @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="sort_by",
* in="query",
* description="정렬 기준 (name)",
*
* @OA\Schema(type="string", default="name")
* ),
*
* @OA\Parameter(
* name="sort_dir",
* in="query",
* description="정렬 방향",
*
* @OA\Schema(type="string", enum={"asc", "desc"}, default="asc")
* ),
*
* @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/VendorLedgerPagination")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음")
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/vendor-ledger/summary",
* operationId="getVendorLedgerSummary",
* tags={"VendorLedger"},
* 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/VendorLedgerSummary")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음")
* )
*/
public function summary() {}
/**
* @OA\Get(
* path="/api/v1/vendor-ledger/{clientId}",
* operationId="getVendorLedgerDetail",
* tags={"VendorLedger"},
* summary="거래처원장 상세 조회",
* description="특정 거래처의 상세 거래 내역을 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="clientId",
* in="path",
* required=true,
* description="거래처 ID",
*
* @OA\Schema(type="integer", example=1)
* ),
*
* @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/VendorLedgerDetail")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음"),
* @OA\Response(response=404, description="거래처를 찾을 수 없음")
* )
*/
public function show() {}
}