Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -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;
|
||||
|
||||
307
app/Http/Controllers/DashboardCalendarController.php
Normal file
307
app/Http/Controllers/DashboardCalendarController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' => '분개가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user