feat: I-5 미수금 현황 API 구현
- ReceivablesController: 미수금 조회 API - ReceivablesService: 미수금 조회 로직 - 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:
49
app/Http/Controllers/Api/V1/ReceivablesController.php
Normal file
49
app/Http/Controllers/Api/V1/ReceivablesController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ReceivablesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 채권 현황 컨트롤러
|
||||
*/
|
||||
class ReceivablesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ReceivablesService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 채권 현황 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'year' => 'nullable|integer|min:2000|max:2100',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'has_receivable' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
return $this->service->index($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 채권 현황 요약 통계
|
||||
*/
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'year' => 'nullable|integer|min:2000|max:2100',
|
||||
]);
|
||||
|
||||
return $this->service->summary($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
301
app/Services/ReceivablesService.php
Normal file
301
app/Services/ReceivablesService.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?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\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 채권 현황 서비스
|
||||
* 거래처별 월별 매출, 입금, 어음, 미수금 현황 조회
|
||||
*/
|
||||
class ReceivablesService extends Service
|
||||
{
|
||||
/**
|
||||
* 채권 현황 목록 조회
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$year = $params['year'] ?? date('Y');
|
||||
$search = $params['search'] ?? null;
|
||||
|
||||
// 거래처 목록 조회
|
||||
$clientsQuery = Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true);
|
||||
|
||||
if ($search) {
|
||||
$clientsQuery->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$clients = $clientsQuery->orderBy('name')->get();
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($clients as $client) {
|
||||
// 월별 데이터 수집
|
||||
$salesByMonth = $this->getSalesByMonth($tenantId, $client->id, $year);
|
||||
$depositsByMonth = $this->getDepositsByMonth($tenantId, $client->id, $year);
|
||||
$billsByMonth = $this->getBillsByMonth($tenantId, $client->id, $year);
|
||||
|
||||
// 미수금 계산 (매출 - 입금 - 어음)
|
||||
$receivablesByMonth = $this->calculateReceivables($salesByMonth, $depositsByMonth, $billsByMonth);
|
||||
|
||||
// 카테고리별 데이터 생성
|
||||
$categories = [
|
||||
[
|
||||
'category' => 'sales',
|
||||
'amounts' => $this->formatMonthlyAmounts($salesByMonth),
|
||||
],
|
||||
[
|
||||
'category' => 'deposit',
|
||||
'amounts' => $this->formatMonthlyAmounts($depositsByMonth),
|
||||
],
|
||||
[
|
||||
'category' => 'bill',
|
||||
'amounts' => $this->formatMonthlyAmounts($billsByMonth),
|
||||
],
|
||||
[
|
||||
'category' => 'receivable',
|
||||
'amounts' => $this->formatMonthlyAmounts($receivablesByMonth),
|
||||
],
|
||||
[
|
||||
'category' => 'memo',
|
||||
'amounts' => $this->getEmptyMonthlyAmounts(),
|
||||
],
|
||||
];
|
||||
|
||||
// 미수금이 있는 월 확인 (연체 표시용)
|
||||
$overdueMonths = [];
|
||||
foreach ($receivablesByMonth as $month => $amount) {
|
||||
if ($amount > 0) {
|
||||
$overdueMonths[] = $month;
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => (string) $client->id,
|
||||
'vendor_id' => $client->id,
|
||||
'vendor_name' => $client->name,
|
||||
'is_overdue' => count($overdueMonths) > 0,
|
||||
'overdue_months' => $overdueMonths,
|
||||
'categories' => $categories,
|
||||
];
|
||||
}
|
||||
|
||||
// 미수금이 있는 거래처만 필터링 (선택적)
|
||||
if (! empty($params['has_receivable'])) {
|
||||
$result = array_filter($result, function ($item) {
|
||||
$receivableCat = collect($item['categories'])->firstWhere('category', 'receivable');
|
||||
|
||||
return $receivableCat && $receivableCat['amounts']['total'] > 0;
|
||||
});
|
||||
$result = array_values($result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회
|
||||
*/
|
||||
public function summary(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$year = $params['year'] ?? date('Y');
|
||||
|
||||
$startDate = "{$year}-01-01";
|
||||
$endDate = "{$year}-12-31";
|
||||
|
||||
// 총 매출
|
||||
$totalSales = Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('sale_date', [$startDate, $endDate])
|
||||
->sum('total_amount');
|
||||
|
||||
// 총 입금
|
||||
$totalDeposits = Deposit::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('deposit_date', [$startDate, $endDate])
|
||||
->sum('amount');
|
||||
|
||||
// 총 어음
|
||||
$totalBills = Bill::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('bill_type', 'received')
|
||||
->whereBetween('issue_date', [$startDate, $endDate])
|
||||
->sum('amount');
|
||||
|
||||
// 총 미수금
|
||||
$totalReceivables = $totalSales - $totalDeposits - $totalBills;
|
||||
if ($totalReceivables < 0) {
|
||||
$totalReceivables = 0;
|
||||
}
|
||||
|
||||
// 거래처 수
|
||||
$vendorCount = Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->whereHas('orders')
|
||||
->count();
|
||||
|
||||
// 연체 거래처 수 (미수금이 있는 거래처)
|
||||
$overdueVendorCount = DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('sale_date', [$startDate, $endDate])
|
||||
->select('client_id')
|
||||
->groupBy('client_id')
|
||||
->havingRaw('SUM(total_amount) > (
|
||||
SELECT COALESCE(SUM(d.amount), 0) FROM deposits d
|
||||
WHERE d.tenant_id = ? AND d.client_id = sales.client_id
|
||||
AND d.deposit_date BETWEEN ? AND ?
|
||||
) + (
|
||||
SELECT COALESCE(SUM(b.amount), 0) FROM bills b
|
||||
WHERE b.tenant_id = ? AND b.client_id = sales.client_id
|
||||
AND b.bill_type = "received"
|
||||
AND b.issue_date BETWEEN ? AND ?
|
||||
)', [$tenantId, $startDate, $endDate, $tenantId, $startDate, $endDate])
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_sales' => (float) $totalSales,
|
||||
'total_deposits' => (float) $totalDeposits,
|
||||
'total_bills' => (float) $totalBills,
|
||||
'total_receivables' => (float) $totalReceivables,
|
||||
'vendor_count' => $vendorCount,
|
||||
'overdue_vendor_count' => $overdueVendorCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 매출 조회
|
||||
*/
|
||||
private function getSalesByMonth(int $tenantId, int $clientId, string $year): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$sales = Sale::where('tenant_id', $tenantId)
|
||||
->where('client_id', $clientId)
|
||||
->whereYear('sale_date', $year)
|
||||
->select(
|
||||
DB::raw('MONTH(sale_date) as month'),
|
||||
DB::raw('SUM(total_amount) as total')
|
||||
)
|
||||
->groupBy(DB::raw('MONTH(sale_date)'))
|
||||
->get();
|
||||
|
||||
foreach ($sales as $sale) {
|
||||
$result[(int) $sale->month] = (float) $sale->total;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 입금 조회
|
||||
*/
|
||||
private function getDepositsByMonth(int $tenantId, int $clientId, string $year): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$deposits = Deposit::where('tenant_id', $tenantId)
|
||||
->where('client_id', $clientId)
|
||||
->whereYear('deposit_date', $year)
|
||||
->select(
|
||||
DB::raw('MONTH(deposit_date) as month'),
|
||||
DB::raw('SUM(amount) as total')
|
||||
)
|
||||
->groupBy(DB::raw('MONTH(deposit_date)'))
|
||||
->get();
|
||||
|
||||
foreach ($deposits as $deposit) {
|
||||
$result[(int) $deposit->month] = (float) $deposit->total;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 어음 조회
|
||||
*/
|
||||
private function getBillsByMonth(int $tenantId, int $clientId, string $year): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$bills = Bill::where('tenant_id', $tenantId)
|
||||
->where('client_id', $clientId)
|
||||
->where('bill_type', 'received')
|
||||
->whereYear('issue_date', $year)
|
||||
->select(
|
||||
DB::raw('MONTH(issue_date) as month'),
|
||||
DB::raw('SUM(amount) as total')
|
||||
)
|
||||
->groupBy(DB::raw('MONTH(issue_date)'))
|
||||
->get();
|
||||
|
||||
foreach ($bills as $bill) {
|
||||
$result[(int) $bill->month] = (float) $bill->total;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금 계산
|
||||
*/
|
||||
private function calculateReceivables(array $sales, array $deposits, array $bills): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$salesAmount = $sales[$month] ?? 0;
|
||||
$depositAmount = $deposits[$month] ?? 0;
|
||||
$billAmount = $bills[$month] ?? 0;
|
||||
|
||||
$receivable = $salesAmount - $depositAmount - $billAmount;
|
||||
$result[$month] = max(0, $receivable);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 금액을 프론트엔드 형식으로 변환
|
||||
*/
|
||||
private function formatMonthlyAmounts(array $monthlyData): array
|
||||
{
|
||||
$amounts = [];
|
||||
$total = 0;
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$key = "month{$month}";
|
||||
$amount = $monthlyData[$month] ?? 0;
|
||||
$amounts[$key] = $amount;
|
||||
$total += $amount;
|
||||
}
|
||||
|
||||
$amounts['total'] = $total;
|
||||
|
||||
return $amounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 월별 금액 데이터
|
||||
*/
|
||||
private function getEmptyMonthlyAmounts(): array
|
||||
{
|
||||
$amounts = [];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$amounts["month{$month}"] = 0;
|
||||
}
|
||||
|
||||
$amounts['total'] = 0;
|
||||
|
||||
return $amounts;
|
||||
}
|
||||
}
|
||||
168
app/Swagger/v1/ReceivablesApi.php
Normal file
168
app/Swagger/v1/ReceivablesApi.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Receivables",
|
||||
* description="채권 현황 API - 거래처별 월별 미수금 현황 조회"
|
||||
* )
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="MonthlyAmount",
|
||||
* description="월별 금액",
|
||||
*
|
||||
* @OA\Property(property="month1", type="number", format="float", description="1월", example=1000000),
|
||||
* @OA\Property(property="month2", type="number", format="float", description="2월", example=0),
|
||||
* @OA\Property(property="month3", type="number", format="float", description="3월", example=0),
|
||||
* @OA\Property(property="month4", type="number", format="float", description="4월", example=0),
|
||||
* @OA\Property(property="month5", type="number", format="float", description="5월", example=0),
|
||||
* @OA\Property(property="month6", type="number", format="float", description="6월", example=0),
|
||||
* @OA\Property(property="month7", type="number", format="float", description="7월", example=0),
|
||||
* @OA\Property(property="month8", type="number", format="float", description="8월", example=0),
|
||||
* @OA\Property(property="month9", type="number", format="float", description="9월", example=0),
|
||||
* @OA\Property(property="month10", type="number", format="float", description="10월", example=0),
|
||||
* @OA\Property(property="month11", type="number", format="float", description="11월", example=0),
|
||||
* @OA\Property(property="month12", type="number", format="float", description="12월", example=500000),
|
||||
* @OA\Property(property="total", type="number", format="float", description="합계", example=1500000)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryAmount",
|
||||
* description="카테고리별 금액",
|
||||
*
|
||||
* @OA\Property(property="category", type="string", enum={"sales", "deposit", "bill", "receivable", "memo"}, description="구분", example="sales"),
|
||||
* @OA\Property(property="amounts", ref="#/components/schemas/MonthlyAmount")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="VendorReceivables",
|
||||
* description="거래처별 채권 현황",
|
||||
*
|
||||
* @OA\Property(property="id", type="string", description="거래처 ID (string)", example="1"),
|
||||
* @OA\Property(property="vendor_id", type="integer", description="거래처 ID", example=1),
|
||||
* @OA\Property(property="vendor_name", type="string", description="거래처명", example="(주)삼성전자"),
|
||||
* @OA\Property(property="is_overdue", type="boolean", description="연체 여부", example=true),
|
||||
* @OA\Property(
|
||||
* property="overdue_months",
|
||||
* type="array",
|
||||
* description="연체 월 목록",
|
||||
*
|
||||
* @OA\Items(type="integer", example=1)
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="categories",
|
||||
* type="array",
|
||||
* description="카테고리별 데이터",
|
||||
*
|
||||
* @OA\Items(ref="#/components/schemas/CategoryAmount")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="ReceivablesSummary",
|
||||
* description="채권 현황 요약 통계",
|
||||
*
|
||||
* @OA\Property(property="total_sales", type="number", format="float", description="총 매출", example=100000000),
|
||||
* @OA\Property(property="total_deposits", type="number", format="float", description="총 입금", example=80000000),
|
||||
* @OA\Property(property="total_bills", type="number", format="float", description="총 어음", example=5000000),
|
||||
* @OA\Property(property="total_receivables", type="number", format="float", description="총 미수금", example=15000000),
|
||||
* @OA\Property(property="vendor_count", type="integer", description="거래처 수", example=50),
|
||||
* @OA\Property(property="overdue_vendor_count", type="integer", description="연체 거래처 수", example=5)
|
||||
* )
|
||||
*/
|
||||
class ReceivablesApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/receivables",
|
||||
* operationId="getReceivablesList",
|
||||
* tags={"Receivables"},
|
||||
* summary="채권 현황 목록 조회",
|
||||
* description="거래처별 월별 매출, 입금, 어음, 미수금 현황을 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="year",
|
||||
* in="query",
|
||||
* description="조회 연도",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=2025)
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="search",
|
||||
* in="query",
|
||||
* description="거래처명 검색",
|
||||
*
|
||||
* @OA\Schema(type="string", example="삼성")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="has_receivable",
|
||||
* in="query",
|
||||
* description="미수금이 있는 거래처만 조회",
|
||||
*
|
||||
* @OA\Schema(type="boolean", example=true)
|
||||
* ),
|
||||
*
|
||||
* @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/VendorReceivables")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=403, description="권한 없음")
|
||||
* )
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/receivables/summary",
|
||||
* operationId="getReceivablesSummary",
|
||||
* tags={"Receivables"},
|
||||
* summary="채권 현황 요약 통계",
|
||||
* description="연도별 총 매출, 입금, 어음, 미수금 합계 및 거래처 통계를 조회합니다.",
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="year",
|
||||
* in="query",
|
||||
* description="조회 연도",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=2025)
|
||||
* ),
|
||||
*
|
||||
* @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/ReceivablesSummary")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패"),
|
||||
* @OA\Response(response=403, description="권한 없음")
|
||||
* )
|
||||
*/
|
||||
public function summary() {}
|
||||
}
|
||||
Reference in New Issue
Block a user