Files
sam-api/app/Services/GeneralJournalEntryService.php
권혁성 1df34b2fa9 feat: [재무] 어음 V8 + 상품권 접대비 연동 + 일반전표/계정과목 API
- Bill 확장 필드 (V8), Loan 상품권 카테고리/접대비 자동 연동
- GeneralJournalEntry CRUD, AccountSubject API
- 접대비/복리후생비 날짜 필터, 매출채권 soft delete 제외
- 바로빌 연동 API 엔드포인트 추가
- 부가세 상세 조회 API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:58:55 +09:00

577 lines
22 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenants\AccountCode;
use App\Models\Tenants\JournalEntry;
use App\Models\Tenants\JournalEntryLine;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class GeneralJournalEntryService extends Service
{
/**
* 일반전표입력 통합 목록 (입금 + 출금 + 수기전표)
* deposits/withdrawals는 계좌이체 건만, LEFT JOIN journal_entries로 분개 여부 표시
*/
public function index(array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
$search = $params['search'] ?? null;
$perPage = (int) ($params['per_page'] ?? 20);
$page = (int) ($params['page'] ?? 1);
// 1) 입금(transfer) UNION 출금(transfer) UNION 수기전표
$depositsQuery = DB::table('deposits')
->leftJoin('journal_entries', function ($join) use ($tenantId) {
$join->on('journal_entries.source_key', '=', DB::raw("CONCAT('deposit_', deposits.id)"))
->where('journal_entries.tenant_id', $tenantId)
->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
->whereNull('journal_entries.deleted_at');
})
->where('deposits.tenant_id', $tenantId)
->where('deposits.payment_method', 'transfer')
->whereNull('deposits.deleted_at')
->select([
'deposits.id',
'deposits.deposit_date as date',
DB::raw("'deposit' as division"),
'deposits.amount',
'deposits.description',
DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'),
'deposits.amount as deposit_amount',
DB::raw('0 as withdrawal_amount'),
DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'),
DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'),
DB::raw("'linked' as source"),
'deposits.created_at',
'deposits.updated_at',
DB::raw('journal_entries.id as journal_entry_id'),
]);
$withdrawalsQuery = DB::table('withdrawals')
->leftJoin('journal_entries', function ($join) use ($tenantId) {
$join->on('journal_entries.source_key', '=', DB::raw("CONCAT('withdrawal_', withdrawals.id)"))
->where('journal_entries.tenant_id', $tenantId)
->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
->whereNull('journal_entries.deleted_at');
})
->where('withdrawals.tenant_id', $tenantId)
->where('withdrawals.payment_method', 'transfer')
->whereNull('withdrawals.deleted_at')
->select([
'withdrawals.id',
'withdrawals.withdrawal_date as date',
DB::raw("'withdrawal' as division"),
'withdrawals.amount',
'withdrawals.description',
DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'),
DB::raw('0 as deposit_amount'),
'withdrawals.amount as withdrawal_amount',
DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'),
DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'),
DB::raw("'linked' as source"),
'withdrawals.created_at',
'withdrawals.updated_at',
DB::raw('journal_entries.id as journal_entry_id'),
]);
$manualQuery = DB::table('journal_entries')
->where('journal_entries.tenant_id', $tenantId)
->where('journal_entries.source_type', JournalEntry::SOURCE_MANUAL)
->whereNull('journal_entries.deleted_at')
->select([
'journal_entries.id',
'journal_entries.entry_date as date',
DB::raw("'transfer' as division"),
'journal_entries.total_debit as amount',
'journal_entries.description',
'journal_entries.description as journal_description',
DB::raw('0 as deposit_amount'),
DB::raw('0 as withdrawal_amount'),
'journal_entries.total_debit as debit_amount',
'journal_entries.total_credit as credit_amount',
DB::raw("'manual' as source"),
'journal_entries.created_at',
'journal_entries.updated_at',
DB::raw('journal_entries.id as journal_entry_id'),
]);
// 날짜 필터
if ($startDate) {
$depositsQuery->where('deposits.deposit_date', '>=', $startDate);
$withdrawalsQuery->where('withdrawals.withdrawal_date', '>=', $startDate);
$manualQuery->where('journal_entries.entry_date', '>=', $startDate);
}
if ($endDate) {
$depositsQuery->where('deposits.deposit_date', '<=', $endDate);
$withdrawalsQuery->where('withdrawals.withdrawal_date', '<=', $endDate);
$manualQuery->where('journal_entries.entry_date', '<=', $endDate);
}
// 검색 필터
if ($search) {
$depositsQuery->where(function ($q) use ($search) {
$q->where('deposits.description', 'like', "%{$search}%")
->orWhere('deposits.client_name', 'like', "%{$search}%");
});
$withdrawalsQuery->where(function ($q) use ($search) {
$q->where('withdrawals.description', 'like', "%{$search}%")
->orWhere('withdrawals.client_name', 'like', "%{$search}%");
});
$manualQuery->where('journal_entries.description', 'like', "%{$search}%");
}
// UNION
$unionQuery = $depositsQuery
->unionAll($withdrawalsQuery)
->unionAll($manualQuery);
// 전체 건수
$totalCount = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
->mergeBindings($unionQuery)
->count();
// 날짜순 정렬 + 페이지네이션
$items = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
->mergeBindings($unionQuery)
->orderBy('date', 'desc')
->orderBy('created_at', 'desc')
->offset(($page - 1) * $perPage)
->limit($perPage)
->get();
// 누적잔액 계산 (해당 기간 전체 기준)
$allForBalance = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
->mergeBindings($unionQuery)
->orderBy('date', 'asc')
->orderBy('created_at', 'asc')
->get(['deposit_amount', 'withdrawal_amount']);
$runningBalance = 0;
$balanceMap = [];
foreach ($allForBalance as $idx => $row) {
$runningBalance += (int) $row->deposit_amount - (int) $row->withdrawal_amount;
$balanceMap[$idx] = $runningBalance;
}
// 역순이므로 현재 페이지에 해당하는 잔액을 매핑
$totalItems = count($allForBalance);
$items = $items->map(function ($item, $index) use ($balanceMap, $totalItems, $page, $perPage) {
// 역순 인덱스 → 정순 인덱스
$reverseIdx = $totalItems - 1 - (($page - 1) * $perPage + $index);
$item->balance = $reverseIdx >= 0 ? ($balanceMap[$reverseIdx] ?? 0) : 0;
return $item;
});
return [
'data' => $items->toArray(),
'current_page' => $page,
'last_page' => (int) ceil($totalCount / $perPage),
'per_page' => $perPage,
'total' => $totalCount,
];
}
/**
* 요약 통계
*/
public function summary(array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
$search = $params['search'] ?? null;
// 입금 통계
$depositQuery = DB::table('deposits')
->where('tenant_id', $tenantId)
->where('payment_method', 'transfer')
->whereNull('deleted_at');
// 출금 통계
$withdrawalQuery = DB::table('withdrawals')
->where('tenant_id', $tenantId)
->where('payment_method', 'transfer')
->whereNull('deleted_at');
if ($startDate) {
$depositQuery->where('deposit_date', '>=', $startDate);
$withdrawalQuery->where('withdrawal_date', '>=', $startDate);
}
if ($endDate) {
$depositQuery->where('deposit_date', '<=', $endDate);
$withdrawalQuery->where('withdrawal_date', '<=', $endDate);
}
if ($search) {
$depositQuery->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('client_name', 'like', "%{$search}%");
});
$withdrawalQuery->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('client_name', 'like', "%{$search}%");
});
}
$depositCount = (clone $depositQuery)->count();
$depositAmount = (int) (clone $depositQuery)->sum('amount');
$withdrawalCount = (clone $withdrawalQuery)->count();
$withdrawalAmount = (int) (clone $withdrawalQuery)->sum('amount');
// 분개 완료/미완료 건수 (journal_entries가 연결된 입출금 수)
$journalCompleteCount = DB::table('journal_entries')
->where('tenant_id', $tenantId)
->where('source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
->whereNull('deleted_at')
->when($startDate, fn ($q) => $q->where('entry_date', '>=', $startDate))
->when($endDate, fn ($q) => $q->where('entry_date', '<=', $endDate))
->count();
$totalCount = $depositCount + $withdrawalCount;
$journalIncompleteCount = max(0, $totalCount - $journalCompleteCount);
return [
'total_count' => $totalCount,
'deposit_count' => $depositCount,
'deposit_amount' => $depositAmount,
'withdrawal_count' => $withdrawalCount,
'withdrawal_amount' => $withdrawalAmount,
'journal_complete_count' => $journalCompleteCount,
'journal_incomplete_count' => $journalIncompleteCount,
];
}
/**
* 전표 상세 조회 (분개 수정 모달용)
*/
public function show(int $id): array
{
$tenantId = $this->tenantId();
$entry = JournalEntry::query()
->where('tenant_id', $tenantId)
->with('lines')
->findOrFail($id);
// source_type에 따라 원본 거래 정보 조회
$sourceInfo = $this->getSourceInfo($entry);
return [
'id' => $entry->id,
'date' => $entry->entry_date->format('Y-m-d'),
'division' => $sourceInfo['division'],
'amount' => $sourceInfo['amount'],
'description' => $sourceInfo['description'] ?? $entry->description,
'bank_name' => $sourceInfo['bank_name'] ?? '',
'account_number' => $sourceInfo['account_number'] ?? '',
'journal_memo' => $entry->description,
'rows' => $entry->lines->map(function ($line) {
return [
'id' => $line->id,
'side' => $line->dc_type,
'account_subject_id' => $line->account_code,
'account_subject_name' => $line->account_name,
'vendor_id' => $line->trading_partner_id,
'vendor_name' => $line->trading_partner_name ?? '',
'debit_amount' => (int) $line->debit_amount,
'credit_amount' => (int) $line->credit_amount,
'memo' => $line->description ?? '',
];
})->toArray(),
];
}
/**
* 수기전표 등록
*/
public function store(array $data): JournalEntry
{
$tenantId = $this->tenantId();
return DB::transaction(function () use ($data, $tenantId) {
// 차대 균형 검증
$this->validateDebitCreditBalance($data['rows']);
// 전표번호 생성
$entryNo = $this->generateEntryNo($tenantId, $data['journal_date']);
// 합계 계산
$totalDebit = 0;
$totalCredit = 0;
foreach ($data['rows'] as $row) {
$totalDebit += (int) ($row['debit_amount'] ?? 0);
$totalCredit += (int) ($row['credit_amount'] ?? 0);
}
// 전표 생성
$entry = new JournalEntry;
$entry->tenant_id = $tenantId;
$entry->entry_no = $entryNo;
$entry->entry_date = $data['journal_date'];
$entry->entry_type = JournalEntry::TYPE_GENERAL;
$entry->description = $data['description'] ?? null;
$entry->total_debit = $totalDebit;
$entry->total_credit = $totalCredit;
$entry->status = JournalEntry::STATUS_CONFIRMED;
$entry->source_type = JournalEntry::SOURCE_MANUAL;
$entry->source_key = null;
$entry->save();
// 분개 행 생성
$this->createLines($entry, $data['rows'], $tenantId);
return $entry->load('lines');
});
}
/**
* 분개 수정 (lines 전체 교체)
*/
public function updateJournal(int $id, array $data): JournalEntry
{
$tenantId = $this->tenantId();
return DB::transaction(function () use ($id, $data, $tenantId) {
$entry = JournalEntry::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
// 메모 업데이트
if (array_key_exists('journal_memo', $data)) {
$entry->description = $data['journal_memo'];
}
// rows가 있으면 lines 교체
if (isset($data['rows']) && ! empty($data['rows'])) {
$this->validateDebitCreditBalance($data['rows']);
// 기존 lines 삭제
JournalEntryLine::query()
->where('journal_entry_id', $entry->id)
->delete();
// 새 lines 생성
$this->createLines($entry, $data['rows'], $tenantId);
// 합계 재계산
$totalDebit = 0;
$totalCredit = 0;
foreach ($data['rows'] as $row) {
$totalDebit += (int) ($row['debit_amount'] ?? 0);
$totalCredit += (int) ($row['credit_amount'] ?? 0);
}
$entry->total_debit = $totalDebit;
$entry->total_credit = $totalCredit;
}
$entry->save();
return $entry->load('lines');
});
}
/**
* 전표 삭제 (soft delete, lines는 FK CASCADE)
*/
public function destroyJournal(int $id): bool
{
$tenantId = $this->tenantId();
return DB::transaction(function () use ($id, $tenantId) {
$entry = JournalEntry::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
// lines 먼저 삭제 (soft delete가 아니므로 물리 삭제)
JournalEntryLine::query()
->where('journal_entry_id', $entry->id)
->delete();
$entry->delete(); // soft delete
return true;
});
}
/**
* 전표번호 생성: JE-YYYYMMDD-NNN (동시성 안전)
*/
private function generateEntryNo(int $tenantId, string $date): string
{
$dateStr = str_replace('-', '', substr($date, 0, 10));
$prefix = "JE-{$dateStr}-";
// SELECT ... FOR UPDATE 락으로 동시성 안전 보장
$lastEntry = DB::table('journal_entries')
->where('tenant_id', $tenantId)
->where('entry_no', 'like', "{$prefix}%")
->lockForUpdate()
->orderBy('entry_no', 'desc')
->first(['entry_no']);
if ($lastEntry) {
$lastSeq = (int) substr($lastEntry->entry_no, -3);
$nextSeq = $lastSeq + 1;
} else {
$nextSeq = 1;
}
return $prefix . str_pad($nextSeq, 3, '0', STR_PAD_LEFT);
}
/**
* 차대 균형 검증
*/
private function validateDebitCreditBalance(array $rows): void
{
$totalDebit = 0;
$totalCredit = 0;
foreach ($rows as $row) {
$totalDebit += (int) ($row['debit_amount'] ?? 0);
$totalCredit += (int) ($row['credit_amount'] ?? 0);
}
if ($totalDebit !== $totalCredit) {
throw new BadRequestHttpException(__('error.journal_entry.debit_credit_mismatch'));
}
}
/**
* 분개 행 생성
*/
private function createLines(JournalEntry $entry, array $rows, int $tenantId): void
{
foreach ($rows as $index => $row) {
$accountCode = $row['account_subject_id'] ?? '';
$accountName = $this->resolveAccountName($tenantId, $accountCode);
$vendorName = $this->resolveVendorName($row['vendor_id'] ?? null);
$line = new JournalEntryLine;
$line->tenant_id = $tenantId;
$line->journal_entry_id = $entry->id;
$line->line_no = $index + 1;
$line->dc_type = $row['side'];
$line->account_code = $accountCode;
$line->account_name = $accountName;
$line->trading_partner_id = ! empty($row['vendor_id']) ? (int) $row['vendor_id'] : null;
$line->trading_partner_name = $vendorName;
$line->debit_amount = (int) ($row['debit_amount'] ?? 0);
$line->credit_amount = (int) ($row['credit_amount'] ?? 0);
$line->description = $row['memo'] ?? null;
$line->save();
}
}
/**
* 계정과목 코드 → 이름 조회
*/
private function resolveAccountName(int $tenantId, string $code): string
{
if (empty($code)) {
return '';
}
$account = AccountCode::query()
->where('tenant_id', $tenantId)
->where('code', $code)
->first(['name']);
return $account ? $account->name : $code;
}
/**
* 거래처 ID → 이름 조회
*/
private function resolveVendorName(?int $vendorId): string
{
if (! $vendorId) {
return '';
}
$vendor = DB::table('clients')
->where('id', $vendorId)
->first(['name']);
return $vendor ? $vendor->name : '';
}
/**
* 원본 거래 정보 조회 (입금/출금)
*/
private function getSourceInfo(JournalEntry $entry): array
{
if ($entry->source_type === JournalEntry::SOURCE_MANUAL) {
return [
'division' => 'transfer',
'amount' => $entry->total_debit,
'description' => $entry->description,
'bank_name' => '',
'account_number' => '',
];
}
// bank_transaction → deposit_123 / withdrawal_456
if ($entry->source_key && str_starts_with($entry->source_key, 'deposit_')) {
$sourceId = (int) str_replace('deposit_', '', $entry->source_key);
$deposit = DB::table('deposits')
->leftJoin('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id')
->where('deposits.id', $sourceId)
->first([
'deposits.amount',
'deposits.description',
'bank_accounts.bank_name',
'bank_accounts.account_number',
]);
if ($deposit) {
return [
'division' => 'deposit',
'amount' => (int) $deposit->amount,
'description' => $deposit->description,
'bank_name' => $deposit->bank_name ?? '',
'account_number' => $deposit->account_number ?? '',
];
}
}
if ($entry->source_key && str_starts_with($entry->source_key, 'withdrawal_')) {
$sourceId = (int) str_replace('withdrawal_', '', $entry->source_key);
$withdrawal = DB::table('withdrawals')
->leftJoin('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id')
->where('withdrawals.id', $sourceId)
->first([
'withdrawals.amount',
'withdrawals.description',
'bank_accounts.bank_name',
'bank_accounts.account_number',
]);
if ($withdrawal) {
return [
'division' => 'withdrawal',
'amount' => (int) $withdrawal->amount,
'description' => $withdrawal->description,
'bank_name' => $withdrawal->bank_name ?? '',
'account_number' => $withdrawal->account_number ?? '',
];
}
}
return [
'division' => 'transfer',
'amount' => $entry->total_debit,
'description' => $entry->description,
'bank_name' => '',
'account_number' => '',
];
}
}