diff --git a/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php b/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php
index 7afc0caa..39d103b7 100644
--- a/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php
+++ b/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php
@@ -24,6 +24,8 @@ public function __construct(
/**
* 회원사 조회 및 비밀번호 검증 헬퍼
*
+ * 회원사의 서버 모드에 따라 BarobillService의 서버 모드도 자동 전환합니다.
+ *
* @return BarobillMember|JsonResponse 회원사 객체 또는 에러 응답
*/
private function validateMemberForUrlApi(int $id): BarobillMember|JsonResponse
@@ -45,6 +47,9 @@ private function validateMemberForUrlApi(int $id): BarobillMember|JsonResponse
], 422);
}
+ // 회원사의 서버 모드로 BarobillService 전환
+ $this->barobillService->setServerMode($member->server_mode ?? 'test');
+
return $member;
}
@@ -856,4 +861,91 @@ public function getServiceCodes(): JsonResponse
],
]);
}
+
+ /**
+ * 회원사별 서버 모드 변경
+ *
+ * 특정 회원사의 바로빌 서버 모드(테스트/운영)를 변경합니다.
+ * 주의: 운영 서버로 전환 시 요금이 부과됩니다.
+ */
+ public function updateServerMode(Request $request, int $id): JsonResponse
+ {
+ $member = BarobillMember::find($id);
+
+ if (!$member) {
+ return response()->json([
+ 'success' => false,
+ 'message' => '회원사를 찾을 수 없습니다.',
+ ], 404);
+ }
+
+ $validated = $request->validate([
+ 'server_mode' => 'required|in:test,production',
+ 'confirmed' => 'required|boolean',
+ ], [
+ 'server_mode.required' => '서버 모드를 선택해주세요.',
+ 'server_mode.in' => '서버 모드는 test 또는 production 이어야 합니다.',
+ 'confirmed.required' => '경고 확인이 필요합니다.',
+ ]);
+
+ // 확인 체크
+ if (!$validated['confirmed']) {
+ return response()->json([
+ 'success' => false,
+ 'message' => '서버 변경 경고를 확인해주세요.',
+ ], 422);
+ }
+
+ $oldMode = $member->server_mode ?? 'test';
+ $newMode = $validated['server_mode'];
+
+ // 변경 없으면 바로 반환
+ if ($oldMode === $newMode) {
+ return response()->json([
+ 'success' => true,
+ 'message' => '서버 모드가 이미 ' . ($newMode === 'test' ? '테스트' : '운영') . ' 서버입니다.',
+ 'data' => $member,
+ ]);
+ }
+
+ $member->update(['server_mode' => $newMode]);
+
+ Log::info('바로빌 회원사 서버 모드 변경', [
+ 'member_id' => $id,
+ 'biz_no' => $member->biz_no,
+ 'corp_name' => $member->corp_name,
+ 'old_mode' => $oldMode,
+ 'new_mode' => $newMode,
+ 'user_id' => auth()->id(),
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => ($newMode === 'test' ? '테스트' : '운영') . ' 서버로 변경되었습니다.',
+ 'data' => $member->fresh(),
+ ]);
+ }
+
+ /**
+ * 회원사별 서버 모드 조회
+ */
+ public function getServerMode(int $id): JsonResponse
+ {
+ $member = BarobillMember::find($id);
+
+ if (!$member) {
+ return response()->json([
+ 'success' => false,
+ 'message' => '회원사를 찾을 수 없습니다.',
+ ], 404);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'data' => [
+ 'server_mode' => $member->server_mode ?? 'test',
+ 'server_mode_label' => $member->server_mode_label,
+ ],
+ ]);
+ }
}
diff --git a/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php b/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php
index 6b582fca..a33cbe0e 100644
--- a/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php
+++ b/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php
@@ -102,6 +102,53 @@ public function store(Request $request): JsonResponse
}
}
+ /**
+ * 서비스 설정 개별 저장 (체크박스 변경 시 즉시 저장)
+ */
+ public function updateService(Request $request): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id', 1);
+
+ $validated = $request->validate([
+ 'field' => 'required|in:use_tax_invoice,use_bank_account,use_card_usage,use_hometax',
+ 'value' => 'required|boolean',
+ ]);
+
+ try {
+ $setting = BarobillSetting::updateOrCreate(
+ ['tenant_id' => $tenantId],
+ [
+ $validated['field'] => $validated['value'],
+ 'corp_name' => '미설정',
+ 'ceo_name' => '미설정',
+ 'corp_num' => '0000000000',
+ ]
+ );
+
+ $serviceNames = [
+ 'use_tax_invoice' => '전자세금계산서',
+ 'use_bank_account' => '계좌조회',
+ 'use_card_usage' => '카드사용내역',
+ 'use_hometax' => '홈텍스매입/매출',
+ ];
+
+ $serviceName = $serviceNames[$validated['field']] ?? $validated['field'];
+ $status = $validated['value'] ? '활성화' : '비활성화';
+
+ return response()->json([
+ 'success' => true,
+ 'message' => "{$serviceName} 서비스가 {$status}되었습니다.",
+ ]);
+ } catch (\Exception $e) {
+ Log::error('바로빌 서비스 설정 저장 실패', ['error' => $e->getMessage()]);
+
+ return response()->json([
+ 'success' => false,
+ 'message' => '설정 저장에 실패했습니다.',
+ ], 500);
+ }
+ }
+
/**
* 서비스 이용 여부 확인 (다른 메뉴에서 참조용)
*/
diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php
index 3f011a89..1edc6e93 100644
--- a/app/Http/Controllers/Barobill/EaccountController.php
+++ b/app/Http/Controllers/Barobill/EaccountController.php
@@ -107,16 +107,58 @@ public function index(Request $request): View|Response
// 해당 테넌트의 바로빌 회원사 정보
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+ // 테넌트별 서버 모드 적용 (회원사 설정 우선)
+ $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
+
+ // 서버 모드에 따라 SOAP 설정 재초기화
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
return view('barobill.eaccount.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
- 'isTestMode' => $this->isTestMode,
+ 'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
+ /**
+ * 회원사 서버 모드에 따라 SOAP 설정 적용
+ */
+ private function applyMemberServerMode(BarobillMember $member): void
+ {
+ $memberTestMode = $member->isTestMode();
+ $targetEnv = $memberTestMode ? 'test' : 'production';
+
+ // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
+ $config = BarobillConfig::where('environment', $targetEnv)->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 . '/BANKACCOUNT.asmx?WSDL';
+
+ // SOAP 클라이언트 재초기화
+ $this->initSoapClient();
+
+ Log::info('[Eaccount] 서버 모드 적용', [
+ 'targetEnv' => $targetEnv,
+ 'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
+ 'corpNum' => $this->corpNum,
+ 'soapUrl' => $this->soapUrl,
+ ]);
+ } else {
+ Log::warning('[Eaccount] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
+ }
+ }
+
/**
* 등록된 계좌 목록 조회 (GetBankAccountEx)
*/
@@ -129,6 +171,11 @@ public function accounts(Request $request): JsonResponse
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+ // 테넌트별 서버 모드 적용
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
// 바로빌 사용자 ID 결정
$userId = $barobillMember?->barobill_id ?? '';
@@ -210,6 +257,11 @@ public function transactions(Request $request): JsonResponse
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
$userId = $barobillMember?->barobill_id ?? '';
+ // 테넌트별 서버 모드 적용
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
// DB에서 저장된 계정과목 데이터 조회
$savedData = BankTransaction::getByDateRange($tenantId, $startDate, $endDate, $bankAccountNum ?: null);
@@ -532,12 +584,11 @@ private function getBankName(string $code): string
}
/**
- * 계정과목 목록 조회
+ * 계정과목 목록 조회 (글로벌 데이터)
*/
public function accountCodes(): JsonResponse
{
- $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
- $codes = AccountCode::getActiveByTenant($tenantId);
+ $codes = AccountCode::getActive();
return response()->json([
'success' => true,
@@ -551,15 +602,11 @@ public function accountCodes(): JsonResponse
}
/**
- * 전체 계정과목 목록 조회 (설정용, 비활성 포함)
+ * 전체 계정과목 목록 조회 (설정용, 비활성 포함, 글로벌 데이터)
*/
public function accountCodesAll(): JsonResponse
{
- $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
- $codes = AccountCode::where('tenant_id', $tenantId)
- ->orderBy('sort_order')
- ->orderBy('code')
- ->get();
+ $codes = AccountCode::getAll();
return response()->json([
'success' => true,
@@ -568,23 +615,19 @@ public function accountCodesAll(): JsonResponse
}
/**
- * 계정과목 추가
+ * 계정과목 추가 (글로벌 데이터)
*/
public function accountCodeStore(Request $request): JsonResponse
{
try {
- $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
-
$validated = $request->validate([
'code' => 'required|string|max:10',
'name' => 'required|string|max:100',
'category' => 'nullable|string|max:50',
]);
- // 중복 체크
- $exists = AccountCode::where('tenant_id', $tenantId)
- ->where('code', $validated['code'])
- ->exists();
+ // 중복 체크 (글로벌)
+ $exists = AccountCode::where('code', $validated['code'])->exists();
if ($exists) {
return response()->json([
@@ -593,10 +636,10 @@ public function accountCodeStore(Request $request): JsonResponse
], 422);
}
- $maxSort = AccountCode::where('tenant_id', $tenantId)->max('sort_order') ?? 0;
+ $maxSort = AccountCode::max('sort_order') ?? 0;
$accountCode = AccountCode::create([
- 'tenant_id' => $tenantId,
+ 'tenant_id' => self::HEADQUARTERS_TENANT_ID, // 글로벌 데이터는 기본 테넌트에 저장
'code' => $validated['code'],
'name' => $validated['name'],
'category' => $validated['category'] ?? null,
@@ -618,16 +661,12 @@ public function accountCodeStore(Request $request): JsonResponse
}
/**
- * 계정과목 수정
+ * 계정과목 수정 (글로벌 데이터)
*/
public function accountCodeUpdate(Request $request, int $id): JsonResponse
{
try {
- $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
-
- $accountCode = AccountCode::where('tenant_id', $tenantId)
- ->where('id', $id)
- ->first();
+ $accountCode = AccountCode::find($id);
if (!$accountCode) {
return response()->json([
@@ -643,10 +682,9 @@ public function accountCodeUpdate(Request $request, int $id): JsonResponse
'is_active' => 'sometimes|boolean',
]);
- // 코드 변경 시 중복 체크
+ // 코드 변경 시 중복 체크 (글로벌)
if (isset($validated['code']) && $validated['code'] !== $accountCode->code) {
- $exists = AccountCode::where('tenant_id', $tenantId)
- ->where('code', $validated['code'])
+ $exists = AccountCode::where('code', $validated['code'])
->where('id', '!=', $id)
->exists();
@@ -674,16 +712,12 @@ public function accountCodeUpdate(Request $request, int $id): JsonResponse
}
/**
- * 계정과목 삭제
+ * 계정과목 삭제 (글로벌 데이터)
*/
public function accountCodeDestroy(int $id): JsonResponse
{
try {
- $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
-
- $accountCode = AccountCode::where('tenant_id', $tenantId)
- ->where('id', $id)
- ->first();
+ $accountCode = AccountCode::find($id);
if (!$accountCode) {
return response()->json([
diff --git a/app/Http/Controllers/Barobill/EcardController.php b/app/Http/Controllers/Barobill/EcardController.php
index 5794627c..e0c471e6 100644
--- a/app/Http/Controllers/Barobill/EcardController.php
+++ b/app/Http/Controllers/Barobill/EcardController.php
@@ -107,16 +107,58 @@ public function index(Request $request): View|Response
// 해당 테넌트의 바로빌 회원사 정보
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+ // 테넌트별 서버 모드 적용 (회원사 설정 우선)
+ $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
+
+ // 서버 모드에 따라 SOAP 설정 재초기화
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
return view('barobill.ecard.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
- 'isTestMode' => $this->isTestMode,
+ 'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
+ /**
+ * 회원사 서버 모드에 따라 SOAP 설정 적용
+ */
+ private function applyMemberServerMode(BarobillMember $member): void
+ {
+ $memberTestMode = $member->isTestMode();
+ $targetEnv = $memberTestMode ? 'test' : 'production';
+
+ // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
+ $config = BarobillConfig::where('environment', $targetEnv)->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';
+
+ // SOAP 클라이언트 재초기화
+ $this->initSoapClient();
+
+ Log::info('[Ecard] 서버 모드 적용', [
+ 'targetEnv' => $targetEnv,
+ 'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
+ 'corpNum' => $this->corpNum,
+ 'soapUrl' => $this->soapUrl,
+ ]);
+ } else {
+ Log::warning('[Ecard] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
+ }
+ }
+
/**
* 등록된 카드 목록 조회 (GetCardEx2)
* 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2
@@ -124,6 +166,13 @@ public function index(Request $request): View|Response
public function cards(Request $request): JsonResponse
{
try {
+ // 테넌트별 서버 모드 적용
+ $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
+ $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
$availOnly = $request->input('availOnly', 0);
$result = $this->callSoap('GetCardEx2', [
@@ -220,6 +269,11 @@ public function transactions(Request $request): JsonResponse
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
$userId = $barobillMember?->barobill_id ?? '';
+ // 테넌트별 서버 모드 적용
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
// 디버그 로그
Log::info('[ECard] 조회 요청', [
'tenantId' => $tenantId,
diff --git a/app/Http/Controllers/Barobill/EtaxController.php b/app/Http/Controllers/Barobill/EtaxController.php
index 3befd6cf..8fc60f55 100644
--- a/app/Http/Controllers/Barobill/EtaxController.php
+++ b/app/Http/Controllers/Barobill/EtaxController.php
@@ -99,16 +99,58 @@ public function index(Request $request): View|Response
? BarobillMember::where('tenant_id', $tenantId)->first()
: null;
+ // 테넌트별 서버 모드 적용 (회원사 설정 우선)
+ $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
+
+ // 서버 모드에 따라 SOAP 설정 재초기화
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
return view('barobill.etax.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
- 'isTestMode' => $this->isTestMode,
+ 'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
+ /**
+ * 회원사 서버 모드에 따라 SOAP 설정 적용
+ */
+ private function applyMemberServerMode(BarobillMember $member): void
+ {
+ $memberTestMode = $member->isTestMode();
+ $targetEnv = $memberTestMode ? 'test' : 'production';
+
+ // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
+ $config = BarobillConfig::where('environment', $targetEnv)->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 . '/TI.asmx?WSDL';
+
+ // SOAP 클라이언트 재초기화
+ $this->initSoapClient();
+
+ Log::info('[Etax] 서버 모드 적용', [
+ 'targetEnv' => $targetEnv,
+ 'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
+ 'corpNum' => $this->corpNum,
+ 'soapUrl' => $this->soapUrl,
+ ]);
+ } else {
+ Log::warning('[Etax] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
+ }
+ }
+
// 바로빌 파트너사 (본사) 테넌트 ID
private const HEADQUARTERS_TENANT_ID = 1;
@@ -153,6 +195,13 @@ public function getInvoices(): JsonResponse
*/
public function issue(Request $request): JsonResponse
{
+ // 테넌트별 서버 모드 적용
+ $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
+ $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
$input = $request->all();
$useRealAPI = $this->soapClient !== null && ($this->isTestMode || !empty($this->certKey));
@@ -221,6 +270,13 @@ public function issue(Request $request): JsonResponse
*/
public function sendToNts(Request $request): JsonResponse
{
+ // 테넌트별 서버 모드 적용
+ $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
+ $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
$invoiceId = $request->input('invoiceId');
// 인보이스 조회
diff --git a/app/Http/Controllers/Barobill/HometaxController.php b/app/Http/Controllers/Barobill/HometaxController.php
index 55b58051..53f12823 100644
--- a/app/Http/Controllers/Barobill/HometaxController.php
+++ b/app/Http/Controllers/Barobill/HometaxController.php
@@ -107,10 +107,18 @@ public function index(Request $request): View|Response
// 해당 테넌트의 바로빌 회원사 정보
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+ // 테넌트별 서버 모드 적용 (회원사 설정 우선)
+ $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
+
+ // 서버 모드에 따라 SOAP 설정 재초기화
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
return view('barobill.hometax.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
- 'isTestMode' => $this->isTestMode,
+ 'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'tenantId' => $tenantId,
'currentTenant' => $currentTenant,
@@ -118,6 +126,39 @@ public function index(Request $request): View|Response
]);
}
+ /**
+ * 회원사 서버 모드에 따라 SOAP 설정 적용
+ */
+ private function applyMemberServerMode(BarobillMember $member): void
+ {
+ $memberTestMode = $member->isTestMode();
+ $targetEnv = $memberTestMode ? 'test' : 'production';
+
+ // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
+ $config = BarobillConfig::where('environment', $targetEnv)->first();
+
+ if ($config) {
+ $this->isTestMode = $memberTestMode;
+ $this->certKey = $config->cert_key;
+ $this->corpNum = $config->corp_num;
+ $this->baseUrl = $config->base_url ?: ($memberTestMode
+ ? 'https://testws.baroservice.com'
+ : 'https://ws.baroservice.com');
+
+ // SOAP 클라이언트 재초기화
+ $this->initSoapClient();
+
+ Log::info('[Hometax] 서버 모드 적용', [
+ 'targetEnv' => $targetEnv,
+ 'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
+ 'corpNum' => $this->corpNum,
+ 'baseUrl' => $this->baseUrl,
+ ]);
+ } else {
+ Log::warning('[Hometax] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
+ }
+ }
+
/**
* 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList)
*
@@ -146,6 +187,11 @@ public function sales(Request $request): JsonResponse
]);
}
+ // 테넌트별 서버 모드 적용
+ if ($barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
$userId = $barobillMember->barobill_id ?? '';
if (empty($userId)) {
@@ -221,12 +267,16 @@ public function sales(Request $request): JsonResponse
// 작성일 기준으로 정렬 (최신순)
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
+ // 마지막 매출 수집 시간 업데이트
+ $barobillMember->update(['last_sales_fetch_at' => now()]);
+
return response()->json([
'success' => true,
'data' => [
'invoices' => $allInvoices,
'pagination' => $lastPagination,
- 'summary' => $totalSummary
+ 'summary' => $totalSummary,
+ 'lastFetchAt' => now()->format('Y-m-d H:i:s')
]
]);
} catch (\Throwable $e) {
@@ -266,6 +316,11 @@ public function purchases(Request $request): JsonResponse
]);
}
+ // 테넌트별 서버 모드 적용
+ if ($barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
$userId = $barobillMember->barobill_id ?? '';
if (empty($userId)) {
@@ -341,12 +396,16 @@ public function purchases(Request $request): JsonResponse
// 작성일 기준으로 정렬 (최신순)
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
+ // 마지막 매입 수집 시간 업데이트
+ $barobillMember->update(['last_purchases_fetch_at' => now()]);
+
return response()->json([
'success' => true,
'data' => [
'invoices' => $allInvoices,
'pagination' => $lastPagination,
- 'summary' => $totalSummary
+ 'summary' => $totalSummary,
+ 'lastFetchAt' => now()->format('Y-m-d H:i:s')
]
]);
} catch (\Throwable $e) {
@@ -491,6 +550,12 @@ public function diagnose(Request $request): JsonResponse
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+
+ // 테넌트별 서버 모드 적용
+ if ($barobillMember && $barobillMember->server_mode) {
+ $this->applyMemberServerMode($barobillMember);
+ }
+
$userId = $barobillMember?->barobill_id ?? '';
$memberCorpNum = $barobillMember?->biz_no ?? '';
@@ -577,18 +642,24 @@ public function requestCollect(Request $request): JsonResponse
}
/**
- * 수집 상태 확인 (미지원 안내)
+ * 수집 상태 확인 (마지막 수집 시간 조회)
*/
public function collectStatus(Request $request): JsonResponse
{
+ $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
+ $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
+
+ $salesLastFetch = $barobillMember?->last_sales_fetch_at;
+ $purchasesLastFetch = $barobillMember?->last_purchases_fetch_at;
+
return response()->json([
'success' => true,
'data' => [
- 'salesLastCollectDate' => '',
- 'purchaseLastCollectDate' => '',
+ 'salesLastCollectDate' => $salesLastFetch ? $salesLastFetch->format('Y-m-d H:i') : '',
+ 'purchaseLastCollectDate' => $purchasesLastFetch ? $purchasesLastFetch->format('Y-m-d H:i') : '',
'isCollecting' => false,
- 'collectStateText' => '확인 필요',
- 'message' => '서비스 상태 진단 기능을 사용하여 홈택스 연동 상태를 확인해주세요.'
+ 'collectStateText' => ($salesLastFetch || $purchasesLastFetch) ? '조회 완료' : '조회 전',
+ 'message' => '매출/매입 탭을 클릭하면 데이터가 조회되고 수집 시간이 기록됩니다.'
]
]);
}
diff --git a/app/Http/Controllers/Finance/CorporateVehicleController.php b/app/Http/Controllers/Finance/CorporateVehicleController.php
new file mode 100644
index 00000000..51eb31e2
--- /dev/null
+++ b/app/Http/Controllers/Finance/CorporateVehicleController.php
@@ -0,0 +1,166 @@
+header('HX-Request')) {
+ return response('', 200)->header('HX-Redirect', route('finance.corporate-vehicles'));
+ }
+
+ return view('finance.corporate-vehicles');
+ }
+
+ public function list(Request $request): JsonResponse
+ {
+ $tenantId = session('tenant_id', 1);
+
+ $query = CorporateVehicle::where('tenant_id', $tenantId);
+
+ // 필터링
+ if ($request->filled('ownership_type') && $request->ownership_type !== 'all') {
+ $query->where('ownership_type', $request->ownership_type);
+ }
+
+ if ($request->filled('vehicle_type') && $request->vehicle_type !== 'all') {
+ $query->where('vehicle_type', $request->vehicle_type);
+ }
+
+ if ($request->filled('status') && $request->status !== 'all') {
+ $query->where('status', $request->status);
+ }
+
+ if ($request->filled('search')) {
+ $search = $request->search;
+ $query->where(function ($q) use ($search) {
+ $q->where('plate_number', 'like', "%{$search}%")
+ ->orWhere('model', 'like', "%{$search}%")
+ ->orWhere('driver', 'like', "%{$search}%");
+ });
+ }
+
+ $vehicles = $query->orderBy('created_at', 'desc')->get();
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $vehicles,
+ ]);
+ }
+
+ public function store(Request $request): JsonResponse
+ {
+ $request->validate([
+ 'plate_number' => 'required|string|max:20',
+ 'model' => 'required|string|max:100',
+ 'vehicle_type' => 'required|string|max:20',
+ 'ownership_type' => 'required|in:corporate,rent,lease',
+ ]);
+
+ $tenantId = session('tenant_id', 1);
+
+ $vehicle = CorporateVehicle::create([
+ 'tenant_id' => $tenantId,
+ 'plate_number' => $request->plate_number,
+ 'model' => $request->model,
+ 'vehicle_type' => $request->vehicle_type,
+ 'ownership_type' => $request->ownership_type,
+ 'year' => $request->year,
+ 'driver' => $request->driver,
+ 'status' => $request->status ?? 'active',
+ 'mileage' => $request->mileage ?? 0,
+ 'memo' => $request->memo,
+ // 법인차량 전용
+ 'purchase_date' => $request->purchase_date,
+ 'purchase_price' => $request->purchase_price ?? 0,
+ // 렌트/리스 전용
+ 'contract_date' => $request->contract_date,
+ 'rent_company' => $request->rent_company,
+ 'rent_company_tel' => $request->rent_company_tel,
+ 'rent_period' => $request->rent_period,
+ 'agreed_mileage' => $request->agreed_mileage,
+ 'vehicle_price' => $request->vehicle_price ?? 0,
+ 'residual_value' => $request->residual_value ?? 0,
+ 'deposit' => $request->deposit ?? 0,
+ 'monthly_rent' => $request->monthly_rent ?? 0,
+ 'monthly_rent_tax' => $request->monthly_rent_tax ?? 0,
+ 'insurance_company' => $request->insurance_company,
+ 'insurance_company_tel' => $request->insurance_company_tel,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '차량이 등록되었습니다.',
+ 'data' => $vehicle,
+ ]);
+ }
+
+ public function update(Request $request, int $id): JsonResponse
+ {
+ $tenantId = session('tenant_id', 1);
+
+ $vehicle = CorporateVehicle::where('tenant_id', $tenantId)->findOrFail($id);
+
+ $request->validate([
+ 'plate_number' => 'required|string|max:20',
+ 'model' => 'required|string|max:100',
+ 'vehicle_type' => 'required|string|max:20',
+ 'ownership_type' => 'required|in:corporate,rent,lease',
+ ]);
+
+ $vehicle->update([
+ 'plate_number' => $request->plate_number,
+ 'model' => $request->model,
+ 'vehicle_type' => $request->vehicle_type,
+ 'ownership_type' => $request->ownership_type,
+ 'year' => $request->year,
+ 'driver' => $request->driver,
+ 'status' => $request->status ?? 'active',
+ 'mileage' => $request->mileage ?? 0,
+ 'memo' => $request->memo,
+ // 법인차량 전용
+ 'purchase_date' => $request->purchase_date,
+ 'purchase_price' => $request->purchase_price ?? 0,
+ // 렌트/리스 전용
+ 'contract_date' => $request->contract_date,
+ 'rent_company' => $request->rent_company,
+ 'rent_company_tel' => $request->rent_company_tel,
+ 'rent_period' => $request->rent_period,
+ 'agreed_mileage' => $request->agreed_mileage,
+ 'vehicle_price' => $request->vehicle_price ?? 0,
+ 'residual_value' => $request->residual_value ?? 0,
+ 'deposit' => $request->deposit ?? 0,
+ 'monthly_rent' => $request->monthly_rent ?? 0,
+ 'monthly_rent_tax' => $request->monthly_rent_tax ?? 0,
+ 'insurance_company' => $request->insurance_company,
+ 'insurance_company_tel' => $request->insurance_company_tel,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '차량 정보가 수정되었습니다.',
+ 'data' => $vehicle,
+ ]);
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $tenantId = session('tenant_id', 1);
+
+ $vehicle = CorporateVehicle::where('tenant_id', $tenantId)->findOrFail($id);
+ $vehicle->delete();
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '차량이 삭제되었습니다.',
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Sales/AdminProspectController.php b/app/Http/Controllers/Sales/AdminProspectController.php
index 4360dd7d..b2b38b0e 100644
--- a/app/Http/Controllers/Sales/AdminProspectController.php
+++ b/app/Http/Controllers/Sales/AdminProspectController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
+use App\Models\Sales\SalesCommission;
use App\Models\Sales\SalesScenarioChecklist;
use App\Models\Sales\SalesTenantManagement;
use App\Models\Sales\TenantProspect;
@@ -82,7 +83,7 @@ private function getIndexData(Request $request): array
// 영업 역할을 가진 사용자 목록 (영업파트너)
$salesPartners = User::whereHas('userRoles', function ($q) {
$q->whereHas('role', function ($rq) {
- $rq->whereIn('name', ['sales', 'manager', 'recruiter']);
+ $rq->whereIn('name', ['sales', 'manager']);
});
})->orderBy('name')->get();
@@ -136,6 +137,14 @@ private function getIndexData(Request $request): array
$prospect->hq_status = $management?->hq_status ?? 'pending';
$prospect->hq_status_label = $management?->hq_status_label ?? '대기';
$prospect->manager_user = $management?->manager;
+
+ // 수당 정보 (management가 있는 경우)
+ if ($management) {
+ $commission = SalesCommission::where('management_id', $management->id)->first();
+ $prospect->commission = $commission;
+ } else {
+ $prospect->commission = null;
+ }
}
// 전체 통계
@@ -185,4 +194,87 @@ public function updateHqStatus(int $id, Request $request)
'hq_status_label' => $management->hq_status_label,
]);
}
+
+ /**
+ * 수당 날짜 기록/수정
+ */
+ public function updateCommissionDate(int $id, Request $request)
+ {
+ $this->checkAdminAccess();
+
+ $request->validate([
+ 'field' => 'required|in:first_payment_at,first_partner_paid_at,second_payment_at,second_partner_paid_at,first_subscription_at,manager_paid_at',
+ 'date' => 'nullable|date',
+ ]);
+
+ $prospect = TenantProspect::findOrFail($id);
+ $management = SalesTenantManagement::findOrCreateByProspect($prospect->id);
+
+ // Commission 레코드 조회 또는 생성
+ $commission = SalesCommission::firstOrCreate(
+ ['management_id' => $management->id],
+ [
+ 'tenant_id' => $prospect->tenant_id ?? 1,
+ 'payment_type' => 'deposit',
+ 'payment_amount' => 0,
+ 'payment_date' => now(),
+ 'base_amount' => 0,
+ 'partner_rate' => 0,
+ 'manager_rate' => 0,
+ 'partner_commission' => 0,
+ 'manager_commission' => 0,
+ 'scheduled_payment_date' => now()->addMonth()->day(10),
+ 'status' => SalesCommission::STATUS_PENDING,
+ 'partner_id' => $management->sales_partner_id ?? 0,
+ 'manager_user_id' => $management->manager_user_id,
+ ]
+ );
+
+ $field = $request->input('field');
+ $date = $request->input('date') ?: now()->format('Y-m-d');
+
+ $commission->update([
+ $field => $date,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'field' => $field,
+ 'date' => $commission->$field?->format('Y-m-d'),
+ 'date_display' => $commission->$field?->format('m/d'),
+ ]);
+ }
+
+ /**
+ * 수당 날짜 삭제 (초기화)
+ */
+ public function clearCommissionDate(int $id, Request $request)
+ {
+ $this->checkAdminAccess();
+
+ $request->validate([
+ 'field' => 'required|in:first_payment_at,first_partner_paid_at,second_payment_at,second_partner_paid_at,first_subscription_at,manager_paid_at',
+ ]);
+
+ $prospect = TenantProspect::findOrFail($id);
+ $management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first();
+
+ if (!$management) {
+ return response()->json(['success' => false, 'message' => '관리 정보가 없습니다.']);
+ }
+
+ $commission = SalesCommission::where('management_id', $management->id)->first();
+
+ if (!$commission) {
+ return response()->json(['success' => false, 'message' => '수당 정보가 없습니다.']);
+ }
+
+ $field = $request->input('field');
+ $commission->update([$field => null]);
+
+ return response()->json([
+ 'success' => true,
+ 'field' => $field,
+ ]);
+ }
}
diff --git a/app/Http/Controllers/Sales/SalesDashboardController.php b/app/Http/Controllers/Sales/SalesDashboardController.php
index 53b523cd..15d9b88c 100644
--- a/app/Http/Controllers/Sales/SalesDashboardController.php
+++ b/app/Http/Controllers/Sales/SalesDashboardController.php
@@ -251,10 +251,8 @@ private function getDashboardData(Request $request): array
->get()
->keyBy('tenant_id');
- // 내가 유치한 영업파트너 목록 (드롭다운용)
- $allManagers = auth()->user()->children()
- ->where('is_active', true)
- ->get(['id', 'name', 'email']);
+ // 상담매니저 역할을 가진 모든 사용자 (드롭다운용)
+ $allManagers = $this->getAllManagerUsers();
// 내가 매니저로만 참여하는 건 (다른 사람이 등록, 내가 매니저)
$managerOnlyProspects = $this->getManagerOnlyProspects($currentUserId);
@@ -412,10 +410,8 @@ public function refreshTenantList(Request $request): View
->get()
->keyBy('tenant_id');
- // 내가 유치한 영업파트너 목록 (드롭다운용)
- $allManagers = auth()->user()->children()
- ->where('is_active', true)
- ->get(['id', 'name', 'email']);
+ // 상담매니저 역할을 가진 모든 사용자 (드롭다운용)
+ $allManagers = $this->getAllManagerUsers();
return view('sales.dashboard.partials.tenant-list', compact(
'tenants',
@@ -768,6 +764,58 @@ private function getCommissionData(): array
return compact('commissionSummary', 'recentCommissions', 'partner');
}
+ /**
+ * 상담매니저 역할을 가진 모든 사용자 조회
+ * (tenant_id 조건 없이 - 파트너 관리 페이지와 동일한 방식)
+ */
+ private function getAllManagerUsers()
+ {
+ return User::whereHas('userRoles.role', fn($q) => $q->where('name', 'manager'))
+ ->where('is_active', true)
+ ->where('id', '!=', auth()->id()) // 본인 제외
+ ->get(['id', 'name', 'email']);
+ }
+
+ /**
+ * 매니저 검색 API (AJAX)
+ * (tenant_id 조건 없이 - 모든 상담매니저 검색 가능)
+ */
+ public function searchManagers(Request $request): JsonResponse
+ {
+ $query = $request->input('q', '');
+ $authId = auth()->id();
+
+ // 디버깅: SQL 쿼리 로깅
+ \DB::enableQueryLog();
+
+ $managers = User::whereHas('userRoles.role', fn($q) => $q->where('name', 'manager'))
+ ->where('is_active', true)
+ ->where('id', '!=', $authId)
+ ->when($query, function ($q) use ($query) {
+ $q->where(function ($subQ) use ($query) {
+ $subQ->where('name', 'like', "%{$query}%")
+ ->orWhere('email', 'like', "%{$query}%");
+ });
+ })
+ ->limit(10)
+ ->get(['id', 'name', 'email']);
+
+ $sqlLog = \DB::getQueryLog();
+
+ \Log::info('searchManagers 디버그', [
+ 'query' => $query,
+ 'auth_id' => $authId,
+ 'result_count' => $managers->count(),
+ 'results' => $managers->pluck('name')->toArray(),
+ 'sql' => $sqlLog,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'managers' => $managers,
+ ]);
+ }
+
/**
* 영업파트너 가이드북 도움말 모달
*/
diff --git a/app/Http/Controllers/Sales/SalesManagerController.php b/app/Http/Controllers/Sales/SalesManagerController.php
index 3d0e1e3b..c78ec003 100644
--- a/app/Http/Controllers/Sales/SalesManagerController.php
+++ b/app/Http/Controllers/Sales/SalesManagerController.php
@@ -261,7 +261,7 @@ public function delegateRole(Request $request, int $id)
{
$validated = $request->validate([
'to_user_id' => 'required|exists:users,id',
- 'role_name' => 'required|string|in:manager,recruiter',
+ 'role_name' => 'required|string|in:manager',
]);
$fromUser = User::findOrFail($id);
@@ -270,7 +270,7 @@ public function delegateRole(Request $request, int $id)
try {
$this->service->delegateRole($fromUser, $toUser, $validated['role_name']);
- $roleLabel = $validated['role_name'] === 'manager' ? '매니저' : '유치담당';
+ $roleLabel = '상담매니저';
return redirect()->back()
->with('success', "{$roleLabel} 역할이 {$toUser->name}님에게 위임되었습니다.");
} catch (\InvalidArgumentException $e) {
@@ -285,13 +285,13 @@ public function delegateRole(Request $request, int $id)
public function assignRole(Request $request, int $id)
{
$validated = $request->validate([
- 'role_name' => 'required|string|in:sales,manager,recruiter',
+ 'role_name' => 'required|string|in:sales,manager',
]);
$partner = User::findOrFail($id);
$this->service->assignRole($partner, $validated['role_name']);
- $roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'];
+ $roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저'];
return redirect()->back()
->with('success', "{$roleLabels[$validated['role_name']]} 역할이 부여되었습니다.");
}
@@ -302,13 +302,13 @@ public function assignRole(Request $request, int $id)
public function removeRole(Request $request, int $id)
{
$validated = $request->validate([
- 'role_name' => 'required|string|in:sales,manager,recruiter',
+ 'role_name' => 'required|string|in:sales,manager',
]);
$partner = User::findOrFail($id);
$this->service->removeRole($partner, $validated['role_name']);
- $roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'];
+ $roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저'];
return redirect()->back()
->with('success', "{$roleLabels[$validated['role_name']]} 역할이 제거되었습니다.");
}
diff --git a/app/Http/Controllers/Sales/SalesScenarioController.php b/app/Http/Controllers/Sales/SalesScenarioController.php
index fee9fe69..d2acd2bd 100644
--- a/app/Http/Controllers/Sales/SalesScenarioController.php
+++ b/app/Http/Controllers/Sales/SalesScenarioController.php
@@ -228,6 +228,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View
$steps = config('sales_scenario.manager_steps');
$currentStep = (int) $request->input('step', 1);
$icons = config('sales_scenario.icons');
+ $readonly = $request->boolean('readonly', false);
// 가망고객 영업 관리 정보 조회 또는 생성
$management = SalesTenantManagement::findOrCreateByProspect($prospectId);
@@ -250,6 +251,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View
'icons' => $icons,
'management' => $management,
'isProspect' => true,
+ 'readonly' => $readonly,
]);
}
@@ -262,6 +264,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View
'icons' => $icons,
'management' => $management,
'isProspect' => true,
+ 'readonly' => $readonly,
]);
}
diff --git a/app/Http/Controllers/Sales/TenantProspectController.php b/app/Http/Controllers/Sales/TenantProspectController.php
index b0f7c30a..1a707b45 100644
--- a/app/Http/Controllers/Sales/TenantProspectController.php
+++ b/app/Http/Controllers/Sales/TenantProspectController.php
@@ -171,9 +171,10 @@ public function destroy(int $id)
->with('error', '이미 테넌트로 전환된 영업권은 삭제할 수 없습니다.');
}
- // 본인 또는 관리자만 삭제 가능
- if ($prospect->registered_by !== auth()->id()) {
- // TODO: 관리자 권한 체크 추가
+ // 관리자만 삭제 가능
+ if (!auth()->user()->isAdmin()) {
+ return redirect()->route('sales.prospects.index')
+ ->with('error', '삭제 권한이 없습니다. 본사 운영팀에 문의하세요.');
}
$prospect->delete();
diff --git a/app/Models/Barobill/AccountCode.php b/app/Models/Barobill/AccountCode.php
index aafa2c7d..7c295872 100644
--- a/app/Models/Barobill/AccountCode.php
+++ b/app/Models/Barobill/AccountCode.php
@@ -36,14 +36,31 @@ public function tenant(): BelongsTo
}
/**
- * 테넌트별 활성 계정과목 조회
+ * 테넌트별 활성 계정과목 조회 (하위 호환용)
*/
public static function getActiveByTenant(int $tenantId)
{
- return self::where('tenant_id', $tenantId)
- ->where('is_active', true)
+ return self::getActive();
+ }
+
+ /**
+ * 전체 활성 계정과목 조회 (글로벌)
+ */
+ public static function getActive()
+ {
+ return self::where('is_active', true)
->orderBy('sort_order')
->orderBy('code')
->get();
}
+
+ /**
+ * 전체 계정과목 조회 (글로벌)
+ */
+ public static function getAll()
+ {
+ return self::orderBy('sort_order')
+ ->orderBy('code')
+ ->get();
+ }
}
diff --git a/app/Models/Barobill/BarobillConfig.php b/app/Models/Barobill/BarobillConfig.php
index 4439350b..b63104fd 100644
--- a/app/Models/Barobill/BarobillConfig.php
+++ b/app/Models/Barobill/BarobillConfig.php
@@ -40,27 +40,25 @@ class BarobillConfig extends Model
];
/**
- * 활성화된 테스트 서버 설정 조회
+ * 테스트 서버 설정 조회 (환경 기준, is_active 무관)
*/
public static function getActiveTest(): ?self
{
- return self::where('environment', 'test')
- ->where('is_active', true)
- ->first();
+ return self::where('environment', 'test')->first();
}
/**
- * 활성화된 운영 서버 설정 조회
+ * 운영 서버 설정 조회 (환경 기준, is_active 무관)
*/
public static function getActiveProduction(): ?self
{
- return self::where('environment', 'production')
- ->where('is_active', true)
- ->first();
+ return self::where('environment', 'production')->first();
}
/**
- * 현재 환경에 맞는 활성 설정 조회
+ * 현재 환경에 맞는 설정 조회
+ *
+ * 테넌트별 서버 모드 지원을 위해 is_active 조건 제거
*/
public static function getActive(bool $isTestMode = false): ?self
{
diff --git a/app/Models/Barobill/BarobillMember.php b/app/Models/Barobill/BarobillMember.php
index 7c087565..01d79f2e 100644
--- a/app/Models/Barobill/BarobillMember.php
+++ b/app/Models/Barobill/BarobillMember.php
@@ -27,6 +27,9 @@ class BarobillMember extends Model
'manager_email',
'manager_hp',
'status',
+ 'server_mode',
+ 'last_sales_fetch_at',
+ 'last_purchases_fetch_at',
];
protected $casts = [
@@ -34,6 +37,8 @@ class BarobillMember extends Model
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
'barobill_pwd' => 'encrypted', // 복호화 가능한 암호화 (바로빌 API 호출 시 필요)
+ 'last_sales_fetch_at' => 'datetime',
+ 'last_purchases_fetch_at' => 'datetime',
];
protected $hidden = [
@@ -85,4 +90,36 @@ public function getStatusColorAttribute(): string
default => 'bg-gray-100 text-gray-800',
};
}
+
+ /**
+ * 서버 모드 라벨
+ */
+ public function getServerModeLabelAttribute(): string
+ {
+ return match ($this->server_mode) {
+ 'test' => '테스트',
+ 'production' => '운영',
+ default => '테스트',
+ };
+ }
+
+ /**
+ * 서버 모드별 색상 클래스
+ */
+ public function getServerModeColorAttribute(): string
+ {
+ return match ($this->server_mode) {
+ 'test' => 'bg-amber-100 text-amber-800',
+ 'production' => 'bg-green-100 text-green-800',
+ default => 'bg-amber-100 text-amber-800',
+ };
+ }
+
+ /**
+ * 테스트 모드 여부
+ */
+ public function isTestMode(): bool
+ {
+ return $this->server_mode !== 'production';
+ }
}
diff --git a/app/Models/CorporateVehicle.php b/app/Models/CorporateVehicle.php
new file mode 100644
index 00000000..66a2dad1
--- /dev/null
+++ b/app/Models/CorporateVehicle.php
@@ -0,0 +1,51 @@
+ 'integer',
+ 'mileage' => 'integer',
+ 'purchase_price' => 'integer',
+ 'vehicle_price' => 'integer',
+ 'residual_value' => 'integer',
+ 'deposit' => 'integer',
+ 'monthly_rent' => 'integer',
+ 'monthly_rent_tax' => 'integer',
+ ];
+}
diff --git a/app/Models/Sales/SalesCommission.php b/app/Models/Sales/SalesCommission.php
index 28c1bccb..3f56279d 100644
--- a/app/Models/Sales/SalesCommission.php
+++ b/app/Models/Sales/SalesCommission.php
@@ -20,6 +20,12 @@
* @property string $payment_type
* @property float $payment_amount
* @property string $payment_date
+ * @property string|null $first_payment_at 1차 납입완료일
+ * @property string|null $first_partner_paid_at 1차 파트너 수당지급일
+ * @property string|null $second_payment_at 2차 납입완료일
+ * @property string|null $second_partner_paid_at 2차 파트너 수당지급일
+ * @property string|null $first_subscription_at 첫 구독료 입금일 (매니저 수당 기준)
+ * @property string|null $manager_paid_at 매니저 수당지급일
* @property float $base_amount
* @property float $partner_rate
* @property float $manager_rate
@@ -79,6 +85,12 @@ class SalesCommission extends Model
'payment_type',
'payment_amount',
'payment_date',
+ 'first_payment_at',
+ 'first_partner_paid_at',
+ 'second_payment_at',
+ 'second_partner_paid_at',
+ 'first_subscription_at',
+ 'manager_paid_at',
'base_amount',
'partner_rate',
'manager_rate',
@@ -103,6 +115,12 @@ class SalesCommission extends Model
'partner_commission' => 'decimal:2',
'manager_commission' => 'decimal:2',
'payment_date' => 'date',
+ 'first_payment_at' => 'date',
+ 'first_partner_paid_at' => 'date',
+ 'second_payment_at' => 'date',
+ 'second_partner_paid_at' => 'date',
+ 'first_subscription_at' => 'date',
+ 'manager_paid_at' => 'date',
'scheduled_payment_date' => 'date',
'actual_payment_date' => 'date',
'approved_at' => 'datetime',
@@ -290,4 +308,147 @@ public function scopePaymentDateBetween(Builder $query, string $startDate, strin
{
return $query->whereBetween('payment_date', [$startDate, $endDate]);
}
+
+ // ========================================
+ // 2단계 수당 지급 관련 메서드
+ // ========================================
+
+ /**
+ * 1차 납입완료 처리
+ */
+ public function recordFirstPayment(?Carbon $paymentDate = null): bool
+ {
+ return $this->update([
+ 'first_payment_at' => $paymentDate ?? now(),
+ ]);
+ }
+
+ /**
+ * 1차 파트너 수당 지급 처리
+ */
+ public function recordFirstPartnerPaid(?Carbon $paidDate = null): bool
+ {
+ if (!$this->first_payment_at) {
+ return false; // 1차 납입이 먼저 완료되어야 함
+ }
+
+ return $this->update([
+ 'first_partner_paid_at' => $paidDate ?? now(),
+ ]);
+ }
+
+ /**
+ * 2차 납입완료 처리
+ */
+ public function recordSecondPayment(?Carbon $paymentDate = null): bool
+ {
+ return $this->update([
+ 'second_payment_at' => $paymentDate ?? now(),
+ ]);
+ }
+
+ /**
+ * 2차 파트너 수당 지급 처리
+ */
+ public function recordSecondPartnerPaid(?Carbon $paidDate = null): bool
+ {
+ if (!$this->second_payment_at) {
+ return false; // 2차 납입이 먼저 완료되어야 함
+ }
+
+ return $this->update([
+ 'second_partner_paid_at' => $paidDate ?? now(),
+ ]);
+ }
+
+ /**
+ * 첫 구독료 입금 기록 (매니저 수당 기준)
+ */
+ public function recordFirstSubscription(?Carbon $subscriptionDate = null): bool
+ {
+ return $this->update([
+ 'first_subscription_at' => $subscriptionDate ?? now(),
+ ]);
+ }
+
+ /**
+ * 매니저 수당 지급 처리
+ */
+ public function recordManagerPaid(?Carbon $paidDate = null): bool
+ {
+ if (!$this->first_subscription_at) {
+ return false; // 첫 구독료 입금이 먼저 완료되어야 함
+ }
+
+ return $this->update([
+ 'manager_paid_at' => $paidDate ?? now(),
+ ]);
+ }
+
+ /**
+ * 1차 파트너 수당 지급예정일 (1차 납입일 익월 10일)
+ */
+ public function getFirstPartnerScheduledDateAttribute(): ?Carbon
+ {
+ if (!$this->first_payment_at) {
+ return null;
+ }
+ return Carbon::parse($this->first_payment_at)->addMonth()->day(10);
+ }
+
+ /**
+ * 2차 파트너 수당 지급예정일 (2차 납입일 익월 10일)
+ */
+ public function getSecondPartnerScheduledDateAttribute(): ?Carbon
+ {
+ if (!$this->second_payment_at) {
+ return null;
+ }
+ return Carbon::parse($this->second_payment_at)->addMonth()->day(10);
+ }
+
+ /**
+ * 매니저 수당 지급예정일 (첫 구독료 입금일 익월 10일)
+ */
+ public function getManagerScheduledDateAttribute(): ?Carbon
+ {
+ if (!$this->first_subscription_at) {
+ return null;
+ }
+ return Carbon::parse($this->first_subscription_at)->addMonth()->day(10);
+ }
+
+ /**
+ * 파트너 수당 1차 지급 완료 여부
+ */
+ public function isFirstPartnerPaid(): bool
+ {
+ return $this->first_partner_paid_at !== null;
+ }
+
+ /**
+ * 파트너 수당 2차 지급 완료 여부
+ */
+ public function isSecondPartnerPaid(): bool
+ {
+ return $this->second_partner_paid_at !== null;
+ }
+
+ /**
+ * 매니저 수당 지급 완료 여부
+ */
+ public function isManagerPaid(): bool
+ {
+ return $this->manager_paid_at !== null;
+ }
+
+ /**
+ * 전체 수당 지급 완료 여부 (파트너 1차/2차 + 매니저)
+ */
+ public function isFullyPaid(): bool
+ {
+ return $this->isFirstPartnerPaid()
+ && $this->isSecondPartnerPaid()
+ && $this->isManagerPaid();
+ }
}
diff --git a/app/Services/Barobill/BarobillService.php b/app/Services/Barobill/BarobillService.php
index f61bc5bf..39b82202 100644
--- a/app/Services/Barobill/BarobillService.php
+++ b/app/Services/Barobill/BarobillService.php
@@ -96,9 +96,55 @@ class BarobillService
public function __construct()
{
- // .env에서 테스트 모드 설정 가져오기 (기본값: true = 테스트 모드)
+ // 기본값: .env 설정 사용
$this->isTestMode = config('services.barobill.test_mode', true);
+ $this->initializeConfig();
+ }
+ /**
+ * 서버 모드 전환 (회원사별 설정 적용)
+ *
+ * @param bool $isTestMode true: 테스트서버, false: 운영서버
+ */
+ public function switchServerMode(bool $isTestMode): self
+ {
+ if ($this->isTestMode !== $isTestMode) {
+ $this->isTestMode = $isTestMode;
+ // SOAP 클라이언트 초기화 (새 서버로 재연결)
+ $this->corpStateClient = null;
+ $this->tiClient = null;
+ $this->bankAccountClient = null;
+ $this->cardClient = null;
+ // 설정 재로드
+ $this->initializeConfig();
+ }
+
+ return $this;
+ }
+
+ /**
+ * 서버 모드 문자열로 전환
+ *
+ * @param string $mode 'test' 또는 'production'
+ */
+ public function setServerMode(string $mode): self
+ {
+ return $this->switchServerMode($mode === 'test');
+ }
+
+ /**
+ * 현재 서버 모드 조회
+ */
+ public function getServerMode(): string
+ {
+ return $this->isTestMode ? 'test' : 'production';
+ }
+
+ /**
+ * 설정 초기화 (서버 모드에 따른 설정 로드)
+ */
+ protected function initializeConfig(): void
+ {
// DB에서 활성화된 설정 가져오기 (우선순위)
$dbConfig = $this->loadConfigFromDatabase();
diff --git a/app/Services/Barobill/BarobillUsageService.php b/app/Services/Barobill/BarobillUsageService.php
index 7669fc13..9cc38e28 100644
--- a/app/Services/Barobill/BarobillUsageService.php
+++ b/app/Services/Barobill/BarobillUsageService.php
@@ -16,7 +16,7 @@
* 과금 정책 (DB 관리):
* - 전자세금계산서: 기본 100건 무료, 추가 50건 단위 5,000원
* - 계좌조회: 기본 1계좌 무료, 추가 1계좌당 10,000원
- * - 카드등록: 기본 3장 무료, 추가 1장당 5,000원
+ * - 카드등록: 기본 5장 무료, 추가 1장당 5,000원
*/
class BarobillUsageService
{
@@ -270,12 +270,12 @@ public static function getPriceInfo(): array
],
'card' => [
'name' => '법인카드 등록',
- 'free_quota' => 3,
+ 'free_quota' => 5,
'free_quota_unit' => '장',
'additional_unit' => 1,
'additional_unit_label' => '장',
'additional_price' => 5000,
- 'description' => '기본 3장 무료, 추가 1장당 5,000원',
+ 'description' => '기본 5장 무료, 추가 1장당 5,000원',
],
'hometax' => [
'name' => '홈텍스 매입/매출',
diff --git a/app/Services/Sales/SalesManagerService.php b/app/Services/Sales/SalesManagerService.php
index c111b3e6..d3d1d54d 100644
--- a/app/Services/Sales/SalesManagerService.php
+++ b/app/Services/Sales/SalesManagerService.php
@@ -19,7 +19,7 @@ class SalesManagerService
/**
* 영업파트너 역할 이름 목록
*/
- public const SALES_ROLES = ['sales', 'manager', 'recruiter'];
+ public const SALES_ROLES = ['sales', 'manager'];
/**
* 영업파트너 생성
@@ -510,9 +510,6 @@ public function getStats(?int $parentId = null): array
'manager' => (clone $baseQuery)
->whereHas('userRoles.role', fn($q) => $q->where('name', 'manager'))
->count(),
- 'recruiter' => (clone $baseQuery)
- ->whereHas('userRoles.role', fn($q) => $q->where('name', 'recruiter'))
- ->count(),
];
}
diff --git a/database/seeders/BarobillPricingPolicySeeder.php b/database/seeders/BarobillPricingPolicySeeder.php
index 33324568..ff90105d 100644
--- a/database/seeders/BarobillPricingPolicySeeder.php
+++ b/database/seeders/BarobillPricingPolicySeeder.php
@@ -16,8 +16,8 @@ public function run(): void
[
'service_type' => 'card',
'name' => '법인카드 등록',
- 'description' => '법인카드 등록 기본 3장 제공, 추가 시 장당 과금',
- 'free_quota' => 3,
+ 'description' => '법인카드 등록 기본 5장 제공, 추가 시 장당 과금',
+ 'free_quota' => 5,
'free_quota_unit' => '장',
'additional_unit' => 1,
'additional_unit_label' => '장',
diff --git a/database/seeders/PartnerMenuRenameSeeder.php b/database/seeders/PartnerMenuRenameSeeder.php
new file mode 100644
index 00000000..2b2efa90
--- /dev/null
+++ b/database/seeders/PartnerMenuRenameSeeder.php
@@ -0,0 +1,31 @@
+where('name', '영업파트너 관리')
+ ->first();
+
+ if ($menu) {
+ $menu->name = '파트너 관리';
+ $menu->save();
+ $this->command->info('메뉴 이름 변경: 영업파트너 관리 → 파트너 관리');
+ } else {
+ $this->command->warn('영업파트너 관리 메뉴를 찾을 수 없습니다.');
+ }
+ }
+}
diff --git a/database/seeders/SalesRoleSeeder.php b/database/seeders/SalesRoleSeeder.php
index 3fc3e9a4..ca731dd7 100644
--- a/database/seeders/SalesRoleSeeder.php
+++ b/database/seeders/SalesRoleSeeder.php
@@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\Role;
+use App\Models\UserRole;
use Illuminate\Database\Seeder;
class SalesRoleSeeder extends Seeder
@@ -20,15 +21,11 @@ public function run(): void
$roles = [
[
'name' => 'sales',
- 'description' => '영업 - 가망고객 발굴, 상담, 계약 체결',
+ 'description' => '영업파트너 - 고객 발굴, 계약 체결',
],
[
'name' => 'manager',
- 'description' => '매니저 - 하위 파트너 관리, 실적 취합, 승인 처리',
- ],
- [
- 'name' => 'recruiter',
- 'description' => '유치담당 - 새로운 영업파트너 유치 활동',
+ 'description' => '상담매니저 - 고객 상담, 인터뷰 정리',
],
];
@@ -45,6 +42,23 @@ public function run(): void
);
}
- $this->command->info('영업파트너 역할이 생성되었습니다: sales, manager, recruiter');
+ // recruiter 역할 삭제 (더 이상 사용하지 않음)
+ $recruiterRole = Role::where('tenant_id', $tenantId)
+ ->where('name', 'recruiter')
+ ->first();
+
+ if ($recruiterRole) {
+ // 해당 역할을 가진 user_roles 강제 삭제 (soft delete 포함)
+ UserRole::withTrashed()
+ ->where('role_id', $recruiterRole->id)
+ ->forceDelete();
+ $this->command->info('recruiter 역할 할당이 삭제되었습니다.');
+
+ // 역할 강제 삭제
+ $recruiterRole->forceDelete();
+ $this->command->info('recruiter 역할이 삭제되었습니다.');
+ }
+
+ $this->command->info('영업파트너 역할이 생성되었습니다: sales, manager');
}
}
diff --git a/docs/TABLE_LAYOUT_STANDARD.md b/docs/TABLE_LAYOUT_STANDARD.md
index f4665e8d..820a8521 100644
--- a/docs/TABLE_LAYOUT_STANDARD.md
+++ b/docs/TABLE_LAYOUT_STANDARD.md
@@ -656,14 +656,147 @@ #### `resources/views/products/partials/table.blade.php`
---
-## 10. 문서 이력
+## 10. React 테이블 (Blade + Babel)
+
+> **참조 파일**: `resources/views/barobill/etax/index.blade.php`
+> **작성일**: 2026-02-03
+
+Blade 템플릿 내에서 React(Babel)를 사용하는 경우, 테이블 컬럼 너비 설정 시 주의해야 할 사항이 있습니다.
+
+### 10.1 colgroup을 사용한 컬럼 너비 지정
+
+React에서 `table-fixed` 레이아웃과 함께 컬럼 너비를 정확하게 지정하려면 `colgroup`을 사용해야 합니다. Tailwind의 `w-[]` 클래스만으로는 정확한 너비 적용이 어려울 수 있습니다.
+
+#### 잘못된 예시 (Tailwind 클래스만 사용)
+
+```jsx
+// ❌ 테이블 셀에 Tailwind 클래스만 적용 - 비율이 의도대로 안 될 수 있음
+
+
+
+
품목명
+
수량
+
단가
+
+
+
+```
+
+#### 올바른 예시 (colgroup 사용)
+
+```jsx
+// ✅ colgroup으로 명시적 너비 지정
+
+
+
{/* 품목명 - 가장 넓게 */}
+
{/* 수량 - 작게 고정 */}
+
{/* 단가 - 수량보다 넓게 */}
+
{/* 공급가액 */}
+
{/* 세액 */}
+
{/* 금액 */}
+
{/* 과세 (select) */}
+
{/* 삭제 버튼 */}
+
+
+
+
품목명
+
수량
+
단가
+ {/* ... */}
+
+
+
+```
+
+### 10.2 Blade 템플릿에서 React 스타일 객체 이스케이프
+
+**중요**: Blade 템플릿(`.blade.php`)에서 React의 스타일 객체 `{{ }}`를 사용하면 Blade가 이를 PHP echo 구문으로 해석하여 에러가 발생합니다.
+
+#### 에러 발생 코드
+
+```jsx
+// ❌ Blade가 {{ }}를 PHP 변수로 해석 → 에러 발생
+
+// Error: Unknown named parameter $tableLayout
+```
+
+#### 해결 방법: `@{{ }}` 사용
+
+```jsx
+// ✅ @를 붙여 Blade 이스케이프 처리
+
+
+```
+
+`@{{ }}`를 사용하면 Blade가 해당 구문을 처리하지 않고 그대로 `{{ }}`로 출력하여 React/Babel이 정상적으로 해석합니다.
+
+### 10.3 입력 테이블 컬럼 비율 가이드
+
+품목 입력 테이블의 권장 컬럼 비율:
+
+| 컬럼 | 너비 | 설명 |
+|------|------|------|
+| 품목명 | **30%** | 텍스트 입력, 가장 넓게 |
+| 수량 | **60px** | 작은 숫자 입력, 고정 너비 |
+| 단가 | **100px** | 금액 입력, 수량보다 넓게 |
+| 공급가액 | **12%** | 계산된 금액 표시 |
+| 세액 | **10%** | 계산된 금액 표시 |
+| 금액 | **12%** | 합계 금액 표시 |
+| 과세유형 | **70px** | select 박스 |
+| 삭제 | **40px** | 버튼 |
+
+#### 비율 설정 원칙
+
+1. **입력 필드**는 내용에 맞는 적절한 너비 확보
+2. **수량**은 보통 작은 숫자이므로 좁게 (60px)
+3. **단가/금액**은 큰 숫자를 표시하므로 넉넉하게
+4. **품목명**은 텍스트 입력이므로 가장 넓게 (%)
+5. **버튼/아이콘**은 고정 픽셀 (px)
+
+### 10.4 전체 예제 코드
+
+```jsx
+// Blade 템플릿 내 React 코드 (@push('scripts') 내부)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
품목명
+
수량
+
단가
+
공급가액
+
세액
+
금액
+
과세
+
+
+
+
+ {/* 데이터 행들 */}
+
+
+```
+
+---
+
+## 11. 문서 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|----------|
| 1.0 | 2025-11-25 | Claude | 초안 작성 (권한 관리 페이지 기반) |
+| 1.1 | 2026-02-03 | Claude | React 테이블 섹션 추가 (colgroup, Blade 이스케이프) |
---
-## 11. 문의
+## 12. 문의
이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요.
\ No newline at end of file
diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php
index 96a9bbee..1f123450 100644
--- a/resources/views/barobill/eaccount/index.blade.php
+++ b/resources/views/barobill/eaccount/index.blade.php
@@ -80,6 +80,14 @@
const CSRF_TOKEN = '{{ csrf_token() }}';
+ // 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준
+ const formatLocalDate = (date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
// 날짜 유틸리티 함수
const getMonthDates = (offset = 0) => {
const now = new Date();
@@ -88,8 +96,8 @@
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
return {
- from: firstDay.toISOString().split('T')[0],
- to: lastDay.toISOString().split('T')[0]
+ from: formatLocalDate(firstDay),
+ to: formatLocalDate(lastDay)
};
};
@@ -102,53 +110,46 @@
}
};
- // StatCard Component
- const StatCard = ({ title, value, subtext, icon, color = 'blue' }) => {
+ // CompactStat Component (큰 크기 통계 표시)
+ const CompactStat = ({ label, value, color = 'stone' }) => {
const colorClasses = {
- blue: 'bg-blue-50 text-blue-600',
- green: 'bg-green-50 text-green-600',
- red: 'bg-red-50 text-red-600',
- stone: 'bg-stone-50 text-stone-600'
+ blue: 'text-blue-600',
+ green: 'text-green-600',
+ red: 'text-red-600',
+ stone: 'text-stone-700'
};
return (
-