Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-02-11 08:51:11 +09:00
17 changed files with 2662 additions and 608 deletions

View File

@@ -395,9 +395,10 @@ public function transactions(Request $request): JsonResponse
// 데이터 파싱 (저장된 계정과목 + 오버라이드 병합)
$logs = $this->parseTransactionLogs($resultData, '', $savedData, $tenantId);
// 수동 입력 건 병합
// 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외)
$manualLogs = $this->convertManualToLogs($manualTransactions);
$mergedLogs = array_merge($logs['logs'], $manualLogs['logs']);
$mergeResult = $this->mergeWithDedup($logs['logs'], $manualLogs['logs']);
$mergedLogs = $mergeResult['logs'];
// 날짜/시간 기준 정렬 (최신순)
usort($mergedLogs, function ($a, $b) {
@@ -410,11 +411,11 @@ public function transactions(Request $request): JsonResponse
$baseBalance = $this->findBaseBalance($tenantId, $startDate, $bankAccountNum);
$mergedLogs = $this->recalcManualBalances($mergedLogs, $baseBalance);
// summary 합산
// summary 합산 (중복 제거된 API 거래 금액 차감)
$mergedSummary = [
'totalDeposit' => $logs['summary']['totalDeposit'] + $manualLogs['summary']['totalDeposit'],
'totalWithdraw' => $logs['summary']['totalWithdraw'] + $manualLogs['summary']['totalWithdraw'],
'count' => $logs['summary']['count'] + $manualLogs['summary']['count'],
'totalDeposit' => $logs['summary']['totalDeposit'] + $manualLogs['summary']['totalDeposit'] - $mergeResult['removedDeposit'],
'totalWithdraw' => $logs['summary']['totalWithdraw'] + $manualLogs['summary']['totalWithdraw'] - $mergeResult['removedWithdraw'],
'count' => count($mergedLogs),
];
return response()->json([
@@ -499,14 +500,13 @@ private function getAllAccountsTransactions(string $userId, string $startDate, s
}
}
// 수동 입력 건 병합
// 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외)
if ($manualTransactions && $manualTransactions->isNotEmpty()) {
$manualLogs = $this->convertManualToLogs($manualTransactions);
foreach ($manualLogs['logs'] as $mLog) {
$allLogs[] = $mLog;
}
$totalDeposit += $manualLogs['summary']['totalDeposit'];
$totalWithdraw += $manualLogs['summary']['totalWithdraw'];
$mergeResult = $this->mergeWithDedup($allLogs, $manualLogs['logs']);
$allLogs = $mergeResult['logs'];
$totalDeposit += $manualLogs['summary']['totalDeposit'] - $mergeResult['removedDeposit'];
$totalWithdraw += $manualLogs['summary']['totalWithdraw'] - $mergeResult['removedWithdraw'];
}
// 날짜/시간 기준 정렬 (최신순)
@@ -892,6 +892,11 @@ public function save(Request $request): JsonResponse
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$transactions = $request->input('transactions', []);
Log::info('[Eaccount Save] 요청 수신', [
'tenant_id' => $tenantId,
'transaction_count' => count($transactions),
]);
if (empty($transactions)) {
return response()->json([
'success' => false,
@@ -899,12 +904,54 @@ public function save(Request $request): JsonResponse
]);
}
// 수동 거래 디버그
$manualCount = 0;
$apiCount = 0;
foreach ($transactions as $t) {
if (!empty($t['isManual'])) {
$manualCount++;
Log::info('[Eaccount Save] 수동 거래 감지', [
'dbId' => $t['dbId'] ?? 'MISSING',
'cast' => $t['cast'] ?? 'EMPTY',
'isManual' => $t['isManual'],
]);
} else {
$apiCount++;
}
}
Log::info('[Eaccount Save] 거래 분류', ['manual' => $manualCount, 'api' => $apiCount]);
$saved = 0;
$updated = 0;
$savedUniqueKeys = [];
DB::beginTransaction();
foreach ($transactions as $trans) {
// 수동 입력 거래: dbId로 직접 찾아서 비-키 필드만 업데이트
// balance는 화면에서 재계산된 값이므로 composite key 매칭 불가
if (!empty($trans['isManual']) && !empty($trans['dbId'])) {
$affectedRows = DB::table('barobill_bank_transactions')
->where('id', $trans['dbId'])
->where('tenant_id', $tenantId)
->update([
'summary' => $trans['summary'] ?? '',
'cast' => $trans['cast'] ?? '',
'memo' => $trans['memo'] ?? '',
'trans_office' => $trans['transOffice'] ?? '',
'account_code' => $trans['accountCode'] ?? null,
'account_name' => $trans['accountName'] ?? null,
'updated_at' => now(),
]);
Log::info('[Eaccount Save] 수동 거래 업데이트', [
'dbId' => $trans['dbId'],
'cast' => $trans['cast'] ?? '',
'affected_rows' => $affectedRows,
]);
$updated++;
continue;
}
// 거래일시 생성
$transDt = ($trans['transDate'] ?? '') . ($trans['transTime'] ?? '');
@@ -927,32 +974,78 @@ public function save(Request $request): JsonResponse
'account_name' => $trans['accountName'] ?? null,
];
// Upsert: 있으면 업데이트, 없으면 생성
// balance 포함: 같은 금액이라도 잔액이 다르면 별도 거래로 구분
$existing = BankTransaction::where('tenant_id', $tenantId)
// 고유 키 생성 (오버라이드 동기화용)
$uniqueKey = implode('|', [
$data['bank_account_num'],
$transDt,
(int) $data['deposit'],
(int) $data['withdraw'],
(int) $data['balance'],
]);
$savedUniqueKeys[] = $uniqueKey;
// 순수 Query Builder로 Upsert (Eloquent 모델 우회)
// balance 제외하고 매칭 → 같은 거래가 잔액만 다르게 들어와도 중복 방지
$existingIds = DB::table('barobill_bank_transactions')
->where('tenant_id', $tenantId)
->where('bank_account_num', $data['bank_account_num'])
->where('trans_dt', $transDt)
->whereRaw('CAST(deposit AS SIGNED) = ?', [(int) $data['deposit']])
->whereRaw('CAST(withdraw AS SIGNED) = ?', [(int) $data['withdraw']])
->whereRaw('CAST(balance AS SIGNED) = ?', [(int) $data['balance']])
->first();
->where('deposit', $data['deposit'])
->where('withdraw', $data['withdraw'])
->orderByDesc('id')
->pluck('id');
if ($existing) {
// 계정과목 + 적요/예금주명 업데이트 (balance는 키값이므로 제외)
$existing->update([
'summary' => $data['summary'],
'cast' => $data['cast'],
'trans_office' => $data['trans_office'],
'account_code' => $data['account_code'],
'account_name' => $data['account_name'],
]);
if ($existingIds->isNotEmpty()) {
$keepId = $existingIds->first(); // 최신 건 유지
// 중복 건 삭제 (잔액만 다른 이전 레코드)
if ($existingIds->count() > 1) {
DB::table('barobill_bank_transactions')
->whereIn('id', $existingIds->slice(1)->values())
->delete();
}
DB::table('barobill_bank_transactions')
->where('id', $keepId)
->update([
'balance' => $data['balance'],
'summary' => $data['summary'],
'cast' => $data['cast'],
'trans_office' => $data['trans_office'],
'account_code' => $data['account_code'],
'account_name' => $data['account_name'],
'updated_at' => now(),
]);
$updated++;
} else {
BankTransaction::create($data);
DB::table('barobill_bank_transactions')->insert(array_merge($data, [
'created_at' => now(),
'updated_at' => now(),
]));
$saved++;
}
}
// 오버라이드 동기화: 메인 테이블에 저장된 값이 최신이므로
// override의 modified_cast를 제거하여 충돌 방지
if (!empty($savedUniqueKeys)) {
$overrides = BankTransactionOverride::forTenant($tenantId)
->whereIn('unique_key', $savedUniqueKeys)
->get();
foreach ($overrides as $override) {
if ($override->modified_cast !== null) {
if (!empty($override->modified_summary)) {
// summary 오버라이드는 유지, cast 오버라이드만 제거
$override->update(['modified_cast' => null]);
} else {
// summary도 없으면 오버라이드 레코드 삭제
$override->delete();
}
}
}
}
DB::commit();
return response()->json([
@@ -1216,23 +1309,20 @@ public function updateManual(Request $request, int $id): JsonResponse
$transTime = $validated['trans_time'] ?? '000000';
$transDt = $validated['trans_date'] . $transTime;
$transaction->update([
'bank_account_num' => $validated['bank_account_num'],
'bank_code' => $validated['bank_code'] ?? '',
'bank_name' => $validated['bank_name'] ?? '',
'trans_date' => $validated['trans_date'],
'trans_time' => $transTime,
'trans_dt' => $transDt,
'deposit' => $validated['deposit'],
'withdraw' => $validated['withdraw'],
'balance' => $validated['balance'] ?? 0,
'summary' => $validated['summary'] ?? '',
'cast' => $validated['cast'] ?? '',
'memo' => $validated['memo'] ?? '',
'trans_office' => $validated['trans_office'] ?? '',
'account_code' => $validated['account_code'] ?? null,
'account_name' => $validated['account_name'] ?? null,
]);
// 수동 거래 수정: unique key 컬럼(deposit/withdraw/balance)은 제외
// balance는 화면에서 재계산(recalcManualBalances)되므로 DB값 유지 필수
// (프론트에서 재계산된 balance를 보내면 다른 레코드와 unique key 충돌)
DB::table('barobill_bank_transactions')
->where('id', $transaction->id)
->update([
'summary' => $validated['summary'] ?? '',
'cast' => $validated['cast'] ?? '',
'memo' => $validated['memo'] ?? '',
'trans_office' => $validated['trans_office'] ?? '',
'account_code' => $validated['account_code'] ?? null,
'account_name' => $validated['account_name'] ?? null,
'updated_at' => now(),
]);
return response()->json([
'success' => true,
@@ -1437,6 +1527,65 @@ private function findBaseBalance(int $tenantId, string $startDate, ?string $bank
return $balance;
}
/**
* API 로그와 수동 로그 병합 (중복 제거)
* 수동 거래와 동일한 API 거래가 있으면 API 거래를 제외하고 수동 거래를 유지
* 매칭 기준: 계좌번호 + 거래일시 + 입금액 + 출금액 (잔액 제외 - 수동 거래는 재계산됨)
*
* @return array ['logs' => array, 'removedDeposit' => float, 'removedWithdraw' => float]
*/
private function mergeWithDedup(array $apiLogs, array $manualLogs): array
{
if (empty($manualLogs)) {
return ['logs' => $apiLogs, 'removedDeposit' => 0, 'removedWithdraw' => 0];
}
// 수동 거래의 매칭 키 생성 (잔액 제외)
$manualKeys = [];
foreach ($manualLogs as $mLog) {
$key = implode('|', [
$mLog['bankAccountNum'] ?? '',
($mLog['transDate'] ?? '') . ($mLog['transTime'] ?? ''),
(int) ($mLog['deposit'] ?? 0),
(int) ($mLog['withdraw'] ?? 0),
]);
$manualKeys[$key] = true;
}
// API 로그에서 수동 거래와 중복되는 것 제외
$dedupedApiLogs = [];
$removedDeposit = 0;
$removedWithdraw = 0;
foreach ($apiLogs as $aLog) {
$key = implode('|', [
$aLog['bankAccountNum'] ?? '',
($aLog['transDate'] ?? '') . ($aLog['transTime'] ?? ''),
(int) ($aLog['deposit'] ?? 0),
(int) ($aLog['withdraw'] ?? 0),
]);
if (isset($manualKeys[$key])) {
$removedDeposit += (float) ($aLog['deposit'] ?? 0);
$removedWithdraw += (float) ($aLog['withdraw'] ?? 0);
continue; // 수동 거래가 우선, API 거래 스킵
}
$dedupedApiLogs[] = $aLog;
}
if ($removedDeposit > 0 || $removedWithdraw > 0) {
Log::info('[Eaccount] 중복 거래 제거', [
'count' => count($manualLogs) - count($dedupedApiLogs) + count($apiLogs) - count($manualLogs),
'removedDeposit' => $removedDeposit,
'removedWithdraw' => $removedWithdraw,
]);
}
return [
'logs' => array_merge($dedupedApiLogs, $manualLogs),
'removedDeposit' => $removedDeposit,
'removedWithdraw' => $removedWithdraw,
];
}
private function recalcManualBalances(array $logs, ?float $baseBalance = null): array
{
if (empty($logs)) return $logs;

View File

@@ -0,0 +1,307 @@
<?php
namespace App\Http\Controllers;
use App\Models\Boards\File;
use App\Models\System\Holiday;
use App\Models\System\Schedule;
use App\Services\GoogleCloudStorageService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class DashboardCalendarController extends Controller
{
/**
* 달력 partial 반환 (HTMX용)
*/
public function calendar(Request $request): View
{
$year = (int) $request->input('year', now()->year);
$month = (int) $request->input('month', now()->month);
$tenantId = session('selected_tenant_id', 1);
$firstDay = Carbon::create($year, $month, 1);
$lastDay = $firstDay->copy()->endOfMonth();
$startOfWeek = $firstDay->copy()->startOfWeek(Carbon::SUNDAY);
$endOfWeek = $lastDay->copy()->endOfWeek(Carbon::SATURDAY);
$calendarData = Schedule::forTenant($tenantId)
->active()
->betweenDates($startOfWeek->toDateString(), $endOfWeek->toDateString())
->withCount('files')
->orderBy('start_date')
->orderBy('start_time')
->get()
->groupBy(fn ($s) => $s->start_date->format('Y-m-d'));
$holidayMap = $this->getHolidayMap($tenantId, $year, $month);
return view('dashboard.partials.calendar', compact(
'year', 'month', 'calendarData', 'holidayMap'
));
}
/**
* 일정 등록
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'type' => 'required|string|max:50',
'color' => 'nullable|string|max:20',
]);
$tenantId = session('selected_tenant_id', 1);
$validated['tenant_id'] = $tenantId;
$validated['created_by'] = auth()->id();
$validated['is_all_day'] = $validated['is_all_day'] ?? true;
if (empty($validated['end_date'])) {
$validated['end_date'] = $validated['start_date'];
}
$schedule = Schedule::create($validated);
return response()->json([
'success' => true,
'message' => '일정이 등록되었습니다.',
'data' => $schedule,
]);
}
/**
* 일정 상세 (JSON)
*/
public function show(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$schedule = Schedule::forTenant($tenantId)->findOrFail($id);
// 첨부파일 목록
$files = File::where('document_type', 'schedule')
->where('document_id', $id)
->whereNull('deleted_at')
->get(['id', 'display_name', 'original_name', 'file_size', 'mime_type', 'created_at']);
return response()->json([
'success' => true,
'data' => $schedule,
'files' => $files,
]);
}
/**
* 일정 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'type' => 'required|string|max:50',
'color' => 'nullable|string|max:20',
]);
$tenantId = session('selected_tenant_id', 1);
$schedule = Schedule::forTenant($tenantId)->findOrFail($id);
$validated['updated_by'] = auth()->id();
$validated['is_all_day'] = $validated['is_all_day'] ?? true;
if (empty($validated['end_date'])) {
$validated['end_date'] = $validated['start_date'];
}
$schedule->update($validated);
return response()->json([
'success' => true,
'message' => '일정이 수정되었습니다.',
'data' => $schedule->fresh(),
]);
}
/**
* 일정 삭제
*/
public function destroy(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$schedule = Schedule::forTenant($tenantId)->findOrFail($id);
$schedule->update(['deleted_by' => auth()->id()]);
$schedule->delete();
return response()->json([
'success' => true,
'message' => '일정이 삭제되었습니다.',
]);
}
/**
* 파일 업로드 (다중)
*/
public function uploadFiles(Request $request, int $scheduleId, GoogleCloudStorageService $gcs): JsonResponse
{
$request->validate([
'files' => 'required|array|min:1',
'files.*' => 'file|max:20480', // 20MB
]);
$tenantId = session('selected_tenant_id', 1);
$schedule = Schedule::forTenant($tenantId)->findOrFail($scheduleId);
$uploaded = [];
foreach ($request->file('files') as $file) {
$originalName = $file->getClientOriginalName();
$storedName = Str::random(40) . '.' . $file->getClientOriginalExtension();
$storagePath = "schedules/{$tenantId}/{$schedule->id}/{$storedName}";
// 로컬(tenant 디스크) 저장
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
// GCS 업로드 (가능한 경우)
$gcsUri = null;
if ($gcs->isAvailable()) {
$gcsObjectName = "schedules/{$tenantId}/{$schedule->id}/{$storedName}";
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
}
// DB 레코드
$fileRecord = File::create([
'tenant_id' => $tenantId,
'document_type' => 'schedule',
'document_id' => $schedule->id,
'file_path' => $storagePath,
'display_name' => $originalName,
'stored_name' => $storedName,
'original_name' => $originalName,
'file_name' => $originalName,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'file_type' => $this->determineFileType($file->getMimeType()),
'is_temp' => false,
'uploaded_by' => auth()->id(),
'created_by' => auth()->id(),
]);
$uploaded[] = [
'id' => $fileRecord->id,
'name' => $originalName,
'size' => $fileRecord->getFormattedSize(),
'gcs' => $gcsUri ? true : false,
];
}
return response()->json([
'success' => true,
'message' => count($uploaded) . '개 파일이 업로드되었습니다.',
'files' => $uploaded,
]);
}
/**
* 파일 삭제
*/
public function deleteFile(int $scheduleId, int $fileId, GoogleCloudStorageService $gcs): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
Schedule::forTenant($tenantId)->findOrFail($scheduleId);
$file = File::where('document_type', 'schedule')
->where('document_id', $scheduleId)
->where('id', $fileId)
->firstOrFail();
// GCS 삭제
if ($gcs->isAvailable() && $file->file_path) {
$gcs->delete($file->file_path);
}
// 로컬 삭제
if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) {
Storage::disk('tenant')->delete($file->file_path);
}
$file->deleted_by = auth()->id();
$file->save();
$file->delete();
return response()->json([
'success' => true,
'message' => '파일이 삭제되었습니다.',
]);
}
/**
* 파일 다운로드
*/
public function downloadFile(int $scheduleId, int $fileId)
{
$tenantId = session('selected_tenant_id', 1);
Schedule::forTenant($tenantId)->findOrFail($scheduleId);
$file = File::where('document_type', 'schedule')
->where('document_id', $scheduleId)
->where('id', $fileId)
->firstOrFail();
return $file->download();
}
/**
* MIME 타입으로 파일 유형 결정
*/
private function determineFileType(string $mimeType): string
{
if (str_starts_with($mimeType, 'image/')) return 'image';
if (str_contains($mimeType, 'spreadsheet') || str_contains($mimeType, 'excel')) return 'excel';
if (str_contains($mimeType, 'zip') || str_contains($mimeType, 'rar') || str_contains($mimeType, 'archive')) return 'archive';
return 'document';
}
/**
* 해당 월의 휴일 맵 생성 (날짜 => 휴일명)
*/
private function getHolidayMap(int $tenantId, int $year, int $month): array
{
$startOfMonth = Carbon::create($year, $month, 1)->startOfWeek(Carbon::SUNDAY);
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth()->endOfWeek(Carbon::SATURDAY);
$holidays = Holiday::forTenant($tenantId)
->where('start_date', '<=', $endOfMonth->toDateString())
->where('end_date', '>=', $startOfMonth->toDateString())
->get();
$map = [];
foreach ($holidays as $holiday) {
$start = $holiday->start_date->copy();
$end = $holiday->end_date->copy();
for ($d = $start; $d->lte($end); $d->addDay()) {
$map[$d->format('Y-m-d')] = $holiday->name;
}
}
return $map;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BankTransaction;
use App\Models\Finance\JournalEntry;
use App\Models\Finance\JournalEntryLine;
use App\Models\Finance\TradingPartner;
@@ -10,6 +11,7 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class JournalEntryController extends Controller
{
@@ -161,45 +163,52 @@ public function store(Request $request): JsonResponse
], 422);
}
$entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) {
$entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date);
try {
$entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) {
$entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date);
$entry = JournalEntry::create([
'tenant_id' => $tenantId,
'entry_no' => $entryNo,
'entry_date' => $request->entry_date,
'description' => $request->description,
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'status' => 'draft',
'created_by_name' => auth()->user()?->name ?? '시스템',
'attachment_note' => $request->attachment_note,
]);
foreach ($lines as $i => $line) {
JournalEntryLine::create([
$entry = JournalEntry::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $entry->id,
'line_no' => $i + 1,
'dc_type' => $line['dc_type'],
'account_code' => $line['account_code'],
'account_name' => $line['account_name'],
'trading_partner_id' => $line['trading_partner_id'] ?? null,
'trading_partner_name' => $line['trading_partner_name'] ?? null,
'debit_amount' => $line['debit_amount'],
'credit_amount' => $line['credit_amount'],
'description' => $line['description'] ?? null,
'entry_no' => $entryNo,
'entry_date' => $request->entry_date,
'description' => $request->description,
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'status' => 'draft',
'created_by_name' => auth()->user()?->name ?? '시스템',
'attachment_note' => $request->attachment_note,
]);
}
return $entry;
});
foreach ($lines as $i => $line) {
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $entry->id,
'line_no' => $i + 1,
'dc_type' => $line['dc_type'],
'account_code' => $line['account_code'],
'account_name' => $line['account_name'],
'trading_partner_id' => $line['trading_partner_id'] ?? null,
'trading_partner_name' => $line['trading_partner_name'] ?? null,
'debit_amount' => $line['debit_amount'],
'credit_amount' => $line['credit_amount'],
'description' => $line['description'] ?? null,
]);
}
return response()->json([
'success' => true,
'message' => '전표가 저장되었습니다.',
'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no],
]);
return $entry;
});
return response()->json([
'success' => true,
'message' => '전표가 저장되었습니다.',
'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no],
]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'message' => '전표 저장 실패: ' . $e->getMessage(),
], 500);
}
}
/**
@@ -352,4 +361,287 @@ public function tradingPartners(Request $request): JsonResponse
}),
]);
}
// ================================================================
// 은행거래 기반 분개 API
// ================================================================
/**
* 은행거래 목록 조회 (DB 직접 조회 + 분개상태 병합)
*/
public function bankTransactions(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', 1);
$startDate = $request->input('startDate', date('Ymd'));
$endDate = $request->input('endDate', date('Ymd'));
$accountNum = $request->input('accountNum', '');
// barobill_bank_transactions 테이블에서 직접 조회
// 같은 거래가 잔액(balance)만 다르게 중복 저장된 경우 최신 건만 사용
$dedupQuery = BankTransaction::where('tenant_id', $tenantId)
->whereBetween('trans_date', [$startDate, $endDate]);
if (!empty($accountNum)) {
$dedupQuery->where('bank_account_num', str_replace('-', '', $accountNum));
}
$latestIds = $dedupQuery
->selectRaw('MAX(id) as id')
->groupBy('bank_account_num', 'trans_dt', 'deposit', 'withdraw')
->pluck('id');
$transactions = BankTransaction::whereIn('id', $latestIds)
->orderByDesc('trans_date')
->orderByDesc('trans_time')
->get();
// 로그 데이터 변환
$logs = $transactions->map(function ($tx) {
return [
'uniqueKey' => $tx->unique_key,
'transDate' => $tx->trans_date,
'transTime' => $tx->trans_time,
'bankAccountNum' => $tx->bank_account_num,
'bankName' => $tx->bank_name,
'deposit' => (int) $tx->deposit,
'withdraw' => (int) $tx->withdraw,
'balance' => (int) $tx->balance,
'summary' => $tx->summary,
'cast' => $tx->cast,
'memo' => $tx->memo,
'transOffice' => $tx->trans_office,
'accountCode' => $tx->account_code,
'accountName' => $tx->account_name,
'isManual' => $tx->is_manual,
];
})->toArray();
// 각 거래의 uniqueKey 수집
$uniqueKeys = array_column($logs, 'uniqueKey');
// 분개 완료된 source_key 조회
$journaledKeys = JournalEntry::getJournaledSourceKeys($tenantId, 'bank_transaction', $uniqueKeys);
$journaledKeysMap = array_flip($journaledKeys);
// 분개된 전표 ID도 조회
$journalMap = [];
if (!empty($journaledKeys)) {
$journals = JournalEntry::where('tenant_id', $tenantId)
->where('source_type', 'bank_transaction')
->whereIn('source_key', $journaledKeys)
->select('id', 'source_key', 'entry_no')
->get();
foreach ($journals as $j) {
$journalMap[$j->source_key] = ['id' => $j->id, 'entry_no' => $j->entry_no];
}
}
// 각 거래에 분개 상태 추가
foreach ($logs as &$log) {
$key = $log['uniqueKey'] ?? '';
$log['hasJournal'] = isset($journaledKeysMap[$key]);
$log['journalId'] = $journalMap[$key]['id'] ?? null;
$log['journalEntryNo'] = $journalMap[$key]['entry_no'] ?? null;
}
unset($log);
// 통계
$totalCount = count($logs);
$depositSum = array_sum(array_column($logs, 'deposit'));
$withdrawSum = array_sum(array_column($logs, 'withdraw'));
$journaledCount = count($journaledKeys);
// 계좌 목록 (드롭다운용)
$accounts = BankTransaction::where('tenant_id', $tenantId)
->select('bank_account_num', 'bank_name')
->distinct()
->get()
->toArray();
return response()->json([
'success' => true,
'data' => [
'logs' => $logs,
'accounts' => $accounts,
'summary' => [
'totalCount' => $totalCount,
'depositSum' => $depositSum,
'withdrawSum' => $withdrawSum,
],
'journalStats' => [
'journaledCount' => $journaledCount,
'unjournaledCount' => $totalCount - $journaledCount,
],
],
]);
} catch (\Throwable $e) {
Log::error('은행거래 목록 조회 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => '은행거래 목록 조회 실패: ' . $e->getMessage(),
], 500);
}
}
/**
* 은행거래 기반 전표 생성
*/
public function storeFromBank(Request $request): JsonResponse
{
$request->validate([
'source_key' => 'required|string|max:255',
'entry_date' => 'required|date',
'description' => 'nullable|string|max:500',
'lines' => 'required|array|min:2',
'lines.*.dc_type' => 'required|in:debit,credit',
'lines.*.account_code' => 'required|string|max:10',
'lines.*.account_name' => 'required|string|max:100',
'lines.*.trading_partner_id' => 'nullable|integer',
'lines.*.trading_partner_name' => 'nullable|string|max:100',
'lines.*.debit_amount' => 'required|integer|min:0',
'lines.*.credit_amount' => 'required|integer|min:0',
'lines.*.description' => 'nullable|string|max:300',
]);
$tenantId = session('selected_tenant_id', 1);
$lines = $request->lines;
$totalDebit = collect($lines)->sum('debit_amount');
$totalCredit = collect($lines)->sum('credit_amount');
if ($totalDebit !== $totalCredit || $totalDebit === 0) {
return response()->json([
'success' => false,
'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.',
], 422);
}
// 중복 분개 체크
$existing = JournalEntry::getJournalBySourceKey($tenantId, 'bank_transaction', $request->source_key);
if ($existing) {
return response()->json([
'success' => false,
'message' => '이미 분개가 완료된 거래입니다. (전표번호: ' . $existing->entry_no . ')',
], 422);
}
try {
$entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) {
$entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date);
$entry = JournalEntry::create([
'tenant_id' => $tenantId,
'entry_no' => $entryNo,
'entry_date' => $request->entry_date,
'description' => $request->description,
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'status' => 'draft',
'source_type' => 'bank_transaction',
'source_key' => $request->source_key,
'created_by_name' => auth()->user()?->name ?? '시스템',
]);
foreach ($lines as $i => $line) {
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $entry->id,
'line_no' => $i + 1,
'dc_type' => $line['dc_type'],
'account_code' => $line['account_code'],
'account_name' => $line['account_name'],
'trading_partner_id' => $line['trading_partner_id'] ?? null,
'trading_partner_name' => $line['trading_partner_name'] ?? null,
'debit_amount' => $line['debit_amount'],
'credit_amount' => $line['credit_amount'],
'description' => $line['description'] ?? null,
]);
}
return $entry;
});
return response()->json([
'success' => true,
'message' => '분개가 저장되었습니다.',
'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no],
]);
} catch (\Throwable $e) {
Log::error('은행거래 분개 저장 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => '분개 저장 실패: ' . $e->getMessage(),
], 500);
}
}
/**
* 특정 은행거래의 기존 분개 조회
*/
public function bankJournals(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$sourceKey = $request->get('source_key');
if (!$sourceKey) {
return response()->json(['success' => false, 'message' => 'source_key가 필요합니다.'], 422);
}
$entry = JournalEntry::forTenant($tenantId)
->where('source_type', 'bank_transaction')
->where('source_key', $sourceKey)
->with('lines')
->first();
if (!$entry) {
return response()->json(['success' => true, 'data' => null]);
}
return response()->json([
'success' => true,
'data' => [
'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,
'status' => $entry->status,
'lines' => $entry->lines->map(function ($line) {
return [
'id' => $line->id,
'line_no' => $line->line_no,
'dc_type' => $line->dc_type,
'account_code' => $line->account_code,
'account_name' => $line->account_name,
'trading_partner_id' => $line->trading_partner_id,
'trading_partner_name' => $line->trading_partner_name,
'debit_amount' => $line->debit_amount,
'credit_amount' => $line->credit_amount,
'description' => $line->description,
];
}),
],
]);
}
/**
* 은행거래 분개 삭제 (soft delete)
*/
public function deleteBankJournal(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$entry = JournalEntry::forTenant($tenantId)
->where('source_type', 'bank_transaction')
->findOrFail($id);
$entry->delete();
return response()->json([
'success' => true,
'message' => '분개가 삭제되었습니다.',
]);
}
}