From 269a17b49c2b50fef1158d9f06d4c05dca8a2df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 17 Mar 2026 13:03:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20[barobill]=20SOAP=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BarobillSoapService: PHP SoapClient 기반 SOAP 래퍼 (회원/계좌/카드/인증서) - BarobillBankSyncService: 은행 거래내역 SOAP 조회 → DB 캐시 동기화 - BarobillCardSyncService: 카드 거래내역 SOAP 조회 → DB 캐시 동기화 - HometaxSyncService: 홈택스 세금계산서 upsert 동기화 - BarobillSyncController: 동기화/회원/인증서/잔액 API 11개 엔드포인트 - SyncBarobillDataJob: 매일 06:00/06:30 자동 동기화 스케줄러 - BarobillController.status() 보강: 실제 계좌/카드 수 표시 --- .../Controllers/Api/V1/BarobillController.php | 41 +- .../Api/V1/BarobillSyncController.php | 306 ++++++++ app/Jobs/Barobill/SyncBarobillDataJob.php | 85 +++ .../Barobill/BarobillBankSyncService.php | 293 ++++++++ .../Barobill/BarobillCardSyncService.php | 186 +++++ app/Services/Barobill/BarobillSoapService.php | 662 ++++++++++++++++++ app/Services/Barobill/HometaxSyncService.php | 116 +++ routes/api/v1/finance.php | 17 + routes/console.php | 26 + 9 files changed, 1727 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/BarobillSyncController.php create mode 100644 app/Jobs/Barobill/SyncBarobillDataJob.php create mode 100644 app/Services/Barobill/BarobillBankSyncService.php create mode 100644 app/Services/Barobill/BarobillCardSyncService.php create mode 100644 app/Services/Barobill/BarobillSoapService.php create mode 100644 app/Services/Barobill/HometaxSyncService.php diff --git a/app/Http/Controllers/Api/V1/BarobillController.php b/app/Http/Controllers/Api/V1/BarobillController.php index a93307c8..60a7c808 100644 --- a/app/Http/Controllers/Api/V1/BarobillController.php +++ b/app/Http/Controllers/Api/V1/BarobillController.php @@ -4,13 +4,18 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Models\Barobill\BarobillBankTransaction; +use App\Models\Barobill\BarobillCardTransaction; +use App\Models\Barobill\BarobillMember; +use App\Services\Barobill\BarobillSoapService; use App\Services\BarobillService; use Illuminate\Http\Request; class BarobillController extends Controller { public function __construct( - private BarobillService $barobillService + private BarobillService $barobillService, + private BarobillSoapService $soapService, ) {} /** @@ -19,17 +24,43 @@ public function __construct( public function status() { return ApiResponse::handle(function () { + $tenantId = app('tenant_id'); $setting = $this->barobillService->getSetting(); + $member = BarobillMember::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->first(); + + $accountCount = 0; + $cardCount = 0; + + if ($member) { + $accountCount = BarobillBankTransaction::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->distinct('bank_account_num') + ->count('bank_account_num'); + + $cardCount = BarobillCardTransaction::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->distinct('card_num') + ->count('card_num'); + } return [ - 'bank_service_count' => 0, - 'account_link_count' => 0, - 'member' => $setting ? [ + 'bank_service_count' => $accountCount, + 'account_link_count' => $accountCount, + 'card_count' => $cardCount, + 'member' => $member ? [ + 'barobill_id' => $member->barobill_id, + 'biz_no' => $member->formatted_biz_no, + 'corp_name' => $member->corp_name, + 'status' => $member->status, + 'server_mode' => $member->server_mode ?? 'test', + ] : ($setting ? [ 'barobill_id' => $setting->barobill_id, 'biz_no' => $setting->corp_num, 'status' => $setting->isVerified() ? 'active' : 'inactive', 'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production', - ] : null, + ] : null), ]; }, __('message.fetched')); } diff --git a/app/Http/Controllers/Api/V1/BarobillSyncController.php b/app/Http/Controllers/Api/V1/BarobillSyncController.php new file mode 100644 index 00000000..84cd657a --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillSyncController.php @@ -0,0 +1,306 @@ +validate([ + 'start_date' => 'nullable|date_format:Ymd', + 'end_date' => 'nullable|date_format:Ymd', + ]); + + return ApiResponse::handle(function () use ($data) { + $tenantId = app('tenant_id'); + $startDate = $data['start_date'] ?? Carbon::now()->subMonth()->format('Ymd'); + $endDate = $data['end_date'] ?? Carbon::now()->format('Ymd'); + + return $this->bankSyncService->syncIfNeeded($tenantId, $startDate, $endDate); + }, __('message.fetched')); + } + + /** + * 수동 카드 동기화 + */ + public function syncCard(Request $request) + { + $data = $request->validate([ + 'start_date' => 'nullable|date_format:Ymd', + 'end_date' => 'nullable|date_format:Ymd', + ]); + + return ApiResponse::handle(function () use ($data) { + $tenantId = app('tenant_id'); + $startDate = $data['start_date'] ?? Carbon::now()->subMonth()->format('Ymd'); + $endDate = $data['end_date'] ?? Carbon::now()->format('Ymd'); + + return $this->cardSyncService->syncCardTransactions($tenantId, $startDate, $endDate); + }, __('message.fetched')); + } + + /** + * 수동 홈택스 동기화 + */ + public function syncHometax(Request $request) + { + $data = $request->validate([ + 'invoices' => 'required|array', + 'invoices.*.ntsConfirmNum' => 'required|string', + 'invoice_type' => 'required|in:sales,purchase', + ]); + + return ApiResponse::handle(function () use ($data) { + $tenantId = app('tenant_id'); + + return $this->hometaxSyncService->syncInvoices( + $data['invoices'], + $tenantId, + $data['invoice_type'] + ); + }, __('message.fetched')); + } + + /** + * 바로빌 등록계좌 목록 (SOAP 실시간) + */ + public function accounts() + { + return ApiResponse::handle(function () { + $member = $this->getMember(); + if (! $member) { + return ['accounts' => [], 'message' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + + return [ + 'accounts' => $this->bankSyncService->getRegisteredAccounts($member), + ]; + }, __('message.fetched')); + } + + /** + * 바로빌 등록카드 목록 (SOAP 실시간) + */ + public function cards() + { + return ApiResponse::handle(function () { + $member = $this->getMember(); + if (! $member) { + return ['cards' => [], 'message' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + + return [ + 'cards' => $this->cardSyncService->getRegisteredCards($member), + ]; + }, __('message.fetched')); + } + + /** + * 인증서 상태 조회 (만료일, 유효성) + */ + public function certificate() + { + return ApiResponse::handle(function () { + $member = $this->getMember(); + if (! $member) { + return ['certificate' => null, 'message' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + $corpNum = $member->biz_no; + + $valid = $this->soapService->checkCertificateValid($corpNum); + $expireDate = $this->soapService->getCertificateExpireDate($corpNum); + $registDate = $this->soapService->getCertificateRegistDate($corpNum); + + return [ + 'certificate' => [ + 'is_valid' => $valid['success'] && ($valid['data'] ?? 0) >= 0, + 'expire_date' => $expireDate['success'] ? ($expireDate['data'] ?? null) : null, + 'regist_date' => $registDate['success'] ? ($registDate['data'] ?? null) : null, + ], + ]; + }, __('message.fetched')); + } + + /** + * 충전잔액 조회 + */ + public function balance() + { + return ApiResponse::handle(function () { + $member = $this->getMember(); + if (! $member) { + return ['balance' => null, 'message' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + $result = $this->soapService->getBalanceCostAmount($member->biz_no); + + return [ + 'balance' => $result['success'] ? ($result['data'] ?? 0) : null, + 'success' => $result['success'], + 'error' => $result['error'] ?? null, + ]; + }, __('message.fetched')); + } + + /** + * 바로빌 회원 등록 (SOAP RegistCorp) + */ + public function registerMember(Request $request) + { + $data = $request->validate([ + 'biz_no' => 'required|string|size:10', + 'corp_name' => 'required|string', + 'ceo_name' => 'required|string', + 'biz_type' => 'nullable|string', + 'biz_class' => 'nullable|string', + 'addr' => 'nullable|string', + 'barobill_id' => 'required|string', + 'barobill_pwd' => 'required|string', + 'manager_name' => 'nullable|string', + 'manager_hp' => 'nullable|string', + 'manager_email' => 'nullable|email', + ]); + + return ApiResponse::handle(function () use ($data) { + $tenantId = app('tenant_id'); + $this->soapService->initForMember( + BarobillMember::withoutGlobalScopes()->where('tenant_id', $tenantId)->first() + ?? new BarobillMember(['server_mode' => 'test']) + ); + + $result = $this->soapService->registCorp($data); + + if ($result['success']) { + BarobillMember::withoutGlobalScopes()->updateOrCreate( + ['tenant_id' => $tenantId], + [ + 'biz_no' => $data['biz_no'], + 'corp_name' => $data['corp_name'], + 'ceo_name' => $data['ceo_name'], + 'biz_type' => $data['biz_type'] ?? null, + 'biz_class' => $data['biz_class'] ?? null, + 'addr' => $data['addr'] ?? null, + 'barobill_id' => $data['barobill_id'], + 'barobill_pwd' => $data['barobill_pwd'], + 'manager_name' => $data['manager_name'] ?? null, + 'manager_hp' => $data['manager_hp'] ?? null, + 'manager_email' => $data['manager_email'] ?? null, + 'status' => 'active', + ] + ); + } + + return $result; + }, __('message.created')); + } + + /** + * 바로빌 회원 수정 (SOAP UpdateCorpInfo) + */ + public function updateMember(Request $request) + { + $data = $request->validate([ + 'corp_name' => 'required|string', + 'ceo_name' => 'required|string', + 'biz_type' => 'nullable|string', + 'biz_class' => 'nullable|string', + 'addr' => 'nullable|string', + 'manager_name' => 'nullable|string', + 'manager_hp' => 'nullable|string', + 'manager_email' => 'nullable|email', + ]); + + return ApiResponse::handle(function () use ($data) { + $member = $this->getMember(); + if (! $member) { + return ['error' => 'NO_MEMBER', 'code' => 404, 'message' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + + $data['biz_no'] = $member->biz_no; + $result = $this->soapService->updateCorpInfo($data); + + if ($result['success']) { + $member->update([ + 'corp_name' => $data['corp_name'], + 'ceo_name' => $data['ceo_name'], + 'biz_type' => $data['biz_type'] ?? $member->biz_type, + 'biz_class' => $data['biz_class'] ?? $member->biz_class, + 'addr' => $data['addr'] ?? $member->addr, + 'manager_name' => $data['manager_name'] ?? $member->manager_name, + 'manager_hp' => $data['manager_hp'] ?? $member->manager_hp, + 'manager_email' => $data['manager_email'] ?? $member->manager_email, + ]); + } + + return $result; + }, __('message.updated')); + } + + /** + * 바로빌 회원 상태 (SOAP GetCorpState) + */ + public function memberStatus() + { + return ApiResponse::handle(function () { + $member = $this->getMember(); + if (! $member) { + return ['status' => null, 'message' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + $result = $this->soapService->getCorpState($member->biz_no); + + return [ + 'member' => [ + 'biz_no' => $member->formatted_biz_no, + 'corp_name' => $member->corp_name, + 'status' => $member->status, + 'server_mode' => $member->server_mode, + ], + 'barobill_state' => $result['success'] ? $result['data'] : null, + 'error' => $result['error'] ?? null, + ]; + }, __('message.fetched')); + } + + /** + * 현재 테넌트의 바로빌 회원 조회 + */ + private function getMember(): ?BarobillMember + { + $tenantId = app('tenant_id'); + + return BarobillMember::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->first(); + } +} diff --git a/app/Jobs/Barobill/SyncBarobillDataJob.php b/app/Jobs/Barobill/SyncBarobillDataJob.php new file mode 100644 index 00000000..2979a270 --- /dev/null +++ b/app/Jobs/Barobill/SyncBarobillDataJob.php @@ -0,0 +1,85 @@ +where('status', 'active') + ->where('server_mode', 'production') + ->get(); + + if ($members->isEmpty()) { + Log::info('[SyncBarobill] 활성 회원 없음, 스킵'); + + return; + } + + $yesterday = Carbon::yesterday()->format('Ymd'); + $today = Carbon::today()->format('Ymd'); + + foreach ($members as $member) { + try { + if (in_array($this->syncType, ['all', 'bank'])) { + $result = $bankSyncService->syncIfNeeded( + $member->tenant_id, + $yesterday, + $today + ); + Log::info('[SyncBarobill] 은행 동기화 완료', [ + 'tenant_id' => $member->tenant_id, + 'result' => $result, + ]); + } + + if (in_array($this->syncType, ['all', 'card'])) { + $result = $cardSyncService->syncCardTransactions( + $member->tenant_id, + $yesterday, + $today + ); + Log::info('[SyncBarobill] 카드 동기화 완료', [ + 'tenant_id' => $member->tenant_id, + 'result' => $result, + ]); + } + } catch (\Throwable $e) { + Log::error('[SyncBarobill] 동기화 실패', [ + 'tenant_id' => $member->tenant_id, + 'sync_type' => $this->syncType, + 'error' => $e->getMessage(), + ]); + } + } + } +} diff --git a/app/Services/Barobill/BarobillBankSyncService.php b/app/Services/Barobill/BarobillBankSyncService.php new file mode 100644 index 00000000..df22e2b4 --- /dev/null +++ b/app/Services/Barobill/BarobillBankSyncService.php @@ -0,0 +1,293 @@ +where('tenant_id', $tenantId) + ->first(); + + if (! $member || empty($member->barobill_id)) { + return ['success' => false, 'error' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + + $accounts = $this->getRegisteredAccounts($member); + if (empty($accounts)) { + return ['success' => true, 'message' => '등록된 계좌 없음', 'synced' => 0]; + } + + $currentYearMonth = Carbon::now()->format('Ym'); + $chunks = $this->splitDateRangeMonthly($startDateYmd, $endDateYmd); + $totalSynced = 0; + + foreach ($accounts as $acc) { + $accNum = $acc['bankAccountNum']; + + foreach ($chunks as $chunk) { + $yearMonth = substr($chunk['start'], 0, 6); + $isCurrentMonth = ($yearMonth === $currentYearMonth); + + $syncStatus = BarobillBankSyncStatus::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('bank_account_num', $accNum) + ->where('synced_year_month', $yearMonth) + ->first(); + + if ($syncStatus) { + if (! $isCurrentMonth) { + continue; + } + if ($syncStatus->synced_at && $syncStatus->synced_at->diffInMinutes(now()) < 10) { + continue; + } + } + + $count = $this->fetchAndCache( + $tenantId, + $member->barobill_id, + $accNum, + $acc['bankName'], + $acc['bankCode'], + $chunk['start'], + $chunk['end'], + $yearMonth + ); + $totalSynced += $count; + } + } + + return [ + 'success' => true, + 'synced' => $totalSynced, + 'accounts' => count($accounts), + ]; + } + + /** + * 바로빌 등록 계좌 목록 조회 (SOAP) + */ + public function getRegisteredAccounts(BarobillMember $member): array + { + $result = $this->soapService->getBankAccounts($member->biz_no, false); + if (! $result['success']) { + return []; + } + + $data = $result['data']; + $accountList = []; + if (isset($data->BankAccount)) { + $accountList = is_array($data->BankAccount) ? $data->BankAccount : [$data->BankAccount]; + } elseif (isset($data->BankAccountEx)) { + $accountList = is_array($data->BankAccountEx) ? $data->BankAccountEx : [$data->BankAccountEx]; + } + + $accounts = []; + foreach ($accountList as $acc) { + if (! is_object($acc)) { + continue; + } + $bankAccountNum = $acc->BankAccountNum ?? ''; + if (empty($bankAccountNum) || (is_numeric($bankAccountNum) && $bankAccountNum < 0)) { + continue; + } + $accounts[] = [ + 'bankAccountNum' => $bankAccountNum, + 'bankCode' => $acc->BankCode ?? '', + 'bankName' => $acc->BankName ?? '', + ]; + } + + return $accounts; + } + + /** + * 바로빌 API에서 거래내역을 가져와 DB에 캐시 + */ + protected function fetchAndCache( + int $tenantId, + string $userId, + string $accNum, + string $bankName, + string $bankCode, + string $startDate, + string $endDate, + string $yearMonth + ): int { + $result = $this->soapService->call('bankaccount', 'GetPeriodBankAccountTransLog', [ + 'ID' => $userId, + 'BankAccountNum' => $accNum, + 'StartDate' => $startDate, + 'EndDate' => $endDate, + 'TransDirection' => 1, + 'CountPerPage' => 1000, + 'CurrentPage' => 1, + 'OrderDirection' => 2, + ]); + + if (! $result['success']) { + $errorCode = $result['error_code'] ?? 0; + if (in_array($errorCode, [-25005, -25001])) { + $this->updateSyncStatus($tenantId, $accNum, $yearMonth); + } + + return 0; + } + + $chunkData = $result['data']; + + if (is_numeric($chunkData) && $chunkData < 0) { + if (in_array((int) $chunkData, [-25005, -25001])) { + $this->updateSyncStatus($tenantId, $accNum, $yearMonth); + } + + return 0; + } + + $rawLogs = []; + if (isset($chunkData->BankAccountLogList) && isset($chunkData->BankAccountLogList->BankAccountTransLog)) { + $logs = $chunkData->BankAccountLogList->BankAccountTransLog; + $rawLogs = is_array($logs) ? $logs : [$logs]; + } + + $count = 0; + if (! empty($rawLogs)) { + $count = $this->cacheTransactions($tenantId, $accNum, $bankName, $bankCode, $rawLogs); + Log::debug("[BankSync] 캐시 저장 ({$startDate}~{$endDate}): {$count}건"); + } + + $this->updateSyncStatus($tenantId, $accNum, $yearMonth); + + return $count; + } + + /** + * API 응답을 DB에 배치 저장 + */ + protected function cacheTransactions(int $tenantId, string $accNum, string $bankName, string $bankCode, array $rawLogs): int + { + $rows = []; + $now = now(); + + foreach ($rawLogs as $log) { + $transDT = $log->TransDT ?? ''; + $transDate = strlen($transDT) >= 8 ? substr($transDT, 0, 8) : ''; + $transTime = strlen($transDT) >= 14 ? substr($transDT, 8, 6) : ''; + $deposit = floatval($log->Deposit ?? 0); + $withdraw = floatval($log->Withdraw ?? 0); + $balance = floatval($log->Balance ?? 0); + $summary = $log->TransRemark1 ?? $log->Summary ?? ''; + $remark2 = $log->TransRemark2 ?? ''; + + $cleanSummary = $this->cleanSummary($summary, $remark2); + + $rows[] = [ + 'tenant_id' => $tenantId, + 'bank_account_num' => $log->BankAccountNum ?? $accNum, + 'bank_code' => $log->BankCode ?? $bankCode, + 'bank_name' => $log->BankName ?? $bankName, + 'trans_date' => $transDate, + 'trans_time' => $transTime, + 'trans_dt' => $transDT, + 'deposit' => $deposit, + 'withdraw' => $withdraw, + 'balance' => $balance, + 'summary' => $cleanSummary, + 'cast' => $remark2, + 'memo' => $log->Memo ?? '', + 'trans_office' => $log->TransOffice ?? '', + 'is_manual' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + $inserted = 0; + foreach (array_chunk($rows, 100) as $batch) { + $inserted += DB::table('barobill_bank_transactions')->insertOrIgnore($batch); + } + + return $inserted; + } + + protected function updateSyncStatus(int $tenantId, string $accNum, string $yearMonth): void + { + BarobillBankSyncStatus::withoutGlobalScopes()->updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'bank_account_num' => $accNum, + 'synced_year_month' => $yearMonth, + ], + ['synced_at' => now()] + ); + } + + /** + * 기간을 월별 청크로 분할 + */ + protected function splitDateRangeMonthly(string $startDate, string $endDate): array + { + $start = Carbon::createFromFormat('Ymd', $startDate)->startOfDay(); + $end = Carbon::createFromFormat('Ymd', $endDate)->endOfDay(); + + $chunks = []; + $cursor = $start->copy(); + + while ($cursor->lte($end)) { + $chunkStart = $cursor->copy(); + $chunkEnd = $cursor->copy()->endOfMonth()->startOfDay(); + + if ($chunkEnd->gt($end)) { + $chunkEnd = $end->copy()->startOfDay(); + } + + $chunks[] = [ + 'start' => $chunkStart->format('Ymd'), + 'end' => $chunkEnd->format('Ymd'), + ]; + + $cursor = $chunkStart->copy()->addMonth()->startOfMonth(); + } + + return $chunks; + } + + /** + * 요약 정리 (중복 정보 제거) + */ + protected function cleanSummary(string $summary, string $remark): string + { + $summary = trim($summary); + $remark = trim($remark); + + if (! empty($remark) && str_contains($summary, $remark)) { + $summary = trim(str_replace($remark, '', $summary)); + } + + return $summary; + } +} diff --git a/app/Services/Barobill/BarobillCardSyncService.php b/app/Services/Barobill/BarobillCardSyncService.php new file mode 100644 index 00000000..0a7ae015 --- /dev/null +++ b/app/Services/Barobill/BarobillCardSyncService.php @@ -0,0 +1,186 @@ +where('tenant_id', $tenantId) + ->first(); + + if (! $member || empty($member->barobill_id)) { + return ['success' => false, 'error' => '바로빌 회원 정보 없음']; + } + + $this->soapService->initForMember($member); + + $cards = $this->getRegisteredCards($member); + if (empty($cards)) { + return ['success' => true, 'message' => '등록된 카드 없음', 'synced' => 0]; + } + + $totalSynced = 0; + + foreach ($cards as $card) { + $cardNum = $card['cardNum']; + + $result = $this->soapService->getPeriodCardLog( + $member->biz_no, + $member->barobill_id, + $cardNum, + $startDateYmd, + $endDateYmd + ); + + if (! $result['success']) { + Log::warning("[CardSync] 카드 조회 실패: {$cardNum}", [ + 'error' => $result['error'] ?? '', + ]); + + continue; + } + + $data = $result['data']; + + if (is_numeric($data) && $data < 0) { + continue; + } + + $rawLogs = []; + if (isset($data->CardLogList) && isset($data->CardLogList->CardLog)) { + $logs = $data->CardLogList->CardLog; + $rawLogs = is_array($logs) ? $logs : [$logs]; + } + + if (! empty($rawLogs)) { + $count = $this->cacheTransactions( + $tenantId, + $cardNum, + $card['cardCompany'], + $card['cardCompanyName'], + $rawLogs + ); + $totalSynced += $count; + Log::debug("[CardSync] 카드 {$cardNum}: {$count}건 저장"); + } + } + + return [ + 'success' => true, + 'synced' => $totalSynced, + 'cards' => count($cards), + ]; + } + + /** + * 바로빌 등록 카드 목록 조회 (SOAP) + */ + public function getRegisteredCards(BarobillMember $member): array + { + $result = $this->soapService->getCards($member->biz_no, false); + if (! $result['success']) { + return []; + } + + $data = $result['data']; + $cardList = []; + if (isset($data->CardEx2)) { + $cardList = is_array($data->CardEx2) ? $data->CardEx2 : [$data->CardEx2]; + } elseif (isset($data->Card)) { + $cardList = is_array($data->Card) ? $data->Card : [$data->Card]; + } + + $cards = []; + foreach ($cardList as $card) { + if (! is_object($card)) { + continue; + } + $cardNum = $card->CardNum ?? ''; + if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) { + continue; + } + $cards[] = [ + 'cardNum' => $cardNum, + 'cardCompany' => $card->CardCompany ?? '', + 'cardCompanyName' => BarobillSoapService::$cardCompanyCodes[$card->CardCompany ?? ''] ?? '', + 'alias' => $card->Alias ?? '', + ]; + } + + return $cards; + } + + /** + * API 응답을 DB에 배치 저장 + */ + protected function cacheTransactions( + int $tenantId, + string $cardNum, + string $cardCompany, + string $cardCompanyName, + array $rawLogs + ): int { + $rows = []; + $now = now(); + + foreach ($rawLogs as $log) { + $useDT = $log->UseDT ?? ''; + $useDate = strlen($useDT) >= 8 ? substr($useDT, 0, 8) : ''; + $useTime = strlen($useDT) >= 14 ? substr($useDT, 8, 6) : ''; + + $rows[] = [ + 'tenant_id' => $tenantId, + 'card_num' => $log->CardNum ?? $cardNum, + 'card_company' => $log->CardCompany ?? $cardCompany, + 'card_company_name' => $cardCompanyName, + 'use_dt' => $useDT, + 'use_date' => $useDate, + 'use_time' => $useTime, + 'approval_num' => $log->ApprovalNum ?? '', + 'approval_type' => $log->ApprovalType ?? '', + 'approval_amount' => floatval($log->ApprovalAmount ?? 0), + 'tax' => floatval($log->Tax ?? 0), + 'service_charge' => floatval($log->ServiceCharge ?? 0), + 'payment_plan' => $log->PaymentPlan ?? '', + 'currency_code' => $log->CurrencyCode ?? '', + 'merchant_name' => $log->MerchantName ?? '', + 'merchant_biz_num' => $log->MerchantBizNum ?? '', + 'merchant_addr' => $log->MerchantAddr ?? '', + 'merchant_ceo' => '', + 'merchant_biz_type' => '', + 'merchant_tel' => '', + 'use_key' => $log->UseKey ?? '', + 'is_manual' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + $inserted = 0; + foreach (array_chunk($rows, 100) as $batch) { + $inserted += DB::table('barobill_card_transactions')->insertOrIgnore($batch); + } + + return $inserted; + } +} diff --git a/app/Services/Barobill/BarobillSoapService.php b/app/Services/Barobill/BarobillSoapService.php new file mode 100644 index 00000000..5a495856 --- /dev/null +++ b/app/Services/Barobill/BarobillSoapService.php @@ -0,0 +1,662 @@ + '사업자번호가 설정되지 않았거나 유효하지 않습니다.', + -11102 => 'CERTKEY가 유효하지 않습니다.', + -11103 => '인증서가 만료되었거나 유효하지 않습니다.', + -11104 => '해당 사업자가 등록되어 있지 않습니다.', + -11105 => '이미 등록된 사업자입니다.', + -11106 => '아이디가 이미 존재합니다.', + -11201 => '필수 파라미터가 누락되었습니다.', + -25001 => '조회 결과가 없습니다.', + -25005 => '조회 기간에 데이터가 없습니다.', + -26001 => '공동인증서가 등록되어 있지 않습니다.', + -32000 => '알 수 없는 오류가 발생했습니다.', + -32001 => '사업자번호가 유효하지 않습니다.', + -32002 => '아이디가 유효하지 않습니다.', + -32003 => '비밀번호가 유효하지 않습니다.', + -32004 => '상호명이 유효하지 않습니다.', + -32005 => '대표자명이 유효하지 않습니다.', + -32006 => '이메일 형식이 유효하지 않습니다.', + -32010 => '이미 등록된 사업자번호입니다.', + -32011 => '이미 등록된 아이디입니다.', + -32012 => '이미 등록된 아이디입니다. 다른 아이디를 사용해주세요.', + -32013 => '비밀번호 형식이 유효하지 않습니다. (영문/숫자/특수문자 조합 8자리 이상)', + -32014 => '연락처 형식이 유효하지 않습니다.', + -32020 => '파트너 사업자번호가 유효하지 않습니다.', + -32021 => '파트너 인증키(CERTKEY)가 유효하지 않습니다.', + -99999 => '서버 내부 오류가 발생했습니다.', + ]; + + public function __construct() + { + $this->isTestMode = (bool) config('services.barobill.test_mode', true); + $this->initializeConfig(); + } + + // ========================================================================= + // 설정/초기화 + // ========================================================================= + + /** + * 서버 모드 전환 (회원사별 설정 적용) + */ + public function switchServerMode(bool $isTestMode): self + { + if ($this->isTestMode !== $isTestMode) { + $this->isTestMode = $isTestMode; + $this->resetClients(); + $this->initializeConfig(); + } + + return $this; + } + + /** + * 서버 모드 문자열로 전환 + */ + public function setServerMode(string $mode): self + { + return $this->switchServerMode($mode === 'test'); + } + + public function getServerMode(): string + { + return $this->isTestMode ? 'test' : 'production'; + } + + /** + * 설정 초기화 (서버 모드에 따른 설정 로드) + */ + protected function initializeConfig(): void + { + $dbConfig = $this->loadConfigFromDatabase(); + + if ($dbConfig) { + $this->certKey = $dbConfig->cert_key; + $this->corpNum = $dbConfig->corp_num ?? ''; + $this->soapUrls = $this->buildSoapUrls($dbConfig->base_url); + } else { + $this->certKey = $this->isTestMode + ? config('services.barobill.cert_key_test', '') + : config('services.barobill.cert_key_prod', ''); + $this->corpNum = config('services.barobill.corp_num', ''); + $baseUrl = $this->isTestMode + ? 'https://testws.baroservice.com' + : 'https://ws.baroservice.com'; + $this->soapUrls = $this->buildSoapUrls($baseUrl); + } + } + + protected function loadConfigFromDatabase(): ?BarobillConfig + { + try { + return BarobillConfig::getActive($this->isTestMode); + } catch (\Exception $e) { + Log::warning('[BarobillSoap] DB 설정 로드 실패', ['error' => $e->getMessage()]); + + return null; + } + } + + protected function buildSoapUrls(string $baseUrl): array + { + $baseUrl = rtrim($baseUrl, '/'); + + return [ + 'corpstate' => $baseUrl.'/CORPSTATE.asmx?WSDL', + 'ti' => $baseUrl.'/TI.asmx?WSDL', + 'bankaccount' => $baseUrl.'/BANKACCOUNT.asmx?WSDL', + 'card' => $baseUrl.'/CARD.asmx?WSDL', + ]; + } + + protected function resetClients(): void + { + $this->corpStateClient = null; + $this->bankAccountClient = null; + $this->cardClient = null; + $this->tiClient = null; + } + + /** + * 설정 상태 확인 + */ + public function checkConfiguration(): array + { + $dbConfig = $this->loadConfigFromDatabase(); + + return [ + 'cert_key_set' => ! empty($this->certKey), + 'corp_num_set' => ! empty($this->corpNum), + 'test_mode' => $this->isTestMode, + 'mode_label' => $this->isTestMode ? '테스트' : '운영', + 'soap_url' => $this->soapUrls['corpstate'] ?? '', + 'config_source' => $dbConfig ? 'database' : 'env', + ]; + } + + /** + * 회원사 설정 기반 초기화 (동기화 서비스에서 사용) + */ + public function initForMember(BarobillMember $member): self + { + $this->switchServerMode($member->isTestMode()); + + return $this; + } + + // ========================================================================= + // SOAP 클라이언트 + // ========================================================================= + + protected function createSoapClient(string $wsdlUrl): ?SoapClient + { + try { + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + + return new SoapClient($wsdlUrl, [ + 'trace' => true, + 'encoding' => 'UTF-8', + 'exceptions' => true, + 'connection_timeout' => 30, + 'stream_context' => $context, + 'cache_wsdl' => WSDL_CACHE_BOTH, + ]); + } catch (Throwable $e) { + Log::error('[BarobillSoap] 클라이언트 생성 실패', [ + 'url' => $wsdlUrl, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + protected function getCorpStateClient(): ?SoapClient + { + return $this->corpStateClient ??= $this->createSoapClient($this->soapUrls['corpstate']); + } + + protected function getBankAccountClient(): ?SoapClient + { + return $this->bankAccountClient ??= $this->createSoapClient($this->soapUrls['bankaccount']); + } + + protected function getCardClient(): ?SoapClient + { + return $this->cardClient ??= $this->createSoapClient($this->soapUrls['card']); + } + + protected function getTiClient(): ?SoapClient + { + return $this->tiClient ??= $this->createSoapClient($this->soapUrls['ti']); + } + + // ========================================================================= + // SOAP 호출 (핵심) + // ========================================================================= + + /** + * SOAP 메서드 호출 + */ + public function call(string $service, string $method, array $params = []): array + { + $client = match ($service) { + 'corpstate' => $this->getCorpStateClient(), + 'ti' => $this->getTiClient(), + 'bankaccount' => $this->getBankAccountClient(), + 'card' => $this->getCardClient(), + default => null, + }; + + if (! $client) { + return [ + 'success' => false, + 'error' => "SOAP 클라이언트 초기화 실패 ({$service})", + 'error_code' => -1, + ]; + } + + if (empty($this->certKey)) { + return [ + 'success' => false, + 'error' => 'CERTKEY가 설정되지 않았습니다.', + 'error_code' => -2, + ]; + } + + if (! isset($params['CERTKEY'])) { + $params['CERTKEY'] = $this->certKey; + } + + try { + $startTime = microtime(true); + $result = $client->$method($params); + $resultProperty = $method.'Result'; + $elapsed = round((microtime(true) - $startTime) * 1000); + + Log::debug("[BarobillSoap] {$method} 완료 ({$elapsed}ms)"); + + if (isset($result->$resultProperty)) { + $resultData = $result->$resultProperty; + + if (is_numeric($resultData) && $resultData < 0) { + $errorMessage = $this->errorMessages[(int) $resultData] + ?? "바로빌 API 오류 코드: {$resultData}"; + + Log::error('[BarobillSoap] API 오류', [ + 'method' => $method, + 'error_code' => $resultData, + 'error_message' => $errorMessage, + ]); + + return [ + 'success' => false, + 'error' => $errorMessage, + 'error_code' => (int) $resultData, + 'data' => $resultData, + ]; + } + + return ['success' => true, 'data' => $resultData]; + } + + return ['success' => true, 'data' => $result]; + + } catch (SoapFault $e) { + Log::error('[BarobillSoap] SOAP 오류', [ + 'method' => $method, + 'fault' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'error' => 'SOAP 오류: '.$e->getMessage(), + 'error_code' => $e->getCode(), + ]; + } catch (Throwable $e) { + Log::error('[BarobillSoap] 호출 오류', [ + 'method' => $method, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'error' => 'API 호출 오류: '.$e->getMessage(), + 'error_code' => -999, + ]; + } + } + + // ========================================================================= + // 회원관리 (CORPSTATE) + // ========================================================================= + + /** + * 회원사 등록 + */ + public function registCorp(array $data): array + { + $params = [ + 'CorpNum' => $this->formatBizNo($data['biz_no']), + 'CorpName' => $data['corp_name'], + 'CEOName' => $data['ceo_name'], + 'BizType' => $data['biz_type'] ?? '', + 'BizClass' => $data['biz_class'] ?? '', + 'PostNum' => $data['post_num'] ?? '', + 'Addr1' => $data['addr'] ?? '', + 'Addr2' => '', + 'MemberName' => $data['manager_name'] ?? '', + 'JuminNum' => '', + 'ID' => $data['barobill_id'], + 'PWD' => $data['barobill_pwd'], + 'Grade' => '2', + 'TEL' => $data['tel'] ?? '', + 'HP' => $data['manager_hp'] ?? '', + 'Email' => $data['manager_email'] ?? '', + ]; + + return $this->call('corpstate', 'RegistCorp', $params); + } + + /** + * 회원사 정보 수정 + */ + public function updateCorpInfo(array $data): array + { + $params = [ + 'CorpNum' => $this->formatBizNo($data['biz_no']), + 'CorpName' => $data['corp_name'], + 'CEOName' => $data['ceo_name'], + 'BizType' => $data['biz_type'] ?? '', + 'BizClass' => $data['biz_class'] ?? '', + 'PostNum' => $data['post_num'] ?? '', + 'Addr1' => $data['addr'] ?? '', + 'Addr2' => '', + 'MemberName' => $data['manager_name'] ?? '', + 'TEL' => $data['tel'] ?? '', + 'HP' => $data['manager_hp'] ?? '', + 'Email' => $data['manager_email'] ?? '', + ]; + + return $this->call('corpstate', 'UpdateCorpInfo', $params); + } + + /** + * 회원사 상태 조회 + */ + public function getCorpState(string $corpNum): array + { + return $this->call('corpstate', 'GetCorpState', [ + 'CorpNum' => $this->formatBizNo($corpNum), + ]); + } + + // ========================================================================= + // 계좌조회 (BANKACCOUNT) + // ========================================================================= + + /** + * 등록계좌 목록 조회 + */ + public function getBankAccounts(string $corpNum, bool $availOnly = true): array + { + return $this->call('bankaccount', 'GetBankAccountEx', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'AvailOnly' => $availOnly ? 1 : 0, + ]); + } + + /** + * 계좌 기간별 입출금내역 조회 + */ + public function getPeriodBankAccountTransLog( + string $corpNum, + string $id, + string $bankAccountNum, + string $startDate, + string $endDate, + int $countPerPage = 1000, + int $currentPage = 1 + ): array { + return $this->call('bankaccount', 'GetPeriodBankAccountTransLog', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $id, + 'BankAccountNum' => $bankAccountNum, + 'StartDate' => $startDate, + 'EndDate' => $endDate, + 'TransDirection' => 1, + 'CountPerPage' => $countPerPage, + 'CurrentPage' => $currentPage, + 'OrderDirection' => 2, + ]); + } + + /** + * 계좌조회 신청 URL 조회 + */ + public function getBankAccountScrapRequestUrl(string $corpNum, string $userId, string $userPwd): array + { + return $this->call('bankaccount', 'GetBankAccountScrapRequestURL', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $userId, + 'PWD' => $userPwd, + ]); + } + + /** + * 인증서 등록 URL 조회 + */ + public function getCertificateRegistUrl(string $corpNum, string $userId, string $userPwd): array + { + return $this->call('bankaccount', 'GetCertificateRegistURL', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $userId, + 'PWD' => $userPwd, + ]); + } + + /** + * 인증서 유효성 확인 + */ + public function checkCertificateValid(string $corpNum): array + { + return $this->call('bankaccount', 'CheckCERTIsValid', [ + 'CorpNum' => $this->formatBizNo($corpNum), + ]); + } + + /** + * 인증서 만료일 조회 + */ + public function getCertificateExpireDate(string $corpNum): array + { + return $this->call('bankaccount', 'GetCertificateExpireDate', [ + 'CorpNum' => $this->formatBizNo($corpNum), + ]); + } + + /** + * 인증서 등록일 조회 + */ + public function getCertificateRegistDate(string $corpNum): array + { + return $this->call('bankaccount', 'GetCertificateRegistDate', [ + 'CorpNum' => $this->formatBizNo($corpNum), + ]); + } + + /** + * 충전잔액 조회 + */ + public function getBalanceCostAmount(string $corpNum): array + { + return $this->call('bankaccount', 'GetBalanceCostAmountEx', [ + 'CorpNum' => $this->formatBizNo($corpNum), + ]); + } + + /** + * 현금충전 URL 조회 + */ + public function getCashChargeUrl(string $corpNum, string $userId, string $userPwd): array + { + return $this->call('bankaccount', 'GetCashChargeURL', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $userId, + 'PWD' => $userPwd, + ]); + } + + // ========================================================================= + // 카드조회 (CARD) + // ========================================================================= + + /** + * 등록카드 목록 조회 + */ + public function getCards(string $corpNum, bool $availOnly = true): array + { + return $this->call('card', 'GetCardEx2', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'AvailOnly' => $availOnly ? 1 : 0, + ]); + } + + /** + * 카드 등록 + */ + public function registCard(string $corpNum, array $cardData): array + { + return $this->call('card', 'RegistCardEx', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'CardCompany' => $cardData['card_company'] ?? '', + 'CardType' => $cardData['card_type'] ?? '1', + 'CardNum' => preg_replace('/[^0-9]/', '', $cardData['card_num'] ?? ''), + 'WebId' => $cardData['web_id'] ?? '', + 'WebPwd' => $cardData['web_pwd'] ?? '', + 'Alias' => $cardData['alias'] ?? '', + 'Usage' => $cardData['usage'] ?? '', + 'CollectCycle' => $cardData['collect_cycle'] ?? '1', + ]); + } + + /** + * 카드 기간별 사용내역 조회 + */ + public function getPeriodCardLog( + string $corpNum, + string $id, + string $cardNum, + string $startDate, + string $endDate, + int $countPerPage = 1000, + int $currentPage = 1 + ): array { + return $this->call('card', 'GetPeriodCardLog', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $id, + 'CardNum' => preg_replace('/[^0-9]/', '', $cardNum), + 'StartDate' => $startDate, + 'EndDate' => $endDate, + 'CountPerPage' => $countPerPage, + 'CurrentPage' => $currentPage, + 'OrderDirection' => 'D', + ]); + } + + /** + * 카드 등록 신청 URL 조회 + */ + public function getCardScrapRequestUrl(string $corpNum, string $userId, string $userPwd): array + { + return $this->call('card', 'GetCardScrapRequestURL', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $userId, + 'PWD' => $userPwd, + ]); + } + + /** + * 카드 관리 URL 조회 + */ + public function getCardManagementUrl(string $corpNum, string $userId, string $userPwd): array + { + return $this->call('card', 'GetCardManagementURL', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $userId, + 'PWD' => $userPwd, + ]); + } + + // ========================================================================= + // 홈택스 (TI) + // ========================================================================= + + /** + * 홈택스 매입/매출 URL 조회 + */ + public function getHomeTaxUrl(string $corpNum, string $userId, string $userPwd): array + { + return $this->call('ti', 'GetHomeTaxURL', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $userId, + 'PWD' => $userPwd, + ]); + } + + /** + * 세금계산서 발행 URL 조회 + */ + public function getTaxInvoiceIssueUrl(string $corpNum, string $userId, string $userPwd): array + { + return $this->call('ti', 'GetTaxInvoiceIssueURL', [ + 'CorpNum' => $this->formatBizNo($corpNum), + 'ID' => $userId, + 'PWD' => $userPwd, + ]); + } + + // ========================================================================= + // 유틸리티 + // ========================================================================= + + protected function formatBizNo(string $bizNo): string + { + return preg_replace('/[^0-9]/', '', $bizNo); + } + + /** + * 은행 코드 목록 + */ + public static array $bankCodes = [ + '02' => '산업은행', '03' => '기업은행', '04' => '국민은행', + '07' => '수협은행', '11' => 'NH농협은행', '20' => '우리은행', + '23' => '제일은행(SC)', '26' => '신한은행', '27' => '한국씨티은행', + '31' => '대구은행', '32' => '부산은행', '34' => '광주은행', + '35' => '제주은행', '37' => '전북은행', '39' => '경남은행', + '45' => '새마을금고연합', '48' => '신협', '71' => '우체국', + '81' => 'KEB하나은행', '88' => '신한은행(통합)', + '89' => '케이뱅크', '90' => '카카오뱅크', '92' => '토스뱅크', + ]; + + /** + * 카드사 코드 목록 + */ + public static array $cardCompanyCodes = [ + '01' => '비씨카드', '02' => '국민카드', '04' => '삼성카드', + '05' => '현대카드', '06' => '롯데카드', '07' => '신한카드', + '08' => '하나카드', '09' => '우리카드', '10' => 'NH농협카드', + '11' => '씨티카드', + ]; +} diff --git a/app/Services/Barobill/HometaxSyncService.php b/app/Services/Barobill/HometaxSyncService.php new file mode 100644 index 00000000..9b93c01f --- /dev/null +++ b/app/Services/Barobill/HometaxSyncService.php @@ -0,0 +1,116 @@ + 0, + 'updated' => 0, + 'failed' => 0, + 'total' => count($invoices), + ]; + + if (empty($invoices)) { + return $result; + } + + DB::beginTransaction(); + + try { + foreach ($invoices as $apiData) { + if (empty($apiData['ntsConfirmNum'])) { + $result['failed']++; + + continue; + } + + $modelData = HometaxInvoice::fromApiData($apiData, $tenantId, $invoiceType); + + $existing = HometaxInvoice::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('nts_confirm_num', $modelData['nts_confirm_num']) + ->where('invoice_type', $invoiceType) + ->first(); + + if ($existing) { + $existing->update([ + 'write_date' => $modelData['write_date'], + 'issue_date' => $modelData['issue_date'], + 'invoicer_corp_num' => $modelData['invoicer_corp_num'], + 'invoicer_corp_name' => $modelData['invoicer_corp_name'], + 'invoicer_ceo_name' => $modelData['invoicer_ceo_name'], + 'invoicee_corp_num' => $modelData['invoicee_corp_num'], + 'invoicee_corp_name' => $modelData['invoicee_corp_name'], + 'invoicee_ceo_name' => $modelData['invoicee_ceo_name'], + 'supply_amount' => $modelData['supply_amount'], + 'tax_amount' => $modelData['tax_amount'], + 'total_amount' => $modelData['total_amount'], + 'tax_type' => $modelData['tax_type'], + 'purpose_type' => $modelData['purpose_type'], + 'item_name' => $modelData['item_name'], + 'remark' => $modelData['remark'], + 'synced_at' => now(), + ]); + $result['updated']++; + } else { + HometaxInvoice::create($modelData); + $result['inserted']++; + } + } + + DB::commit(); + + Log::info('[HometaxSync] 동기화 완료', [ + 'tenant_id' => $tenantId, + 'invoice_type' => $invoiceType, + 'result' => $result, + ]); + } catch (\Throwable $e) { + DB::rollBack(); + Log::error('[HometaxSync] 동기화 실패', [ + 'tenant_id' => $tenantId, + 'invoice_type' => $invoiceType, + 'error' => $e->getMessage(), + ]); + throw $e; + } + + return $result; + } + + /** + * 마지막 동기화 시간 조회 + */ + public function getLastSyncTime(int $tenantId, string $invoiceType): ?string + { + $lastSync = HometaxInvoice::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('invoice_type', $invoiceType) + ->orderByDesc('synced_at') + ->value('synced_at'); + + return $lastSync?->format('Y-m-d H:i:s'); + } +} diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index b7b82953..c3e5bb2c 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -20,6 +20,7 @@ use App\Http\Controllers\Api\V1\BarobillCardTransactionController; use App\Http\Controllers\Api\V1\BarobillController; use App\Http\Controllers\Api\V1\BarobillSettingController; +use App\Http\Controllers\Api\V1\BarobillSyncController; use App\Http\Controllers\Api\V1\BillController; use App\Http\Controllers\Api\V1\CalendarController; use App\Http\Controllers\Api\V1\CardController; @@ -286,6 +287,22 @@ 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'); + + // SOAP 동기화 API + Route::post('/sync/bank', [BarobillSyncController::class, 'syncBank'])->name('v1.barobill.sync.bank'); + Route::post('/sync/card', [BarobillSyncController::class, 'syncCard'])->name('v1.barobill.sync.card'); + Route::post('/sync/hometax', [BarobillSyncController::class, 'syncHometax'])->name('v1.barobill.sync.hometax'); + + // SOAP 실시간 조회 API + Route::get('/accounts', [BarobillSyncController::class, 'accounts'])->name('v1.barobill.accounts'); + Route::get('/cards', [BarobillSyncController::class, 'cards'])->name('v1.barobill.cards'); + Route::get('/certificate', [BarobillSyncController::class, 'certificate'])->name('v1.barobill.certificate'); + Route::get('/balance', [BarobillSyncController::class, 'balance'])->name('v1.barobill.balance'); + + // SOAP 회원관리 API + Route::post('/members', [BarobillSyncController::class, 'registerMember'])->name('v1.barobill.members.register'); + Route::put('/members', [BarobillSyncController::class, 'updateMember'])->name('v1.barobill.members.update'); + Route::get('/members/status', [BarobillSyncController::class, 'memberStatus'])->name('v1.barobill.members.status'); }); // Barobill Card Transaction API (바로빌 카드 거래 - React 연동) diff --git a/routes/console.php b/routes/console.php index bff1ee1c..58516683 100644 --- a/routes/console.php +++ b/routes/console.php @@ -191,3 +191,29 @@ ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ demo:reset-showcase 스케줄러 실행 실패', ['time' => now()]); }); + +// ─── 바로빌 데이터 동기화 ─── + +// 매일 06:00 — 전일 은행 거래내역 동기화 +Schedule::job(new \App\Jobs\Barobill\SyncBarobillDataJob('bank')) + ->dailyAt('06:00') + ->withoutOverlapping() + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('[Scheduler] 바로빌 은행 동기화 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('[Scheduler] 바로빌 은행 동기화 실패', ['time' => now()]); + }); + +// 매일 06:30 — 전일 카드 거래내역 동기화 +Schedule::job(new \App\Jobs\Barobill\SyncBarobillDataJob('card')) + ->dailyAt('06:30') + ->withoutOverlapping() + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('[Scheduler] 바로빌 카드 동기화 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('[Scheduler] 바로빌 카드 동기화 실패', ['time' => now()]); + }); From c11ac7867c9039ceb94887010eb54d332a85968c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 17 Mar 2026 13:06:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20[bending]=20=EC=A0=88=EA=B3=A1?= =?UTF-8?q?=ED=92=88=20=EC=BD=94=EB=93=9C=EB=A7=B5/=ED=92=88=EB=AA=A9?= =?UTF-8?q?=EB=A7=A4=ED=95=91/LOT=20=EC=B1=84=EB=B2=88=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bending_item_mappings 테이블 마이그레이션 - BendingCodeService: 코드 체계, 품목 매핑, LOT 일련번호 생성 - BendingController: code-map, resolve-item, generate-lot 엔드포인트 - StoreOrderRequest/UpdateOrderRequest: bending_lot validation 추가 --- .../Controllers/Api/V1/BendingController.php | 79 ++++++++ app/Http/Requests/Order/StoreOrderRequest.php | 10 + .../Requests/Order/UpdateOrderRequest.php | 10 + app/Models/Production/BendingItemMapping.php | 33 ++++ app/Services/BendingCodeService.php | 182 ++++++++++++++++++ ...000_create_bending_item_mappings_table.php | 30 +++ routes/api/v1/production.php | 8 + 7 files changed, 352 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/BendingController.php create mode 100644 app/Models/Production/BendingItemMapping.php create mode 100644 app/Services/BendingCodeService.php create mode 100644 database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php diff --git a/app/Http/Controllers/Api/V1/BendingController.php b/app/Http/Controllers/Api/V1/BendingController.php new file mode 100644 index 00000000..6fc75229 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BendingController.php @@ -0,0 +1,79 @@ +service->getCodeMap(); + }, __('message.fetched')); + } + + /** + * 드롭다운 선택 → 품목 매핑 조회 + */ + public function resolveItem(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $prodCode = $request->query('prod'); + $specCode = $request->query('spec'); + $lengthCode = $request->query('length'); + + if (! $prodCode || ! $specCode || ! $lengthCode) { + return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod, spec, length 파라미터가 필요합니다.']; + } + + $item = $this->service->resolveItem($prodCode, $specCode, $lengthCode); + + if (! $item) { + return ['error' => 'NOT_MAPPED', 'code' => 404, 'message' => '해당 조합에 매핑된 품목이 없습니다.']; + } + + return $item; + }, __('message.fetched')); + } + + /** + * LOT 번호 생성 (프리뷰 + 일련번호 확정) + */ + public function generateLotNumber(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $prodCode = $request->input('prod_code'); + $specCode = $request->input('spec_code'); + $lengthCode = $request->input('length_code'); + $regDate = $request->input('reg_date', now()->toDateString()); + + if (! $prodCode || ! $specCode || ! $lengthCode) { + return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod_code, spec_code, length_code가 필요합니다.']; + } + + $dateCode = BendingCodeService::generateDateCode($regDate); + $lotBase = "{$prodCode}{$specCode}{$dateCode}-{$lengthCode}"; + $lotNumber = $this->service->generateLotNumber($lotBase); + $material = BendingCodeService::getMaterial($prodCode, $specCode); + + return [ + 'lot_base' => $lotBase, + 'lot_number' => $lotNumber, + 'date_code' => $dateCode, + 'material' => $material, + ]; + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Order/StoreOrderRequest.php b/app/Http/Requests/Order/StoreOrderRequest.php index 272f924c..e4a4a5c4 100644 --- a/app/Http/Requests/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Order/StoreOrderRequest.php @@ -58,6 +58,16 @@ public function rules(): array 'options.production_reason' => 'nullable|string|max:500', 'options.target_stock_qty' => 'nullable|numeric|min:0', + // 절곡품 LOT 정보 (STOCK 전용) + 'options.bending_lot' => 'nullable|array', + 'options.bending_lot.lot_number' => 'nullable|string|max:30', + 'options.bending_lot.prod_code' => 'nullable|string|max:2', + 'options.bending_lot.spec_code' => 'nullable|string|max:2', + 'options.bending_lot.length_code' => 'nullable|string|max:2', + 'options.bending_lot.raw_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.material' => 'nullable|string|max:50', + // 품목 배열 'items' => 'nullable|array', 'items.*.item_id' => 'nullable|integer|exists:items,id', diff --git a/app/Http/Requests/Order/UpdateOrderRequest.php b/app/Http/Requests/Order/UpdateOrderRequest.php index 53cd3168..ae9db234 100644 --- a/app/Http/Requests/Order/UpdateOrderRequest.php +++ b/app/Http/Requests/Order/UpdateOrderRequest.php @@ -52,6 +52,16 @@ public function rules(): array 'options.production_reason' => 'nullable|string|max:500', 'options.target_stock_qty' => 'nullable|numeric|min:0', + // 절곡품 LOT 정보 (STOCK 전용) + 'options.bending_lot' => 'nullable|array', + 'options.bending_lot.lot_number' => 'nullable|string|max:30', + 'options.bending_lot.prod_code' => 'nullable|string|max:2', + 'options.bending_lot.spec_code' => 'nullable|string|max:2', + 'options.bending_lot.length_code' => 'nullable|string|max:2', + 'options.bending_lot.raw_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50', + 'options.bending_lot.material' => 'nullable|string|max:50', + // 품목 배열 (전체 교체) 'items' => 'nullable|array', 'items.*.item_id' => 'nullable|integer|exists:items,id', diff --git a/app/Models/Production/BendingItemMapping.php b/app/Models/Production/BendingItemMapping.php new file mode 100644 index 00000000..cb264c38 --- /dev/null +++ b/app/Models/Production/BendingItemMapping.php @@ -0,0 +1,33 @@ + 'boolean', + ]; + + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } +} diff --git a/app/Services/BendingCodeService.php b/app/Services/BendingCodeService.php new file mode 100644 index 00000000..818f4c04 --- /dev/null +++ b/app/Services/BendingCodeService.php @@ -0,0 +1,182 @@ + 'R', 'name' => '가이드레일(벽면형)'], + ['code' => 'S', 'name' => '가이드레일(측면형)'], + ['code' => 'G', 'name' => '연기차단재'], + ['code' => 'B', 'name' => '하단마감재(스크린)'], + ['code' => 'T', 'name' => '하단마감재(철재)'], + ['code' => 'L', 'name' => 'L-Bar'], + ['code' => 'C', 'name' => '케이스'], + ]; + + // ========================================================================= + // 종류 코드 + 사용 가능 제품 + // ========================================================================= + public const SPECS = [ + ['code' => 'M', 'name' => '본체', 'products' => ['R', 'S']], + ['code' => 'T', 'name' => '본체(철재)', 'products' => ['R', 'S']], + ['code' => 'C', 'name' => 'C형', 'products' => ['R', 'S']], + ['code' => 'D', 'name' => 'D형', 'products' => ['R', 'S']], + ['code' => 'S', 'name' => 'SUS(마감)', 'products' => ['R', 'S', 'B', 'T']], + ['code' => 'U', 'name' => 'SUS(마감)2', 'products' => ['S']], + ['code' => 'E', 'name' => 'EGI(마감)', 'products' => ['R', 'S', 'B', 'T']], + ['code' => 'I', 'name' => '화이바원단', 'products' => ['G']], + ['code' => 'A', 'name' => '스크린용', 'products' => ['L']], + ['code' => 'F', 'name' => '전면부', 'products' => ['C']], + ['code' => 'P', 'name' => '점검구', 'products' => ['C']], + ['code' => 'L', 'name' => '린텔부', 'products' => ['C']], + ['code' => 'B', 'name' => '후면코너부', 'products' => ['C']], + ]; + + // ========================================================================= + // 모양&길이 코드 + // ========================================================================= + public const LENGTHS_SMOKE_BARRIER = [ + ['code' => '53', 'name' => 'W50 × 3000'], + ['code' => '54', 'name' => 'W50 × 4000'], + ['code' => '83', 'name' => 'W80 × 3000'], + ['code' => '84', 'name' => 'W80 × 4000'], + ]; + + public const LENGTHS_GENERAL = [ + ['code' => '12', 'name' => '1219'], + ['code' => '24', 'name' => '2438'], + ['code' => '30', 'name' => '3000'], + ['code' => '35', 'name' => '3500'], + ['code' => '40', 'name' => '4000'], + ['code' => '41', 'name' => '4150'], + ['code' => '42', 'name' => '4200'], + ['code' => '43', 'name' => '4300'], + ]; + + // ========================================================================= + // 제품+종류 → 원자재(재질) 매핑 + // ========================================================================= + public const MATERIAL_MAP = [ + 'G:I' => '화이바원단', + 'B:S' => 'SUS 1.2T', + 'B:E' => 'EGI 1.55T', + 'T:S' => 'SUS 1.2T', + 'T:E' => 'EGI 1.55T', + 'L:A' => 'EGI 1.55T', + 'R:M' => 'EGI 1.55T', + 'R:T' => 'EGI 1.55T', + 'R:C' => 'EGI 1.55T', + 'R:D' => 'EGI 1.55T', + 'R:S' => 'SUS 1.2T', + 'R:E' => 'EGI 1.55T', + 'S:M' => 'EGI 1.55T', + 'S:T' => 'EGI 1.55T', + 'S:C' => 'EGI 1.55T', + 'S:D' => 'EGI 1.55T', + 'S:S' => 'SUS 1.2T', + 'S:U' => 'SUS 1.2T', + 'S:E' => 'EGI 1.55T', + 'C:F' => 'EGI 1.55T', + 'C:P' => 'EGI 1.55T', + 'C:L' => 'EGI 1.55T', + 'C:B' => 'EGI 1.55T', + ]; + + /** + * 코드맵 전체 반환 (프론트엔드 드롭다운 구성용) + */ + public function getCodeMap(): array + { + return [ + 'products' => self::PRODUCTS, + 'specs' => self::SPECS, + 'lengths' => [ + 'smoke_barrier' => self::LENGTHS_SMOKE_BARRIER, + 'general' => self::LENGTHS_GENERAL, + ], + 'material_map' => self::MATERIAL_MAP, + ]; + } + + /** + * 드롭다운 선택 조합 → items 테이블 품목 매핑 조회 + */ + public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array + { + $mapping = BendingItemMapping::where('tenant_id', $this->tenantId()) + ->where('prod_code', $prodCode) + ->where('spec_code', $specCode) + ->where('length_code', $lengthCode) + ->where('is_active', true) + ->with('item:id,code,name,specification,unit') + ->first(); + + if (! $mapping || ! $mapping->item) { + return null; + } + + return [ + 'item_id' => $mapping->item->id, + 'item_code' => $mapping->item->code, + 'item_name' => $mapping->item->name, + 'specification' => $mapping->item->specification, + 'unit' => $mapping->item->unit ?? 'EA', + ]; + } + + /** + * LOT 번호 생성 (일련번호 suffix 포함) + * + * base: 'GI6317-53' → 결과: 'GI6317-53-001' + */ + public function generateLotNumber(string $lotBase): string + { + $tenantId = $this->tenantId(); + + // 같은 base로 시작하는 기존 LOT 수 조회 + $count = Order::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('order_type_code', Order::TYPE_STOCK) + ->where('options->bending_lot->lot_number', 'LIKE', $lotBase.'-%') + ->count(); + + $seq = str_pad($count + 1, 3, '0', STR_PAD_LEFT); + + return "{$lotBase}-{$seq}"; + } + + /** + * 날짜 → 4자리 날짜코드 + * + * 2026-03-17 → '6317' + * 2026-10-05 → '6A05' + */ + public static function generateDateCode(string $date): string + { + $dt = \Carbon\Carbon::parse($date); + $year = $dt->year % 10; + $month = $dt->month; + $day = $dt->day; + + $monthCode = $month >= 10 + ? chr(55 + $month) // 10=A, 11=B, 12=C + : (string) $month; + + return $year.$monthCode.str_pad($day, 2, '0', STR_PAD_LEFT); + } + + /** + * 제품+종류 → 원자재(재질) 반환 + */ + public static function getMaterial(string $prodCode, string $specCode): ?string + { + return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null; + } +} diff --git a/database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php b/database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php new file mode 100644 index 00000000..04273a59 --- /dev/null +++ b/database/migrations/2026_03_17_120000_create_bending_item_mappings_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('prod_code', 2)->comment('제품코드: R,S,G,B,T,L,C'); + $table->string('spec_code', 2)->comment('종류코드: M,S,I,E,A,D,C,U,T,F,P,L,B'); + $table->string('length_code', 2)->comment('모양&길이코드: 53,42...'); + $table->unsignedBigInteger('item_id')->comment('매핑된 품목 ID'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['tenant_id', 'prod_code', 'spec_code', 'length_code'], 'bim_tenant_prod_spec_length_unique'); + $table->index(['tenant_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bending_item_mappings'); + } +}; diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 17cb3f5b..0bddd2e3 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -9,6 +9,7 @@ * - 검사 관리 */ +use App\Http\Controllers\Api\V1\BendingController; use App\Http\Controllers\Api\V1\InspectionController; use App\Http\Controllers\Api\V1\ProductionOrderController; use App\Http\Controllers\Api\V1\WorkOrderController; @@ -123,6 +124,13 @@ Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 }); +// Bending API (절곡품 코드맵/품목매핑/LOT) +Route::prefix('bending')->group(function () { + Route::get('/code-map', [BendingController::class, 'codeMap'])->name('v1.bending.code-map'); + Route::get('/resolve-item', [BendingController::class, 'resolveItem'])->name('v1.bending.resolve-item'); + Route::post('/generate-lot', [BendingController::class, 'generateLotNumber'])->name('v1.bending.generate-lot'); +}); + // Production Order API (생산지시 조회) Route::prefix('production-orders')->group(function () { Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index');