From 12d172e4c33a53039336da73c2c2bac4d23d4abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 6 Mar 2026 13:10:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[finance]=20=EA=B3=84=EC=A0=95=EA=B3=BC?= =?UTF-8?q?=EB=AA=A9=20=EB=B0=8F=20=EC=9D=BC=EB=B0=98=EC=A0=84=ED=91=9C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountCode 모델/서비스/컨트롤러 구현 - JournalEntry, JournalEntryLine 모델 구현 - GeneralJournalEntry 서비스/컨트롤러 구현 - FormRequest 검증 클래스 추가 - finance 라우트 등록 - i18n 메시지 키 추가 (message.php, error.php) Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/AccountSubjectController.php | 60 ++ .../Api/V1/GeneralJournalEntryController.php | 85 +++ .../StoreAccountSubjectRequest.php | 31 + .../StoreManualJournalRequest.php | 42 ++ .../UpdateJournalRequest.php | 38 ++ app/Models/Tenants/AccountCode.php | 49 ++ app/Models/Tenants/JournalEntry.php | 53 ++ app/Models/Tenants/JournalEntryLine.php | 45 ++ app/Services/AccountCodeService.php | 109 ++++ app/Services/GeneralJournalEntryService.php | 578 ++++++++++++++++++ lang/ko/error.php | 11 + lang/ko/message.php | 16 + routes/api/v1/finance.php | 20 + 13 files changed, 1137 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/AccountSubjectController.php create mode 100644 app/Http/Controllers/Api/V1/GeneralJournalEntryController.php create mode 100644 app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php create mode 100644 app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php create mode 100644 app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php create mode 100644 app/Models/Tenants/AccountCode.php create mode 100644 app/Models/Tenants/JournalEntry.php create mode 100644 app/Models/Tenants/JournalEntryLine.php create mode 100644 app/Services/AccountCodeService.php create mode 100644 app/Services/GeneralJournalEntryService.php diff --git a/app/Http/Controllers/Api/V1/AccountSubjectController.php b/app/Http/Controllers/Api/V1/AccountSubjectController.php new file mode 100644 index 0000000..20af5a2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AccountSubjectController.php @@ -0,0 +1,60 @@ +only(['search', 'category']); + + $subjects = $this->service->index($params); + + return ApiResponse::success($subjects, __('message.fetched')); + } + + /** + * 계정과목 등록 + */ + public function store(StoreAccountSubjectRequest $request) + { + $subject = $this->service->store($request->validated()); + + return ApiResponse::success($subject, __('message.created'), [], 201); + } + + /** + * 계정과목 활성/비활성 토글 + */ + public function toggleStatus(int $id, Request $request) + { + $isActive = (bool) $request->input('is_active', true); + + $subject = $this->service->toggleStatus($id, $isActive); + + return ApiResponse::success($subject, __('message.toggled')); + } + + /** + * 계정과목 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::success(null, __('message.deleted')); + } +} diff --git a/app/Http/Controllers/Api/V1/GeneralJournalEntryController.php b/app/Http/Controllers/Api/V1/GeneralJournalEntryController.php new file mode 100644 index 0000000..4979da9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/GeneralJournalEntryController.php @@ -0,0 +1,85 @@ +only([ + 'start_date', 'end_date', 'search', 'page', 'per_page', + ]); + + $result = $this->service->index($params); + + return ApiResponse::success($result, __('message.fetched')); + } + + /** + * 요약 통계 + */ + public function summary(Request $request) + { + $params = $request->only([ + 'start_date', 'end_date', 'search', + ]); + + $summary = $this->service->summary($params); + + return ApiResponse::success($summary, __('message.fetched')); + } + + /** + * 수기전표 등록 + */ + public function store(StoreManualJournalRequest $request) + { + $entry = $this->service->store($request->validated()); + + return ApiResponse::success($entry, __('message.created'), [], 201); + } + + /** + * 전표 상세 조회 (분개 수정 모달용) + */ + public function show(int $id) + { + $detail = $this->service->show($id); + + return ApiResponse::success($detail, __('message.fetched')); + } + + /** + * 분개 수정 + */ + public function updateJournal(int $id, UpdateJournalRequest $request) + { + $entry = $this->service->updateJournal($id, $request->validated()); + + return ApiResponse::success($entry, __('message.updated')); + } + + /** + * 분개 삭제 + */ + public function destroyJournal(int $id) + { + $this->service->destroyJournal($id); + + return ApiResponse::success(null, __('message.deleted')); + } +} diff --git a/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php b/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php new file mode 100644 index 0000000..74316ab --- /dev/null +++ b/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:10'], + 'name' => ['required', 'string', 'max:100'], + 'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'], + ]; + } + + public function messages(): array + { + return [ + 'code.required' => '계정과목 코드를 입력하세요.', + 'name.required' => '계정과목명을 입력하세요.', + 'category.in' => '유효한 분류를 선택하세요.', + ]; + } +} diff --git a/app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php b/app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php new file mode 100644 index 0000000..4508680 --- /dev/null +++ b/app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php @@ -0,0 +1,42 @@ + ['required', 'date'], + 'description' => ['nullable', 'string', 'max:500'], + 'rows' => ['required', 'array', 'min:2'], + 'rows.*.side' => ['required', 'in:debit,credit'], + 'rows.*.account_subject_id' => ['required', 'string', 'max:10'], + 'rows.*.vendor_id' => ['nullable', 'integer'], + 'rows.*.debit_amount' => ['required', 'integer', 'min:0'], + 'rows.*.credit_amount' => ['required', 'integer', 'min:0'], + 'rows.*.memo' => ['nullable', 'string', 'max:300'], + ]; + } + + public function messages(): array + { + return [ + 'journal_date.required' => '전표일자를 입력하세요.', + 'rows.required' => '분개 행을 입력하세요.', + 'rows.min' => '최소 2개 이상의 분개 행이 필요합니다.', + 'rows.*.side.required' => '차/대 구분을 선택하세요.', + 'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.', + 'rows.*.account_subject_id.required' => '계정과목을 선택하세요.', + 'rows.*.debit_amount.required' => '차변 금액을 입력하세요.', + 'rows.*.credit_amount.required' => '대변 금액을 입력하세요.', + ]; + } +} diff --git a/app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php b/app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php new file mode 100644 index 0000000..cf3d364 --- /dev/null +++ b/app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php @@ -0,0 +1,38 @@ + ['sometimes', 'nullable', 'string', 'max:1000'], + 'rows' => ['sometimes', 'array', 'min:1'], + 'rows.*.side' => ['required_with:rows', 'in:debit,credit'], + 'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'], + 'rows.*.vendor_id' => ['nullable', 'integer'], + 'rows.*.debit_amount' => ['required_with:rows', 'integer', 'min:0'], + 'rows.*.credit_amount' => ['required_with:rows', 'integer', 'min:0'], + 'rows.*.memo' => ['nullable', 'string', 'max:300'], + ]; + } + + public function messages(): array + { + return [ + 'rows.*.side.required_with' => '차/대 구분을 선택하세요.', + 'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.', + 'rows.*.account_subject_id.required_with' => '계정과목을 선택하세요.', + 'rows.*.debit_amount.required_with' => '차변 금액을 입력하세요.', + 'rows.*.credit_amount.required_with' => '대변 금액을 입력하세요.', + ]; + } +} diff --git a/app/Models/Tenants/AccountCode.php b/app/Models/Tenants/AccountCode.php new file mode 100644 index 0000000..7eb465a --- /dev/null +++ b/app/Models/Tenants/AccountCode.php @@ -0,0 +1,49 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + // Categories + public const CATEGORY_ASSET = 'asset'; + public const CATEGORY_LIABILITY = 'liability'; + public const CATEGORY_CAPITAL = 'capital'; + public const CATEGORY_REVENUE = 'revenue'; + public const CATEGORY_EXPENSE = 'expense'; + + public const CATEGORIES = [ + self::CATEGORY_ASSET => '자산', + self::CATEGORY_LIABILITY => '부채', + self::CATEGORY_CAPITAL => '자본', + self::CATEGORY_REVENUE => '수익', + self::CATEGORY_EXPENSE => '비용', + ]; + + /** + * 활성 계정과목만 조회 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/Tenants/JournalEntry.php b/app/Models/Tenants/JournalEntry.php new file mode 100644 index 0000000..17cdd6f --- /dev/null +++ b/app/Models/Tenants/JournalEntry.php @@ -0,0 +1,53 @@ + 'date', + 'total_debit' => 'integer', + 'total_credit' => 'integer', + ]; + + // Status + public const STATUS_DRAFT = 'draft'; + public const STATUS_CONFIRMED = 'confirmed'; + + // Source type + public const SOURCE_MANUAL = 'manual'; + public const SOURCE_BANK_TRANSACTION = 'bank_transaction'; + + // Entry type + public const TYPE_GENERAL = 'general'; + + /** + * 분개 행 관계 + */ + public function lines(): HasMany + { + return $this->hasMany(JournalEntryLine::class)->orderBy('line_no'); + } +} diff --git a/app/Models/Tenants/JournalEntryLine.php b/app/Models/Tenants/JournalEntryLine.php new file mode 100644 index 0000000..b906306 --- /dev/null +++ b/app/Models/Tenants/JournalEntryLine.php @@ -0,0 +1,45 @@ + 'integer', + 'debit_amount' => 'integer', + 'credit_amount' => 'integer', + 'trading_partner_id' => 'integer', + ]; + + // DC Type + public const DC_DEBIT = 'debit'; + public const DC_CREDIT = 'credit'; + + /** + * 전표 관계 + */ + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class); + } +} diff --git a/app/Services/AccountCodeService.php b/app/Services/AccountCodeService.php new file mode 100644 index 0000000..c6342db --- /dev/null +++ b/app/Services/AccountCodeService.php @@ -0,0 +1,109 @@ +tenantId(); + + $query = AccountCode::query() + ->where('tenant_id', $tenantId); + + // 검색 (코드/이름) + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + // 분류 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + + return $query->orderBy('sort_order')->orderBy('code')->get()->toArray(); + } + + /** + * 계정과목 등록 + */ + public function store(array $data): AccountCode + { + $tenantId = $this->tenantId(); + + // 중복 코드 체크 + $exists = AccountCode::query() + ->where('tenant_id', $tenantId) + ->where('code', $data['code']) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.account_subject.duplicate_code')); + } + + $accountCode = new AccountCode; + $accountCode->tenant_id = $tenantId; + $accountCode->code = $data['code']; + $accountCode->name = $data['name']; + $accountCode->category = $data['category'] ?? null; + $accountCode->sort_order = $data['sort_order'] ?? 0; + $accountCode->is_active = true; + $accountCode->save(); + + return $accountCode; + } + + /** + * 계정과목 활성/비활성 토글 + */ + public function toggleStatus(int $id, bool $isActive): AccountCode + { + $tenantId = $this->tenantId(); + + $accountCode = AccountCode::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $accountCode->is_active = $isActive; + $accountCode->save(); + + return $accountCode; + } + + /** + * 계정과목 삭제 (사용 중이면 차단) + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + + $accountCode = AccountCode::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 전표에서 사용 중인지 확인 + $inUse = JournalEntryLine::query() + ->where('tenant_id', $tenantId) + ->where('account_code', $accountCode->code) + ->exists(); + + if ($inUse) { + throw new BadRequestHttpException(__('error.account_subject.in_use')); + } + + $accountCode->delete(); + + return true; + } +} diff --git a/app/Services/GeneralJournalEntryService.php b/app/Services/GeneralJournalEntryService.php new file mode 100644 index 0000000..3e2d1f9 --- /dev/null +++ b/app/Services/GeneralJournalEntryService.php @@ -0,0 +1,578 @@ +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("CASE WHEN journal_entries.id IS NOT NULL THEN 'linked' ELSE 'manual' END 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("CASE WHEN journal_entries.id IS NOT NULL THEN 'linked' ELSE 'manual' END 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(), + 'meta' => [ + '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' => '', + ]; + } +} diff --git a/lang/ko/error.php b/lang/ko/error.php index 54e11df..bdad200 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -481,4 +481,15 @@ 'cannot_delete' => '해당 계약은 삭제할 수 없습니다.', 'invalid_status' => '유효하지 않은 계약 상태입니다.', ], + + // 일반전표입력 + 'journal_entry' => [ + 'debit_credit_mismatch' => '차변 합계와 대변 합계가 일치해야 합니다.', + ], + + // 계정과목 + 'account_subject' => [ + 'duplicate_code' => '이미 존재하는 계정과목 코드입니다.', + 'in_use' => '전표에서 사용 중인 계정과목은 삭제할 수 없습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 086008e..5f20784 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -567,6 +567,22 @@ 'downloaded' => '문서가 다운로드되었습니다.', ], + // 일반전표입력 + 'journal_entry' => [ + 'fetched' => '전표 조회 성공', + 'created' => '전표가 등록되었습니다.', + 'updated' => '분개가 수정되었습니다.', + 'deleted' => '분개가 삭제되었습니다.', + ], + + // 계정과목 + 'account_subject' => [ + 'fetched' => '계정과목 조회 성공', + 'created' => '계정과목이 등록되었습니다.', + 'toggled' => '계정과목 상태가 변경되었습니다.', + 'deleted' => '계정과목이 삭제되었습니다.', + ], + // CEO 대시보드 부가세 현황 'vat' => [ 'sales_tax' => '매출세액', diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 0ce30b9..64924f9 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -12,6 +12,7 @@ * - 대시보드/보고서 */ +use App\Http\Controllers\Api\V1\AccountSubjectController; use App\Http\Controllers\Api\V1\BadDebtController; use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankTransactionController; @@ -26,6 +27,7 @@ use App\Http\Controllers\Api\V1\DepositController; use App\Http\Controllers\Api\V1\EntertainmentController; use App\Http\Controllers\Api\V1\ExpectedExpenseController; +use App\Http\Controllers\Api\V1\GeneralJournalEntryController; use App\Http\Controllers\Api\V1\LoanController; use App\Http\Controllers\Api\V1\PaymentController; use App\Http\Controllers\Api\V1\PayrollController; @@ -304,6 +306,24 @@ Route::delete('/{id}/memos/{memoId}', [BadDebtController::class, 'removeMemo'])->whereNumber(['id', 'memoId'])->name('v1.bad-debts.memos.destroy'); }); +// General Journal Entry API (일반전표입력) +Route::prefix('general-journal-entries')->group(function () { + Route::get('', [GeneralJournalEntryController::class, 'index'])->name('v1.general-journal-entries.index'); + Route::get('/summary', [GeneralJournalEntryController::class, 'summary'])->name('v1.general-journal-entries.summary'); + Route::post('', [GeneralJournalEntryController::class, 'store'])->name('v1.general-journal-entries.store'); + Route::get('/{id}', [GeneralJournalEntryController::class, 'show'])->whereNumber('id')->name('v1.general-journal-entries.show'); + Route::put('/{id}/journal', [GeneralJournalEntryController::class, 'updateJournal'])->whereNumber('id')->name('v1.general-journal-entries.update-journal'); + Route::delete('/{id}/journal', [GeneralJournalEntryController::class, 'destroyJournal'])->whereNumber('id')->name('v1.general-journal-entries.destroy-journal'); +}); + +// Account Subject API (계정과목) +Route::prefix('account-subjects')->group(function () { + Route::get('', [AccountSubjectController::class, 'index'])->name('v1.account-subjects.index'); + Route::post('', [AccountSubjectController::class, 'store'])->name('v1.account-subjects.store'); + Route::patch('/{id}/status', [AccountSubjectController::class, 'toggleStatus'])->whereNumber('id')->name('v1.account-subjects.toggle-status'); + Route::delete('/{id}', [AccountSubjectController::class, 'destroy'])->whereNumber('id')->name('v1.account-subjects.destroy'); +}); + // Bill API (어음관리) Route::prefix('bills')->group(function () { Route::get('', [BillController::class, 'index'])->name('v1.bills.index');