feat: [재무] 어음 V8 + 상품권 접대비 연동 + 일반전표/계정과목 API
- Bill 확장 필드 (V8), Loan 상품권 카테고리/접대비 자동 연동 - GeneralJournalEntry CRUD, AccountSubject API - 접대비/복리후생비 날짜 필터, 매출채권 soft delete 제외 - 바로빌 연동 API 엔드포인트 추가 - 부가세 상세 조회 API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -117,11 +117,14 @@ public function index(array $params): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회
|
||||
* 요약 통계 조회 (D1.7 cards + check_points 구조)
|
||||
*
|
||||
* @return array{cards: array, check_points: array}
|
||||
*/
|
||||
public function summary(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
$recentYear = $params['recent_year'] ?? false;
|
||||
$year = $params['year'] ?? date('Y');
|
||||
|
||||
@@ -137,19 +140,19 @@ public function summary(array $params): array
|
||||
$totalCarryForward = $this->getTotalCarryForwardBalance($tenantId, $carryForwardDate);
|
||||
|
||||
// 기간 내 총 매출
|
||||
$totalSales = Sale::where('tenant_id', $tenantId)
|
||||
$totalSales = (float) Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('sale_date', [$startDate, $endDate])
|
||||
->sum('total_amount');
|
||||
|
||||
// 기간 내 총 입금
|
||||
$totalDeposits = Deposit::where('tenant_id', $tenantId)
|
||||
$totalDeposits = (float) Deposit::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('deposit_date', [$startDate, $endDate])
|
||||
->sum('amount');
|
||||
|
||||
// 기간 내 총 어음
|
||||
$totalBills = Bill::where('tenant_id', $tenantId)
|
||||
$totalBills = (float) Bill::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('bill_type', 'received')
|
||||
->whereBetween('issue_date', [$startDate, $endDate])
|
||||
@@ -158,26 +161,242 @@ public function summary(array $params): array
|
||||
// 총 미수금 (이월잔액 + 매출 - 입금 - 어음)
|
||||
$totalReceivables = $totalCarryForward + $totalSales - $totalDeposits - $totalBills;
|
||||
|
||||
// 당월 미수금
|
||||
$currentMonthStart = $now->copy()->startOfMonth()->format('Y-m-d');
|
||||
$currentMonthEnd = $now->copy()->endOfMonth()->format('Y-m-d');
|
||||
|
||||
$currentMonthSales = (float) Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('sale_date', [$currentMonthStart, $currentMonthEnd])
|
||||
->sum('total_amount');
|
||||
|
||||
$currentMonthDeposits = (float) Deposit::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('deposit_date', [$currentMonthStart, $currentMonthEnd])
|
||||
->sum('amount');
|
||||
|
||||
$currentMonthReceivables = $currentMonthSales - $currentMonthDeposits;
|
||||
|
||||
// 거래처 수
|
||||
$vendorCount = Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
// 연체 거래처 수 (미수금이 양수인 거래처)
|
||||
// 연체 거래처 수
|
||||
$overdueVendorCount = Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where('is_overdue', true)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_carry_forward' => (float) $totalCarryForward,
|
||||
'total_sales' => (float) $totalSales,
|
||||
'total_deposits' => (float) $totalDeposits,
|
||||
'total_bills' => (float) $totalBills,
|
||||
'total_receivables' => (float) $totalReceivables,
|
||||
'vendor_count' => $vendorCount,
|
||||
'overdue_vendor_count' => $overdueVendorCount,
|
||||
// 악성채권 건수
|
||||
$badDebtCount = $this->getBadDebtCount($tenantId);
|
||||
|
||||
// Top 3 미수금 거래처
|
||||
$topVendors = $this->getTopReceivableVendors($tenantId, 3);
|
||||
|
||||
// 카드 데이터 구성
|
||||
$cards = [
|
||||
[
|
||||
'id' => 'rv_cumulative',
|
||||
'label' => '누적 미수금',
|
||||
'amount' => (int) $totalReceivables,
|
||||
'sub_items' => [
|
||||
['label' => '매출', 'value' => (int) $totalSales],
|
||||
['label' => '입금', 'value' => (int) $totalDeposits],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'rv_monthly',
|
||||
'label' => '당월 미수금',
|
||||
'amount' => (int) $currentMonthReceivables,
|
||||
'sub_items' => [
|
||||
['label' => '매출', 'value' => (int) $currentMonthSales],
|
||||
['label' => '입금', 'value' => (int) $currentMonthDeposits],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'rv_vendors',
|
||||
'label' => '미수금 거래처',
|
||||
'amount' => $vendorCount,
|
||||
'unit' => '건',
|
||||
'subLabel' => "연체 {$overdueVendorCount}건" . ($badDebtCount > 0 ? " · 악성채권 {$badDebtCount}건" : ''),
|
||||
],
|
||||
[
|
||||
'id' => 'rv_top3',
|
||||
'label' => '미수금 Top 3',
|
||||
'amount' => ! empty($topVendors) ? (int) $topVendors[0]['amount'] : 0,
|
||||
'top_items' => $topVendors,
|
||||
],
|
||||
];
|
||||
|
||||
// 체크포인트 생성
|
||||
$checkPoints = $this->generateSummaryCheckPoints(
|
||||
$tenantId,
|
||||
$totalReceivables,
|
||||
$overdueVendorCount,
|
||||
$topVendors,
|
||||
$vendorCount
|
||||
);
|
||||
|
||||
return [
|
||||
'cards' => $cards,
|
||||
'check_points' => $checkPoints,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 악성채권 건수 조회
|
||||
*/
|
||||
private function getBadDebtCount(int $tenantId): int
|
||||
{
|
||||
// bad_debts 테이블이 존재하면 사용, 없으면 0
|
||||
try {
|
||||
return \DB::table('bad_debts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', ['collecting', 'legal_action'])
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
} catch (\Exception $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금 Top N 거래처 조회
|
||||
*/
|
||||
private function getTopReceivableVendors(int $tenantId, int $limit = 3): array
|
||||
{
|
||||
$salesSub = \DB::table('sales')
|
||||
->select('client_id', \DB::raw('SUM(total_amount) as total'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('client_id');
|
||||
|
||||
$depositsSub = \DB::table('deposits')
|
||||
->select('client_id', \DB::raw('SUM(amount) as total'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('client_id');
|
||||
|
||||
$billsSub = \DB::table('bills')
|
||||
->select('client_id', \DB::raw('SUM(amount) as total'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereNull('deleted_at')
|
||||
->where('bill_type', 'received')
|
||||
->groupBy('client_id');
|
||||
|
||||
$results = \DB::table('clients as c')
|
||||
->leftJoinSub($salesSub, 's', 'c.id', '=', 's.client_id')
|
||||
->leftJoinSub($depositsSub, 'd', 'c.id', '=', 'd.client_id')
|
||||
->leftJoinSub($billsSub, 'b', 'c.id', '=', 'b.client_id')
|
||||
->select(
|
||||
'c.name',
|
||||
\DB::raw('(COALESCE(s.total, 0) - COALESCE(d.total, 0) - COALESCE(b.total, 0)) as receivable')
|
||||
)
|
||||
->where('c.tenant_id', $tenantId)
|
||||
->where('c.is_active', true)
|
||||
->having('receivable', '>', 0)
|
||||
->orderByDesc('receivable')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $results->map(fn ($v) => [
|
||||
'name' => $v->name,
|
||||
'amount' => (int) $v->receivable,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 요약 체크포인트 생성
|
||||
*/
|
||||
private function generateSummaryCheckPoints(
|
||||
int $tenantId,
|
||||
float $totalReceivables,
|
||||
int $overdueVendorCount,
|
||||
array $topVendors,
|
||||
int $vendorCount
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
|
||||
// 연체 거래처 경고
|
||||
if ($overdueVendorCount > 0) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_overdue',
|
||||
'type' => 'warning',
|
||||
'message' => "연체 거래처 {$overdueVendorCount}곳. 회수 조치가 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "연체 거래처 {$overdueVendorCount}곳", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 90일 이상 장기 미수금 체크
|
||||
$longTermCount = $this->getLongTermReceivableCount($tenantId, 90);
|
||||
if ($longTermCount > 0) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_longterm',
|
||||
'type' => 'error',
|
||||
'message' => "90일 이상 장기 미수금 {$longTermCount}건 감지. 악성채권 전환 위험이 있습니다.",
|
||||
'highlights' => [
|
||||
['text' => "90일 이상 장기 미수금 {$longTermCount}건", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Top1 거래처 집중도 경고
|
||||
if (! empty($topVendors) && $totalReceivables > 0) {
|
||||
$top1Ratio = round(($topVendors[0]['amount'] / $totalReceivables) * 100);
|
||||
if ($top1Ratio >= 50) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_concentration',
|
||||
'type' => 'warning',
|
||||
'message' => "{$topVendors[0]['name']} 미수금이 전체의 {$top1Ratio}%를 차지합니다. 리스크 분산이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$topVendors[0]['name']}", 'color' => 'orange'],
|
||||
['text' => "전체의 {$top1Ratio}%", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 정상 상태 메시지
|
||||
if (empty($checkPoints)) {
|
||||
$totalFormatted = number_format($totalReceivables / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => "총 미수금 {$totalFormatted}만원. 정상적으로 관리되고 있습니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$totalFormatted}만원", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $checkPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* N일 이상 장기 미수금 거래처 수 조회
|
||||
*/
|
||||
private function getLongTermReceivableCount(int $tenantId, int $days): int
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days)->format('Y-m-d');
|
||||
|
||||
// 연체 상태이면서 오래된 매출이 있는 거래처 수
|
||||
$clientIds = Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('sale_date', '<=', $cutoffDate)
|
||||
->distinct()
|
||||
->pluck('client_id');
|
||||
|
||||
return Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where('is_overdue', true)
|
||||
->whereIn('id', $clientIds)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user