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/BarobillController.php b/app/Http/Controllers/Api/V1/BarobillController.php new file mode 100644 index 0000000..9e90b91 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillController.php @@ -0,0 +1,144 @@ +barobillService->getSetting(); + + return [ + 'bank_service_count' => 0, + 'account_link_count' => 0, + 'member' => $setting ? [ + 'barobill_id' => $setting->barobill_id, + 'biz_no' => $setting->corp_num, + 'status' => $setting->isVerified() ? 'active' : 'inactive', + 'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production', + ] : null, + ]; + }, __('message.fetched')); + } + + /** + * 바로빌 로그인 정보 등록 + */ + public function login(Request $request) + { + $data = $request->validate([ + 'barobill_id' => 'required|string', + 'password' => 'required|string', + ]); + + return ApiResponse::handle(function () use ($data) { + return $this->barobillService->saveSetting([ + 'barobill_id' => $data['barobill_id'], + ]); + }, __('message.saved')); + } + + /** + * 바로빌 회원가입 정보 등록 + */ + public function signup(Request $request) + { + $data = $request->validate([ + 'business_number' => 'required|string|size:10', + 'company_name' => 'required|string', + 'ceo_name' => 'required|string', + 'business_type' => 'nullable|string', + 'business_category' => 'nullable|string', + 'address' => 'nullable|string', + 'barobill_id' => 'required|string', + 'password' => 'required|string', + 'manager_name' => 'nullable|string', + 'manager_phone' => 'nullable|string', + 'manager_email' => 'nullable|email', + ]); + + return ApiResponse::handle(function () use ($data) { + return $this->barobillService->saveSetting([ + 'corp_num' => $data['business_number'], + 'corp_name' => $data['company_name'], + 'ceo_name' => $data['ceo_name'], + 'biz_type' => $data['business_type'] ?? null, + 'biz_class' => $data['business_category'] ?? null, + 'addr' => $data['address'] ?? null, + 'barobill_id' => $data['barobill_id'], + 'contact_name' => $data['manager_name'] ?? null, + 'contact_tel' => $data['manager_phone'] ?? null, + 'contact_id' => $data['manager_email'] ?? null, + ]); + }, __('message.saved')); + } + + /** + * 은행 빠른조회 서비스 URL 조회 + */ + public function bankServiceUrl(Request $request) + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Bank/BankAccountService']; + }, __('message.fetched')); + } + + /** + * 계좌 연동 등록 URL 조회 + */ + public function accountLinkUrl() + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Bank/AccountLink']; + }, __('message.fetched')); + } + + /** + * 카드 연동 등록 URL 조회 + */ + public function cardLinkUrl() + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Card/CardLink']; + }, __('message.fetched')); + } + + /** + * 공인인증서 등록 URL 조회 + */ + public function certificateUrl() + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Certificate/Register']; + }, __('message.fetched')); + } +} diff --git a/app/Http/Controllers/Api/V1/EntertainmentController.php b/app/Http/Controllers/Api/V1/EntertainmentController.php index a010bee..0c6a4b7 100644 --- a/app/Http/Controllers/Api/V1/EntertainmentController.php +++ b/app/Http/Controllers/Api/V1/EntertainmentController.php @@ -33,4 +33,20 @@ public function summary(Request $request): JsonResponse return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter); }, __('message.fetched')); } + + /** + * 접대비 상세 조회 (모달용) + */ + public function detail(Request $request): JsonResponse + { + $companyType = $request->query('company_type', 'medium'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); + + return ApiResponse::handle(function () use ($companyType, $year, $quarter, $startDate, $endDate) { + return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate); + }, __('message.fetched')); + } } diff --git a/app/Http/Controllers/Api/V1/ExpectedExpenseController.php b/app/Http/Controllers/Api/V1/ExpectedExpenseController.php index 14e6c6c..e51b431 100644 --- a/app/Http/Controllers/Api/V1/ExpectedExpenseController.php +++ b/app/Http/Controllers/Api/V1/ExpectedExpenseController.php @@ -128,13 +128,16 @@ public function summary(Request $request) /** * 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용) * - * @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체) + * @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search */ public function dashboardDetail(Request $request) { $transactionType = $request->query('transaction_type'); + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); + $search = $request->query('search'); - $data = $this->service->dashboardDetail($transactionType); + $data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search); return ApiResponse::success($data, __('message.fetched')); } 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/Controllers/Api/V1/LoanController.php b/app/Http/Controllers/Api/V1/LoanController.php index fe27b67..276902e 100644 --- a/app/Http/Controllers/Api/V1/LoanController.php +++ b/app/Http/Controllers/Api/V1/LoanController.php @@ -11,6 +11,7 @@ use App\Http\Requests\Loan\LoanUpdateRequest; use App\Services\LoanService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class LoanController extends Controller { @@ -33,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse */ public function summary(LoanIndexRequest $request): JsonResponse { - $userId = $request->validated()['user_id'] ?? null; - $result = $this->loanService->summary($userId); + $validated = $request->validated(); + $userId = $validated['user_id'] ?? null; + $category = $validated['category'] ?? null; + $result = $this->loanService->summary($userId, $category); return ApiResponse::success($result, __('message.fetched')); } @@ -42,9 +45,12 @@ public function summary(LoanIndexRequest $request): JsonResponse /** * 가지급금 대시보드 */ - public function dashboard(): JsonResponse + public function dashboard(Request $request): JsonResponse { - $result = $this->loanService->dashboard(); + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); + + $result = $this->loanService->dashboard($startDate, $endDate); return ApiResponse::success($result, __('message.fetched')); } diff --git a/app/Http/Controllers/Api/V1/VatController.php b/app/Http/Controllers/Api/V1/VatController.php index 765692b..1f02954 100644 --- a/app/Http/Controllers/Api/V1/VatController.php +++ b/app/Http/Controllers/Api/V1/VatController.php @@ -32,4 +32,18 @@ public function summary(Request $request): JsonResponse return $this->vatService->getSummary($periodType, $year, $period); }, __('message.fetched')); } + + /** + * 부가세 상세 조회 (모달용) + */ + public function detail(Request $request): JsonResponse + { + $periodType = $request->query('period_type', 'quarter'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $period = $request->query('period') ? (int) $request->query('period') : null; + + return ApiResponse::handle(function () use ($periodType, $year, $period) { + return $this->vatService->getDetail($periodType, $year, $period); + }, __('message.fetched')); + } } diff --git a/app/Http/Requests/Loan/LoanIndexRequest.php b/app/Http/Requests/Loan/LoanIndexRequest.php index c493b1b..26282ef 100644 --- a/app/Http/Requests/Loan/LoanIndexRequest.php +++ b/app/Http/Requests/Loan/LoanIndexRequest.php @@ -29,6 +29,7 @@ public function rules(): array return [ 'user_id' => ['nullable', 'integer', 'exists:users,id'], 'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)], + 'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)], 'start_date' => ['nullable', 'date', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'], 'search' => ['nullable', 'string', 'max:100'], diff --git a/app/Http/Requests/Loan/LoanStoreRequest.php b/app/Http/Requests/Loan/LoanStoreRequest.php index 345baac..096c827 100644 --- a/app/Http/Requests/Loan/LoanStoreRequest.php +++ b/app/Http/Requests/Loan/LoanStoreRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests\Loan; +use App\Models\Tenants\Loan; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class LoanStoreRequest extends FormRequest { @@ -21,12 +23,27 @@ public function authorize(): bool */ public function rules(): array { + $isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE; + return [ - 'user_id' => ['required', 'integer', 'exists:users,id'], + 'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'], 'loan_date' => ['required', 'date', 'date_format:Y-m-d'], 'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'], 'purpose' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)], + 'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)], + 'metadata' => ['nullable', 'array'], + 'metadata.serial_number' => ['nullable', 'string', 'max:100'], + 'metadata.cert_name' => ['nullable', 'string', 'max:200'], + 'metadata.vendor_id' => ['nullable', 'string', 'max:50'], + 'metadata.vendor_name' => ['nullable', 'string', 'max:200'], + 'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'], + 'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'], + 'metadata.recipient_name' => ['nullable', 'string', 'max:100'], + 'metadata.recipient_organization' => ['nullable', 'string', 'max:200'], + 'metadata.usage_description' => ['nullable', 'string', 'max:1000'], + 'metadata.memo' => ['nullable', 'string', 'max:2000'], ]; } diff --git a/app/Http/Requests/Loan/LoanUpdateRequest.php b/app/Http/Requests/Loan/LoanUpdateRequest.php index ba8449c..510089f 100644 --- a/app/Http/Requests/Loan/LoanUpdateRequest.php +++ b/app/Http/Requests/Loan/LoanUpdateRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests\Loan; +use App\Models\Tenants\Loan; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class LoanUpdateRequest extends FormRequest { @@ -27,6 +29,20 @@ public function rules(): array 'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'], 'purpose' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)], + 'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)], + 'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'], + 'metadata' => ['nullable', 'array'], + 'metadata.serial_number' => ['nullable', 'string', 'max:100'], + 'metadata.cert_name' => ['nullable', 'string', 'max:200'], + 'metadata.vendor_id' => ['nullable', 'string', 'max:50'], + 'metadata.vendor_name' => ['nullable', 'string', 'max:200'], + 'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'], + 'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'], + 'metadata.recipient_name' => ['nullable', 'string', 'max:100'], + 'metadata.recipient_organization' => ['nullable', 'string', 'max:200'], + 'metadata.usage_description' => ['nullable', 'string', 'max:1000'], + 'metadata.memo' => ['nullable', 'string', 'max:2000'], ]; } 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/Bill/StoreBillRequest.php b/app/Http/Requests/V1/Bill/StoreBillRequest.php index 562dc98..3aaed47 100644 --- a/app/Http/Requests/V1/Bill/StoreBillRequest.php +++ b/app/Http/Requests/V1/Bill/StoreBillRequest.php @@ -17,6 +17,7 @@ public function rules(): array $tenantId = app('tenant_id') ?? 0; return [ + // === 기존 필드 === 'bill_number' => [ 'nullable', 'string', @@ -30,16 +31,99 @@ public function rules(): array 'client_name' => ['nullable', 'string', 'max:100'], 'amount' => ['required', 'numeric', 'min:0'], 'issue_date' => ['required', 'date'], - 'maturity_date' => ['required', 'date', 'after_or_equal:issue_date'], - 'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'], + 'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'], + 'status' => ['nullable', 'string', 'max:30'], 'reason' => ['nullable', 'string', 'max:255'], 'installment_count' => ['nullable', 'integer', 'min:0'], 'note' => ['nullable', 'string', 'max:1000'], 'is_electronic' => ['nullable', 'boolean'], 'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'], + + // === V8 증권종류/매체/구분 === + 'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'], + 'medium' => ['nullable', 'string', 'in:electronic,paper'], + 'bill_category' => ['nullable', 'string', 'in:commercial,other'], + + // === 전자어음 === + 'electronic_bill_no' => ['nullable', 'string', 'max:100'], + 'registration_org' => ['nullable', 'string', 'in:kftc,bank'], + + // === 환어음 === + 'drawee' => ['nullable', 'string', 'max:100'], + 'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'], + 'acceptance_date' => ['nullable', 'date'], + 'acceptance_refusal_date' => ['nullable', 'date'], + 'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'], + + // === 받을어음 전용 === + 'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'], + 'endorsement_order' => ['nullable', 'string', 'max:5'], + 'storage_place' => ['nullable', 'string', 'in:safe,bank,other'], + 'issuer_bank' => ['nullable', 'string', 'max:100'], + + // 할인 + 'is_discounted' => ['nullable', 'boolean'], + 'discount_date' => ['nullable', 'date'], + 'discount_bank' => ['nullable', 'string', 'max:100'], + 'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'discount_amount' => ['nullable', 'numeric', 'min:0'], + + // 배서양도 + 'endorsement_date' => ['nullable', 'date'], + 'endorsee' => ['nullable', 'string', 'max:100'], + 'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'], + + // 추심 + 'collection_bank' => ['nullable', 'string', 'max:100'], + 'collection_request_date' => ['nullable', 'date'], + 'collection_fee' => ['nullable', 'numeric', 'min:0'], + 'collection_complete_date' => ['nullable', 'date'], + 'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'], + 'collection_deposit_date' => ['nullable', 'date'], + 'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'], + + // === 지급어음 전용 === + 'settlement_bank' => ['nullable', 'string', 'max:100'], + 'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'], + 'actual_payment_date' => ['nullable', 'date'], + + // === 공통 === + 'payment_place' => ['nullable', 'string', 'max:30'], + 'payment_place_detail' => ['nullable', 'string', 'max:200'], + + // 개서 + 'renewal_date' => ['nullable', 'date'], + 'renewal_new_bill_no' => ['nullable', 'string', 'max:50'], + 'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'], + + // 소구 + 'recourse_date' => ['nullable', 'date'], + 'recourse_amount' => ['nullable', 'numeric', 'min:0'], + 'recourse_target' => ['nullable', 'string', 'max:100'], + 'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'], + + // 환매 + 'buyback_date' => ['nullable', 'date'], + 'buyback_amount' => ['nullable', 'numeric', 'min:0'], + 'buyback_bank' => ['nullable', 'string', 'max:100'], + + // 부도/법적절차 + 'dishonored_date' => ['nullable', 'date'], + 'dishonored_reason' => ['nullable', 'string', 'max:30'], + 'has_protest' => ['nullable', 'boolean'], + 'protest_date' => ['nullable', 'date'], + 'recourse_notice_date' => ['nullable', 'date'], + 'recourse_notice_deadline' => ['nullable', 'date'], + + // 분할배서 + 'is_split' => ['nullable', 'boolean'], + + // === 차수 관리 === 'installments' => ['nullable', 'array'], 'installments.*.date' => ['required_with:installments', 'date'], 'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'], + 'installments.*.type' => ['nullable', 'string', 'max:30'], + 'installments.*.counterparty' => ['nullable', 'string', 'max:100'], 'installments.*.note' => ['nullable', 'string', 'max:255'], ]; } diff --git a/app/Http/Requests/V1/Bill/UpdateBillRequest.php b/app/Http/Requests/V1/Bill/UpdateBillRequest.php index 029da50..dc0a3c9 100644 --- a/app/Http/Requests/V1/Bill/UpdateBillRequest.php +++ b/app/Http/Requests/V1/Bill/UpdateBillRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + // === 기존 필드 === 'bill_number' => ['nullable', 'string', 'max:50'], 'bill_type' => ['nullable', 'string', 'in:received,issued'], 'client_id' => ['nullable', 'integer', 'exists:clients,id'], @@ -21,15 +22,72 @@ public function rules(): array 'amount' => ['nullable', 'numeric', 'min:0'], 'issue_date' => ['nullable', 'date'], 'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'], - 'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'], + 'status' => ['nullable', 'string', 'max:30'], 'reason' => ['nullable', 'string', 'max:255'], 'installment_count' => ['nullable', 'integer', 'min:0'], 'note' => ['nullable', 'string', 'max:1000'], 'is_electronic' => ['nullable', 'boolean'], 'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'], + + // === V8 확장 === + 'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'], + 'medium' => ['nullable', 'string', 'in:electronic,paper'], + 'bill_category' => ['nullable', 'string', 'in:commercial,other'], + 'electronic_bill_no' => ['nullable', 'string', 'max:100'], + 'registration_org' => ['nullable', 'string', 'in:kftc,bank'], + 'drawee' => ['nullable', 'string', 'max:100'], + 'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'], + 'acceptance_date' => ['nullable', 'date'], + 'acceptance_refusal_date' => ['nullable', 'date'], + 'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'], + 'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'], + 'endorsement_order' => ['nullable', 'string', 'max:5'], + 'storage_place' => ['nullable', 'string', 'in:safe,bank,other'], + 'issuer_bank' => ['nullable', 'string', 'max:100'], + 'is_discounted' => ['nullable', 'boolean'], + 'discount_date' => ['nullable', 'date'], + 'discount_bank' => ['nullable', 'string', 'max:100'], + 'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'discount_amount' => ['nullable', 'numeric', 'min:0'], + 'endorsement_date' => ['nullable', 'date'], + 'endorsee' => ['nullable', 'string', 'max:100'], + 'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'], + 'collection_bank' => ['nullable', 'string', 'max:100'], + 'collection_request_date' => ['nullable', 'date'], + 'collection_fee' => ['nullable', 'numeric', 'min:0'], + 'collection_complete_date' => ['nullable', 'date'], + 'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'], + 'collection_deposit_date' => ['nullable', 'date'], + 'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'], + 'settlement_bank' => ['nullable', 'string', 'max:100'], + 'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'], + 'actual_payment_date' => ['nullable', 'date'], + 'payment_place' => ['nullable', 'string', 'max:30'], + 'payment_place_detail' => ['nullable', 'string', 'max:200'], + 'renewal_date' => ['nullable', 'date'], + 'renewal_new_bill_no' => ['nullable', 'string', 'max:50'], + 'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'], + 'recourse_date' => ['nullable', 'date'], + 'recourse_amount' => ['nullable', 'numeric', 'min:0'], + 'recourse_target' => ['nullable', 'string', 'max:100'], + 'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'], + 'buyback_date' => ['nullable', 'date'], + 'buyback_amount' => ['nullable', 'numeric', 'min:0'], + 'buyback_bank' => ['nullable', 'string', 'max:100'], + 'dishonored_date' => ['nullable', 'date'], + 'dishonored_reason' => ['nullable', 'string', 'max:30'], + 'has_protest' => ['nullable', 'boolean'], + 'protest_date' => ['nullable', 'date'], + 'recourse_notice_date' => ['nullable', 'date'], + 'recourse_notice_deadline' => ['nullable', 'date'], + 'is_split' => ['nullable', 'boolean'], + + // === 차수 관리 === 'installments' => ['nullable', 'array'], 'installments.*.date' => ['required_with:installments', 'date'], 'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'], + 'installments.*.type' => ['nullable', 'string', 'max:30'], + 'installments.*.counterparty' => ['nullable', 'string', 'max:100'], 'installments.*.note' => ['nullable', 'string', 'max:255'], ]; } 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/Bill.php b/app/Models/Tenants/Bill.php index 1200fa7..e14d7b8 100644 --- a/app/Models/Tenants/Bill.php +++ b/app/Models/Tenants/Bill.php @@ -31,6 +31,58 @@ class Bill extends Model 'created_by', 'updated_by', 'deleted_by', + // V8 확장 필드 + 'instrument_type', + 'medium', + 'bill_category', + 'electronic_bill_no', + 'registration_org', + 'drawee', + 'acceptance_status', + 'acceptance_date', + 'acceptance_refusal_date', + 'acceptance_refusal_reason', + 'endorsement', + 'endorsement_order', + 'storage_place', + 'issuer_bank', + 'is_discounted', + 'discount_date', + 'discount_bank', + 'discount_rate', + 'discount_amount', + 'endorsement_date', + 'endorsee', + 'endorsement_reason', + 'collection_bank', + 'collection_request_date', + 'collection_fee', + 'collection_complete_date', + 'collection_result', + 'collection_deposit_date', + 'collection_deposit_amount', + 'settlement_bank', + 'payment_method', + 'actual_payment_date', + 'payment_place', + 'payment_place_detail', + 'renewal_date', + 'renewal_new_bill_no', + 'renewal_reason', + 'recourse_date', + 'recourse_amount', + 'recourse_target', + 'recourse_reason', + 'buyback_date', + 'buyback_amount', + 'buyback_bank', + 'dishonored_date', + 'dishonored_reason', + 'has_protest', + 'protest_date', + 'recourse_notice_date', + 'recourse_notice_deadline', + 'is_split', ]; protected $casts = [ @@ -41,21 +93,57 @@ class Bill extends Model 'bank_account_id' => 'integer', 'installment_count' => 'integer', 'is_electronic' => 'boolean', + // V8 확장 casts + 'acceptance_date' => 'date', + 'acceptance_refusal_date' => 'date', + 'discount_date' => 'date', + 'discount_rate' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'endorsement_date' => 'date', + 'collection_request_date' => 'date', + 'collection_fee' => 'decimal:2', + 'collection_complete_date' => 'date', + 'collection_deposit_date' => 'date', + 'collection_deposit_amount' => 'decimal:2', + 'actual_payment_date' => 'date', + 'renewal_date' => 'date', + 'recourse_date' => 'date', + 'recourse_amount' => 'decimal:2', + 'buyback_date' => 'date', + 'buyback_amount' => 'decimal:2', + 'dishonored_date' => 'date', + 'protest_date' => 'date', + 'recourse_notice_date' => 'date', + 'recourse_notice_deadline' => 'date', + 'is_discounted' => 'boolean', + 'has_protest' => 'boolean', + 'is_split' => 'boolean', ]; /** * 배열/JSON 변환 시 날짜 형식 지정 */ + /** + * 날짜 cast 필드 목록 (toArray에서 Y-m-d 형식 변환용) + */ + private const DATE_FIELDS = [ + 'issue_date', 'maturity_date', + 'acceptance_date', 'acceptance_refusal_date', + 'discount_date', 'endorsement_date', + 'collection_request_date', 'collection_complete_date', 'collection_deposit_date', + 'actual_payment_date', + 'renewal_date', 'recourse_date', 'buyback_date', + 'dishonored_date', 'protest_date', 'recourse_notice_date', 'recourse_notice_deadline', + ]; + public function toArray(): array { $array = parent::toArray(); - // 날짜 필드를 Y-m-d 형식으로 변환 - if (isset($array['issue_date']) && $this->issue_date) { - $array['issue_date'] = $this->issue_date->format('Y-m-d'); - } - if (isset($array['maturity_date']) && $this->maturity_date) { - $array['maturity_date'] = $this->maturity_date->format('Y-m-d'); + foreach (self::DATE_FIELDS as $field) { + if (isset($array[$field]) && $this->{$field}) { + $array[$field] = $this->{$field}->format('Y-m-d'); + } } return $array; @@ -69,14 +157,42 @@ public function toArray(): array 'issued' => '발행', ]; + /** + * 증권종류 + */ + public const INSTRUMENT_TYPES = [ + 'promissory' => '약속어음', + 'exchange' => '환어음', + 'cashierCheck' => '자기앞수표', + 'currentCheck' => '당좌수표', + ]; + /** * 수취 어음 상태 목록 */ public const RECEIVED_STATUSES = [ 'stored' => '보관중', + 'endorsed' => '배서양도', + 'discounted' => '할인', + 'collectionRequest' => '추심의뢰', + 'collectionComplete' => '추심완료', + 'maturityDeposit' => '만기입금', + 'paymentComplete' => '결제완료', + 'dishonored' => '부도', + 'renewed' => '개서', + 'buyback' => '환매', + // 하위호환 'maturityAlert' => '만기입금(7일전)', 'maturityResult' => '만기결과', - 'paymentComplete' => '결제완료', + ]; + + /** + * 수취 수표 상태 목록 + */ + public const RECEIVED_CHECK_STATUSES = [ + 'stored' => '보관중', + 'endorsed' => '배서양도', + 'deposited' => '입금', 'dishonored' => '부도', ]; @@ -85,10 +201,25 @@ public function toArray(): array */ public const ISSUED_STATUSES = [ 'stored' => '보관중', + 'issued' => '지급대기', + 'maturityPayment' => '만기결제', + 'paymentComplete' => '결제완료', + 'dishonored' => '부도', + 'renewed' => '개서', + // 하위호환 'maturityAlert' => '만기입금(7일전)', 'collectionRequest' => '추심의뢰', 'collectionComplete' => '추심완료', 'suing' => '추소중', + ]; + + /** + * 발행 수표 상태 목록 + */ + public const ISSUED_CHECK_STATUSES = [ + 'stored' => '보관중', + 'issued' => '지급대기', + 'cashed' => '현금화', 'dishonored' => '부도', ]; @@ -149,11 +280,25 @@ public function getBillTypeLabelAttribute(): string */ public function getStatusLabelAttribute(): string { + $isCheck = in_array($this->instrument_type, ['cashierCheck', 'currentCheck']); + if ($this->bill_type === 'received') { - return self::RECEIVED_STATUSES[$this->status] ?? $this->status; + $statuses = $isCheck ? self::RECEIVED_CHECK_STATUSES : self::RECEIVED_STATUSES; + + return $statuses[$this->status] ?? self::RECEIVED_STATUSES[$this->status] ?? $this->status; } - return self::ISSUED_STATUSES[$this->status] ?? $this->status; + $statuses = $isCheck ? self::ISSUED_CHECK_STATUSES : self::ISSUED_STATUSES; + + return $statuses[$this->status] ?? self::ISSUED_STATUSES[$this->status] ?? $this->status; + } + + /** + * 증권종류 라벨 + */ + public function getInstrumentTypeLabelAttribute(): string + { + return self::INSTRUMENT_TYPES[$this->instrument_type] ?? $this->instrument_type ?? '약속어음'; } /** diff --git a/app/Models/Tenants/BillInstallment.php b/app/Models/Tenants/BillInstallment.php index 41656e2..926efc1 100644 --- a/app/Models/Tenants/BillInstallment.php +++ b/app/Models/Tenants/BillInstallment.php @@ -12,8 +12,10 @@ class BillInstallment extends Model protected $fillable = [ 'bill_id', + 'type', 'installment_date', 'amount', + 'counterparty', 'note', 'created_by', ]; diff --git a/app/Models/Tenants/ExpenseAccount.php b/app/Models/Tenants/ExpenseAccount.php index b57c677..c9fe540 100644 --- a/app/Models/Tenants/ExpenseAccount.php +++ b/app/Models/Tenants/ExpenseAccount.php @@ -34,6 +34,7 @@ class ExpenseAccount extends Model 'vendor_name', 'payment_method', 'card_no', + 'loan_id', 'created_by', 'updated_by', 'deleted_by', @@ -53,6 +54,9 @@ class ExpenseAccount extends Model public const TYPE_OFFICE = 'office'; + // 세부 유형 상수 (접대비) + public const SUB_TYPE_GIFT_CERTIFICATE = 'gift_certificate'; + // 세부 유형 상수 (복리후생) public const SUB_TYPE_MEAL = 'meal'; 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/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php index 441ec50..cd10527 100644 --- a/app/Models/Tenants/Loan.php +++ b/app/Models/Tenants/Loan.php @@ -28,6 +28,12 @@ class Loan extends Model public const STATUS_PARTIAL = 'partial'; // 부분정산 + public const STATUS_HOLDING = 'holding'; // 보유 (상품권) + + public const STATUS_USED = 'used'; // 사용 (상품권) + + public const STATUS_DISPOSED = 'disposed'; // 폐기 (상품권) + /** * 상태 목록 */ @@ -35,6 +41,40 @@ class Loan extends Model self::STATUS_OUTSTANDING, self::STATUS_SETTLED, self::STATUS_PARTIAL, + self::STATUS_HOLDING, + self::STATUS_USED, + self::STATUS_DISPOSED, + ]; + + /** + * 카테고리 상수 (D1.7 기획서) + */ + public const CATEGORY_CARD = 'card'; // 카드 + + public const CATEGORY_CONGRATULATORY = 'congratulatory'; // 경조사 + + public const CATEGORY_GIFT_CERTIFICATE = 'gift_certificate'; // 상품권 + + public const CATEGORY_ENTERTAINMENT = 'entertainment'; // 접대비 + + /** + * 카테고리 목록 + */ + public const CATEGORIES = [ + self::CATEGORY_CARD, + self::CATEGORY_CONGRATULATORY, + self::CATEGORY_GIFT_CERTIFICATE, + self::CATEGORY_ENTERTAINMENT, + ]; + + /** + * 카테고리 라벨 매핑 + */ + public const CATEGORY_LABELS = [ + self::CATEGORY_CARD => '카드', + self::CATEGORY_CONGRATULATORY => '경조사', + self::CATEGORY_GIFT_CERTIFICATE => '상품권', + self::CATEGORY_ENTERTAINMENT => '접대비', ]; /** @@ -72,6 +112,8 @@ class Loan extends Model 'settlement_date', 'settlement_amount', 'status', + 'category', + 'metadata', 'withdrawal_id', 'created_by', 'updated_by', @@ -83,6 +125,7 @@ class Loan extends Model 'settlement_date' => 'date', 'amount' => 'decimal:2', 'settlement_amount' => 'decimal:2', + 'metadata' => 'array', ]; // ========================================================================= @@ -134,10 +177,21 @@ public function getStatusLabelAttribute(): string self::STATUS_OUTSTANDING => '미정산', self::STATUS_SETTLED => '정산완료', self::STATUS_PARTIAL => '부분정산', + self::STATUS_HOLDING => '보유', + self::STATUS_USED => '사용', + self::STATUS_DISPOSED => '폐기', default => $this->status, }; } + /** + * 카테고리 라벨 + */ + public function getCategoryLabelAttribute(): string + { + return self::CATEGORY_LABELS[$this->category] ?? $this->category ?? '카드'; + } + /** * 미정산 잔액 */ @@ -165,19 +219,33 @@ public function getElapsedDaysAttribute(): int // ========================================================================= /** - * 수정 가능 여부 (미정산 상태만) + * 수정 가능 여부 (미정산 상태 또는 상품권) */ public function isEditable(): bool { - return $this->status === self::STATUS_OUTSTANDING; + if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) { + return true; + } + + return in_array($this->status, [ + self::STATUS_OUTSTANDING, + self::STATUS_HOLDING, + ]); } /** - * 삭제 가능 여부 (미정산 상태만) + * 삭제 가능 여부 (미정산/보유 상태 또는 상품권) */ public function isDeletable(): bool { - return $this->status === self::STATUS_OUTSTANDING; + if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) { + return true; + } + + return in_array($this->status, [ + self::STATUS_OUTSTANDING, + self::STATUS_HOLDING, + ]); } /** 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/BillService.php b/app/Services/BillService.php index b2d1512..e6a451a 100644 --- a/app/Services/BillService.php +++ b/app/Services/BillService.php @@ -48,6 +48,16 @@ public function index(array $params): LengthAwarePaginator $query->where('client_id', $params['client_id']); } + // 증권종류 필터 + if (! empty($params['instrument_type'])) { + $query->where('instrument_type', $params['instrument_type']); + } + + // 매체 필터 + if (! empty($params['medium'])) { + $query->where('medium', $params['medium']); + } + // 전자어음 필터 if (isset($params['is_electronic']) && $params['is_electronic'] !== '') { $query->where('is_electronic', (bool) $params['is_electronic']); @@ -113,32 +123,23 @@ public function store(array $data): Bill $bill->client_name = $data['client_name'] ?? null; $bill->amount = $data['amount']; $bill->issue_date = $data['issue_date']; - $bill->maturity_date = $data['maturity_date']; + $bill->maturity_date = $data['maturity_date'] ?? null; $bill->status = $data['status'] ?? 'stored'; $bill->reason = $data['reason'] ?? null; $bill->installment_count = $data['installment_count'] ?? 0; $bill->note = $data['note'] ?? null; $bill->is_electronic = $data['is_electronic'] ?? false; $bill->bank_account_id = $data['bank_account_id'] ?? null; + + // V8 확장 필드 + $this->assignV8Fields($bill, $data); + $bill->created_by = $userId; $bill->updated_by = $userId; $bill->save(); // 차수 관리 저장 - if (! empty($data['installments'])) { - foreach ($data['installments'] as $installment) { - BillInstallment::create([ - 'bill_id' => $bill->id, - 'installment_date' => $installment['date'], - 'amount' => $installment['amount'], - 'note' => $installment['note'] ?? null, - 'created_by' => $userId, - ]); - } - // 차수 카운트 업데이트 - $bill->installment_count = count($data['installments']); - $bill->save(); - } + $this->syncInstallments($bill, $data['installments'] ?? [], $userId); return $bill->load(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']); }); @@ -157,6 +158,7 @@ public function update(int $id, array $data): Bill ->where('tenant_id', $tenantId) ->findOrFail($id); + // 기존 필드 if (isset($data['bill_number'])) { $bill->bill_number = $data['bill_number']; } @@ -175,7 +177,7 @@ public function update(int $id, array $data): Bill if (isset($data['issue_date'])) { $bill->issue_date = $data['issue_date']; } - if (isset($data['maturity_date'])) { + if (array_key_exists('maturity_date', $data)) { $bill->maturity_date = $data['maturity_date']; } if (isset($data['status'])) { @@ -194,27 +196,15 @@ public function update(int $id, array $data): Bill $bill->bank_account_id = $data['bank_account_id']; } + // V8 확장 필드 + $this->assignV8Fields($bill, $data); + $bill->updated_by = $userId; $bill->save(); // 차수 관리 업데이트 (전체 교체) if (isset($data['installments'])) { - // 기존 차수 삭제 - $bill->installments()->delete(); - - // 새 차수 추가 - foreach ($data['installments'] as $installment) { - BillInstallment::create([ - 'bill_id' => $bill->id, - 'installment_date' => $installment['date'], - 'amount' => $installment['amount'], - 'note' => $installment['note'] ?? null, - 'created_by' => $userId, - ]); - } - // 차수 카운트 업데이트 - $bill->installment_count = count($data['installments']); - $bill->save(); + $this->syncInstallments($bill, $data['installments'], $userId); } return $bill->fresh(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']); @@ -440,6 +430,68 @@ public function dashboardDetail(): array ]; } + /** + * V8 확장 필드를 Bill 모델에 할당 + */ + private function assignV8Fields(Bill $bill, array $data): void + { + $v8Fields = [ + 'instrument_type', 'medium', 'bill_category', + 'electronic_bill_no', 'registration_org', + 'drawee', 'acceptance_status', 'acceptance_date', + 'acceptance_refusal_date', 'acceptance_refusal_reason', + 'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank', + 'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount', + 'endorsement_date', 'endorsee', 'endorsement_reason', + 'collection_bank', 'collection_request_date', 'collection_fee', + 'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount', + 'settlement_bank', 'payment_method', 'actual_payment_date', + 'payment_place', 'payment_place_detail', + 'renewal_date', 'renewal_new_bill_no', 'renewal_reason', + 'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason', + 'buyback_date', 'buyback_amount', 'buyback_bank', + 'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date', + 'recourse_notice_date', 'recourse_notice_deadline', + 'is_split', + ]; + + foreach ($v8Fields as $field) { + if (array_key_exists($field, $data)) { + $bill->{$field} = $data[$field]; + } + } + } + + /** + * 차수(이력) 동기화 — 기존 삭제 후 새로 생성 + */ + private function syncInstallments(Bill $bill, array $installments, int $userId): void + { + if (empty($installments)) { + return; + } + + // 기존 차수 삭제 + $bill->installments()->delete(); + + // 새 차수 추가 + foreach ($installments as $installment) { + BillInstallment::create([ + 'bill_id' => $bill->id, + 'type' => $installment['type'] ?? 'other', + 'installment_date' => $installment['date'], + 'amount' => $installment['amount'], + 'counterparty' => $installment['counterparty'] ?? null, + 'note' => $installment['note'] ?? null, + 'created_by' => $userId, + ]); + } + + // 차수 카운트 업데이트 + $bill->installment_count = count($installments); + $bill->save(); + } + /** * 어음번호 자동 생성 */ diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index a701028..7495e6d 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -6,29 +6,35 @@ use Illuminate\Support\Facades\DB; /** - * 접대비 현황 서비스 + * 접대비 현황 서비스 (D1.7 리스크 감지형) * - * CEO 대시보드용 접대비 데이터를 제공합니다. + * CEO 대시보드용 접대비 리스크 데이터를 제공합니다. + * 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비 */ class EntertainmentService extends Service { - // 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%) - private const DEFAULT_LIMIT_RATE = 0.003; + // 고액 결제 기준 (1회 50만원 초과) + private const HIGH_AMOUNT_THRESHOLD = 500000; - // 기업 규모별 기본 한도 (연간) - private const COMPANY_TYPE_LIMITS = [ - 'large' => 36000000, // 대기업: 연 3,600만원 - 'medium' => 36000000, // 중견기업: 연 3,600만원 - 'small' => 24000000, // 중소기업: 연 2,400만원 + // 기피업종 MCC 코드 (유흥, 귀금속, 숙박 등) + private const PROHIBITED_MCC_CODES = [ + '5813', // 음주업소 + '7011', // 숙박업 + '5944', // 귀금속 + '7941', // 레저/스포츠 + '7992', // 골프장 + '7273', // 데이트서비스 + '5932', // 골동품 ]; + // 심야 시간대 (22시 ~ 06시) + private const LATE_NIGHT_START = 22; + + private const LATE_NIGHT_END = 6; + /** - * 접대비 현황 요약 조회 + * 접대비 리스크 현황 요약 조회 (D1.7) * - * @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly) - * @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium) - * @param int|null $year 연도 (기본: 현재 연도) - * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) * @return array{cards: array, check_points: array} */ public function getSummary( @@ -40,73 +46,58 @@ public function getSummary( $tenantId = $this->tenantId(); $now = Carbon::now(); - // 기본값 설정 $year = $year ?? $now->year; - $limitType = $limitType ?? 'quarterly'; - $companyType = $companyType ?? 'medium'; $quarter = $quarter ?? $now->quarter; // 기간 범위 계산 if ($limitType === 'annual') { $startDate = Carbon::create($year, 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - $periodLabel = "{$year}년"; } else { $startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); - $periodLabel = "{$quarter}사분기"; } - // 연간 시작일 (매출 계산용) - $yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); - $yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - - // 매출액 조회 (연간) - $annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate); - - // 접대비 한도 계산 - $annualLimit = $this->calculateLimit($annualSales, $companyType); - $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); - - // 접대비 사용액 조회 - $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); - - // 잔여 한도 - $remainingLimit = max(0, $periodLimit - $usedAmount); + // 리스크 감지 쿼리 + $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $startDate, $endDate); + $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $startDate, $endDate); + $highAmount = $this->getHighAmountRisk($tenantId, $startDate, $endDate); + $missingReceipt = $this->getMissingReceiptRisk($tenantId, $startDate, $endDate); // 카드 데이터 구성 $cards = [ [ - 'id' => 'et_sales', - 'label' => '매출', - 'amount' => (int) $annualSales, + 'id' => 'et_weekend', + 'label' => '주말/심야', + 'amount' => (int) $weekendLateNight['total'], + 'subLabel' => "{$weekendLateNight['count']}건", ], [ - 'id' => 'et_limit', - 'label' => "{{$periodLabel}} 접대비 총 한도", - 'amount' => (int) $periodLimit, + 'id' => 'et_prohibited', + 'label' => '기피업종', + 'amount' => (int) $prohibitedBiz['total'], + 'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건', ], [ - 'id' => 'et_remaining', - 'label' => "{{$periodLabel}} 접대비 잔여한도", - 'amount' => (int) $remainingLimit, + 'id' => 'et_high_amount', + 'label' => '고액 결제', + 'amount' => (int) $highAmount['total'], + 'subLabel' => "{$highAmount['count']}건", ], [ - 'id' => 'et_used', - 'label' => "{{$periodLabel}} 접대비 사용금액", - 'amount' => (int) $usedAmount, + 'id' => 'et_no_receipt', + 'label' => '증빙 미비', + 'amount' => (int) $missingReceipt['total'], + 'subLabel' => "{$missingReceipt['count']}건", ], ]; // 체크포인트 생성 - $checkPoints = $this->generateCheckPoints( - $periodLabel, - $periodLimit, - $usedAmount, - $remainingLimit, - $tenantId, - $startDate, - $endDate + $checkPoints = $this->generateRiskCheckPoints( + $weekendLateNight, + $prohibitedBiz, + $highAmount, + $missingReceipt ); return [ @@ -116,65 +107,83 @@ public function getSummary( } /** - * 연간 매출액 조회 + * 주말/심야 사용 리스크 조회 + * expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시 */ - private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float + private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array { - // orders 테이블에서 확정된 수주 합계 조회 - $amount = DB::table('orders') - ->where('tenant_id', $tenantId) - ->where('status_code', 'confirmed') - ->whereBetween('received_at', [$startDate, $endDate]) - ->whereNull('deleted_at') - ->sum('total_amount'); - - return $amount ?: 30530000000; // 임시 기본값 (305억) - } - - /** - * 접대비 한도 계산 - */ - private function calculateLimit(float $annualSales, string $companyType): float - { - // 기본 한도 (기업 규모별) - $baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium']; - - // 매출 기반 한도 (0.3%) - $salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE; - - // 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화) - return $baseLimit + $salesBasedLimit; - } - - /** - * 접대비 사용액 조회 - */ - private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float - { - // TODO: 실제 접대비 계정과목에서 조회 - // expense_accounts 또는 card_transactions에서 접대비 항목 합계 - $amount = DB::table('expense_accounts') + // 주말 사용 (토요일=7, 일요일=1 in MySQL DAYOFWEEK) + $weekendResult = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') - ->sum('amount'); + ->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); - return $amount ?: 10000000; // 임시 기본값 + // 심야 사용 (barobill 카드 거래 내역에서 시간 확인) + $lateNightResult = DB::table('expense_accounts as ea') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_num') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') // 주말 제외 (중복 방지) + ->whereNotNull('bct.use_time') + ->where(function ($q) { + $q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= ?', [self::LATE_NIGHT_START]) + ->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < ?', [self::LATE_NIGHT_END]); + }) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') + ->first(); + + $totalCount = ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0); + $totalAmount = ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0); + + return ['count' => $totalCount, 'total' => $totalAmount]; } /** - * 거래처 누락 건수 조회 + * 기피업종 사용 리스크 조회 + * barobill의 merchant_biz_type가 MCC 코드 매칭 */ - private function getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array + private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, string $endDate): array + { + $result = DB::table('expense_accounts as ea') + ->join('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_num') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->whereIn('bct.merchant_biz_type', self::PROHIBITED_MCC_CODES) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') + ->first(); + + return [ + 'count' => $result->count ?? 0, + 'total' => $result->total ?? 0, + ]; + } + + /** + * 고액 결제 리스크 조회 + * 1회 50만원 초과 결제 + */ + private function getHighAmountRisk(int $tenantId, string $startDate, string $endDate): array { - // TODO: 거래처 정보 누락 건수 조회 $result = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) - ->whereNull('vendor_id') ->whereNull('deleted_at') + ->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD) ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') ->first(); @@ -185,72 +194,436 @@ private function getMissingVendorCount(int $tenantId, string $startDate, string } /** - * 체크포인트 생성 + * 증빙 미비 리스크 조회 + * receipt_no가 NULL 또는 빈 값 */ - private function generateCheckPoints( - string $periodLabel, - float $limit, - float $used, - float $remaining, - int $tenantId, - string $startDate, - string $endDate + private function getMissingReceiptRisk(int $tenantId, string $startDate, string $endDate): array + { + $result = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->where(function ($q) { + $q->whereNull('receipt_no') + ->orWhere('receipt_no', ''); + }) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + return [ + 'count' => $result->count ?? 0, + 'total' => $result->total ?? 0, + ]; + } + + /** + * 접대비 상세 정보 조회 (모달용) + * + * @param string|null $companyType 법인 유형 (large|medium|small, 기본: medium) + * @param int|null $year 연도 (기본: 현재 연도) + * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) + */ + public function getDetail( + ?string $companyType = 'medium', + ?int $year = null, + ?int $quarter = null, + ?string $startDate = null, + ?string $endDate = null + ): array { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + + $year = $year ?? $now->year; + $companyType = $companyType ?? 'medium'; + $quarter = $quarter ?? $now->quarter; + + // 연간 기간 범위 (summary, calculation, quarterly, monthly_usage용 - 항상 연간) + $annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); + $annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); + + // 거래/리스크 필터 기간 (start_date/end_date 전달 시 사용, 없으면 분기 기본) + if ($startDate && $endDate) { + $filterStartDate = $startDate; + $filterEndDate = $endDate; + } else { + $filterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); + $filterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); + } + + // 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만) + $baseLimit = $companyType === 'large' ? 12000000 : 36000000; + + // 수입금액 조회 (sales 테이블) + $revenue = $this->getAnnualRevenue($tenantId, $year); + + // 수입금액별 추가한도 계산 + $revenueAdditional = $this->calculateRevenueAdditionalLimit($revenue); + + // 연간 총 한도 + $annualLimit = $baseLimit + $revenueAdditional; + $quarterlyLimit = $annualLimit / 4; + + // 연간/분기 사용액 조회 + $annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate); + $quarterlyUsed = $this->getUsedAmount($tenantId, $filterStartDate, $filterEndDate); + + // 잔여/초과 계산 + $annualRemaining = max(0, $annualLimit - $annualUsed); + $annualExceeded = max(0, $annualUsed - $annualLimit); + + // 1. 요약 데이터 + $summary = [ + 'annual_limit' => (int) $annualLimit, + 'annual_remaining' => (int) $annualRemaining, + 'annual_used' => (int) $annualUsed, + 'annual_exceeded' => (int) $annualExceeded, + ]; + + // 2. 리스크 검토 카드 (날짜 필터 적용) + $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $filterStartDate, $filterEndDate); + $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $filterStartDate, $filterEndDate); + $highAmount = $this->getHighAmountRisk($tenantId, $filterStartDate, $filterEndDate); + $missingReceipt = $this->getMissingReceiptRisk($tenantId, $filterStartDate, $filterEndDate); + + $riskReview = [ + ['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']], + ['label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'count' => $prohibitedBiz['count']], + ['label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'count' => $highAmount['count']], + ['label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'count' => $missingReceipt['count']], + ]; + + // 3. 월별 사용 추이 + $monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year); + + // 4. 사용자별 분포 (날짜 필터 적용) + $userDistribution = $this->getUserDistribution($tenantId, $filterStartDate, $filterEndDate); + + // 5. 거래 내역 (날짜 필터 적용) + $transactions = $this->getTransactions($tenantId, $filterStartDate, $filterEndDate); + + // 6. 손금한도 계산 정보 + $calculation = [ + 'company_type' => $companyType, + 'base_limit' => (int) $baseLimit, + 'revenue' => (int) $revenue, + 'revenue_additional' => (int) $revenueAdditional, + 'annual_limit' => (int) $annualLimit, + ]; + + // 7. 분기별 현황 + $quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit); + + return [ + 'summary' => $summary, + 'risk_review' => $riskReview, + 'monthly_usage' => $monthlyUsage, + 'user_distribution' => $userDistribution, + 'transactions' => $transactions, + 'calculation' => $calculation, + 'quarterly' => $quarterly, + ]; + } + + /** + * 접대비 사용액 조회 + */ + private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float + { + return DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount') ?: 0; + } + + /** + * 연간 수입금액(매출) 조회 + */ + private function getAnnualRevenue(int $tenantId, int $year): float + { + return DB::table('sales') + ->where('tenant_id', $tenantId) + ->whereYear('sale_date', $year) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + } + + /** + * 수입금액별 추가한도 계산 (세법 기준) + * 100억 이하: 수입금액 × 0.2% + * 100억 초과 ~ 500억 이하: 2,000만 + (수입금액 - 100억) × 0.1% + * 500억 초과: 6,000만 + (수입금액 - 500억) × 0.03% + */ + private function calculateRevenueAdditionalLimit(float $revenue): float + { + $b10 = 10000000000; // 100억 + $b50 = 50000000000; // 500억 + + if ($revenue <= $b10) { + return $revenue * 0.002; + } elseif ($revenue <= $b50) { + return 20000000 + ($revenue - $b10) * 0.001; + } else { + return 60000000 + ($revenue - $b50) * 0.0003; + } + } + + /** + * 월별 사용 추이 조회 + */ + private function getMonthlyUsageTrend(int $tenantId, int $year): array + { + $monthlyData = DB::table('expense_accounts') + ->select(DB::raw('MONTH(expense_date) as month'), DB::raw('SUM(amount) as amount')) + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereYear('expense_date', $year) + ->whereNull('deleted_at') + ->groupBy(DB::raw('MONTH(expense_date)')) + ->orderBy('month') + ->get(); + + $result = []; + for ($i = 1; $i <= 12; $i++) { + $found = $monthlyData->firstWhere('month', $i); + $result[] = [ + 'month' => $i, + 'label' => $i . '월', + 'amount' => $found ? (int) $found->amount : 0, + ]; + } + + return $result; + } + + /** + * 사용자별 분포 조회 + */ + private function getUserDistribution(int $tenantId, string $startDate, string $endDate): array + { + $colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#FB923C']; + + $distribution = DB::table('expense_accounts as ea') + ->leftJoin('users as u', 'ea.created_by', '=', 'u.id') + ->select('u.name as user_name', DB::raw('SUM(ea.amount) as amount')) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->groupBy('ea.created_by', 'u.name') + ->orderByDesc('amount') + ->limit(5) + ->get(); + + $total = $distribution->sum('amount'); + $result = []; + $idx = 0; + + foreach ($distribution as $item) { + $result[] = [ + 'user_name' => $item->user_name ?? '사용자', + 'amount' => (int) $item->amount, + 'percentage' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0, + 'color' => $colors[$idx % count($colors)], + ]; + $idx++; + } + + return $result; + } + + /** + * 거래 내역 조회 + */ + private function getTransactions(int $tenantId, string $startDate, string $endDate): array + { + $transactions = DB::table('expense_accounts as ea') + ->leftJoin('users as u', 'ea.created_by', '=', 'u.id') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_num') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->select([ + 'ea.id', + 'ea.card_no', + 'u.name as user_name', + 'ea.expense_date', + 'ea.vendor_name', + 'ea.amount', + 'ea.receipt_no', + 'bct.use_time', + 'bct.merchant_biz_type', + ]) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->orderByDesc('ea.expense_date') + ->limit(100) + ->get(); + + $result = []; + foreach ($transactions as $t) { + $riskType = $this->detectTransactionRiskType($t); + + $result[] = [ + 'id' => $t->id, + 'card_name' => $t->card_no ? '카드 *' . substr($t->card_no, -4) : '카드명', + 'user_name' => $t->user_name ?? '사용자', + 'expense_date' => Carbon::parse($t->expense_date)->format('Y-m-d H:i'), + 'vendor_name' => $t->vendor_name ?? '가맹점명', + 'amount' => (int) $t->amount, + 'risk_type' => $riskType, + ]; + } + + return $result; + } + + /** + * 거래 건별 리스크 유형 감지 + */ + private function detectTransactionRiskType(object $transaction): string + { + // 기피업종 + if ($transaction->merchant_biz_type && in_array($transaction->merchant_biz_type, self::PROHIBITED_MCC_CODES)) { + return '기피업종'; + } + + // 고액 결제 + if ($transaction->amount > self::HIGH_AMOUNT_THRESHOLD) { + return '고액 결제'; + } + + // 증빙 미비 + if (empty($transaction->receipt_no)) { + return '증빙 미비'; + } + + // 주말/심야 감지 + $expenseDate = Carbon::parse($transaction->expense_date); + if ($expenseDate->isWeekend()) { + return '주말/심야'; + } + if ($transaction->use_time) { + $hour = (int) substr($transaction->use_time, 0, 2); + if ($hour >= self::LATE_NIGHT_START || $hour < self::LATE_NIGHT_END) { + return '주말/심야'; + } + } + + return '정상'; + } + + /** + * 분기별 현황 조회 + */ + private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLimit): array + { + $result = []; + $previousRemaining = 0; + + for ($q = 1; $q <= 4; $q++) { + $startDate = Carbon::create($year, ($q - 1) * 3 + 1, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, $q * 3, 1)->endOfMonth()->format('Y-m-d'); + + $used = $this->getUsedAmount($tenantId, $startDate, $endDate); + $carryover = $previousRemaining > 0 ? $previousRemaining : 0; + $totalLimit = $quarterlyLimit + $carryover; + $remaining = max(0, $totalLimit - $used); + $exceeded = max(0, $used - $totalLimit); + + $result[] = [ + 'quarter' => $q, + 'limit' => (int) $quarterlyLimit, + 'carryover' => (int) $carryover, + 'used' => (int) $used, + 'remaining' => (int) $remaining, + 'exceeded' => (int) $exceeded, + ]; + + $previousRemaining = $remaining; + } + + return $result; + } + + /** + * 리스크 감지 체크포인트 생성 + */ + private function generateRiskCheckPoints( + array $weekendLateNight, + array $prohibitedBiz, + array $highAmount, + array $missingReceipt ): array { $checkPoints = []; - $usageRate = $limit > 0 ? ($used / $limit) * 100 : 0; - $usedFormatted = number_format($used / 10000); - $limitFormatted = number_format($limit / 10000); - $remainingFormatted = number_format($remaining / 10000); + $totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count'] + + $highAmount['count'] + $missingReceipt['count']; - // 사용률에 따른 체크포인트 - if ($usageRate <= 75) { - // 정상 운영 - $remainingRate = round(100 - $usageRate); + // 주말/심야 + if ($weekendLateNight['count'] > 0) { + $amountFormatted = number_format($weekendLateNight['total'] / 10000); $checkPoints[] = [ - 'id' => 'et_cp_normal', - 'type' => 'success', - 'message' => "{$periodLabel} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate}%). 여유 있게 운영 중입니다.", - 'highlights' => [ - ['text' => "{$usedFormatted}만원", 'color' => 'green'], - ['text' => "{$limitFormatted}만원 ({$remainingRate}%)", 'color' => 'green'], - ], - ]; - } elseif ($usageRate <= 100) { - // 주의 (85% 이상) - $usageRateRounded = round($usageRate); - $checkPoints[] = [ - 'id' => 'et_cp_warning', + 'id' => 'et_cp_weekend', 'type' => 'warning', - 'message' => "접대비 한도 {$usageRateRounded}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.", + 'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.", 'highlights' => [ - ['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'], - ], - ]; - } else { - // 한도 초과 - $overAmount = $used - $limit; - $overFormatted = number_format($overAmount / 10000); - $checkPoints[] = [ - 'id' => 'et_cp_over', - 'type' => 'error', - 'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.", - 'highlights' => [ - ['text' => "{$overFormatted}만원 발생", 'color' => 'red'], + ['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'], ], ]; } - // 거래처 정보 누락 체크 - $missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate); - if ($missingVendor['count'] > 0) { - $missingTotal = number_format($missingVendor['total'] / 10000); + // 기피업종 + if ($prohibitedBiz['count'] > 0) { + $amountFormatted = number_format($prohibitedBiz['total'] / 10000); $checkPoints[] = [ - 'id' => 'et_cp_missing', + 'id' => 'et_cp_prohibited', 'type' => 'error', - 'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.", + 'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.", 'highlights' => [ - ['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'], - ['text' => '거래처 정보가 누락', 'color' => 'red'], + ['text' => "{$prohibitedBiz['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ['text' => '접대비 불인정', 'color' => 'red'], + ], + ]; + } + + // 고액 결제 + if ($highAmount['count'] > 0) { + $amountFormatted = number_format($highAmount['total'] / 10000); + $checkPoints[] = [ + 'id' => 'et_cp_high', + 'type' => 'warning', + 'message' => "고액 결제 {$highAmount['count']}건({$amountFormatted}만원) 감지. 1회 50만원 초과 결제입니다. 증빙이 필요합니다.", + 'highlights' => [ + ['text' => "{$highAmount['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ], + ]; + } + + // 증빙 미비 + if ($missingReceipt['count'] > 0) { + $amountFormatted = number_format($missingReceipt['total'] / 10000); + $checkPoints[] = [ + 'id' => 'et_cp_receipt', + 'type' => 'error', + 'message' => "미증빙 {$missingReceipt['count']}건({$amountFormatted}만원) 감지. 증빙이 필요합니다.", + 'highlights' => [ + ['text' => "{$missingReceipt['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ], + ]; + } + + // 리스크 0건이면 정상 메시지 + if ($totalRiskCount === 0) { + $checkPoints[] = [ + 'id' => 'et_cp_normal', + 'type' => 'success', + 'message' => '접대비 사용 현황이 정상입니다.', + 'highlights' => [ + ['text' => '정상', 'color' => 'green'], ], ]; } diff --git a/app/Services/ExpectedExpenseService.php b/app/Services/ExpectedExpenseService.php index da717d0..4998ddc 100644 --- a/app/Services/ExpectedExpenseService.php +++ b/app/Services/ExpectedExpenseService.php @@ -304,34 +304,41 @@ public function summary(array $params): array * 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용) * * @param string|null $transactionType 거래유형 필터 (purchase, card, bill, null=전체) - * @return array{ - * summary: array{ - * total_amount: float, - * previous_month_amount: float, - * change_rate: float, - * remaining_balance: float, - * item_count: int - * }, - * monthly_trend: array, - * vendor_distribution: array, - * items: array, - * footer_summary: array - * } + * @param string|null $startDate 조회 시작일 (null이면 당월 1일) + * @param string|null $endDate 조회 종료일 (null이면 당월 말일) + * @param string|null $search 검색어 (거래처명, 적요) */ - public function dashboardDetail(?string $transactionType = null): array - { + public function dashboardDetail( + ?string $transactionType = null, + ?string $startDate = null, + ?string $endDate = null, + ?string $search = null + ): array { $tenantId = $this->tenantId(); - $currentMonthStart = now()->startOfMonth()->toDateString(); - $currentMonthEnd = now()->endOfMonth()->toDateString(); - $previousMonthStart = now()->subMonth()->startOfMonth()->toDateString(); - $previousMonthEnd = now()->subMonth()->endOfMonth()->toDateString(); - // 기본 쿼리 빌더 (transaction_type 필터 적용) - $baseQuery = function () use ($tenantId, $transactionType) { + // 날짜 범위: 파라미터 우선, 없으면 당월 기본값 + $currentMonthStart = $startDate ?? now()->startOfMonth()->toDateString(); + $currentMonthEnd = $endDate ?? now()->endOfMonth()->toDateString(); + + // 전월 대비: 조회 기간과 동일한 길이의 이전 기간 계산 + $startCarbon = \Carbon\Carbon::parse($currentMonthStart); + $endCarbon = \Carbon\Carbon::parse($currentMonthEnd); + $daysDiff = $startCarbon->diffInDays($endCarbon) + 1; + $previousMonthStart = $startCarbon->copy()->subDays($daysDiff)->toDateString(); + $previousMonthEnd = $startCarbon->copy()->subDay()->toDateString(); + + // 기본 쿼리 빌더 (transaction_type + search 필터 적용) + $baseQuery = function () use ($tenantId, $transactionType, $search) { $query = ExpectedExpense::query()->where('tenant_id', $tenantId); if ($transactionType) { $query->where('transaction_type', $transactionType); } + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('client_name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } return $query; }; @@ -361,10 +368,10 @@ public function dashboardDetail(?string $transactionType = null): array // 2. 월별 추이 (최근 7개월) $monthlyTrend = $this->getMonthlyTrend($tenantId, $transactionType); - // 3. 거래처별 분포 (당월, 상위 5개) + // 3. 거래처별 분포 (조회 기간, 상위 5개) $vendorDistribution = $this->getVendorDistribution($tenantId, $transactionType, $currentMonthStart, $currentMonthEnd); - // 4. 지출예상 목록 (당월, 지급일 순) + // 4. 지출예상 목록 (조회 기간, 지급일 순) $itemsQuery = ExpectedExpense::query() ->select([ 'expected_expenses.id', @@ -385,6 +392,13 @@ public function dashboardDetail(?string $transactionType = null): array $itemsQuery->where('expected_expenses.transaction_type', $transactionType); } + if ($search) { + $itemsQuery->where(function ($q) use ($search) { + $q->where('expected_expenses.client_name', 'like', "%{$search}%") + ->orWhere('expected_expenses.description', 'like', "%{$search}%"); + }); + } + $items = $itemsQuery ->orderBy('expected_expenses.expected_payment_date', 'asc') ->get() diff --git a/app/Services/GeneralJournalEntryService.php b/app/Services/GeneralJournalEntryService.php new file mode 100644 index 0000000..8056c0c --- /dev/null +++ b/app/Services/GeneralJournalEntryService.php @@ -0,0 +1,576 @@ +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("'linked' 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("'linked' 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(), + '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/app/Services/LoanService.php b/app/Services/LoanService.php index 036ae41..87cb509 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Tenants\ExpenseAccount; use App\Models\Tenants\Loan; use App\Models\Tenants\Withdrawal; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -25,6 +26,11 @@ public function index(array $params): LengthAwarePaginator ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'creator:id,name']); + // 카테고리 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); @@ -84,7 +90,7 @@ public function show(int $id): Loan /** * 가지급금 요약 (특정 사용자 또는 전체) */ - public function summary(?int $userId = null): array + public function summary(?int $userId = null, ?string $category = null): array { $tenantId = $this->tenantId(); @@ -95,7 +101,14 @@ public function summary(?int $userId = null): array $query->where('user_id', $userId); } - $stats = $query->selectRaw(' + if ($category) { + $query->where('category', $category); + } + + // 상품권 카테고리: holding/used/disposed 상태별 집계 추가 + $isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE; + + $selectRaw = ' COUNT(*) as total_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count, @@ -103,10 +116,27 @@ public function summary(?int $userId = null): array SUM(amount) as total_amount, SUM(COALESCE(settlement_amount, 0)) as total_settled, SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding - ', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL]) - ->first(); + '; + $bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL]; - return [ + if ($isGiftCertificate) { + $selectRaw .= ', + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count + '; + $bindings = array_merge($bindings, [ + Loan::STATUS_HOLDING, Loan::STATUS_HOLDING, + Loan::STATUS_USED, Loan::STATUS_USED, + Loan::STATUS_DISPOSED, + ]); + } + + $stats = $query->selectRaw($selectRaw, $bindings)->first(); + + $result = [ 'total_count' => (int) $stats->total_count, 'outstanding_count' => (int) $stats->outstanding_count, 'settled_count' => (int) $stats->settled_count, @@ -115,6 +145,27 @@ public function summary(?int $userId = null): array 'total_settled' => (float) $stats->total_settled, 'total_outstanding' => (float) $stats->total_outstanding, ]; + + if ($isGiftCertificate) { + $result['holding_count'] = (int) $stats->holding_count; + $result['holding_amount'] = (float) $stats->holding_amount; + $result['used_count'] = (int) $stats->used_count; + $result['used_amount'] = (float) $stats->used_amount; + $result['disposed_count'] = (int) $stats->disposed_count; + + // 접대비 해당 집계 (expense_accounts 테이블에서 조회) + $entertainmentStats = ExpenseAccount::query() + ->where('tenant_id', $tenantId) + ->where('account_type', ExpenseAccount::TYPE_ENTERTAINMENT) + ->where('sub_type', ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount') + ->first(); + + $result['entertainment_count'] = (int) ($entertainmentStats->count ?? 0); + $result['entertainment_amount'] = (float) ($entertainmentStats->amount ?? 0); + } + + return $result; } // ========================================================================= @@ -144,17 +195,34 @@ public function store(array $data): Loan $withdrawalId = $withdrawal->id; } - return Loan::create([ + // 상품권: user_id 미지정 시 현재 사용자로 대체 + $loanUserId = $data['user_id'] ?? $userId; + + // 상태 결정: 상품권은 holding, 그 외는 outstanding + $category = $data['category'] ?? null; + $status = $data['status'] + ?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING); + + $loan = Loan::create([ 'tenant_id' => $tenantId, - 'user_id' => $data['user_id'], + 'user_id' => $loanUserId, 'loan_date' => $data['loan_date'], 'amount' => $data['amount'], 'purpose' => $data['purpose'] ?? null, - 'status' => Loan::STATUS_OUTSTANDING, + 'status' => $status, + 'category' => $category, + 'metadata' => $data['metadata'] ?? null, 'withdrawal_id' => $withdrawalId, 'created_by' => $userId, 'updated_by' => $userId, ]); + + // 상품권 → 접대비 자동 연동 + if ($category === Loan::CATEGORY_GIFT_CERTIFICATE) { + $this->syncGiftCertificateExpense($loan); + } + + return $loan; }); } @@ -186,20 +254,83 @@ public function update(int $id, array $data): Loan } } - $loan->fill([ + $fillData = [ 'user_id' => $data['user_id'] ?? $loan->user_id, 'loan_date' => $data['loan_date'] ?? $loan->loan_date, 'amount' => $data['amount'] ?? $loan->amount, 'purpose' => $data['purpose'] ?? $loan->purpose, 'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id, 'updated_by' => $userId, - ]); + ]; + + if (isset($data['category'])) { + $fillData['category'] = $data['category']; + } + if (array_key_exists('metadata', $data)) { + $fillData['metadata'] = $data['metadata']; + } + if (isset($data['status'])) { + $fillData['status'] = $data['status']; + } + if (array_key_exists('settlement_date', $data)) { + $fillData['settlement_date'] = $data['settlement_date']; + } + + $loan->fill($fillData); $loan->save(); + // 상품권 → 접대비 자동 연동 + if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) { + $this->syncGiftCertificateExpense($loan); + } + return $loan->fresh(['user:id,name,email', 'creator:id,name']); } + /** + * 상품권 → 접대비 자동 연동 + * + * 상태가 'used' + entertainment_expense='applicable' → expense_accounts에 INSERT + * 그 외 → 기존 연결된 expense_accounts 삭제 + */ + private function syncGiftCertificateExpense(Loan $loan): void + { + $metadata = $loan->metadata ?? []; + $isEntertainment = ($loan->status === Loan::STATUS_USED) + && ($metadata['entertainment_expense'] ?? '') === 'applicable'; + + if ($isEntertainment) { + // upsert: loan_id 기준으로 있으면 업데이트, 없으면 생성 + ExpenseAccount::query() + ->updateOrCreate( + [ + 'tenant_id' => $loan->tenant_id, + 'loan_id' => $loan->id, + ], + [ + 'account_type' => ExpenseAccount::TYPE_ENTERTAINMENT, + 'sub_type' => ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE, + 'expense_date' => $loan->settlement_date ?? $loan->loan_date, + 'amount' => $loan->amount, + 'description' => ($metadata['cert_name'] ?? '상품권') . ' 접대비 전환', + 'receipt_no' => $metadata['serial_number'] ?? null, + 'vendor_name' => $metadata['vendor_name'] ?? null, + 'vendor_id' => ! empty($metadata['vendor_id']) ? (int) $metadata['vendor_id'] : null, + 'payment_method' => ExpenseAccount::PAYMENT_CASH, + 'created_by' => $loan->updated_by ?? $loan->created_by, + 'updated_by' => $loan->updated_by ?? $loan->created_by, + ] + ); + } else { + // 접대비 해당이 아니면 연결된 레코드 삭제 + ExpenseAccount::query() + ->where('tenant_id', $loan->tenant_id) + ->where('loan_id', $loan->id) + ->delete(); + } + } + /** * 가지급금 삭제 */ @@ -216,6 +347,14 @@ public function destroy(int $id): bool throw new BadRequestHttpException(__('error.loan.not_deletable')); } + // 상품권 연결 접대비 레코드도 삭제 + if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) { + ExpenseAccount::query() + ->where('tenant_id', $tenantId) + ->where('loan_id', $loan->id) + ->delete(); + } + $loan->deleted_by = $userId; $loan->save(); $loan->delete(); @@ -365,7 +504,8 @@ public function calculateInterest(int $year, ?int $userId = null): array /** * 가지급금 대시보드 데이터 * - * CEO 대시보드 카드/가지급금 관리 섹션(cm2) 모달용 데이터 제공 + * CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공 + * D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류) * * @return array{ * summary: array{ @@ -373,38 +513,79 @@ public function calculateInterest(int $year, ?int $userId = null): array * recognized_interest: float, * outstanding_count: int * }, + * category_breakdown: array, * loans: array * } */ - public function dashboard(): array + public function dashboard(?string $startDate = null, ?string $endDate = null): array { $tenantId = $this->tenantId(); $currentYear = now()->year; - // 1. Summary 데이터 - $summaryData = $this->summary(); + // 날짜 필터 조건 클로저 + $applyDateFilter = function ($query) use ($startDate, $endDate) { + if ($startDate) { + $query->where('loan_date', '>=', $startDate); + } + if ($endDate) { + $query->where('loan_date', '<=', $endDate); + } + return $query; + }; - // 2. 인정이자 계산 (현재 연도 기준) + // 상품권 중 used/disposed 제외 조건 (접대비로 전환됨) + $excludeUsedGiftCert = function ($query) { + $query->whereNot(function ($q) { + $q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE) + ->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]); + }); + }; + + // 1. Summary 데이터 (날짜 필터 적용) + $summaryQuery = Loan::query()->where('tenant_id', $tenantId); + $applyDateFilter($summaryQuery); + $excludeUsedGiftCert($summaryQuery); + + $stats = $summaryQuery->selectRaw(' + COUNT(*) as total_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count, + SUM(amount) as total_amount, + SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding + ', [Loan::STATUS_OUTSTANDING]) + ->first(); + + // 2. 인정이자 계산 (현재 연도 기준, 날짜 필터 무관) $interestData = $this->calculateInterest($currentYear); $recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0; - // 3. 가지급금 목록 (최근 10건, 미정산 우선) - $loans = Loan::query() + // 3. 카테고리별 집계 (날짜 필터 적용) + $categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate); + + // 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용, used/disposed 상품권 제외) + $loansQuery = Loan::query() ->where('tenant_id', $tenantId) - ->with(['user:id,name,email', 'withdrawal']) + ->with(['user:id,name,email', 'withdrawal']); + $applyDateFilter($loansQuery); + $excludeUsedGiftCert($loansQuery); + + $loans = $loansQuery ->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [ Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL, ]) ->orderByDesc('loan_date') - ->limit(10) + ->limit(50) ->get() ->map(function ($loan) { return [ 'id' => $loan->id, 'loan_date' => $loan->loan_date->format('Y-m-d'), 'user_name' => $loan->user?->name ?? '미지정', - 'category' => $loan->withdrawal_id ? '카드' : '계좌', + 'category' => $loan->category_label, 'amount' => (float) $loan->amount, 'status' => $loan->status, 'content' => $loan->purpose ?? '', @@ -414,14 +595,70 @@ public function dashboard(): array return [ 'summary' => [ - 'total_outstanding' => (float) $summaryData['total_outstanding'], + 'total_outstanding' => (float) ($stats->total_outstanding ?? 0), 'recognized_interest' => (float) $recognizedInterest, - 'outstanding_count' => (int) $summaryData['outstanding_count'], + 'outstanding_count' => (int) ($stats->outstanding_count ?? 0), ], + 'category_breakdown' => $categoryBreakdown, 'loans' => $loans, ]; } + /** + * 카테고리별 가지급금 집계 + * + * @return array + */ + private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array + { + // 기본값: 4개 카테고리 모두 0으로 초기화 + $breakdown = []; + foreach (Loan::CATEGORIES as $category) { + $breakdown[$category] = [ + 'outstanding_amount' => 0.0, + 'total_count' => 0, + 'unverified_count' => 0, + ]; + } + + // 카테고리별 집계 (날짜 필터 적용) + // 상품권 중 used/disposed는 접대비로 전환되므로 가지급금 집계에서 제외 + $query = Loan::query() + ->where('tenant_id', $tenantId) + ->whereNot(function ($q) { + $q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE) + ->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]); + }); + + if ($startDate) { + $query->where('loan_date', '>=', $startDate); + } + if ($endDate) { + $query->where('loan_date', '<=', $endDate); + } + + // NOTE: SQL alias를 'cat_outstanding'으로 사용 — Loan 모델의 + // getOutstandingAmountAttribute() accessor와 이름 충돌 방지 + $stats = $query + ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as cat_outstanding') + ->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count') + ->groupBy('category') + ->get(); + + foreach ($stats as $stat) { + $cat = $stat->category ?? Loan::CATEGORY_CARD; + if (isset($breakdown[$cat])) { + $breakdown[$cat] = [ + 'outstanding_amount' => (float) $stat->cat_outstanding, + 'total_count' => (int) $stat->total_count, + 'unverified_count' => (int) $stat->unverified_count, + ]; + } + } + + return $breakdown; + } + /** * 세금 시뮬레이션 데이터 * diff --git a/app/Services/ReceivablesService.php b/app/Services/ReceivablesService.php index 3c7b2c9..3ebb046 100644 --- a/app/Services/ReceivablesService.php +++ b/app/Services/ReceivablesService.php @@ -117,11 +117,14 @@ public function index(array $params): array } /** - * 요약 통계 조회 + * 요약 통계 조회 (D1.7 cards + check_points 구조) + * + * @return array{cards: array, check_points: array} */ public function summary(array $params): array { $tenantId = $this->tenantId(); + $now = Carbon::now(); $recentYear = $params['recent_year'] ?? false; $year = $params['year'] ?? date('Y'); @@ -137,19 +140,19 @@ public function summary(array $params): array $totalCarryForward = $this->getTotalCarryForwardBalance($tenantId, $carryForwardDate); // 기간 내 총 매출 - $totalSales = Sale::where('tenant_id', $tenantId) + $totalSales = (float) Sale::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('sale_date', [$startDate, $endDate]) ->sum('total_amount'); // 기간 내 총 입금 - $totalDeposits = Deposit::where('tenant_id', $tenantId) + $totalDeposits = (float) Deposit::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('deposit_date', [$startDate, $endDate]) ->sum('amount'); // 기간 내 총 어음 - $totalBills = Bill::where('tenant_id', $tenantId) + $totalBills = (float) Bill::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->where('bill_type', 'received') ->whereBetween('issue_date', [$startDate, $endDate]) @@ -158,26 +161,242 @@ public function summary(array $params): array // 총 미수금 (이월잔액 + 매출 - 입금 - 어음) $totalReceivables = $totalCarryForward + $totalSales - $totalDeposits - $totalBills; + // 당월 미수금 + $currentMonthStart = $now->copy()->startOfMonth()->format('Y-m-d'); + $currentMonthEnd = $now->copy()->endOfMonth()->format('Y-m-d'); + + $currentMonthSales = (float) Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('sale_date', [$currentMonthStart, $currentMonthEnd]) + ->sum('total_amount'); + + $currentMonthDeposits = (float) Deposit::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('deposit_date', [$currentMonthStart, $currentMonthEnd]) + ->sum('amount'); + + $currentMonthReceivables = $currentMonthSales - $currentMonthDeposits; + // 거래처 수 $vendorCount = Client::where('tenant_id', $tenantId) ->where('is_active', true) ->count(); - // 연체 거래처 수 (미수금이 양수인 거래처) + // 연체 거래처 수 $overdueVendorCount = Client::where('tenant_id', $tenantId) ->where('is_active', true) ->where('is_overdue', true) ->count(); - return [ - 'total_carry_forward' => (float) $totalCarryForward, - 'total_sales' => (float) $totalSales, - 'total_deposits' => (float) $totalDeposits, - 'total_bills' => (float) $totalBills, - 'total_receivables' => (float) $totalReceivables, - 'vendor_count' => $vendorCount, - 'overdue_vendor_count' => $overdueVendorCount, + // 악성채권 건수 + $badDebtCount = $this->getBadDebtCount($tenantId); + + // Top 3 미수금 거래처 + $topVendors = $this->getTopReceivableVendors($tenantId, 3); + + // 카드 데이터 구성 + $cards = [ + [ + 'id' => 'rv_cumulative', + 'label' => '누적 미수금', + 'amount' => (int) $totalReceivables, + 'sub_items' => [ + ['label' => '매출', 'value' => (int) $totalSales], + ['label' => '입금', 'value' => (int) $totalDeposits], + ], + ], + [ + 'id' => 'rv_monthly', + 'label' => '당월 미수금', + 'amount' => (int) $currentMonthReceivables, + 'sub_items' => [ + ['label' => '매출', 'value' => (int) $currentMonthSales], + ['label' => '입금', 'value' => (int) $currentMonthDeposits], + ], + ], + [ + 'id' => 'rv_vendors', + 'label' => '미수금 거래처', + 'amount' => $vendorCount, + 'unit' => '건', + 'subLabel' => "연체 {$overdueVendorCount}건" . ($badDebtCount > 0 ? " · 악성채권 {$badDebtCount}건" : ''), + ], + [ + 'id' => 'rv_top3', + 'label' => '미수금 Top 3', + 'amount' => ! empty($topVendors) ? (int) $topVendors[0]['amount'] : 0, + 'top_items' => $topVendors, + ], ]; + + // 체크포인트 생성 + $checkPoints = $this->generateSummaryCheckPoints( + $tenantId, + $totalReceivables, + $overdueVendorCount, + $topVendors, + $vendorCount + ); + + return [ + 'cards' => $cards, + 'check_points' => $checkPoints, + ]; + } + + /** + * 악성채권 건수 조회 + */ + private function getBadDebtCount(int $tenantId): int + { + // bad_debts 테이블이 존재하면 사용, 없으면 0 + try { + return \DB::table('bad_debts') + ->where('tenant_id', $tenantId) + ->whereIn('status', ['collecting', 'legal_action']) + ->whereNull('deleted_at') + ->count(); + } catch (\Exception $e) { + return 0; + } + } + + /** + * 미수금 Top N 거래처 조회 + */ + private function getTopReceivableVendors(int $tenantId, int $limit = 3): array + { + $salesSub = \DB::table('sales') + ->select('client_id', \DB::raw('SUM(total_amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereNull('deleted_at') + ->groupBy('client_id'); + + $depositsSub = \DB::table('deposits') + ->select('client_id', \DB::raw('SUM(amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereNull('deleted_at') + ->groupBy('client_id'); + + $billsSub = \DB::table('bills') + ->select('client_id', \DB::raw('SUM(amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereNull('deleted_at') + ->where('bill_type', 'received') + ->groupBy('client_id'); + + $results = \DB::table('clients as c') + ->leftJoinSub($salesSub, 's', 'c.id', '=', 's.client_id') + ->leftJoinSub($depositsSub, 'd', 'c.id', '=', 'd.client_id') + ->leftJoinSub($billsSub, 'b', 'c.id', '=', 'b.client_id') + ->select( + 'c.name', + \DB::raw('(COALESCE(s.total, 0) - COALESCE(d.total, 0) - COALESCE(b.total, 0)) as receivable') + ) + ->where('c.tenant_id', $tenantId) + ->where('c.is_active', true) + ->having('receivable', '>', 0) + ->orderByDesc('receivable') + ->limit($limit) + ->get(); + + return $results->map(fn ($v) => [ + 'name' => $v->name, + 'amount' => (int) $v->receivable, + ])->toArray(); + } + + /** + * 대시보드 요약 체크포인트 생성 + */ + private function generateSummaryCheckPoints( + int $tenantId, + float $totalReceivables, + int $overdueVendorCount, + array $topVendors, + int $vendorCount + ): array { + $checkPoints = []; + + // 연체 거래처 경고 + if ($overdueVendorCount > 0) { + $checkPoints[] = [ + 'id' => 'rv_cp_overdue', + 'type' => 'warning', + 'message' => "연체 거래처 {$overdueVendorCount}곳. 회수 조치가 필요합니다.", + 'highlights' => [ + ['text' => "연체 거래처 {$overdueVendorCount}곳", 'color' => 'red'], + ], + ]; + } + + // 90일 이상 장기 미수금 체크 + $longTermCount = $this->getLongTermReceivableCount($tenantId, 90); + if ($longTermCount > 0) { + $checkPoints[] = [ + 'id' => 'rv_cp_longterm', + 'type' => 'error', + 'message' => "90일 이상 장기 미수금 {$longTermCount}건 감지. 악성채권 전환 위험이 있습니다.", + 'highlights' => [ + ['text' => "90일 이상 장기 미수금 {$longTermCount}건", 'color' => 'red'], + ], + ]; + } + + // Top1 거래처 집중도 경고 + if (! empty($topVendors) && $totalReceivables > 0) { + $top1Ratio = round(($topVendors[0]['amount'] / $totalReceivables) * 100); + if ($top1Ratio >= 50) { + $checkPoints[] = [ + 'id' => 'rv_cp_concentration', + 'type' => 'warning', + 'message' => "{$topVendors[0]['name']} 미수금이 전체의 {$top1Ratio}%를 차지합니다. 리스크 분산이 필요합니다.", + 'highlights' => [ + ['text' => "{$topVendors[0]['name']}", 'color' => 'orange'], + ['text' => "전체의 {$top1Ratio}%", 'color' => 'orange'], + ], + ]; + } + } + + // 정상 상태 메시지 + if (empty($checkPoints)) { + $totalFormatted = number_format($totalReceivables / 10000); + $checkPoints[] = [ + 'id' => 'rv_cp_normal', + 'type' => 'success', + 'message' => "총 미수금 {$totalFormatted}만원. 정상적으로 관리되고 있습니다.", + 'highlights' => [ + ['text' => "{$totalFormatted}만원", 'color' => 'green'], + ], + ]; + } + + return $checkPoints; + } + + /** + * N일 이상 장기 미수금 거래처 수 조회 + */ + private function getLongTermReceivableCount(int $tenantId, int $days): int + { + $cutoffDate = Carbon::now()->subDays($days)->format('Y-m-d'); + + // 연체 상태이면서 오래된 매출이 있는 거래처 수 + $clientIds = Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('sale_date', '<=', $cutoffDate) + ->distinct() + ->pluck('client_id'); + + return Client::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('is_overdue', true) + ->whereIn('id', $clientIds) + ->count(); } /** diff --git a/app/Services/VatService.php b/app/Services/VatService.php index 9f9b0b2..9e937ef 100644 --- a/app/Services/VatService.php +++ b/app/Services/VatService.php @@ -237,6 +237,139 @@ private function getPeriodLabel(int $year, string $periodType, int $period): str }; } + /** + * 부가세 상세 조회 (모달용) + * + * @param string|null $periodType 기간 타입 (quarter|half|year) + * @param int|null $year 연도 + * @param int|null $period 기간 번호 + * @return array + */ + public function getDetail(?string $periodType = 'quarter', ?int $year = null, ?int $period = null): array + { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + + $year = $year ?? $now->year; + $periodType = $periodType ?? 'quarter'; + $period = $period ?? $this->getCurrentPeriod($periodType, $now); + + [$startDate, $endDate] = $this->getPeriodDateRange($year, $periodType, $period); + $periodLabel = $this->getPeriodLabel($year, $periodType, $period); + + $validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT]; + + // 매출 공급가액 + 세액 + $salesData = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_SALES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount') + ->first(); + + // 매입 공급가액 + 세액 + $purchasesData = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_PURCHASES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount') + ->first(); + + $salesSupplyAmount = (int) ($salesData->supply_amount ?? 0); + $salesTaxAmount = (int) ($salesData->tax_amount ?? 0); + $purchasesSupplyAmount = (int) ($purchasesData->supply_amount ?? 0); + $purchasesTaxAmount = (int) ($purchasesData->tax_amount ?? 0); + $estimatedPayment = $salesTaxAmount - $purchasesTaxAmount; + + // 신고기간 옵션 생성 + $periodOptions = $this->generatePeriodOptions($year, $periodType, $period); + + // 부가세 요약 테이블 (direction + invoice_type 별 GROUP BY) + $referenceTable = TaxInvoice::where('tenant_id', $tenantId) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw(" + direction, + invoice_type, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount + ") + ->groupBy('direction', 'invoice_type') + ->get() + ->map(fn ($row) => [ + 'direction' => $row->direction, + 'direction_label' => $row->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입', + 'invoice_type' => $row->invoice_type, + 'invoice_type_label' => match ($row->invoice_type) { + TaxInvoice::TYPE_TAX_INVOICE => '전자세금계산서', + TaxInvoice::TYPE_INVOICE => '계산서', + TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서', + default => $row->invoice_type, + }, + 'supply_amount' => (int) $row->supply_amount, + 'tax_amount' => (int) $row->tax_amount, + ]) + ->toArray(); + + // 미발행/미수취 세금계산서 목록 (status=draft) + $unissuedInvoices = TaxInvoice::where('tenant_id', $tenantId) + ->where('status', TaxInvoice::STATUS_DRAFT) + ->orderBy('issue_date', 'desc') + ->limit(100) + ->get() + ->map(fn ($invoice) => [ + 'id' => $invoice->id, + 'direction' => $invoice->direction, + 'direction_label' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입', + 'issue_date' => $invoice->issue_date, + 'vendor_name' => $invoice->direction === TaxInvoice::DIRECTION_SALES + ? ($invoice->buyer_corp_name ?? '-') + : ($invoice->supplier_corp_name ?? '-'), + 'tax_amount' => (int) $invoice->tax_amount, + 'status' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '미발행' : '미수취', + ]) + ->toArray(); + + return [ + 'period_label' => $periodLabel, + 'period_options' => $periodOptions, + 'summary' => [ + 'sales_supply_amount' => $salesSupplyAmount, + 'sales_tax_amount' => $salesTaxAmount, + 'purchases_supply_amount' => $purchasesSupplyAmount, + 'purchases_tax_amount' => $purchasesTaxAmount, + 'estimated_payment' => (int) abs($estimatedPayment), + 'is_refund' => $estimatedPayment < 0, + ], + 'reference_table' => $referenceTable, + 'unissued_invoices' => $unissuedInvoices, + ]; + } + + /** + * 신고기간 드롭다운 옵션 생성 + * 현재 기간 포함 최근 8개 기간 + */ + private function generatePeriodOptions(int $currentYear, string $periodType, int $currentPeriod): array + { + $options = []; + $year = $currentYear; + $period = $currentPeriod; + + for ($i = 0; $i < 8; $i++) { + $label = $this->getPeriodLabel($year, $periodType, $period); + $value = "{$year}-{$periodType}-{$period}"; + $options[] = ['value' => $value, 'label' => $label]; + + // 이전 기간으로 이동 + $prev = $this->getPreviousPeriod($year, $periodType, $period); + $year = $prev['year']; + $period = $prev['period']; + } + + return $options; + } + /** * 이전 기간 계산 * diff --git a/app/Services/WelfareService.php b/app/Services/WelfareService.php index c17aa54..6231f9f 100644 --- a/app/Services/WelfareService.php +++ b/app/Services/WelfareService.php @@ -6,9 +6,10 @@ use Illuminate\Support\Facades\DB; /** - * 복리후생비 현황 서비스 + * 복리후생비 현황 서비스 (D1.7 리스크 감지형) * - * CEO 대시보드용 복리후생비 데이터를 제공합니다. + * CEO 대시보드용 복리후생비 리스크 데이터를 제공합니다. + * 카드 4개: 비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과 */ class WelfareService extends Service { @@ -20,15 +21,22 @@ class WelfareService extends Service private const INDUSTRY_AVG_MAX = 250000; + // 특정인 편중 기준 (전체 대비 5% 초과) + private const CONCENTRATION_THRESHOLD = 0.05; + + // 항목별 1인당 월 기준 금액 + private const SUB_TYPE_LIMITS = [ + 'meal' => 200000, // 식대 20만원 + 'transportation' => 100000, // 교통비 10만원 + 'congratulation' => 50000, // 경조사 5만원 + 'health_check' => 30000, // 건강검진 3만원 + 'education' => 80000, // 교육비 8만원 + 'welfare_point' => 100000, // 복지포인트 10만원 + ]; + /** - * 복리후생비 현황 요약 조회 + * 복리후생비 리스크 현황 요약 조회 (D1.7) * - * @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly) - * @param string|null $calculationType 계산 방식 (fixed|ratio, 기본: fixed) - * @param int|null $fixedAmountPerMonth 1인당 월 정액 (기본: 200000) - * @param float|null $ratio 급여 대비 비율 (기본: 0.05) - * @param int|null $year 연도 (기본: 현재 연도) - * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) * @return array{cards: array, check_points: array} */ public function getSummary( @@ -42,79 +50,68 @@ public function getSummary( $tenantId = $this->tenantId(); $now = Carbon::now(); - // 기본값 설정 $year = $year ?? $now->year; - $limitType = $limitType ?? 'quarterly'; - $calculationType = $calculationType ?? 'fixed'; - $fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000; - $ratio = $ratio ?? 0.05; $quarter = $quarter ?? $now->quarter; // 기간 범위 계산 if ($limitType === 'annual') { $startDate = Carbon::create($year, 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - $periodLabel = "{$year}년"; $monthCount = 12; } else { $startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); - $periodLabel = "{$quarter}사분기"; $monthCount = 3; } // 직원 수 조회 $employeeCount = $this->getEmployeeCount($tenantId); - // 한도 계산 - if ($calculationType === 'fixed') { - $annualLimit = $fixedAmountPerMonth * 12 * $employeeCount; - } else { - // 급여 총액 기반 비율 계산 - $totalSalary = $this->getTotalSalary($tenantId, $year); - $annualLimit = $totalSalary * $ratio; - } - - $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); - - // 복리후생비 사용액 조회 - $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); - - // 잔여 한도 - $remainingLimit = max(0, $periodLimit - $usedAmount); + // 리스크 감지 쿼리 + $taxFreeExcess = $this->getTaxFreeExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount); + $privateUse = $this->getPrivateUseRisk($tenantId, $startDate, $endDate); + $concentration = $this->getConcentrationRisk($tenantId, $startDate, $endDate); + $categoryExcess = $this->getCategoryExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount); // 카드 데이터 구성 $cards = [ [ - 'id' => 'wf_annual_limit', - 'label' => '당해년도 복리후생비 한도', - 'amount' => (int) $annualLimit, + 'id' => 'wf_tax_excess', + 'label' => '비과세 한도 초과', + 'amount' => (int) $taxFreeExcess['total'], + 'subLabel' => "{$taxFreeExcess['count']}건", ], [ - 'id' => 'wf_period_limit', - 'label' => "{{$periodLabel}} 복리후생비 총 한도", - 'amount' => (int) $periodLimit, + 'id' => 'wf_private_use', + 'label' => '사적 사용 의심', + 'amount' => (int) $privateUse['total'], + 'subLabel' => "{$privateUse['count']}건", ], [ - 'id' => 'wf_remaining', - 'label' => "{{$periodLabel}} 복리후생비 잔여한도", - 'amount' => (int) $remainingLimit, + 'id' => 'wf_concentration', + 'label' => '특정인 편중', + 'amount' => (int) $concentration['total'], + 'subLabel' => "{$concentration['count']}건", ], [ - 'id' => 'wf_used', - 'label' => "{{$periodLabel}} 복리후생비 사용금액", - 'amount' => (int) $usedAmount, + 'id' => 'wf_category_excess', + 'label' => '항목별 한도 초과', + 'amount' => (int) $categoryExcess['total'], + 'subLabel' => "{$categoryExcess['count']}건", ], ]; // 체크포인트 생성 - $checkPoints = $this->generateCheckPoints( + $checkPoints = $this->generateRiskCheckPoints( $tenantId, $employeeCount, - $usedAmount, $monthCount, $startDate, - $endDate + $endDate, + $taxFreeExcess, + $privateUse, + $concentration, + $categoryExcess ); return [ @@ -123,6 +120,260 @@ public function getSummary( ]; } + /** + * 비과세 한도 초과 리스크 조회 + * sub_type='meal' 1인당 월 > 200,000원 + */ + private function getTaxFreeExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array + { + if ($employeeCount <= 0) { + return ['count' => 0, 'total' => 0]; + } + + // 식대 총액 조회 + $mealTotal = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->where('sub_type', 'meal') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + $perPersonMonthly = $mealTotal / $employeeCount / max(1, $monthCount); + $excessAmount = max(0, $perPersonMonthly - self::TAX_FREE_MEAL_LIMIT) * $employeeCount * $monthCount; + + if ($excessAmount > 0) { + // 초과 건수 (식대 건수 기준) + $count = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->where('sub_type', 'meal') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->count(); + + return ['count' => $count, 'total' => (int) $excessAmount]; + } + + return ['count' => 0, 'total' => 0]; + } + + /** + * 사적 사용 의심 리스크 조회 + * 주말/심야 사용 (접대비와 동일 로직, account_type='welfare') + */ + private function getPrivateUseRisk(int $tenantId, string $startDate, string $endDate): array + { + // 주말 사용 + $weekendResult = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 심야 사용 (barobill 조인) + $lateNightResult = DB::table('expense_accounts as ea') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_num') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'welfare') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') + ->whereNotNull('bct.use_time') + ->where(function ($q) { + $q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= 22') + ->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < 6'); + }) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') + ->first(); + + return [ + 'count' => ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0), + 'total' => ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0), + ]; + } + + /** + * 특정인 편중 리스크 조회 + * 1인 사용비율 > 전체의 5% + */ + private function getConcentrationRisk(int $tenantId, string $startDate, string $endDate): array + { + // 전체 복리후생비 사용액 + $totalAmount = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + if ($totalAmount <= 0) { + return ['count' => 0, 'total' => 0]; + } + + $threshold = $totalAmount * self::CONCENTRATION_THRESHOLD; + + // 사용자별 사용액 조회 (편중된 사용자) + $concentrated = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->groupBy('created_by') + ->havingRaw('SUM(amount) > ?', [$threshold]) + ->selectRaw('COUNT(*) as count, SUM(amount) as total') + ->get(); + + $totalConcentrated = $concentrated->sum('total'); + $userCount = $concentrated->count(); + + return ['count' => $userCount, 'total' => (int) $totalConcentrated]; + } + + /** + * 항목별 한도 초과 리스크 조회 + * 각 sub_type별 1인당 월 기준금액 초과 + */ + private function getCategoryExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array + { + if ($employeeCount <= 0) { + return ['count' => 0, 'total' => 0]; + } + + $totalExcess = 0; + $excessCount = 0; + + foreach (self::SUB_TYPE_LIMITS as $subType => $monthlyLimit) { + $amount = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->where('sub_type', $subType) + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + $perPersonMonthly = $amount / $employeeCount / max(1, $monthCount); + if ($perPersonMonthly > $monthlyLimit) { + $excess = ($perPersonMonthly - $monthlyLimit) * $employeeCount * $monthCount; + $totalExcess += $excess; + $excessCount++; + } + } + + return ['count' => $excessCount, 'total' => (int) $totalExcess]; + } + + /** + * 리스크 감지 체크포인트 생성 + */ + private function generateRiskCheckPoints( + int $tenantId, + int $employeeCount, + int $monthCount, + string $startDate, + string $endDate, + array $taxFreeExcess, + array $privateUse, + array $concentration, + array $categoryExcess + ): array { + $checkPoints = []; + + // 1인당 월 복리후생비 계산 (업계 평균 비교) + $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); + $perPersonMonthly = $employeeCount > 0 && $monthCount > 0 + ? $usedAmount / $employeeCount / $monthCount + : 0; + $perPersonFormatted = number_format($perPersonMonthly / 10000); + + if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) { + $checkPoints[] = [ + 'id' => 'wf_cp_avg', + 'type' => 'success', + 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.", + 'highlights' => [ + ['text' => "{$perPersonFormatted}만원", 'color' => 'green'], + ], + ]; + } elseif ($perPersonMonthly > self::INDUSTRY_AVG_MAX) { + $checkPoints[] = [ + 'id' => 'wf_cp_avg_high', + 'type' => 'warning', + 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.", + 'highlights' => [ + ['text' => "{$perPersonFormatted}만원", 'color' => 'orange'], + ], + ]; + } + + // 식대 비과세 한도 체크 + $mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate); + $perPersonMeal = $employeeCount > 0 && $monthCount > 0 + ? $mealAmount / $employeeCount / $monthCount + : 0; + + if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) { + $mealFormatted = number_format($perPersonMeal / 10000); + $limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000); + $checkPoints[] = [ + 'id' => 'wf_cp_meal', + 'type' => 'error', + 'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.", + 'highlights' => [ + ['text' => "월 {$mealFormatted}만원", 'color' => 'red'], + ['text' => '초과', 'color' => 'red'], + ], + ]; + } + + // 사적 사용 의심 + if ($privateUse['count'] > 0) { + $amountFormatted = number_format($privateUse['total'] / 10000); + $checkPoints[] = [ + 'id' => 'wf_cp_private', + 'type' => 'warning', + 'message' => "주말/심야 사용 {$privateUse['count']}건({$amountFormatted}만원) 감지. 사적 사용 여부를 확인해주세요.", + 'highlights' => [ + ['text' => "{$privateUse['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ], + ]; + } + + // 특정인 편중 + if ($concentration['count'] > 0) { + $amountFormatted = number_format($concentration['total'] / 10000); + $checkPoints[] = [ + 'id' => 'wf_cp_concentration', + 'type' => 'warning', + 'message' => "특정인 편중 {$concentration['count']}명({$amountFormatted}만원). 전체의 5% 초과 사용자가 있습니다.", + 'highlights' => [ + ['text' => "{$concentration['count']}명({$amountFormatted}만원)", 'color' => 'orange'], + ], + ]; + } + + // 리스크 0건이면 정상 + $totalRisk = $taxFreeExcess['count'] + $privateUse['count'] + $concentration['count'] + $categoryExcess['count']; + if ($totalRisk === 0 && empty($checkPoints)) { + $checkPoints[] = [ + 'id' => 'wf_cp_normal', + 'type' => 'success', + 'message' => '복리후생비 사용 현황이 정상입니다.', + 'highlights' => [ + ['text' => '정상', 'color' => 'green'], + ], + ]; + } + + return $checkPoints; + } + /** * 직원 수 조회 (급여 대상 직원 기준) * @@ -506,73 +757,4 @@ private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLi return $result; } - /** - * 체크포인트 생성 - */ - private function generateCheckPoints( - int $tenantId, - int $employeeCount, - float $usedAmount, - int $monthCount, - string $startDate, - string $endDate - ): array { - $checkPoints = []; - - // 1인당 월 복리후생비 계산 - $perPersonMonthly = $employeeCount > 0 && $monthCount > 0 - ? $usedAmount / $employeeCount / $monthCount - : 0; - $perPersonFormatted = number_format($perPersonMonthly / 10000); - - // 업계 평균 비교 - if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) { - $checkPoints[] = [ - 'id' => 'wf_cp_normal', - 'type' => 'success', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'green'], - ], - ]; - } elseif ($perPersonMonthly < self::INDUSTRY_AVG_MIN) { - $checkPoints[] = [ - 'id' => 'wf_cp_low', - 'type' => 'warning', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 낮습니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], - ], - ]; - } else { - $checkPoints[] = [ - 'id' => 'wf_cp_high', - 'type' => 'warning', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], - ], - ]; - } - - // 식대 비과세 한도 체크 - $mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate); - $perPersonMeal = $employeeCount > 0 ? $mealAmount / $employeeCount : 0; - - if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) { - $mealFormatted = number_format($perPersonMeal / 10000); - $limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000); - $checkPoints[] = [ - 'id' => 'wf_cp_meal', - 'type' => 'error', - 'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.", - 'highlights' => [ - ['text' => "식대가 월 {$mealFormatted}만원으로", 'color' => 'red'], - ['text' => '초과', 'color' => 'red'], - ], - ]; - } - - return $checkPoints; - } } diff --git a/database/migrations/2026_03_04_100000_add_category_to_loans_table.php b/database/migrations/2026_03_04_100000_add_category_to_loans_table.php new file mode 100644 index 0000000..70c279c --- /dev/null +++ b/database/migrations/2026_03_04_100000_add_category_to_loans_table.php @@ -0,0 +1,32 @@ +string('category', 30) + ->default('card') + ->after('status') + ->comment('카테고리: card, congratulatory, gift_certificate, entertainment'); + + $table->index(['tenant_id', 'category'], 'idx_tenant_category'); + }); + } + + public function down(): void + { + Schema::table('loans', function (Blueprint $table) { + $table->dropIndex('idx_tenant_category'); + $table->dropColumn('category'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php b/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php new file mode 100644 index 0000000..91bdad1 --- /dev/null +++ b/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php @@ -0,0 +1,195 @@ +string('instrument_type', 30)->default('promissory')->after('bill_type') + ->comment('증권종류: promissory/exchange/cashierCheck/currentCheck'); + $table->string('medium', 20)->default('paper')->after('instrument_type') + ->comment('매체: electronic/paper'); + $table->string('bill_category', 30)->nullable()->after('medium') + ->comment('어음구분: commercial/other'); + + // === 전자어음 정보 === + $table->string('electronic_bill_no', 100)->nullable()->after('is_electronic') + ->comment('전자어음 관리번호'); + $table->string('registration_org', 30)->nullable()->after('electronic_bill_no') + ->comment('등록기관: kftc/bank'); + + // === 환어음 정보 === + $table->string('drawee', 100)->nullable()->after('registration_org') + ->comment('환어음 지급인 (Drawee)'); + $table->string('acceptance_status', 20)->nullable()->after('drawee') + ->comment('인수여부: accepted/pending/refused'); + $table->date('acceptance_date')->nullable()->after('acceptance_status') + ->comment('인수일자'); + $table->date('acceptance_refusal_date')->nullable()->after('acceptance_date') + ->comment('인수거절일'); + $table->string('acceptance_refusal_reason', 50)->nullable()->after('acceptance_refusal_date') + ->comment('인수거절사유'); + + // === 받을어음 전용 === + $table->string('endorsement', 30)->nullable()->after('acceptance_refusal_reason') + ->comment('배서여부: endorsable/nonEndorsable'); + $table->string('endorsement_order', 5)->nullable()->after('endorsement') + ->comment('배서차수: 1~20'); + $table->string('storage_place', 30)->nullable()->after('endorsement_order') + ->comment('보관장소: safe/bank/other'); + $table->string('issuer_bank', 100)->nullable()->after('storage_place') + ->comment('발행은행'); + + // 할인 정보 + $table->boolean('is_discounted')->default(false)->after('issuer_bank') + ->comment('할인여부'); + $table->date('discount_date')->nullable()->after('is_discounted') + ->comment('할인일자'); + $table->string('discount_bank', 100)->nullable()->after('discount_date') + ->comment('할인처 (은행)'); + $table->decimal('discount_rate', 5, 2)->nullable()->after('discount_bank') + ->comment('할인율 (%)'); + $table->decimal('discount_amount', 15, 2)->nullable()->after('discount_rate') + ->comment('할인금액'); + + // 배서양도 정보 + $table->date('endorsement_date')->nullable()->after('discount_amount') + ->comment('배서일자'); + $table->string('endorsee', 100)->nullable()->after('endorsement_date') + ->comment('피배서인 (양수인)'); + $table->string('endorsement_reason', 30)->nullable()->after('endorsee') + ->comment('배서사유: payment/guarantee/collection/other'); + + // 추심 정보 + $table->string('collection_bank', 100)->nullable()->after('endorsement_reason') + ->comment('추심은행'); + $table->date('collection_request_date')->nullable()->after('collection_bank') + ->comment('추심의뢰일'); + $table->decimal('collection_fee', 15, 2)->nullable()->after('collection_request_date') + ->comment('추심수수료'); + $table->date('collection_complete_date')->nullable()->after('collection_fee') + ->comment('추심완료일'); + $table->string('collection_result', 20)->nullable()->after('collection_complete_date') + ->comment('추심결과: success/partial/failed/pending'); + $table->date('collection_deposit_date')->nullable()->after('collection_result') + ->comment('추심입금일'); + $table->decimal('collection_deposit_amount', 15, 2)->nullable()->after('collection_deposit_date') + ->comment('추심입금액 (수수료 차감후)'); + + // === 지급어음 전용 === + $table->string('settlement_bank', 100)->nullable()->after('collection_deposit_amount') + ->comment('결제은행'); + $table->string('payment_method', 30)->nullable()->after('settlement_bank') + ->comment('결제방법: autoTransfer/currentAccount/other'); + $table->date('actual_payment_date')->nullable()->after('payment_method') + ->comment('실제결제일'); + + // === 공통 === + $table->string('payment_place', 30)->nullable()->after('actual_payment_date') + ->comment('지급장소: issuerBank/issuerBankBranch/payerAddress/designatedBank/other'); + $table->string('payment_place_detail', 200)->nullable()->after('payment_place') + ->comment('지급장소 상세 (기타 선택 시)'); + + // === 개서 정보 === + $table->date('renewal_date')->nullable()->after('payment_place_detail') + ->comment('개서일자'); + $table->string('renewal_new_bill_no', 50)->nullable()->after('renewal_date') + ->comment('신어음번호'); + $table->string('renewal_reason', 30)->nullable()->after('renewal_new_bill_no') + ->comment('개서사유: maturityExtension/amountChange/conditionChange/other'); + + // === 소구 정보 === + $table->date('recourse_date')->nullable()->after('renewal_reason') + ->comment('소구일자'); + $table->decimal('recourse_amount', 15, 2)->nullable()->after('recourse_date') + ->comment('소구금액'); + $table->string('recourse_target', 100)->nullable()->after('recourse_amount') + ->comment('소구대상 (청구인)'); + $table->string('recourse_reason', 30)->nullable()->after('recourse_target') + ->comment('소구사유: endorsedDishonor/discountDishonor/other'); + + // === 환매 정보 === + $table->date('buyback_date')->nullable()->after('recourse_reason') + ->comment('환매일자'); + $table->decimal('buyback_amount', 15, 2)->nullable()->after('buyback_date') + ->comment('환매금액'); + $table->string('buyback_bank', 100)->nullable()->after('buyback_amount') + ->comment('환매요청 은행'); + + // === 부도/법적절차 === + $table->date('dishonored_date')->nullable()->after('buyback_bank') + ->comment('부도일자'); + $table->string('dishonored_reason', 30)->nullable()->after('dishonored_date') + ->comment('부도사유'); + $table->boolean('has_protest')->default(false)->after('dishonored_reason') + ->comment('거절증서 작성 여부'); + $table->date('protest_date')->nullable()->after('has_protest') + ->comment('거절증서 작성일'); + $table->date('recourse_notice_date')->nullable()->after('protest_date') + ->comment('소구 통지일'); + $table->date('recourse_notice_deadline')->nullable()->after('recourse_notice_date') + ->comment('소구 통지 기한 (부도일+4영업일)'); + + // === 분할배서 === + $table->boolean('is_split')->default(false)->after('recourse_notice_deadline') + ->comment('분할배서 허용 여부'); + }); + + // bill_installments 에 처리구분, 상대처 추가 + Schema::table('bill_installments', function (Blueprint $table) { + $table->string('type', 30)->default('other')->after('bill_id') + ->comment('처리구분: received/endorsement/splitEndorsement/collection/...'); + $table->string('counterparty', 100)->nullable()->after('amount') + ->comment('상대처 (거래처/은행)'); + }); + } + + public function down(): void + { + Schema::table('bill_installments', function (Blueprint $table) { + $table->dropColumn(['type', 'counterparty']); + }); + + Schema::table('bills', function (Blueprint $table) { + $table->dropColumn([ + 'instrument_type', 'medium', 'bill_category', + 'electronic_bill_no', 'registration_org', + 'drawee', 'acceptance_status', 'acceptance_date', + 'acceptance_refusal_date', 'acceptance_refusal_reason', + 'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank', + 'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount', + 'endorsement_date', 'endorsee', 'endorsement_reason', + 'collection_bank', 'collection_request_date', 'collection_fee', + 'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount', + 'settlement_bank', 'payment_method', 'actual_payment_date', + 'payment_place', 'payment_place_detail', + 'renewal_date', 'renewal_new_bill_no', 'renewal_reason', + 'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason', + 'buyback_date', 'buyback_amount', 'buyback_bank', + 'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date', + 'recourse_notice_date', 'recourse_notice_deadline', + 'is_split', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php b/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php new file mode 100644 index 0000000..ea8deab --- /dev/null +++ b/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php @@ -0,0 +1,28 @@ +json('metadata')->nullable()->after('category'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('loans', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/database/migrations/2026_03_05_210000_add_loan_id_to_expense_accounts_table.php b/database/migrations/2026_03_05_210000_add_loan_id_to_expense_accounts_table.php new file mode 100644 index 0000000..10d4a43 --- /dev/null +++ b/database/migrations/2026_03_05_210000_add_loan_id_to_expense_accounts_table.php @@ -0,0 +1,32 @@ +unsignedBigInteger('loan_id')->nullable()->after('card_no') + ->comment('연결된 가지급금 ID (상품권→접대비 전환 시)'); + + $table->index('loan_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('expense_accounts', function (Blueprint $table) { + $table->dropIndex(['loan_id']); + $table->dropColumn('loan_id'); + }); + } +}; diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index aa04c13..64924f9 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -12,9 +12,11 @@ * - 대시보드/보고서 */ +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; +use App\Http\Controllers\Api\V1\BarobillController; use App\Http\Controllers\Api\V1\BarobillSettingController; use App\Http\Controllers\Api\V1\BillController; use App\Http\Controllers\Api\V1\CalendarController; @@ -25,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; @@ -182,6 +185,7 @@ Route::get('/note-receivables', [DailyReportController::class, 'noteReceivables'])->name('v1.daily-report.note-receivables'); Route::get('/daily-accounts', [DailyReportController::class, 'dailyAccounts'])->name('v1.daily-report.daily-accounts'); Route::get('/summary', [DailyReportController::class, 'summary'])->name('v1.daily-report.summary'); + Route::get('/export', [DailyReportController::class, 'export'])->name('v1.daily-report.export'); }); // Comprehensive Analysis API (종합 분석 보고서) @@ -199,12 +203,17 @@ // Calendar API (CEO 대시보드 캘린더) Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules'); +Route::post('/calendar/schedules', [CalendarController::class, 'store'])->name('v1.calendar.schedules.store'); +Route::put('/calendar/schedules/{id}', [CalendarController::class, 'update'])->whereNumber('id')->name('v1.calendar.schedules.update'); +Route::delete('/calendar/schedules/{id}', [CalendarController::class, 'destroy'])->whereNumber('id')->name('v1.calendar.schedules.destroy'); // Vat API (CEO 대시보드 부가세 현황) Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary'); +Route::get('/vat/detail', [VatController::class, 'detail'])->name('v1.vat.detail'); // Entertainment API (CEO 대시보드 접대비 현황) Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary'); +Route::get('/entertainment/detail', [EntertainmentController::class, 'detail'])->name('v1.entertainment.detail'); // Welfare API (CEO 대시보드 복리후생비 현황) Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary'); @@ -255,6 +264,17 @@ Route::post('/test-connection', [BarobillSettingController::class, 'testConnection'])->name('v1.barobill-settings.test-connection'); }); +// Barobill Integration API (바로빌 연동) +Route::prefix('barobill')->group(function () { + Route::get('/status', [BarobillController::class, 'status'])->name('v1.barobill.status'); + Route::post('/login', [BarobillController::class, 'login'])->name('v1.barobill.login'); + Route::post('/signup', [BarobillController::class, 'signup'])->name('v1.barobill.signup'); + Route::get('/bank-service-url', [BarobillController::class, 'bankServiceUrl'])->name('v1.barobill.bank-service-url'); + Route::get('/account-link-url', [BarobillController::class, 'accountLinkUrl'])->name('v1.barobill.account-link-url'); + Route::get('/card-link-url', [BarobillController::class, 'cardLinkUrl'])->name('v1.barobill.card-link-url'); + Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url'); +}); + // Tax Invoice API (세금계산서) Route::prefix('tax-invoices')->group(function () { Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index'); @@ -286,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');