Files
sam-api/app/Services/JournalSyncService.php
유병철 0044779eb4 feat: [finance] 계정과목 확장 및 전표 연동 시스템 구현
- AccountCode 모델/서비스 확장 (업데이트, 기본 계정과목 시딩)
- JournalSyncService 추가 (전표 자동 연동)
- SyncsExpenseAccounts 트레이트 추가
- CardTransactionController, TaxInvoiceController 기능 확장
- expense_accounts 테이블에 전표 연결 컬럼 마이그레이션
- account_codes 테이블 확장 마이그레이션
- 전체 테넌트 기본 계정과목 시딩 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:32:20 +09:00

215 lines
7.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenants\AccountCode;
use App\Models\Tenants\JournalEntry;
use App\Models\Tenants\JournalEntryLine;
use App\Traits\SyncsExpenseAccounts;
use Illuminate\Support\Facades\DB;
/**
* 세금계산서/카드거래 등 외부 소스의 분개 통합 관리 서비스
*
* journal_entries + journal_entry_lines에 저장하고
* 복리후생비/접대비는 expense_accounts에 동기화 (CEO 대시보드)
*/
class JournalSyncService extends Service
{
use SyncsExpenseAccounts;
/**
* 소스에 대한 분개 저장 (생성 또는 교체)
*
* @param string $sourceType JournalEntry::SOURCE_TAX_INVOICE 등
* @param string $sourceKey 'tax_invoice_123' 등
* @param string $entryDate 전표일자 (Y-m-d)
* @param string|null $description 적요
* @param array $rows 분개 행 [{side, account_code, account_name?, debit_amount, credit_amount, vendor_id?, vendor_name?, memo?}]
*/
public function saveForSource(
string $sourceType,
string $sourceKey,
string $entryDate,
?string $description,
array $rows,
): JournalEntry {
$tenantId = $this->tenantId();
return DB::transaction(function () use ($sourceType, $sourceKey, $entryDate, $description, $rows, $tenantId) {
// 기존 전표가 있으면 삭제 후 재생성 (교체 방식)
$existing = JournalEntry::query()
->where('tenant_id', $tenantId)
->where('source_type', $sourceType)
->where('source_key', $sourceKey)
->first();
if ($existing) {
$this->cleanupExpenseAccounts($tenantId, $existing->id);
JournalEntryLine::where('journal_entry_id', $existing->id)->delete();
$existing->forceDelete();
}
// 합계 계산
$totalDebit = 0;
$totalCredit = 0;
foreach ($rows as $row) {
$totalDebit += (int) ($row['debit_amount'] ?? 0);
$totalCredit += (int) ($row['credit_amount'] ?? 0);
}
// 전표번호 생성
$entryNo = $this->generateEntryNo($tenantId, $entryDate);
// 전표 생성
$entry = new JournalEntry;
$entry->tenant_id = $tenantId;
$entry->entry_no = $entryNo;
$entry->entry_date = $entryDate;
$entry->entry_type = JournalEntry::TYPE_GENERAL;
$entry->description = $description;
$entry->total_debit = $totalDebit;
$entry->total_credit = $totalCredit;
$entry->status = JournalEntry::STATUS_CONFIRMED;
$entry->source_type = $sourceType;
$entry->source_key = $sourceKey;
$entry->save();
// 분개 행 생성
foreach ($rows as $index => $row) {
$accountCode = $row['account_code'] ?? '';
$accountName = $row['account_name'] ?? $this->resolveAccountName($tenantId, $accountCode);
$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 = $row['vendor_name'] ?? '';
$line->debit_amount = (int) ($row['debit_amount'] ?? 0);
$line->credit_amount = (int) ($row['credit_amount'] ?? 0);
$line->description = $row['memo'] ?? null;
$line->save();
}
// expense_accounts 동기화 (복리후생비/접대비 → CEO 대시보드)
$this->syncExpenseAccounts($entry);
return $entry->load('lines');
});
}
/**
* 소스에 대한 분개 조회
*/
public function getForSource(string $sourceType, string $sourceKey): ?array
{
$tenantId = $this->tenantId();
$entry = JournalEntry::query()
->where('tenant_id', $tenantId)
->where('source_type', $sourceType)
->where('source_key', $sourceKey)
->whereNull('deleted_at')
->with('lines')
->first();
if (! $entry) {
return null;
}
return [
'id' => $entry->id,
'entry_no' => $entry->entry_no,
'entry_date' => $entry->entry_date->format('Y-m-d'),
'description' => $entry->description,
'total_debit' => $entry->total_debit,
'total_credit' => $entry->total_credit,
'rows' => $entry->lines->map(function ($line) {
return [
'id' => $line->id,
'side' => $line->dc_type,
'account_code' => $line->account_code,
'account_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 deleteForSource(string $sourceType, string $sourceKey): bool
{
$tenantId = $this->tenantId();
return DB::transaction(function () use ($sourceType, $sourceKey, $tenantId) {
$entry = JournalEntry::query()
->where('tenant_id', $tenantId)
->where('source_type', $sourceType)
->where('source_key', $sourceKey)
->first();
if (! $entry) {
return false;
}
$this->cleanupExpenseAccounts($tenantId, $entry->id);
JournalEntryLine::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}-";
$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 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;
}
}