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:
2026-03-07 02:58:55 +09:00
parent 3d12687a2d
commit 1df34b2fa9
36 changed files with 3579 additions and 378 deletions

View File

@@ -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();
}
/**