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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금 시뮬레이션 데이터
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user