feat: [barobill] 카드 사용내역 자동 동기화 스케줄러 추가
- BarobillCardSyncService: 전체/테넌트별 카드거래 자동 동기화 - SyncBarobillCardTransactions: artisan 커맨드 (barobill:sync-cards) - 2시간마다 영업시간(08~22시) 자동 실행 - 신규 거래 자동 등록, 기존 거래 바로빌 원본 필드만 갱신 (사용자 편집 보존)
This commit is contained in:
40
app/Console/Commands/SyncBarobillCardTransactions.php
Normal file
40
app/Console/Commands/SyncBarobillCardTransactions.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Barobill\BarobillCardSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncBarobillCardTransactions extends Command
|
||||
{
|
||||
protected $signature = 'barobill:sync-cards
|
||||
{--tenant= : 특정 테넌트만 동기화}
|
||||
{--days=7 : 동기화 대상 일수 (기본 7일)}';
|
||||
|
||||
protected $description = '바로빌 카드 사용내역 자동 동기화 (전체 테넌트)';
|
||||
|
||||
public function handle(BarobillCardSyncService $service): int
|
||||
{
|
||||
$tenantId = $this->option('tenant');
|
||||
$days = (int) $this->option('days');
|
||||
|
||||
if ($tenantId) {
|
||||
$this->info("테넌트 {$tenantId} 카드 동기화 시작 (최근 {$days}일)");
|
||||
$result = $service->syncTenant((int) $tenantId, $days);
|
||||
$this->info("완료: 신규 {$result['created']}건, 갱신 {$result['updated']}건");
|
||||
} else {
|
||||
$this->info("전체 테넌트 카드 동기화 시작 (최근 {$days}일)");
|
||||
$result = $service->syncAll($days);
|
||||
$this->info("완료: {$result['synced']}개 테넌트, 신규 {$result['created']}건, 갱신 {$result['updated']}건");
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
$this->warn('오류 발생:');
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
401
app/Services/Barobill/BarobillCardSyncService.php
Normal file
401
app/Services/Barobill/BarobillCardSyncService.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Barobill;
|
||||
|
||||
use App\Models\Barobill\BarobillConfig;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Models\Barobill\CardTransaction;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 바로빌 카드 사용내역 자동 동기화 서비스
|
||||
*
|
||||
* 스케줄러에서 주기적으로 호출하여 바로빌 SOAP API의 카드 거래를
|
||||
* barobill_card_transactions 테이블에 자동 동기화한다.
|
||||
*
|
||||
* - 신규 거래: DB에 자동 등록 (계정과목 등 사용자 입력 필드는 비워둠)
|
||||
* - 기존 거래: 바로빌 원본 필드만 갱신 (사용자 편집 필드 보존)
|
||||
*/
|
||||
class BarobillCardSyncService
|
||||
{
|
||||
private ?string $certKey = null;
|
||||
|
||||
private ?string $corpNum = null;
|
||||
|
||||
private bool $isTestMode = false;
|
||||
|
||||
private ?string $soapUrl = null;
|
||||
|
||||
private ?\SoapClient $soapClient = null;
|
||||
|
||||
/**
|
||||
* 전체 테넌트 카드 거래 동기화
|
||||
*
|
||||
* @param int $days 동기화 대상 일수 (기본 7일)
|
||||
* @return array{synced: int, created: int, updated: int, errors: array}
|
||||
*/
|
||||
public function syncAll(int $days = 7): array
|
||||
{
|
||||
$members = BarobillMember::whereNotNull('barobill_id')
|
||||
->where('barobill_id', '!=', '')
|
||||
->get();
|
||||
|
||||
$totalCreated = 0;
|
||||
$totalUpdated = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($members as $member) {
|
||||
try {
|
||||
$result = $this->syncTenant($member->tenant_id, $days);
|
||||
$totalCreated += $result['created'];
|
||||
$totalUpdated += $result['updated'];
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "tenant:{$member->tenant_id} - {$e->getMessage()}";
|
||||
Log::error('[CardSync] 테넌트 동기화 실패', [
|
||||
'tenantId' => $member->tenant_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'synced' => $members->count(),
|
||||
'created' => $totalCreated,
|
||||
'updated' => $totalUpdated,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테넌트 카드 거래 동기화
|
||||
*
|
||||
* @return array{created: int, updated: int}
|
||||
*/
|
||||
public function syncTenant(int $tenantId, int $days = 7): array
|
||||
{
|
||||
$member = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
if (! $member || empty($member->barobill_id)) {
|
||||
return ['created' => 0, 'updated' => 0];
|
||||
}
|
||||
|
||||
$this->initFromConfig($member);
|
||||
if (! $this->soapClient) {
|
||||
Log::warning('[CardSync] SOAP 클라이언트 초기화 실패', ['tenantId' => $tenantId]);
|
||||
|
||||
return ['created' => 0, 'updated' => 0];
|
||||
}
|
||||
|
||||
// 등록된 카드 목록 조회
|
||||
$cards = $this->getRegisteredCards($member->barobill_id);
|
||||
if (empty($cards)) {
|
||||
Log::debug('[CardSync] 등록된 카드 없음', ['tenantId' => $tenantId]);
|
||||
|
||||
return ['created' => 0, 'updated' => 0];
|
||||
}
|
||||
|
||||
$endDate = Carbon::now()->format('Ymd');
|
||||
$startDate = Carbon::now()->subDays($days)->format('Ymd');
|
||||
|
||||
$totalCreated = 0;
|
||||
$totalUpdated = 0;
|
||||
|
||||
foreach ($cards as $cardNum) {
|
||||
$transactions = $this->fetchTransactions($member->barobill_id, $cardNum, $startDate, $endDate);
|
||||
|
||||
if (! empty($transactions)) {
|
||||
$result = $this->upsertTransactions($tenantId, $transactions);
|
||||
$totalCreated += $result['created'];
|
||||
$totalUpdated += $result['updated'];
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('[CardSync] 동기화 완료', [
|
||||
'tenantId' => $tenantId,
|
||||
'cards' => count($cards),
|
||||
'created' => $totalCreated,
|
||||
'updated' => $totalUpdated,
|
||||
'period' => "{$startDate}~{$endDate}",
|
||||
]);
|
||||
|
||||
return ['created' => $totalCreated, 'updated' => $totalUpdated];
|
||||
}
|
||||
|
||||
/**
|
||||
* BarobillConfig + BarobillMember 설정으로 SOAP 초기화 (CARD.asmx)
|
||||
*/
|
||||
private function initFromConfig(BarobillMember $member): void
|
||||
{
|
||||
$memberTestMode = $member->isTestMode();
|
||||
$targetEnv = $memberTestMode ? 'test' : 'production';
|
||||
|
||||
$config = BarobillConfig::where('environment', $targetEnv)->first();
|
||||
if (! $config) {
|
||||
$config = BarobillConfig::where('is_active', true)->first();
|
||||
}
|
||||
|
||||
if ($config) {
|
||||
$this->isTestMode = $memberTestMode;
|
||||
$this->certKey = $config->cert_key;
|
||||
$this->corpNum = $config->corp_num;
|
||||
$baseUrl = $config->base_url ?: ($memberTestMode
|
||||
? 'https://testws.baroservice.com'
|
||||
: 'https://ws.baroservice.com');
|
||||
$this->soapUrl = $baseUrl.'/CARD.asmx?WSDL';
|
||||
}
|
||||
|
||||
$this->initSoapClient();
|
||||
}
|
||||
|
||||
private function initSoapClient(): void
|
||||
{
|
||||
$this->soapClient = null;
|
||||
|
||||
if (! empty($this->certKey) || $this->isTestMode) {
|
||||
try {
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->soapClient = new \SoapClient($this->soapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_BOTH,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[CardSync] SOAP 클라이언트 생성 실패: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 카드번호 목록 조회 (GetCardEx2)
|
||||
*
|
||||
* @return string[] 카드번호 배열
|
||||
*/
|
||||
private function getRegisteredCards(string $userId): array
|
||||
{
|
||||
$result = $this->callSoap('GetCardEx2', [
|
||||
'ID' => $userId,
|
||||
'AvailOnly' => 0,
|
||||
]);
|
||||
|
||||
if (! $result['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
$cardList = [];
|
||||
|
||||
if (isset($data->CardInfo)) {
|
||||
$cardList = is_array($data->CardInfo) ? $data->CardInfo : [$data->CardInfo];
|
||||
}
|
||||
|
||||
$cards = [];
|
||||
foreach ($cardList as $card) {
|
||||
if (! is_object($card)) {
|
||||
continue;
|
||||
}
|
||||
$cardNum = $card->CardNum ?? '';
|
||||
if (! empty($cardNum) && ! (is_numeric($cardNum) && $cardNum < 0)) {
|
||||
$cards[] = $cardNum;
|
||||
}
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌에서 카드 사용내역 조회 (GetPeriodCardApprovalLog)
|
||||
*
|
||||
* @return array 파싱된 거래 배열
|
||||
*/
|
||||
private function fetchTransactions(string $userId, string $cardNum, string $startDate, string $endDate): array
|
||||
{
|
||||
$result = $this->callSoap('GetPeriodCardApprovalLog', [
|
||||
'ID' => $userId,
|
||||
'CardNum' => $cardNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => 10000,
|
||||
'CurrentPage' => 1,
|
||||
'OrderDirection' => 2,
|
||||
]);
|
||||
|
||||
if (! $result['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
|
||||
// 에러 코드 체크
|
||||
if (is_numeric($data) && $data < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rawLogs = [];
|
||||
if (isset($data->CardLogList) && isset($data->CardLogList->CardLog)) {
|
||||
$logs = $data->CardLogList->CardLog;
|
||||
$rawLogs = is_array($logs) ? $logs : [$logs];
|
||||
}
|
||||
|
||||
// SOAP 응답을 DB 저장용 배열로 변환
|
||||
$transactions = [];
|
||||
foreach ($rawLogs as $log) {
|
||||
$useDT = $log->UseDT ?? '';
|
||||
$useDate = strlen($useDT) >= 8 ? substr($useDT, 0, 8) : '';
|
||||
$useTime = strlen($useDT) >= 14 ? substr($useDT, 8, 6) : '';
|
||||
|
||||
$transactions[] = [
|
||||
'card_num' => $cardNum,
|
||||
'card_company' => $log->CardCompany ?? '',
|
||||
'card_company_name' => $this->getCardBrandName($log->CardCompany ?? ''),
|
||||
'use_dt' => $useDT,
|
||||
'use_date' => $useDate,
|
||||
'use_time' => $useTime,
|
||||
'approval_num' => $log->ApprovalNum ?? '',
|
||||
'approval_type' => $log->ApprovalType ?? '1',
|
||||
'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 ?? 'KRW',
|
||||
'merchant_name' => $log->MerchantName ?? '',
|
||||
'merchant_biz_num' => $log->MerchantBizNum ?? '',
|
||||
'merchant_addr' => $log->MerchantAddr ?? '',
|
||||
'merchant_ceo' => $log->MerchantCeo ?? '',
|
||||
'merchant_biz_type' => $log->MerchantBizType ?? '',
|
||||
'merchant_tel' => $log->MerchantTel ?? '',
|
||||
'memo' => $log->Memo ?? '',
|
||||
'use_key' => $log->UseKey ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 데이터 Upsert (사용자 편집 필드 보존)
|
||||
*
|
||||
* @return array{created: int, updated: int}
|
||||
*/
|
||||
private function upsertTransactions(int $tenantId, array $transactions): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($transactions as $trans) {
|
||||
// 고유 키: tenant_id + card_num + use_dt + approval_num + approval_amount
|
||||
$existing = CardTransaction::where('tenant_id', $tenantId)
|
||||
->where('card_num', $trans['card_num'])
|
||||
->where('use_dt', $trans['use_dt'])
|
||||
->where('approval_num', $trans['approval_num'])
|
||||
->where('approval_amount', $trans['approval_amount'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// 기존 레코드: 바로빌 원본 필드만 갱신 (사용자 편집 필드 보존)
|
||||
$existing->update([
|
||||
'card_company' => $trans['card_company'],
|
||||
'card_company_name' => $trans['card_company_name'],
|
||||
'merchant_name' => $trans['merchant_name'],
|
||||
'merchant_biz_num' => $trans['merchant_biz_num'],
|
||||
'merchant_addr' => $trans['merchant_addr'],
|
||||
'merchant_ceo' => $trans['merchant_ceo'],
|
||||
'merchant_biz_type' => $trans['merchant_biz_type'],
|
||||
'merchant_tel' => $trans['merchant_tel'],
|
||||
'memo' => $trans['memo'],
|
||||
'use_key' => $trans['use_key'],
|
||||
]);
|
||||
$updated++;
|
||||
} else {
|
||||
// 신규 레코드: 바로빌 데이터로 생성 (사용자 입력 필드는 null)
|
||||
CardTransaction::create(array_merge($trans, [
|
||||
'tenant_id' => $tenantId,
|
||||
]));
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('[CardSync] Upsert 실패', [
|
||||
'tenantId' => $tenantId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['created' => $created, 'updated' => $updated];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드사 코드 → 카드사명 변환
|
||||
*/
|
||||
private function getCardBrandName(string $code): string
|
||||
{
|
||||
$brands = [
|
||||
'01' => 'BC', '02' => 'KB국민', '03' => '하나(외환)',
|
||||
'04' => '삼성', '06' => '신한', '07' => '현대',
|
||||
'08' => '롯데', '11' => 'NH농협', '12' => '수협',
|
||||
'13' => '씨티', '14' => '우리', '15' => '광주',
|
||||
'16' => '전북', '21' => '하나', '22' => '제주',
|
||||
];
|
||||
|
||||
return $brands[$code] ?? $code;
|
||||
}
|
||||
|
||||
private function callSoap(string $method, array $params = []): array
|
||||
{
|
||||
if (! $this->soapClient) {
|
||||
return ['success' => false, 'error' => 'SOAP 클라이언트 미초기화'];
|
||||
}
|
||||
|
||||
if (! isset($params['CERTKEY'])) {
|
||||
$params['CERTKEY'] = $this->certKey ?? '';
|
||||
}
|
||||
if (! isset($params['CorpNum'])) {
|
||||
$params['CorpNum'] = $this->corpNum;
|
||||
}
|
||||
|
||||
try {
|
||||
$soapStartTime = microtime(true);
|
||||
$result = $this->soapClient->$method($params);
|
||||
$resultProperty = $method.'Result';
|
||||
$elapsed = round((microtime(true) - $soapStartTime) * 1000);
|
||||
|
||||
Log::debug("[CardSync] SOAP {$method} 완료 ({$elapsed}ms)");
|
||||
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
if (is_numeric($resultData) && $resultData < 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => "바로빌 API 오류: {$resultData}",
|
||||
'data' => $resultData,
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $resultData];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $result];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[CardSync] SOAP 오류: '.$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,9 @@
|
||||
|
||||
// 매일 23:50 자동 결근 처리
|
||||
Schedule::command('attendance:mark-absent')->dailyAt('23:50');
|
||||
|
||||
// 2시간마다 바로빌 카드 사용내역 자동 동기화 (영업시간 08~22시)
|
||||
Schedule::command('barobill:sync-cards --days=7')
|
||||
->everyTwoHours()
|
||||
->between('08:00', '22:00')
|
||||
->withoutOverlapping();
|
||||
|
||||
Reference in New Issue
Block a user