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

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\Tenants\ExpenseAccount;
use App\Models\Tenants\Loan;
use App\Models\Tenants\Withdrawal;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -25,6 +26,11 @@ public function index(array $params): LengthAwarePaginator
->where('tenant_id', $tenantId)
->with(['user:id,name,email', 'creator:id,name']);
// 카테고리 필터
if (! empty($params['category'])) {
$query->where('category', $params['category']);
}
// 사용자 필터
if (! empty($params['user_id'])) {
$query->where('user_id', $params['user_id']);
@@ -84,7 +90,7 @@ public function show(int $id): Loan
/**
* 가지급금 요약 (특정 사용자 또는 전체)
*/
public function summary(?int $userId = null): array
public function summary(?int $userId = null, ?string $category = null): array
{
$tenantId = $this->tenantId();
@@ -95,7 +101,14 @@ public function summary(?int $userId = null): array
$query->where('user_id', $userId);
}
$stats = $query->selectRaw('
if ($category) {
$query->where('category', $category);
}
// 상품권 카테고리: holding/used/disposed 상태별 집계 추가
$isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE;
$selectRaw = '
COUNT(*) as total_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count,
@@ -103,10 +116,27 @@ public function summary(?int $userId = null): array
SUM(amount) as total_amount,
SUM(COALESCE(settlement_amount, 0)) as total_settled,
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL])
->first();
';
$bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL];
return [
if ($isGiftCertificate) {
$selectRaw .= ',
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count,
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count
';
$bindings = array_merge($bindings, [
Loan::STATUS_HOLDING, Loan::STATUS_HOLDING,
Loan::STATUS_USED, Loan::STATUS_USED,
Loan::STATUS_DISPOSED,
]);
}
$stats = $query->selectRaw($selectRaw, $bindings)->first();
$result = [
'total_count' => (int) $stats->total_count,
'outstanding_count' => (int) $stats->outstanding_count,
'settled_count' => (int) $stats->settled_count,
@@ -115,6 +145,27 @@ public function summary(?int $userId = null): array
'total_settled' => (float) $stats->total_settled,
'total_outstanding' => (float) $stats->total_outstanding,
];
if ($isGiftCertificate) {
$result['holding_count'] = (int) $stats->holding_count;
$result['holding_amount'] = (float) $stats->holding_amount;
$result['used_count'] = (int) $stats->used_count;
$result['used_amount'] = (float) $stats->used_amount;
$result['disposed_count'] = (int) $stats->disposed_count;
// 접대비 해당 집계 (expense_accounts 테이블에서 조회)
$entertainmentStats = ExpenseAccount::query()
->where('tenant_id', $tenantId)
->where('account_type', ExpenseAccount::TYPE_ENTERTAINMENT)
->where('sub_type', ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE)
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount')
->first();
$result['entertainment_count'] = (int) ($entertainmentStats->count ?? 0);
$result['entertainment_amount'] = (float) ($entertainmentStats->amount ?? 0);
}
return $result;
}
// =========================================================================
@@ -144,17 +195,34 @@ public function store(array $data): Loan
$withdrawalId = $withdrawal->id;
}
return Loan::create([
// 상품권: user_id 미지정 시 현재 사용자로 대체
$loanUserId = $data['user_id'] ?? $userId;
// 상태 결정: 상품권은 holding, 그 외는 outstanding
$category = $data['category'] ?? null;
$status = $data['status']
?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING);
$loan = Loan::create([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'user_id' => $loanUserId,
'loan_date' => $data['loan_date'],
'amount' => $data['amount'],
'purpose' => $data['purpose'] ?? null,
'status' => Loan::STATUS_OUTSTANDING,
'status' => $status,
'category' => $category,
'metadata' => $data['metadata'] ?? null,
'withdrawal_id' => $withdrawalId,
'created_by' => $userId,
'updated_by' => $userId,
]);
// 상품권 → 접대비 자동 연동
if ($category === Loan::CATEGORY_GIFT_CERTIFICATE) {
$this->syncGiftCertificateExpense($loan);
}
return $loan;
});
}
@@ -186,20 +254,83 @@ public function update(int $id, array $data): Loan
}
}
$loan->fill([
$fillData = [
'user_id' => $data['user_id'] ?? $loan->user_id,
'loan_date' => $data['loan_date'] ?? $loan->loan_date,
'amount' => $data['amount'] ?? $loan->amount,
'purpose' => $data['purpose'] ?? $loan->purpose,
'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id,
'updated_by' => $userId,
]);
];
if (isset($data['category'])) {
$fillData['category'] = $data['category'];
}
if (array_key_exists('metadata', $data)) {
$fillData['metadata'] = $data['metadata'];
}
if (isset($data['status'])) {
$fillData['status'] = $data['status'];
}
if (array_key_exists('settlement_date', $data)) {
$fillData['settlement_date'] = $data['settlement_date'];
}
$loan->fill($fillData);
$loan->save();
// 상품권 → 접대비 자동 연동
if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) {
$this->syncGiftCertificateExpense($loan);
}
return $loan->fresh(['user:id,name,email', 'creator:id,name']);
}
/**
* 상품권 → 접대비 자동 연동
*
* 상태가 'used' + entertainment_expense='applicable' → expense_accounts에 INSERT
* 그 외 → 기존 연결된 expense_accounts 삭제
*/
private function syncGiftCertificateExpense(Loan $loan): void
{
$metadata = $loan->metadata ?? [];
$isEntertainment = ($loan->status === Loan::STATUS_USED)
&& ($metadata['entertainment_expense'] ?? '') === 'applicable';
if ($isEntertainment) {
// upsert: loan_id 기준으로 있으면 업데이트, 없으면 생성
ExpenseAccount::query()
->updateOrCreate(
[
'tenant_id' => $loan->tenant_id,
'loan_id' => $loan->id,
],
[
'account_type' => ExpenseAccount::TYPE_ENTERTAINMENT,
'sub_type' => ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE,
'expense_date' => $loan->settlement_date ?? $loan->loan_date,
'amount' => $loan->amount,
'description' => ($metadata['cert_name'] ?? '상품권') . ' 접대비 전환',
'receipt_no' => $metadata['serial_number'] ?? null,
'vendor_name' => $metadata['vendor_name'] ?? null,
'vendor_id' => ! empty($metadata['vendor_id']) ? (int) $metadata['vendor_id'] : null,
'payment_method' => ExpenseAccount::PAYMENT_CASH,
'created_by' => $loan->updated_by ?? $loan->created_by,
'updated_by' => $loan->updated_by ?? $loan->created_by,
]
);
} else {
// 접대비 해당이 아니면 연결된 레코드 삭제
ExpenseAccount::query()
->where('tenant_id', $loan->tenant_id)
->where('loan_id', $loan->id)
->delete();
}
}
/**
* 가지급금 삭제
*/
@@ -216,6 +347,14 @@ public function destroy(int $id): bool
throw new BadRequestHttpException(__('error.loan.not_deletable'));
}
// 상품권 연결 접대비 레코드도 삭제
if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) {
ExpenseAccount::query()
->where('tenant_id', $tenantId)
->where('loan_id', $loan->id)
->delete();
}
$loan->deleted_by = $userId;
$loan->save();
$loan->delete();
@@ -365,7 +504,8 @@ public function calculateInterest(int $year, ?int $userId = null): array
/**
* 가지급금 대시보드 데이터
*
* CEO 대시보드 카드/가지급금 관리 섹션(cm2) 모달용 데이터 제공
* CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공
* D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류)
*
* @return array{
* summary: array{
@@ -373,38 +513,79 @@ public function calculateInterest(int $year, ?int $userId = null): array
* recognized_interest: float,
* outstanding_count: int
* },
* category_breakdown: array<string, array{
* outstanding_amount: float,
* total_count: int,
* unverified_count: int
* }>,
* loans: array
* }
*/
public function dashboard(): array
public function dashboard(?string $startDate = null, ?string $endDate = null): array
{
$tenantId = $this->tenantId();
$currentYear = now()->year;
// 1. Summary 데이터
$summaryData = $this->summary();
// 날짜 필터 조건 클로저
$applyDateFilter = function ($query) use ($startDate, $endDate) {
if ($startDate) {
$query->where('loan_date', '>=', $startDate);
}
if ($endDate) {
$query->where('loan_date', '<=', $endDate);
}
return $query;
};
// 2. 인정이자 계산 (현재 연도 기준)
// 상품권 중 used/disposed 제외 조건 (접대비로 전환됨)
$excludeUsedGiftCert = function ($query) {
$query->whereNot(function ($q) {
$q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE)
->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]);
});
};
// 1. Summary 데이터 (날짜 필터 적용)
$summaryQuery = Loan::query()->where('tenant_id', $tenantId);
$applyDateFilter($summaryQuery);
$excludeUsedGiftCert($summaryQuery);
$stats = $summaryQuery->selectRaw('
COUNT(*) as total_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
SUM(amount) as total_amount,
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
', [Loan::STATUS_OUTSTANDING])
->first();
// 2. 인정이자 계산 (현재 연도 기준, 날짜 필터 무관)
$interestData = $this->calculateInterest($currentYear);
$recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0;
// 3. 가지급금 목록 (최근 10건, 미정산 우선)
$loans = Loan::query()
// 3. 카테고리별 집계 (날짜 필터 적용)
$categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate);
// 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용, used/disposed 상품권 제외)
$loansQuery = Loan::query()
->where('tenant_id', $tenantId)
->with(['user:id,name,email', 'withdrawal'])
->with(['user:id,name,email', 'withdrawal']);
$applyDateFilter($loansQuery);
$excludeUsedGiftCert($loansQuery);
$loans = $loansQuery
->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [
Loan::STATUS_OUTSTANDING,
Loan::STATUS_PARTIAL,
])
->orderByDesc('loan_date')
->limit(10)
->limit(50)
->get()
->map(function ($loan) {
return [
'id' => $loan->id,
'loan_date' => $loan->loan_date->format('Y-m-d'),
'user_name' => $loan->user?->name ?? '미지정',
'category' => $loan->withdrawal_id ? '카드' : '계좌',
'category' => $loan->category_label,
'amount' => (float) $loan->amount,
'status' => $loan->status,
'content' => $loan->purpose ?? '',
@@ -414,14 +595,70 @@ public function dashboard(): array
return [
'summary' => [
'total_outstanding' => (float) $summaryData['total_outstanding'],
'total_outstanding' => (float) ($stats->total_outstanding ?? 0),
'recognized_interest' => (float) $recognizedInterest,
'outstanding_count' => (int) $summaryData['outstanding_count'],
'outstanding_count' => (int) ($stats->outstanding_count ?? 0),
],
'category_breakdown' => $categoryBreakdown,
'loans' => $loans,
];
}
/**
* 카테고리별 가지급금 집계
*
* @return array<string, array{outstanding_amount: float, total_count: int, unverified_count: int}>
*/
private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array
{
// 기본값: 4개 카테고리 모두 0으로 초기화
$breakdown = [];
foreach (Loan::CATEGORIES as $category) {
$breakdown[$category] = [
'outstanding_amount' => 0.0,
'total_count' => 0,
'unverified_count' => 0,
];
}
// 카테고리별 집계 (날짜 필터 적용)
// 상품권 중 used/disposed는 접대비로 전환되므로 가지급금 집계에서 제외
$query = Loan::query()
->where('tenant_id', $tenantId)
->whereNot(function ($q) {
$q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE)
->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]);
});
if ($startDate) {
$query->where('loan_date', '>=', $startDate);
}
if ($endDate) {
$query->where('loan_date', '<=', $endDate);
}
// NOTE: SQL alias를 'cat_outstanding'으로 사용 — Loan 모델의
// getOutstandingAmountAttribute() accessor와 이름 충돌 방지
$stats = $query
->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as cat_outstanding')
->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count')
->groupBy('category')
->get();
foreach ($stats as $stat) {
$cat = $stat->category ?? Loan::CATEGORY_CARD;
if (isset($breakdown[$cat])) {
$breakdown[$cat] = [
'outstanding_amount' => (float) $stat->cat_outstanding,
'total_count' => (int) $stat->total_count,
'unverified_count' => (int) $stat->unverified_count,
];
}
}
return $breakdown;
}
/**
* 세금 시뮬레이션 데이터
*