diff --git a/app/Http/Controllers/Finance/JournalEntryController.php b/app/Http/Controllers/Finance/JournalEntryController.php new file mode 100644 index 00000000..8464bd1c --- /dev/null +++ b/app/Http/Controllers/Finance/JournalEntryController.php @@ -0,0 +1,355 @@ +with('lines') + ->orderByDesc('entry_date') + ->orderByDesc('entry_no'); + + if ($request->filled('start_date')) { + $query->where('entry_date', '>=', $request->start_date); + } + if ($request->filled('end_date')) { + $query->where('entry_date', '<=', $request->end_date); + } + if ($request->filled('status') && $request->status !== 'all') { + $query->where('status', $request->status); + } + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('entry_no', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + $entries = $query->get(); + + $data = $entries->map(function ($entry) { + 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, + 'status' => $entry->status, + 'created_by_name' => $entry->created_by_name, + '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, + ]; + }), + ]; + }); + + $stats = [ + 'totalCount' => $entries->count(), + 'totalDebit' => $entries->sum('total_debit'), + 'totalCredit' => $entries->sum('total_credit'), + 'draftCount' => $entries->where('status', 'draft')->count(), + 'confirmedCount' => $entries->where('status', 'confirmed')->count(), + ]; + + return response()->json([ + 'success' => true, + 'data' => $data, + 'stats' => $stats, + ]); + } + + /** + * 전표 상세 조회 + */ + public function show(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + + $entry = JournalEntry::forTenant($tenantId) + ->with('lines') + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => [ + 'id' => $entry->id, + 'entry_no' => $entry->entry_no, + 'entry_date' => $entry->entry_date->format('Y-m-d'), + 'entry_type' => $entry->entry_type, + 'description' => $entry->description, + 'total_debit' => $entry->total_debit, + 'total_credit' => $entry->total_credit, + 'status' => $entry->status, + 'created_by_name' => $entry->created_by_name, + 'attachment_note' => $entry->attachment_note, + '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, + ]; + }), + ], + ]); + } + + /** + * 전표 저장 + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'entry_date' => 'required|date', + 'description' => 'nullable|string|max:500', + 'attachment_note' => 'nullable|string', + '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); + } + + $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([ + '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], + ]); + } + + /** + * 전표 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $request->validate([ + 'entry_date' => 'required|date', + 'description' => 'nullable|string|max:500', + 'attachment_note' => 'nullable|string', + '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); + } + + DB::transaction(function () use ($tenantId, $id, $request, $lines, $totalDebit, $totalCredit) { + $entry = JournalEntry::forTenant($tenantId)->findOrFail($id); + + $entry->update([ + 'entry_date' => $request->entry_date, + 'description' => $request->description, + 'total_debit' => $totalDebit, + 'total_credit' => $totalCredit, + 'attachment_note' => $request->attachment_note, + ]); + + // 기존 lines 삭제 후 재생성 + $entry->lines()->delete(); + + 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' => '전표가 수정되었습니다.', + ]); + } + + /** + * 전표 삭제 (soft delete) + */ + public function destroy(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $entry = JournalEntry::forTenant($tenantId)->findOrFail($id); + $entry->delete(); + + return response()->json([ + 'success' => true, + 'message' => '전표가 삭제되었습니다.', + ]); + } + + /** + * 다음 전표번호 미리보기 + */ + public function nextEntryNo(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $date = $request->get('date', date('Y-m-d')); + $entryNo = JournalEntry::generateEntryNo($tenantId, $date); + + return response()->json([ + 'success' => true, + 'entry_no' => $entryNo, + ]); + } + + /** + * 계정과목 목록 + */ + public function accountCodes(): JsonResponse + { + $codes = AccountCode::getActive(); + + return response()->json([ + 'success' => true, + 'data' => $codes->map(function ($code) { + return [ + 'code' => $code->code, + 'name' => $code->name, + 'category' => $code->category, + ]; + }), + ]); + } + + /** + * 거래처 목록 + */ + public function tradingPartners(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + + $query = TradingPartner::forTenant($tenantId)->active(); + + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('biz_no', 'like', "%{$search}%"); + }); + } + + $partners = $query->orderBy('name')->limit(50)->get(); + + return response()->json([ + 'success' => true, + 'data' => $partners->map(function ($p) { + return [ + 'id' => $p->id, + 'name' => $p->name, + 'biz_no' => $p->biz_no, + 'type' => $p->type, + ]; + }), + ]); + } +} diff --git a/app/Models/Finance/JournalEntry.php b/app/Models/Finance/JournalEntry.php new file mode 100644 index 00000000..49750433 --- /dev/null +++ b/app/Models/Finance/JournalEntry.php @@ -0,0 +1,66 @@ + 'date', + 'total_debit' => 'integer', + 'total_credit' => 'integer', + ]; + + public function lines() + { + return $this->hasMany(JournalEntryLine::class)->orderBy('line_no'); + } + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + /** + * 전표번호 자동채번: JE-YYYYMMDD-NNN + */ + public static function generateEntryNo($tenantId, $date) + { + $dateStr = date('Ymd', strtotime($date)); + $prefix = "JE-{$dateStr}-"; + + $last = static::where('tenant_id', $tenantId) + ->where('entry_no', 'like', $prefix . '%') + ->lockForUpdate() + ->orderByDesc('entry_no') + ->value('entry_no'); + + if ($last) { + $seq = (int) substr($last, -3) + 1; + } else { + $seq = 1; + } + + return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT); + } +} diff --git a/app/Models/Finance/JournalEntryLine.php b/app/Models/Finance/JournalEntryLine.php new file mode 100644 index 00000000..091281c8 --- /dev/null +++ b/app/Models/Finance/JournalEntryLine.php @@ -0,0 +1,40 @@ + 'integer', + 'credit_amount' => 'integer', + 'line_no' => 'integer', + ]; + + public function journalEntry() + { + return $this->belongsTo(JournalEntry::class); + } + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/database/seeders/JournalEntryMenuSeeder.php b/database/seeders/JournalEntryMenuSeeder.php new file mode 100644 index 00000000..d6a4cb44 --- /dev/null +++ b/database/seeders/JournalEntryMenuSeeder.php @@ -0,0 +1,74 @@ +where('tenant_id', $tenantId) + ->where('name', '일일자금일보') + ->first(); + + if (!$dailyFundMenu) { + $this->command->error('일일자금일보 메뉴를 찾을 수 없습니다.'); + return; + } + + $parentId = $dailyFundMenu->parent_id; + + // 일반전표입력 메뉴가 이미 있는지 확인 + $existingMenu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', '일반전표입력') + ->where('parent_id', $parentId) + ->first(); + + if ($existingMenu) { + $this->command->info('일반전표입력 메뉴가 이미 존재합니다.'); + return; + } + + // 일일자금일보 바로 다음 sort_order로 추가 + $newSortOrder = $dailyFundMenu->sort_order + 1; + + // 기존 메뉴들의 sort_order를 밀어내기 + Menu::withoutGlobalScopes() + ->where('parent_id', $parentId) + ->where('tenant_id', $tenantId) + ->where('sort_order', '>=', $newSortOrder) + ->increment('sort_order'); + + $menu = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentId, + 'name' => '일반전표입력', + 'url' => '/finance/journal-entries', + 'icon' => 'file-text', + 'sort_order' => $newSortOrder, + 'is_active' => true, + ]); + + $this->command->info("일반전표입력 메뉴 생성: {$menu->url} (sort_order: {$newSortOrder})"); + + // 결과 출력 + $this->command->info(''); + $this->command->info('=== 같은 그룹 하위 메뉴 ==='); + $children = Menu::withoutGlobalScopes() + ->where('parent_id', $parentId) + ->where('tenant_id', $tenantId) + ->orderBy('sort_order') + ->get(['name', 'url', 'sort_order']); + + foreach ($children as $child) { + $this->command->info("{$child->sort_order}. {$child->name} ({$child->url})"); + } + } +} diff --git a/resources/views/finance/journal-entries.blade.php b/resources/views/finance/journal-entries.blade.php new file mode 100644 index 00000000..9f5724c8 --- /dev/null +++ b/resources/views/finance/journal-entries.blade.php @@ -0,0 +1,817 @@ +@extends('layouts.app') + +@section('title', '일반전표입력') + +@push('styles') + +@endpush + +@section('content') + +
+@endsection + +@push('scripts') + + + + +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 24995a64..425fac18 100644 --- a/routes/web.php +++ b/routes/web.php @@ -769,6 +769,25 @@ Route::post('/memo', [\App\Http\Controllers\Finance\DailyFundController::class, 'saveMemo'])->name('memo'); }); + // 일반전표입력 + Route::get('/journal-entries', function () { + if (request()->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('finance.journal-entries')); + } + return view('finance.journal-entries'); + })->name('journal-entries'); + + Route::prefix('journal-entries')->name('journal-entries.')->group(function () { + Route::get('/list', [\App\Http\Controllers\Finance\JournalEntryController::class, 'index'])->name('list'); + Route::get('/next-entry-no', [\App\Http\Controllers\Finance\JournalEntryController::class, 'nextEntryNo'])->name('next-entry-no'); + Route::get('/account-codes', [\App\Http\Controllers\Finance\JournalEntryController::class, 'accountCodes'])->name('account-codes'); + Route::get('/trading-partners', [\App\Http\Controllers\Finance\JournalEntryController::class, 'tradingPartners'])->name('trading-partners'); + Route::get('/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'show'])->name('show'); + Route::post('/store', [\App\Http\Controllers\Finance\JournalEntryController::class, 'store'])->name('store'); + Route::put('/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'update'])->name('update'); + Route::delete('/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'destroy'])->name('destroy'); + }); + // 카드관리 Route::get('/corporate-cards', function () { if (request()->header('HX-Request')) {