chore: [env] .env.example 업데이트 및 .gitignore 정리

- .env.example을 SAM 프로젝트 실제 키 구조로 업데이트
- .gitignore에 !.env.example 예외 추가
- GCS_* 중복 키 제거, Gemini/Claude/Vertex 키 섹션 추가
This commit is contained in:
김보곤
2026-02-23 10:17:37 +09:00
parent 3ae3a1dcda
commit 240199af9d
51 changed files with 623 additions and 2726 deletions

137
.env.example Normal file
View File

@@ -0,0 +1,137 @@
# ─────────────────────────────────────────────────
# SAM API (REST API 서버) 환경 변수
# ─────────────────────────────────────────────────
# 이 파일을 .env로 복사한 후 실제 값을 입력하세요.
# cp .env.example .env && php artisan key:generate
# ─────────────────────────────────────────────────
APP_NAME="SAM API"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://api.sam.kr/
APP_LOCALE=ko
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ko_KR
APP_TIMEZONE=Asia/Seoul
APP_MAINTENANCE_DRIVER=file
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# ─── Slack 로그 알림 ───
LOG_SLACK_WEBHOOK_URL=
LOG_SLACK_USERNAME=API_SERVER
LOG_SLACK_EMOJI=:boom:
# ─── Database ───
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=samdb
DB_USERNAME=samuser
DB_PASSWORD=sampass
# 도커 환경: docker-compose.yml의 환경변수로 오버라이드 (DB_HOST=sam-mysql-1)
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# ─── Mail ───
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME="${APP_NAME}"
# ─── AWS (미사용) ───
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# ─── Swagger ───
L5_SWAGGER_GENERATE_ALWAYS=true
L5_SWAGGER_CONST_HOST=https://api.sam.kr/
L5_SWAGGER_CONST_NAME="SAM API 서버"
# ─── Sanctum 토큰 만료 (분) ───
SANCTUM_ACCESS_TOKEN_EXPIRATION=120
SANCTUM_REFRESH_TOKEN_EXPIRATION=10080
# ─── 내부 통신 키 (MNG ↔ API HMAC 검증) ───
# MNG 프로젝트의 INTERNAL_EXCHANGE_SECRET과 동일한 값 사용
INTERNAL_EXCHANGE_SECRET=
# ─── Firebase (FCM) ───
FCM_PROJECT_ID=
FCM_SA_PATH=secrets/codebridge-x-firebase-sa.json
# ─── 5130 Legacy DB ───
CHANDJ_DB_HOST=sam-mysql-1
CHANDJ_DB_DATABASE=chandj
CHANDJ_DB_USERNAME=root
CHANDJ_DB_PASSWORD=root
# ─── 바로빌 SOAP API ───
BAROBILL_CERT_KEY_TEST=
BAROBILL_CERT_KEY_PROD=
BAROBILL_CORP_NUM=
BAROBILL_TEST_MODE=true
# ─────────────────────────────────────────────────
# 공유 API 키 (MNG 프로젝트와 동일한 값 사용)
# ─────────────────────────────────────────────────
# ─── Google Gemini AI ───
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.0-flash
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
GEMINI_PROJECT_ID=codebridge-chatbot
# ─── Claude AI ───
CLAUDE_API_KEY=
# ─── Vertex AI (Veo 영상 생성) ───
VERTEX_AI_PROJECT_ID=codebridge-chatbot
VERTEX_AI_LOCATION=us-central1
# ─── Google Cloud (STT + GCS Storage) ───
GOOGLE_APPLICATION_CREDENTIALS=/var/www/mng/apikey/google_service_account.json
GOOGLE_STORAGE_BUCKET=codebridge-speech-audio-files
GOOGLE_STT_LOCATION=asia-southeast1
# ─── FCM (Firebase Cloud Messaging) ───
FCM_BATCH_CHUNK_SIZE=200
FCM_BATCH_DELAY_MS=100
FCM_LOGGING_ENABLED=true
FCM_LOG_CHANNEL=stack

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@
!storage/.gitignore
.env
.env.*
!.env.example
.phpunit.result.cache
Homestead.yaml
Homestead.json

View File

@@ -1,73 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Barobill\BankServiceUrlRequest;
use App\Http\Requests\Barobill\BarobillLoginRequest;
use App\Http\Requests\Barobill\BarobillSignupRequest;
use App\Services\Barobill\BarobillSoapService;
class BarobillController extends Controller
{
public function __construct(
private BarobillSoapService $barobillSoapService
) {}
public function login(BarobillLoginRequest $request)
{
return ApiResponse::handle(
fn () => $this->barobillSoapService->registerLogin($request->validated()),
__('message.barobill.login_success')
);
}
public function signup(BarobillSignupRequest $request)
{
return ApiResponse::handle(
fn () => $this->barobillSoapService->registerSignup($request->validated()),
__('message.barobill.signup_success')
);
}
public function bankServiceUrl(BankServiceUrlRequest $request)
{
return ApiResponse::handle(
fn () => $this->barobillSoapService->getBankServiceRedirectUrl($request->validated()),
__('message.fetched')
);
}
public function status()
{
return ApiResponse::handle(
fn () => $this->barobillSoapService->getIntegrationStatus(),
__('message.fetched')
);
}
public function accountLinkUrl()
{
return ApiResponse::handle(
fn () => $this->barobillSoapService->getAccountLinkRedirectUrl(),
__('message.fetched')
);
}
public function cardLinkUrl()
{
return ApiResponse::handle(
fn () => $this->barobillSoapService->getCardLinkRedirectUrl(),
__('message.fetched')
);
}
public function certificateUrl()
{
return ApiResponse::handle(
fn () => $this->barobillSoapService->getCertificateRedirectUrl(),
__('message.fetched')
);
}
}

View File

@@ -1,97 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\CorporateCard\StoreCorporateCardRequest;
use App\Http\Requests\V1\CorporateCard\UpdateCorporateCardRequest;
use App\Services\CorporateCardService;
use Illuminate\Http\Request;
class CorporateCardController extends Controller
{
public function __construct(
private readonly CorporateCardService $service
) {}
/**
* 법인카드 목록
*/
public function index(Request $request)
{
$params = $request->only([
'search',
'status',
'card_type',
'sort_by',
'sort_dir',
'per_page',
'page',
]);
$cards = $this->service->index($params);
return ApiResponse::success($cards, __('message.fetched'));
}
/**
* 법인카드 등록
*/
public function store(StoreCorporateCardRequest $request)
{
$card = $this->service->store($request->validated());
return ApiResponse::success($card, __('message.created'), [], 201);
}
/**
* 법인카드 상세
*/
public function show(int $id)
{
$card = $this->service->show($id);
return ApiResponse::success($card, __('message.fetched'));
}
/**
* 법인카드 수정
*/
public function update(int $id, UpdateCorporateCardRequest $request)
{
$card = $this->service->update($id, $request->validated());
return ApiResponse::success($card, __('message.updated'));
}
/**
* 법인카드 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::success(null, __('message.deleted'));
}
/**
* 법인카드 상태 토글 (사용/정지)
*/
public function toggle(int $id)
{
$card = $this->service->toggleStatus($id);
return ApiResponse::success($card, __('message.updated'));
}
/**
* 활성 법인카드 목록 (셀렉트박스용)
*/
public function active()
{
$cards = $this->service->getActiveCards();
return ApiResponse::success($cards, __('message.fetched'));
}
}

View File

@@ -4,30 +4,28 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Authz\RoleIndexRequest;
use App\Http\Requests\Authz\RoleStoreRequest;
use App\Http\Requests\Authz\RoleUpdateRequest;
use App\Services\Authz\RoleService;
use Illuminate\Http\Request;
class RoleController extends Controller
{
/**
* 역할 목록 조회
*/
public function index(RoleIndexRequest $request)
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return RoleService::index($request->validated());
return RoleService::index($request->all());
}, __('message.fetched'));
}
/**
* 역할 생성
*/
public function store(RoleStoreRequest $request)
public function store(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return RoleService::store($request->validated());
return RoleService::store($request->all());
}, __('message.created'));
}
@@ -44,10 +42,10 @@ public function show($id)
/**
* 역할 수정
*/
public function update(RoleUpdateRequest $request, $id)
public function update(Request $request, $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return RoleService::update((int) $id, $request->validated());
return RoleService::update((int) $id, $request->all());
}, __('message.updated'));
}

View File

@@ -4,38 +4,37 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Authz\RolePermissionGrantRequest;
use App\Http\Requests\Authz\RolePermissionToggleRequest;
use App\Services\Authz\RolePermissionService;
use Illuminate\Http\Request;
class RolePermissionController extends Controller
{
public function index($id)
public function index($id, Request $request)
{
return ApiResponse::handle(function () use ($id) {
return RolePermissionService::list((int) $id);
}, __('message.fetched'));
}, '역할 퍼미션 목록 조회');
}
public function grant($id, RolePermissionGrantRequest $request)
public function grant($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return RolePermissionService::grant((int) $id, $request->validated());
}, __('message.updated'));
return RolePermissionService::grant((int) $id, $request->all());
}, '역할 퍼미션 부여');
}
public function revoke($id, RolePermissionGrantRequest $request)
public function revoke($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return RolePermissionService::revoke((int) $id, $request->validated());
}, __('message.updated'));
return RolePermissionService::revoke((int) $id, $request->all());
}, '역할 퍼미션 회수');
}
public function sync($id, RolePermissionGrantRequest $request)
public function sync($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return RolePermissionService::sync((int) $id, $request->validated());
}, __('message.updated'));
return RolePermissionService::sync((int) $id, $request->all());
}, '역할 퍼미션 동기화');
}
/**
@@ -61,10 +60,10 @@ public function matrix($id)
/**
* 특정 메뉴의 특정 권한 토글
*/
public function toggle($id, RolePermissionToggleRequest $request)
public function toggle($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return RolePermissionService::toggle((int) $id, $request->validated());
return RolePermissionService::toggle((int) $id, $request->all());
}, __('message.updated'));
}

View File

@@ -6,7 +6,6 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\TaxInvoice\CancelTaxInvoiceRequest;
use App\Http\Requests\TaxInvoice\CreateTaxInvoiceRequest;
use App\Http\Requests\TaxInvoice\SaveSupplierSettingsRequest;
use App\Http\Requests\TaxInvoice\TaxInvoiceListRequest;
use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest;
use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest;
@@ -24,9 +23,11 @@ public function __construct(
*/
public function index(TaxInvoiceListRequest $request)
{
$taxInvoices = $this->taxInvoiceService->list($request->validated());
return ApiResponse::handle(
fn () => $this->taxInvoiceService->list($request->validated()),
__('message.fetched')
data: $taxInvoices,
message: __('message.fetched')
);
}
@@ -35,9 +36,11 @@ public function index(TaxInvoiceListRequest $request)
*/
public function show(int $id)
{
$taxInvoice = $this->taxInvoiceService->show($id);
return ApiResponse::handle(
fn () => $this->taxInvoiceService->show($id),
__('message.fetched')
data: $taxInvoice,
message: __('message.fetched')
);
}
@@ -46,9 +49,12 @@ public function show(int $id)
*/
public function store(CreateTaxInvoiceRequest $request)
{
$taxInvoice = $this->taxInvoiceService->create($request->validated());
return ApiResponse::handle(
fn () => $this->taxInvoiceService->create($request->validated()),
__('message.created')
data: $taxInvoice,
message: __('message.created'),
status: 201
);
}
@@ -57,9 +63,11 @@ public function store(CreateTaxInvoiceRequest $request)
*/
public function update(UpdateTaxInvoiceRequest $request, int $id)
{
$taxInvoice = $this->taxInvoiceService->update($id, $request->validated());
return ApiResponse::handle(
fn () => $this->taxInvoiceService->update($id, $request->validated()),
__('message.updated')
data: $taxInvoice,
message: __('message.updated')
);
}
@@ -68,9 +76,11 @@ public function update(UpdateTaxInvoiceRequest $request, int $id)
*/
public function destroy(int $id)
{
$this->taxInvoiceService->delete($id);
return ApiResponse::handle(
fn () => $this->taxInvoiceService->delete($id),
__('message.deleted')
data: null,
message: __('message.deleted')
);
}
@@ -79,9 +89,11 @@ public function destroy(int $id)
*/
public function issue(int $id)
{
$taxInvoice = $this->taxInvoiceService->issue($id);
return ApiResponse::handle(
fn () => $this->taxInvoiceService->issue($id),
__('message.tax_invoice.issued')
data: $taxInvoice,
message: __('message.tax_invoice.issued')
);
}
@@ -90,9 +102,11 @@ public function issue(int $id)
*/
public function bulkIssue(BulkIssueRequest $request)
{
$result = $this->taxInvoiceService->bulkIssue($request->getIds());
return ApiResponse::handle(
fn () => $this->taxInvoiceService->bulkIssue($request->getIds()),
__('message.tax_invoice.bulk_issued')
data: $result,
message: __('message.tax_invoice.bulk_issued')
);
}
@@ -101,9 +115,11 @@ public function bulkIssue(BulkIssueRequest $request)
*/
public function cancel(CancelTaxInvoiceRequest $request, int $id)
{
$taxInvoice = $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
return ApiResponse::handle(
fn () => $this->taxInvoiceService->cancel($id, $request->validated()['reason']),
__('message.tax_invoice.cancelled')
data: $taxInvoice,
message: __('message.tax_invoice.cancelled')
);
}
@@ -112,9 +128,11 @@ public function cancel(CancelTaxInvoiceRequest $request, int $id)
*/
public function checkStatus(int $id)
{
$taxInvoice = $this->taxInvoiceService->checkStatus($id);
return ApiResponse::handle(
fn () => $this->taxInvoiceService->checkStatus($id),
__('message.fetched')
data: $taxInvoice,
message: __('message.fetched')
);
}
@@ -123,42 +141,11 @@ public function checkStatus(int $id)
*/
public function summary(TaxInvoiceSummaryRequest $request)
{
return ApiResponse::handle(
fn () => $this->taxInvoiceService->summary($request->validated()),
__('message.fetched')
);
}
$summary = $this->taxInvoiceService->summary($request->validated());
/**
* 공급자 설정 조회
*/
public function supplierSettings()
{
return ApiResponse::handle(
fn () => $this->taxInvoiceService->getSupplierSettings(),
__('message.fetched')
);
}
/**
* 공급자 설정 저장
*/
public function saveSupplierSettings(SaveSupplierSettingsRequest $request)
{
return ApiResponse::handle(
fn () => $this->taxInvoiceService->saveSupplierSettings($request->validated()),
__('message.updated')
);
}
/**
* 세금계산서 생성 + 즉시 발행
*/
public function storeAndIssue(CreateTaxInvoiceRequest $request)
{
return ApiResponse::handle(
fn () => $this->taxInvoiceService->createAndIssue($request->validated()),
__('message.tax_invoice.issued')
data: $summary,
message: __('message.fetched')
);
}
}

View File

@@ -4,8 +4,8 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Authz\UserRoleGrantRequest;
use App\Services\Authz\UserRoleService;
use Illuminate\Http\Request;
class UserRoleController extends Controller
{
@@ -13,27 +13,27 @@ public function index($id)
{
return ApiResponse::handle(function () use ($id) {
return UserRoleService::list((int) $id);
}, __('message.fetched'));
}, '사용자의 역할 목록 조회');
}
public function grant($id, UserRoleGrantRequest $request)
public function grant($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return UserRoleService::grant((int) $id, $request->validated());
}, __('message.updated'));
return UserRoleService::grant((int) $id, $request->all());
}, '사용자에게 역할 부여');
}
public function revoke($id, UserRoleGrantRequest $request)
public function revoke($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return UserRoleService::revoke((int) $id, $request->validated());
}, __('message.updated'));
return UserRoleService::revoke((int) $id, $request->all());
}, '사용자의 역할 회수');
}
public function sync($id, UserRoleGrantRequest $request)
public function sync($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return UserRoleService::sync((int) $id, $request->validated());
}, __('message.updated'));
return UserRoleService::sync((int) $id, $request->all());
}, '사용자의 역할 동기화');
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Http\Requests\Authz;
use Illuminate\Foundation\Http\FormRequest;
class RoleIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => 'sometimes|integer|min:1',
'size' => 'sometimes|integer|min:1|max:100',
'q' => 'sometimes|nullable|string|max:100',
'is_hidden' => 'sometimes|boolean',
];
}
}

View File

@@ -1,38 +0,0 @@
<?php
namespace App\Http\Requests\Authz;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RolePermissionGrantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'permission_names' => 'sometimes|array',
'permission_names.*' => 'string|min:1',
'menus' => 'sometimes|array',
'menus.*' => 'integer|min:1',
'actions' => 'sometimes|array',
'actions.*' => [
'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])),
],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$data = $this->all();
if (empty($data['permission_names']) && (empty($data['menus']) || empty($data['actions']))) {
$validator->errors()->add('permission_names', __('error.role.permission_input_required'));
}
});
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Http\Requests\Authz;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RolePermissionToggleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$permissionTypes = config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']);
return [
'menu_id' => 'required|integer|min:1',
'permission_type' => ['required', 'string', Rule::in($permissionTypes)],
];
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests\Authz;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RoleStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = (int) app('tenant_id');
$guard = 'api';
return [
'name' => [
'required', 'string', 'max:100',
Rule::unique('roles', 'name')->where(fn ($q) => $q
->where('tenant_id', $tenantId)
->where('guard_name', $guard)),
],
'description' => 'nullable|string|max:255',
'is_hidden' => 'sometimes|boolean',
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Http\Requests\Authz;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RoleUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = (int) app('tenant_id');
$guard = 'api';
$roleId = (int) $this->route('id');
return [
'name' => [
'sometimes', 'string', 'max:100',
Rule::unique('roles', 'name')
->where(fn ($q) => $q->where('tenant_id', $tenantId)->where('guard_name', $guard))
->ignore($roleId),
],
'description' => 'sometimes|nullable|string|max:255',
'is_hidden' => 'sometimes|boolean',
];
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Http\Requests\Authz;
use Illuminate\Foundation\Http\FormRequest;
class UserRoleGrantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'role_names' => 'sometimes|array',
'role_names.*' => 'string|min:1',
'role_ids' => 'sometimes|array',
'role_ids.*' => 'integer|min:1',
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$data = $this->all();
if (empty($data['role_names']) && empty($data['role_ids'])) {
$validator->errors()->add('role_names', __('error.role.role_input_required'));
}
});
}
}

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Http\Requests\Barobill;
use Illuminate\Foundation\Http\FormRequest;
class BankServiceUrlRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'bank_code' => ['required', 'string', 'max:10'],
'account_type' => ['required', 'string', 'max:10'],
];
}
}

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Http\Requests\Barobill;
use Illuminate\Foundation\Http\FormRequest;
class BarobillLoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'barobill_id' => ['required', 'string', 'max:50'],
'password' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Http\Requests\Barobill;
use Illuminate\Foundation\Http\FormRequest;
class BarobillSignupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'business_number' => ['required', 'string', 'max:20'],
'company_name' => ['required', 'string', 'max:100'],
'ceo_name' => ['required', 'string', 'max:50'],
'business_type' => ['nullable', 'string', 'max:50'],
'business_category' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:255'],
'barobill_id' => ['required', 'string', 'max:50'],
'password' => ['required', 'string', 'max:255'],
'manager_name' => ['nullable', 'string', 'max:50'],
'manager_phone' => ['nullable', 'string', 'max:20'],
'manager_email' => ['nullable', 'email', 'max:100'],
];
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Http\Requests\TaxInvoice;
use Illuminate\Foundation\Http\FormRequest;
class SaveSupplierSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'business_number' => ['required', 'string', 'max:20'],
'company_name' => ['required', 'string', 'max:100'],
'representative_name' => ['required', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:255'],
'business_type' => ['nullable', 'string', 'max:100'],
'business_item' => ['nullable', 'string', 'max:100'],
'contact_name' => ['nullable', 'string', 'max:50'],
'contact_phone' => ['nullable', 'string', 'max:20'],
'contact_email' => ['nullable', 'email', 'max:100'],
];
}
}

View File

@@ -19,16 +19,9 @@ public function rules(): array
'account_number' => ['required', 'string', 'max:30', 'regex:/^[\d-]+$/'],
'account_holder' => ['required', 'string', 'max:50'],
'account_name' => ['required', 'string', 'max:100'],
'account_type' => ['nullable', 'string', 'max:30'],
'balance' => ['nullable', 'numeric', 'min:0'],
'currency' => ['nullable', 'string', 'max:3'],
'opened_at' => ['nullable', 'date'],
'branch_name' => ['nullable', 'string', 'max:100'],
'memo' => ['nullable', 'string', 'max:500'],
'status' => ['nullable', 'string', 'in:active,inactive'],
'assigned_user_id' => ['nullable', 'integer', 'exists:users,id'],
'is_primary' => ['nullable', 'boolean'],
'sort_order' => ['nullable', 'integer', 'min:0'],
];
}

View File

@@ -19,15 +19,8 @@ public function rules(): array
'account_number' => ['sometimes', 'string', 'max:30', 'regex:/^[\d-]+$/'],
'account_holder' => ['sometimes', 'string', 'max:50'],
'account_name' => ['sometimes', 'string', 'max:100'],
'account_type' => ['sometimes', 'nullable', 'string', 'max:30'],
'balance' => ['sometimes', 'nullable', 'numeric', 'min:0'],
'currency' => ['sometimes', 'nullable', 'string', 'max:3'],
'opened_at' => ['sometimes', 'nullable', 'date'],
'branch_name' => ['sometimes', 'nullable', 'string', 'max:100'],
'memo' => ['sometimes', 'nullable', 'string', 'max:500'],
'status' => ['sometimes', 'string', 'in:active,inactive'],
'assigned_user_id' => ['nullable', 'integer', 'exists:users,id'],
'sort_order' => ['sometimes', 'nullable', 'integer', 'min:0'],
];
}

View File

@@ -15,21 +15,12 @@ public function rules(): array
{
return [
'card_company' => ['required', 'string', 'max:50'],
'card_type' => ['nullable', 'string', 'max:50'],
'card_number' => ['required', 'string', 'regex:/^\d{13,19}$/'],
'expiry_date' => ['required', 'string', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'],
'card_password' => ['nullable', 'string', 'size:2', 'regex:/^\d{2}$/'],
'card_name' => ['required', 'string', 'max:100'],
'alias' => ['nullable', 'string', 'max:100'],
'csv' => ['nullable', 'string', 'max:4'],
'payment_day' => ['nullable', 'integer', 'min:1', 'max:31'],
'total_limit' => ['nullable', 'numeric', 'min:0'],
'used_amount' => ['nullable', 'numeric', 'min:0'],
'remaining_limit' => ['nullable', 'numeric', 'min:0'],
'status' => ['nullable', 'string', 'in:active,inactive'],
'is_manual' => ['nullable', 'boolean'],
'assigned_user_id' => ['nullable', 'integer', 'exists:users,id'],
'memo' => ['nullable', 'string', 'max:500'],
];
}

View File

@@ -15,21 +15,12 @@ public function rules(): array
{
return [
'card_company' => ['sometimes', 'string', 'max:50'],
'card_type' => ['nullable', 'string', 'max:50'],
'card_number' => ['sometimes', 'string', 'regex:/^\d{13,19}$/'],
'expiry_date' => ['sometimes', 'string', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'],
'card_password' => ['nullable', 'string', 'size:2', 'regex:/^\d{2}$/'],
'card_name' => ['sometimes', 'string', 'max:100'],
'alias' => ['nullable', 'string', 'max:100'],
'csv' => ['nullable', 'string', 'max:4'],
'payment_day' => ['nullable', 'integer', 'min:1', 'max:31'],
'total_limit' => ['nullable', 'numeric', 'min:0'],
'used_amount' => ['nullable', 'numeric', 'min:0'],
'remaining_limit' => ['nullable', 'numeric', 'min:0'],
'status' => ['sometimes', 'string', 'in:active,inactive'],
'is_manual' => ['nullable', 'boolean'],
'assigned_user_id' => ['nullable', 'integer', 'exists:users,id'],
'memo' => ['nullable', 'string', 'max:500'],
];
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests\V1\CorporateCard;
use Illuminate\Foundation\Http\FormRequest;
class StoreCorporateCardRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'card_name' => ['required', 'string', 'max:100'],
'card_company' => ['required', 'string', 'max:50'],
'card_number' => ['required', 'string', 'max:30'],
'card_type' => ['required', 'string', 'in:credit,debit'],
'payment_day' => ['nullable', 'integer', 'min:1', 'max:31'],
'credit_limit' => ['nullable', 'numeric', 'min:0'],
'card_holder_name' => ['required', 'string', 'max:100'],
'actual_user' => ['required', 'string', 'max:100'],
'expiry_date' => ['nullable', 'string', 'max:10'],
'cvc' => ['nullable', 'string', 'max:4'],
'status' => ['nullable', 'string', 'in:active,inactive'],
'memo' => ['nullable', 'string', 'max:500'],
];
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests\V1\CorporateCard;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCorporateCardRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'card_name' => ['sometimes', 'string', 'max:100'],
'card_company' => ['sometimes', 'string', 'max:50'],
'card_number' => ['sometimes', 'string', 'max:30'],
'card_type' => ['sometimes', 'string', 'in:credit,debit'],
'payment_day' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:31'],
'credit_limit' => ['sometimes', 'nullable', 'numeric', 'min:0'],
'card_holder_name' => ['sometimes', 'string', 'max:100'],
'actual_user' => ['sometimes', 'string', 'max:100'],
'expiry_date' => ['sometimes', 'nullable', 'string', 'max:10'],
'cvc' => ['sometimes', 'nullable', 'string', 'max:4'],
'status' => ['sometimes', 'string', 'in:active,inactive'],
'memo' => ['sometimes', 'nullable', 'string', 'max:500'],
];
}
}

View File

@@ -8,14 +8,13 @@
use App\Models\Tenants\Tenant;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Permission\Models\Role as SpatieRole;
/**
* @mixin IdeHelperRole
*/
class Role extends SpatieRole
class Role extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
@@ -35,6 +34,14 @@ class Role extends SpatieRole
'tenant_id' => 'integer',
];
/**
* 관계: 메뉴 권한 (다대다)
*/
public function menuPermissions()
{
return $this->hasMany(RoleMenuPermission::class, 'role_id');
}
/**
* 관계: 테넌트
*/
@@ -54,7 +61,7 @@ public function userRoles()
/**
* 관계: 사용자 (user_roles 테이블 통해)
*/
public function users(): BelongsToMany
public function users()
{
return $this->belongsToMany(
User::class,
@@ -64,6 +71,19 @@ public function users(): BelongsToMany
);
}
/**
* 관계: 권한 (role_has_permissions 테이블 통해)
*/
public function permissions()
{
return $this->belongsToMany(
Permission::class,
'role_has_permissions',
'role_id',
'permission_id'
);
}
/**
* 스코프: 공개된 역할만
*/

View File

@@ -40,34 +40,16 @@ class BankAccount extends Model
'account_number',
'account_holder',
'account_name',
'account_type',
'balance',
'currency',
'opened_at',
'last_transaction_at',
'branch_name',
'memo',
'status',
'assigned_user_id',
'is_primary',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $hidden = [
'created_by',
'updated_by',
'deleted_by',
'deleted_at',
];
protected $casts = [
'balance' => 'decimal:2',
'is_primary' => 'boolean',
'opened_at' => 'date',
'last_transaction_at' => 'datetime',
];
protected $attributes = [
@@ -107,69 +89,22 @@ public function updater(): BelongsTo
// 헬퍼 메서드
// =========================================================================
// =========================================================================
// Accessors
// =========================================================================
/**
* 포맷된 잔액
*/
public function getFormattedBalanceAttribute(): string
{
$amount = abs($this->balance ?? 0);
if ($amount >= 100000000) {
return number_format($amount / 100000000, 1).'억원';
} elseif ($amount >= 10000000) {
return number_format($amount / 10000000, 0).'천만원';
} elseif ($amount >= 10000) {
return number_format($amount / 10000, 0).'만원';
}
return number_format($amount).'원';
}
/**
* 마스킹된 계좌번호
* 마스킹된 계좌번호 조회
*/
public function getMaskedAccountNumber(): string
{
$number = $this->account_number;
if (strlen($number) <= 6) {
return $number;
$length = strlen($this->account_number);
if ($length <= 4) {
return $this->account_number;
}
return substr($number, 0, 3).'-***-'.substr($number, -4);
$visibleEnd = substr($this->account_number, -4);
$maskedPart = str_repeat('*', $length - 4);
return $maskedPart.$visibleEnd;
}
// =========================================================================
// Scopes
// =========================================================================
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopePrimary($query)
{
return $query->where('is_primary', true);
}
public function scopeByType($query, string $accountType)
{
return $query->where('account_type', $accountType);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('bank_name');
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 활성 상태 여부
*/
@@ -187,13 +122,10 @@ public function toggleStatus(): void
}
/**
* 잔액 업데이트
* 대표계좌로 설정
*/
public function updateBalance(float $newBalance): void
public function setAsPrimary(): void
{
$this->update([
'balance' => $newBalance,
'last_transaction_at' => now(),
]);
$this->is_primary = true;
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillConfig extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'environment',
'cert_key',
'corp_num',
'base_url',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public static function getActiveTest(): ?self
{
return self::where('environment', 'test')->first();
}
public static function getActiveProduction(): ?self
{
return self::where('environment', 'production')->first();
}
public static function getActive(bool $isTestMode = false): ?self
{
return $isTestMode ? self::getActiveTest() : self::getActiveProduction();
}
}

View File

@@ -1,66 +0,0 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillMember extends Model
{
use SoftDeletes;
protected $table = 'barobill_members';
protected $fillable = [
'tenant_id',
'biz_no',
'corp_name',
'ceo_name',
'addr',
'biz_type',
'biz_class',
'barobill_id',
'barobill_pwd',
'manager_name',
'manager_email',
'manager_hp',
'status',
'server_mode',
'last_sales_fetch_at',
'last_purchases_fetch_at',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
'barobill_pwd' => 'encrypted',
'last_sales_fetch_at' => 'datetime',
'last_purchases_fetch_at' => 'datetime',
];
protected $hidden = [
'barobill_pwd',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function getFormattedBizNoAttribute(): string
{
$bizNo = preg_replace('/[^0-9]/', '', $this->biz_no);
if (strlen($bizNo) === 10) {
return substr($bizNo, 0, 3).'-'.substr($bizNo, 3, 2).'-'.substr($bizNo, 5);
}
return $this->biz_no;
}
public function isTestMode(): bool
{
return $this->server_mode !== 'production';
}
}

View File

@@ -22,17 +22,8 @@
* @property string $expiry_date
* @property string|null $card_password_encrypted
* @property string $card_name
* @property string|null $card_type
* @property string|null $alias
* @property string|null $cvc_encrypted
* @property int|null $payment_day
* @property float|null $total_limit
* @property float|null $used_amount
* @property float|null $remaining_limit
* @property string $status
* @property bool $is_manual
* @property int|null $assigned_user_id
* @property string|null $memo
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
@@ -46,22 +37,13 @@ class Card extends Model
protected $fillable = [
'tenant_id',
'card_company',
'card_type',
'card_number_encrypted',
'card_number_last4',
'expiry_date',
'card_password_encrypted',
'card_name',
'alias',
'cvc_encrypted',
'payment_day',
'total_limit',
'used_amount',
'remaining_limit',
'status',
'is_manual',
'assigned_user_id',
'memo',
'created_by',
'updated_by',
'deleted_by',
@@ -70,29 +52,12 @@ class Card extends Model
protected $hidden = [
'card_number_encrypted',
'card_password_encrypted',
'cvc_encrypted',
];
protected $appends = [
'csv',
];
protected $attributes = [
'status' => 'active',
'is_manual' => false,
];
protected function casts(): array
{
return [
'payment_day' => 'integer',
'total_limit' => 'decimal:2',
'used_amount' => 'decimal:2',
'remaining_limit' => 'decimal:2',
'is_manual' => 'boolean',
];
}
// =========================================================================
// 관계 정의
// =========================================================================
@@ -162,38 +127,6 @@ public function getDecryptedCardPassword(): ?string
: null;
}
/**
* CVC/CVV 암호화 설정
*/
public function setCvc(?string $cvc): void
{
$this->cvc_encrypted = $cvc
? Crypt::encryptString($cvc)
: null;
}
/**
* CVC/CVV 복호화 조회
*/
public function getDecryptedCvc(): ?string
{
return $this->cvc_encrypted
? Crypt::decryptString($this->cvc_encrypted)
: null;
}
// =========================================================================
// Accessor (JSON 직렬화용)
// =========================================================================
/**
* CSV(CVC) 복호화 값 — $appends로 JSON 응답에 포함
*/
public function getCsvAttribute(): ?string
{
return $this->getDecryptedCvc();
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================

View File

@@ -1,110 +0,0 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 법인카드 모델
*
* @property int $id
* @property int $tenant_id
* @property string $card_name
* @property string $card_company
* @property string $card_number
* @property string $card_type
* @property int $payment_day
* @property float $credit_limit
* @property float $current_usage
* @property string $card_holder_name
* @property string $actual_user
* @property string|null $expiry_date
* @property string|null $cvc
* @property string $status
* @property string|null $memo
*/
class CorporateCard extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'corporate_cards';
protected $fillable = [
'tenant_id',
'card_name',
'card_company',
'card_number',
'card_type',
'payment_day',
'credit_limit',
'current_usage',
'card_holder_name',
'actual_user',
'expiry_date',
'cvc',
'status',
'memo',
];
protected $hidden = [
'cvc',
'deleted_at',
];
protected $casts = [
'payment_day' => 'integer',
'credit_limit' => 'decimal:2',
'current_usage' => 'decimal:2',
];
protected $attributes = [
'status' => 'active',
'payment_day' => 15,
'credit_limit' => 0,
'current_usage' => 0,
];
// =========================================================================
// Scopes
// =========================================================================
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeByType($query, string $cardType)
{
return $query->where('card_type', $cardType);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public function isActive(): bool
{
return $this->status === 'active';
}
public function toggleStatus(): void
{
$this->status = $this->status === 'active' ? 'inactive' : 'active';
}
/**
* 마스킹된 카드번호
*/
public function getMaskedCardNumber(): string
{
$number = preg_replace('/[^0-9]/', '', $this->card_number);
if (strlen($number) <= 4) {
return $this->card_number;
}
return '****-****-****-'.substr($number, -4);
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Services\Authz;
use App\Models\Permissions\Role;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolePermissionService
@@ -16,13 +18,6 @@ protected static function setTeam(int $tenantId): void
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
}
/** 권한 캐시 무효화 */
protected static function invalidateCache(int $tenantId): void
{
AccessService::bumpVersion($tenantId);
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
/** 역할 로드 (테넌트/가드 검증) */
protected static function loadRoleOrError(int $roleId, int $tenantId): ?Role
{
@@ -42,6 +37,7 @@ protected static function resolvePermissionNames(int $tenantId, array $params):
$names = [];
if (! empty($params['permission_names']) && is_array($params['permission_names'])) {
// 문자열 배열만 추림
foreach ($params['permission_names'] as $n) {
if (is_string($n) && $n !== '') {
$names[] = trim($n);
@@ -87,7 +83,7 @@ public static function list(int $roleId)
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
self::setTeam($tenantId);
@@ -108,20 +104,37 @@ public static function grant(int $roleId, array $params = [])
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
// 유효성: 두 방식 중 하나만 요구하진 않지만, 최소 하나는 있어야 함
$v = Validator::make($params, [
'permission_names' => 'sometimes|array',
'permission_names.*' => 'string|min:1',
'menus' => 'sometimes|array',
'menus.*' => 'integer|min:1',
'actions' => 'sometimes|array',
'actions.*' => [
'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])),
],
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) {
return ['error' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.', 'code' => 422];
}
self::setTeam($tenantId);
$names = self::resolvePermissionNames($tenantId, $params);
if (empty($names)) {
return ['error' => __('error.role.no_valid_permissions'), 'code' => 422];
return ['error' => '유효한 퍼미션이 없습니다.', 'code' => 422];
}
// Spatie: 이름 배열 부여 OK (teams 컨텍스트 적용됨)
$role->givePermissionTo($names);
self::invalidateCache($tenantId);
return 'success';
}
@@ -132,20 +145,35 @@ public static function revoke(int $roleId, array $params = [])
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
$v = Validator::make($params, [
'permission_names' => 'sometimes|array',
'permission_names.*' => 'string|min:1',
'menus' => 'sometimes|array',
'menus.*' => 'integer|min:1',
'actions' => 'sometimes|array',
'actions.*' => [
'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])),
],
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) {
return ['error' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.', 'code' => 422];
}
self::setTeam($tenantId);
$names = self::resolvePermissionNames($tenantId, $params);
if (empty($names)) {
return ['error' => __('error.role.no_valid_permissions'), 'code' => 422];
return ['error' => '유효한 퍼미션이 없습니다.', 'code' => 422];
}
$role->revokePermissionTo($names);
self::invalidateCache($tenantId);
return 'success';
}
@@ -156,16 +184,32 @@ public static function sync(int $roleId, array $params = [])
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
$v = Validator::make($params, [
'permission_names' => 'sometimes|array',
'permission_names.*' => 'string|min:1',
'menus' => 'sometimes|array',
'menus.*' => 'integer|min:1',
'actions' => 'sometimes|array',
'actions.*' => [
'string', Rule::in(config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve'])),
],
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) {
return ['error' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.', 'code' => 422];
}
self::setTeam($tenantId);
$names = self::resolvePermissionNames($tenantId, $params);
$names = self::resolvePermissionNames($tenantId, $params); // 존재하지 않으면 생성
// 동기화
$role->syncPermissions($names);
self::invalidateCache($tenantId);
return 'success';
}
@@ -182,7 +226,7 @@ public static function matrix(int $roleId)
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
self::setTeam($tenantId);
@@ -232,6 +276,7 @@ public static function menus()
->orderBy('id', 'asc')
->get(['id', 'parent_id', 'name', 'url', 'icon', 'sort_order', 'is_active']);
// 트리 구조를 플랫한 배열로 변환 (depth 정보 포함)
$flatMenus = self::flattenMenuTree($menus->toArray(), null, 0);
return [
@@ -253,6 +298,7 @@ protected static function flattenMenuTree(array $menus, ?int $parentId = null, i
$menu['has_children'] = count(array_filter($menus, fn ($m) => $m['parent_id'] === $menu['id'])) > 0;
$result[] = $menu;
// 자식 메뉴 재귀적으로 추가
$children = self::flattenMenuTree($menus, $menu['id'], $depth + 1);
$result = array_merge($result, $children);
}
@@ -267,7 +313,15 @@ public static function toggle(int $roleId, array $params = [])
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
$v = Validator::make($params, [
'menu_id' => 'required|integer|min:1',
'permission_type' => ['required', 'string', Rule::in(self::getPermissionTypes())],
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
$menuId = (int) $params['menu_id'];
@@ -291,12 +345,14 @@ public static function toggle(int $roleId, array $params = [])
->exists();
if ($exists) {
// 권한 제거
\Illuminate\Support\Facades\DB::table('role_has_permissions')
->where('role_id', $roleId)
->where('permission_id', $permission->id)
->delete();
$newValue = false;
} else {
// 권한 부여
\Illuminate\Support\Facades\DB::table('role_has_permissions')->insert([
'role_id' => $roleId,
'permission_id' => $permission->id,
@@ -307,8 +363,6 @@ public static function toggle(int $roleId, array $params = [])
// 하위 메뉴에 권한 전파
self::propagateToChildren($roleId, $menuId, $permissionType, $newValue, $tenantId);
self::invalidateCache($tenantId);
return [
'menu_id' => $menuId,
'permission_type' => $permissionType,
@@ -332,6 +386,7 @@ protected static function propagateToChildren(int $roleId, int $parentMenuId, st
]);
if ($value) {
// 권한 부여
$exists = \Illuminate\Support\Facades\DB::table('role_has_permissions')
->where('role_id', $roleId)
->where('permission_id', $permission->id)
@@ -344,12 +399,14 @@ protected static function propagateToChildren(int $roleId, int $parentMenuId, st
]);
}
} else {
// 권한 제거
\Illuminate\Support\Facades\DB::table('role_has_permissions')
->where('role_id', $roleId)
->where('permission_id', $permission->id)
->delete();
}
// 재귀적으로 하위 메뉴 처리
self::propagateToChildren($roleId, $child->id, $permissionType, $value, $tenantId);
}
}
@@ -361,7 +418,7 @@ public static function allowAll(int $roleId)
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
self::setTeam($tenantId);
@@ -381,6 +438,7 @@ public static function allowAll(int $roleId)
'tenant_id' => $tenantId,
]);
// 권한 부여
$exists = \Illuminate\Support\Facades\DB::table('role_has_permissions')
->where('role_id', $roleId)
->where('permission_id', $permission->id)
@@ -395,8 +453,6 @@ public static function allowAll(int $roleId)
}
}
self::invalidateCache($tenantId);
return 'success';
}
@@ -407,7 +463,7 @@ public static function denyAll(int $roleId)
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
self::setTeam($tenantId);
@@ -435,8 +491,6 @@ public static function denyAll(int $roleId)
}
}
self::invalidateCache($tenantId);
return 'success';
}
@@ -447,7 +501,7 @@ public static function reset(int $roleId)
$role = self::loadRoleOrError($roleId, $tenantId);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
self::setTeam($tenantId);
@@ -468,6 +522,7 @@ public static function reset(int $roleId)
'tenant_id' => $tenantId,
]);
// 권한 부여
$exists = \Illuminate\Support\Facades\DB::table('role_has_permissions')
->where('role_id', $roleId)
->where('permission_id', $permission->id)
@@ -481,8 +536,6 @@ public static function reset(int $roleId)
}
}
self::invalidateCache($tenantId);
return 'success';
}
}

View File

@@ -4,6 +4,8 @@
use App\Models\Permissions\Role;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Spatie\Permission\PermissionRegistrar;
class RoleService
@@ -49,13 +51,28 @@ public static function store(array $params = [])
$tenantId = (int) app('tenant_id');
$userId = app('api_user');
$v = Validator::make($params, [
'name' => [
'required', 'string', 'max:100',
Rule::unique('roles', 'name')->where(fn ($q) => $q
->where('tenant_id', $tenantId)
->where('guard_name', self::$guard)),
],
'description' => 'nullable|string|max:255',
'is_hidden' => 'sometimes|boolean',
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
// Spatie 팀(테넌트) 컨텍스트
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
$role = Role::create([
'tenant_id' => $tenantId,
'guard_name' => self::$guard,
'name' => $params['name'],
'name' => $v->validated()['name'],
'description' => $params['description'] ?? null,
'is_hidden' => $params['is_hidden'] ?? false,
'created_by' => $userId,
@@ -75,7 +92,7 @@ public static function show(int $id)
->find($id);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
return $role;
@@ -92,10 +109,25 @@ public static function update(int $id, array $params = [])
->find($id);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
$updateData = $params;
$v = Validator::make($params, [
'name' => [
'sometimes', 'string', 'max:100',
Rule::unique('roles', 'name')
->where(fn ($q) => $q->where('tenant_id', $tenantId)->where('guard_name', self::$guard))
->ignore($role->id),
],
'description' => 'sometimes|nullable|string|max:255',
'is_hidden' => 'sometimes|boolean',
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
$updateData = $v->validated();
$updateData['updated_by'] = $userId;
$role->fill($updateData)->save();
@@ -114,7 +146,7 @@ public static function destroy(int $id)
->find($id);
if (! $role) {
return ['error' => __('error.role.not_found'), 'code' => 404];
return ['error' => '역할을 찾을 수 없습니다.', 'code' => 404];
}
DB::transaction(function () use ($role, $userId) {
@@ -126,9 +158,6 @@ public static function destroy(int $id)
$role->delete();
});
AccessService::bumpVersion($tenantId);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return 'success';
}

View File

@@ -3,7 +3,8 @@
namespace App\Services\Authz;
use App\Models\Members\User;
use App\Models\Permissions\Role;
use Illuminate\Support\Facades\Validator;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class UserRoleService
@@ -16,13 +17,6 @@ protected static function setTeam(int $tenantId): void
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
}
/** 권한 캐시 무효화 */
protected static function invalidateCache(int $tenantId): void
{
AccessService::bumpVersion($tenantId);
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
/** 유저 로드 (존재 체크) */
protected static function loadUserOrError(int $userId): ?User
{
@@ -60,13 +54,14 @@ protected static function resolveRoleNames(int $tenantId, array $params): array
// 정제
$names = array_values(array_unique(array_filter($names)));
// 존재 확인
// 존재 확인(필요시 에러 처리 확장 가능)
if (! empty($names)) {
Role::query()
$count = Role::query()
->where('tenant_id', $tenantId)
->where('guard_name', self::$guard)
->whereIn('name', $names)
->count();
// if ($count !== count($names)) { ... 필요시 상세 에러 반환 }
}
return $names;
@@ -79,11 +74,12 @@ public static function list(int $userId)
$user = self::loadUserOrError($userId);
if (! $user) {
return ['error' => __('error.role.user_not_found'), 'code' => 404];
return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404];
}
self::setTeam($tenantId);
// 현재 테넌트의 역할만
$builder = $user->roles()
->where('roles.tenant_id', $tenantId)
->where('roles.guard_name', self::$guard)
@@ -100,19 +96,30 @@ public static function grant(int $userId, array $params = [])
$user = self::loadUserOrError($userId);
if (! $user) {
return ['error' => __('error.role.user_not_found'), 'code' => 404];
return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404];
}
$v = Validator::make($params, [
'role_names' => 'sometimes|array',
'role_names.*' => 'string|min:1',
'role_ids' => 'sometimes|array',
'role_ids.*' => 'integer|min:1',
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
if (empty($params['role_names']) && empty($params['role_ids'])) {
return ['error' => 'role_names 또는 role_ids 중 하나는 필요합니다.', 'code' => 422];
}
self::setTeam($tenantId);
$names = self::resolveRoleNames($tenantId, $params);
if (empty($names)) {
return ['error' => __('error.role.no_valid_roles'), 'code' => 422];
return ['error' => '유효한 역할이 없습니다.', 'code' => 422];
}
$user->assignRole($names);
self::invalidateCache($tenantId);
$user->assignRole($names); // teams 컨텍스트 적용됨
return 'success';
}
@@ -124,19 +131,30 @@ public static function revoke(int $userId, array $params = [])
$user = self::loadUserOrError($userId);
if (! $user) {
return ['error' => __('error.role.user_not_found'), 'code' => 404];
return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404];
}
$v = Validator::make($params, [
'role_names' => 'sometimes|array',
'role_names.*' => 'string|min:1',
'role_ids' => 'sometimes|array',
'role_ids.*' => 'integer|min:1',
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
if (empty($params['role_names']) && empty($params['role_ids'])) {
return ['error' => 'role_names 또는 role_ids 중 하나는 필요합니다.', 'code' => 422];
}
self::setTeam($tenantId);
$names = self::resolveRoleNames($tenantId, $params);
if (empty($names)) {
return ['error' => __('error.role.no_valid_roles'), 'code' => 422];
return ['error' => '유효한 역할이 없습니다.', 'code' => 422];
}
$user->removeRole($names);
self::invalidateCache($tenantId);
$user->removeRole($names); // 배열 허용
return 'success';
}
@@ -148,20 +166,32 @@ public static function sync(int $userId, array $params = [])
$user = self::loadUserOrError($userId);
if (! $user) {
return ['error' => __('error.role.user_not_found'), 'code' => 404];
return ['error' => '사용자를 찾을 수 없습니다.', 'code' => 404];
}
$v = Validator::make($params, [
'role_names' => 'sometimes|array',
'role_names.*' => 'string|min:1',
'role_ids' => 'sometimes|array',
'role_ids.*' => 'integer|min:1',
]);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
if (empty($params['role_names']) && empty($params['role_ids'])) {
return ['error' => 'role_names 또는 role_ids 중 하나는 필요합니다.', 'code' => 422];
}
self::setTeam($tenantId);
$names = self::resolveRoleNames($tenantId, $params);
if (empty($names)) {
return ['error' => __('error.role.no_valid_roles'), 'code' => 422];
// 정책상 빈 목록 sync 허용 시: $user->syncRoles([]) 로 전부 제거 가능
return ['error' => '유효한 역할이 없습니다.', 'code' => 422];
}
$user->syncRoles($names);
self::invalidateCache($tenantId);
return 'success';
}
}

View File

@@ -94,16 +94,9 @@ public function store(array $data): BankAccount
'account_number' => $data['account_number'],
'account_holder' => $data['account_holder'],
'account_name' => $data['account_name'],
'account_type' => $data['account_type'] ?? null,
'balance' => $data['balance'] ?? 0,
'currency' => $data['currency'] ?? 'KRW',
'opened_at' => $data['opened_at'] ?? null,
'branch_name' => $data['branch_name'] ?? null,
'memo' => $data['memo'] ?? null,
'status' => $data['status'] ?? 'active',
'assigned_user_id' => $data['assigned_user_id'] ?? null,
'is_primary' => $isPrimary,
'sort_order' => $data['sort_order'] ?? 0,
'created_by' => $userId,
'updated_by' => $userId,
]);
@@ -131,15 +124,8 @@ public function update(int $id, array $data): BankAccount
'account_number' => $data['account_number'] ?? $account->account_number,
'account_holder' => $data['account_holder'] ?? $account->account_holder,
'account_name' => $data['account_name'] ?? $account->account_name,
'account_type' => $data['account_type'] ?? $account->account_type,
'balance' => $data['balance'] ?? $account->balance,
'currency' => $data['currency'] ?? $account->currency,
'opened_at' => $data['opened_at'] ?? $account->opened_at,
'branch_name' => $data['branch_name'] ?? $account->branch_name,
'memo' => $data['memo'] ?? $account->memo,
'status' => $data['status'] ?? $account->status,
'assigned_user_id' => $data['assigned_user_id'] ?? $account->assigned_user_id,
'sort_order' => $data['sort_order'] ?? $account->sort_order,
'updated_by' => $userId,
]);

View File

@@ -1,604 +0,0 @@
<?php
namespace App\Services\Barobill;
use App\Models\Tenants\BarobillConfig;
use App\Models\Tenants\BarobillMember;
use App\Services\Service;
use Illuminate\Support\Facades\Log;
use SoapClient;
use SoapFault;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable;
class BarobillSoapService extends Service
{
protected ?SoapClient $corpStateClient = null;
protected ?SoapClient $bankAccountClient = null;
protected ?SoapClient $cardClient = null;
protected string $certKey;
protected string $corpNum;
protected bool $isTestMode;
protected array $soapUrls;
protected array $errorMessages = [
-11101 => '사업자번호가 설정되지 않았거나 유효하지 않습니다.',
-11102 => 'CERTKEY가 유효하지 않습니다.',
-11103 => '인증서가 만료되었거나 유효하지 않습니다.',
-11104 => '해당 사업자가 등록되어 있지 않습니다.',
-11105 => '이미 등록된 사업자입니다.',
-11106 => '아이디가 이미 존재합니다.',
-11201 => '필수 파라미터가 누락되었습니다.',
-26001 => '공동인증서가 등록되어 있지 않습니다.',
-32000 => '알 수 없는 오류가 발생했습니다.',
-32001 => '사업자번호가 유효하지 않습니다.',
-32002 => '아이디가 유효하지 않습니다.',
-32003 => '비밀번호가 유효하지 않습니다.',
-32004 => '상호명이 유효하지 않습니다.',
-32005 => '대표자명이 유효하지 않습니다.',
-32006 => '이메일 형식이 유효하지 않습니다.',
-32010 => '이미 등록된 사업자번호입니다.',
-32011 => '이미 등록된 아이디입니다.',
-32012 => '이미 등록된 아이디입니다. 다른 아이디를 사용해주세요.',
-32013 => '비밀번호 형식이 유효하지 않습니다. (영문/숫자/특수문자 조합 8자리 이상)',
-32014 => '연락처 형식이 유효하지 않습니다.',
-32020 => '파트너 사업자번호가 유효하지 않습니다.',
-32021 => '파트너 인증키(CERTKEY)가 유효하지 않습니다.',
-99999 => '서버 내부 오류가 발생했습니다.',
];
public function __construct()
{
$this->isTestMode = config('services.barobill.test_mode', true);
$this->initializeConfig();
}
public function switchServerMode(bool $isTestMode): self
{
if ($this->isTestMode !== $isTestMode) {
$this->isTestMode = $isTestMode;
$this->corpStateClient = null;
$this->bankAccountClient = null;
$this->cardClient = null;
$this->initializeConfig();
}
return $this;
}
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('바로빌 DB 설정 로드 실패', ['error' => $e->getMessage()]);
return null;
}
}
protected function buildSoapUrls(string $baseUrl): array
{
$baseUrl = rtrim($baseUrl, '/');
return [
'corpstate' => $baseUrl.'/CORPSTATE.asmx?WSDL',
'bankaccount' => $baseUrl.'/BANKACCOUNT.asmx?WSDL',
'card' => $baseUrl.'/CARD.asmx?WSDL',
];
}
protected function getCorpStateClient(): ?SoapClient
{
if ($this->corpStateClient === null) {
try {
$this->corpStateClient = new SoapClient($this->soapUrls['corpstate'], [
'trace' => true,
'encoding' => 'UTF-8',
'exceptions' => true,
'connection_timeout' => 30,
'cache_wsdl' => WSDL_CACHE_NONE,
]);
} catch (Throwable $e) {
Log::error('바로빌 CORPSTATE SOAP 클라이언트 생성 실패', [
'error' => $e->getMessage(),
'url' => $this->soapUrls['corpstate'],
]);
return null;
}
}
return $this->corpStateClient;
}
protected function getBankAccountClient(): ?SoapClient
{
if ($this->bankAccountClient === null) {
try {
$this->bankAccountClient = new SoapClient($this->soapUrls['bankaccount'], [
'trace' => true,
'encoding' => 'UTF-8',
'exceptions' => true,
'connection_timeout' => 30,
'cache_wsdl' => WSDL_CACHE_NONE,
]);
} catch (Throwable $e) {
Log::error('바로빌 BANKACCOUNT SOAP 클라이언트 생성 실패', [
'error' => $e->getMessage(),
'url' => $this->soapUrls['bankaccount'],
]);
return null;
}
}
return $this->bankAccountClient;
}
protected function getCardClient(): ?SoapClient
{
if ($this->cardClient === null) {
try {
$this->cardClient = new SoapClient($this->soapUrls['card'], [
'trace' => true,
'encoding' => 'UTF-8',
'exceptions' => true,
'connection_timeout' => 30,
'cache_wsdl' => WSDL_CACHE_NONE,
]);
} catch (Throwable $e) {
Log::error('바로빌 CARD SOAP 클라이언트 생성 실패', [
'error' => $e->getMessage(),
'url' => $this->soapUrls['card'],
]);
return null;
}
}
return $this->cardClient;
}
protected function call(string $service, string $method, array $params = []): array
{
$client = match ($service) {
'corpstate' => $this->getCorpStateClient(),
'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 {
Log::info('바로빌 SOAP API 호출', [
'service' => $service,
'method' => $method,
'test_mode' => $this->isTestMode,
]);
$result = $client->$method($params);
$resultProperty = $method.'Result';
if (isset($result->$resultProperty)) {
$resultData = $result->$resultProperty;
if (is_numeric($resultData) && $resultData < 0) {
$errorMessage = $this->errorMessages[$resultData] ?? "바로빌 API 오류 코드: {$resultData}";
Log::error('바로빌 SOAP API 오류', [
'method' => $method,
'error_code' => $resultData,
'error_message' => $errorMessage,
]);
return [
'success' => false,
'error' => $errorMessage,
'error_code' => $resultData,
];
}
return [
'success' => true,
'data' => $resultData,
];
}
return [
'success' => true,
'data' => $result,
];
} catch (SoapFault $e) {
Log::error('바로빌 SOAP 오류', [
'method' => $method,
'fault_code' => $e->faultcode ?? null,
'fault_string' => $e->faultstring ?? null,
]);
return [
'success' => false,
'error' => 'SOAP 오류: '.$e->getMessage(),
'error_code' => $e->getCode(),
];
} catch (Throwable $e) {
Log::error('바로빌 SOAP API 호출 오류', [
'method' => $method,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => 'API 호출 오류: '.$e->getMessage(),
'error_code' => -999,
];
}
}
protected function formatBizNo(string $bizNo): string
{
return preg_replace('/[^0-9]/', '', $bizNo);
}
// =========================================================================
// 회원사 조회
// =========================================================================
protected function getMember(): BarobillMember
{
$tenantId = $this->tenantId();
$member = BarobillMember::where('tenant_id', $tenantId)->first();
if (! $member) {
throw new BadRequestHttpException(__('error.barobill.member_not_found'));
}
$this->switchServerMode($member->isTestMode());
return $member;
}
// =========================================================================
// SOAP 비즈니스 메서드
// =========================================================================
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 getCorpState(string $corpNum): array
{
$params = [
'CorpNum' => $this->formatBizNo($corpNum),
];
return $this->call('corpstate', 'GetCorpState', $params);
}
public function getBankAccountScrapRequestUrl(string $corpNum, string $userId, string $userPwd): array
{
$params = [
'CorpNum' => $this->formatBizNo($corpNum),
'ID' => $userId,
'PWD' => $userPwd,
];
return $this->call('bankaccount', 'GetBankAccountScrapRequestURL', $params);
}
public function getBankAccountLogUrl(string $corpNum, string $userId, string $userPwd): array
{
$params = [
'CorpNum' => $this->formatBizNo($corpNum),
'ID' => $userId,
'PWD' => $userPwd,
];
return $this->call('bankaccount', 'GetBankAccountLogURL', $params);
}
public function getCertificateRegistUrl(string $corpNum, string $userId, string $userPwd): array
{
$params = [
'CorpNum' => $this->formatBizNo($corpNum),
'ID' => $userId,
'PWD' => $userPwd,
];
return $this->call('bankaccount', 'GetCertificateRegistURL', $params);
}
public function getBankAccounts(string $corpNum, bool $availOnly = true): array
{
$params = [
'CorpNum' => $this->formatBizNo($corpNum),
'AvailOnly' => $availOnly,
];
return $this->call('bankaccount', 'GetBankAccountEx', $params);
}
public function getCards(string $corpNum, bool $availOnly = true): array
{
$params = [
'CorpNum' => $this->formatBizNo($corpNum),
'AvailOnly' => $availOnly ? 1 : 0,
];
return $this->call('card', 'GetCardEx2', $params);
}
public function getCardScrapRequestUrl(string $corpNum, string $userId, string $userPwd): array
{
$params = [
'CorpNum' => $this->formatBizNo($corpNum),
'ID' => $userId,
'PWD' => $userPwd,
];
return $this->call('card', 'GetCardScrapRequestURL', $params);
}
// =========================================================================
// 컨트롤러 비즈니스 메서드
// =========================================================================
public function registerLogin(array $data): array
{
$tenantId = $this->tenantId();
$barobillId = $data['barobill_id'];
$password = $data['password'];
$member = BarobillMember::where('tenant_id', $tenantId)->first();
if ($member) {
$this->switchServerMode($member->isTestMode());
}
$result = $this->getCorpState($member?->biz_no ?? config('services.barobill.corp_num'));
if (! $result['success']) {
throw new BadRequestHttpException($result['error']);
}
$member = BarobillMember::updateOrCreate(
['tenant_id' => $tenantId],
[
'barobill_id' => $barobillId,
'barobill_pwd' => $password,
'status' => 'active',
]
);
return [
'id' => $member->id,
'barobill_id' => $member->barobill_id,
'status' => $member->status,
];
}
public function registerSignup(array $data): array
{
$tenantId = $this->tenantId();
$soapResult = $this->registCorp([
'biz_no' => $data['business_number'],
'corp_name' => $data['company_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['business_type'] ?? '',
'biz_class' => $data['business_category'] ?? '',
'addr' => $data['address'] ?? '',
'barobill_id' => $data['barobill_id'],
'barobill_pwd' => $data['password'],
'manager_name' => $data['manager_name'] ?? '',
'manager_hp' => $data['manager_phone'] ?? '',
'manager_email' => $data['manager_email'] ?? '',
]);
if (! $soapResult['success']) {
throw new BadRequestHttpException($soapResult['error']);
}
$member = BarobillMember::updateOrCreate(
['tenant_id' => $tenantId],
[
'biz_no' => $this->formatBizNo($data['business_number']),
'corp_name' => $data['company_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['business_type'] ?? '',
'biz_class' => $data['business_category'] ?? '',
'addr' => $data['address'] ?? '',
'barobill_id' => $data['barobill_id'],
'barobill_pwd' => $data['password'],
'manager_name' => $data['manager_name'] ?? '',
'manager_hp' => $data['manager_phone'] ?? '',
'manager_email' => $data['manager_email'] ?? '',
'status' => 'active',
'server_mode' => $this->isTestMode ? 'test' : 'production',
]
);
return [
'id' => $member->id,
'barobill_id' => $member->barobill_id,
'biz_no' => $member->formatted_biz_no,
'status' => $member->status,
];
}
public function getBankServiceRedirectUrl(array $params): array
{
$member = $this->getMember();
$result = $this->getBankAccountLogUrl(
$member->biz_no,
$member->barobill_id,
$member->barobill_pwd
);
if (! $result['success']) {
throw new BadRequestHttpException($result['error']);
}
return ['url' => $result['data']];
}
public function getIntegrationStatus(): array
{
$member = $this->getMember();
$bankResult = $this->getBankAccounts($member->biz_no);
$cardResult = $this->getCards($member->biz_no);
$bankCount = 0;
if ($bankResult['success'] && isset($bankResult['data'])) {
$data = $bankResult['data'];
if (is_object($data) && isset($data->BankAccountInfo)) {
$info = $data->BankAccountInfo;
$bankCount = is_array($info) ? count($info) : 1;
} elseif (is_array($data)) {
$bankCount = count($data);
}
}
$cardCount = 0;
if ($cardResult['success'] && isset($cardResult['data'])) {
$data = $cardResult['data'];
if (is_object($data) && isset($data->CardInfo)) {
$info = $data->CardInfo;
$cardCount = is_array($info) ? count($info) : 1;
} elseif (is_array($data)) {
$cardCount = count($data);
}
}
return [
'bank_account_count' => $bankCount,
'card_count' => $cardCount,
'member' => [
'barobill_id' => $member->barobill_id,
'biz_no' => $member->formatted_biz_no,
'status' => $member->status,
'server_mode' => $member->server_mode,
],
];
}
public function getAccountLinkRedirectUrl(): array
{
$member = $this->getMember();
$result = $this->getBankAccountScrapRequestUrl(
$member->biz_no,
$member->barobill_id,
$member->barobill_pwd
);
if (! $result['success']) {
throw new BadRequestHttpException($result['error']);
}
return ['url' => $result['data']];
}
public function getCardLinkRedirectUrl(): array
{
$member = $this->getMember();
$result = $this->getCardScrapRequestUrl(
$member->biz_no,
$member->barobill_id,
$member->barobill_pwd
);
if (! $result['success']) {
throw new BadRequestHttpException($result['error']);
}
return ['url' => $result['data']];
}
public function getCertificateRedirectUrl(): array
{
$member = $this->getMember();
$result = $this->getCertificateRegistUrl(
$member->biz_no,
$member->barobill_id,
$member->barobill_pwd
);
if (! $result['success']) {
throw new BadRequestHttpException($result['error']);
}
return ['url' => $result['data']];
}
}

View File

@@ -4,130 +4,37 @@
use App\Models\Tenants\BarobillSetting;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use SoapClient;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* 바로빌 API 연동 서비스 (SOAP)
* 바로빌 API 연동 서비스
*
* 바로빌 개발자센터: https://dev.barobill.co.kr/
* 바로빌은 SOAP API만 제공하므로 SoapClient를 사용합니다.
*/
class BarobillService extends Service
{
/**
* 바로빌 SOAP 기본 URL
* 바로빌 API 기본 URL
*/
private const SOAP_BASE_URL = 'https://ws.baroservice.com';
private const API_BASE_URL = 'https://ws.barobill.co.kr';
/**
* 바로빌 SOAP 테스트 URL
* 바로빌 API 테스트 URL
*/
private const SOAP_TEST_URL = 'https://testws.baroservice.com';
private const API_TEST_URL = 'https://testws.barobill.co.kr';
/**
* 테스트 모드 여부
*/
private bool $testMode;
/**
* TI(Tax Invoice) SOAP 클라이언트
*/
private ?SoapClient $tiSoapClient = null;
public function __construct()
{
$this->testMode = config('services.barobill.test_mode', true);
}
// =========================================================================
// SOAP 클라이언트
// =========================================================================
/**
* TI SOAP 클라이언트 초기화/반환
*/
private function getTiSoapClient(): SoapClient
{
if ($this->tiSoapClient === null) {
$baseUrl = $this->testMode ? self::SOAP_TEST_URL : self::SOAP_BASE_URL;
$context = stream_context_create([
'ssl' => [
'verify_peer' => ! $this->testMode,
'verify_peer_name' => ! $this->testMode,
'allow_self_signed' => $this->testMode,
],
]);
$this->tiSoapClient = new SoapClient($baseUrl.'/TI.asmx?WSDL', [
'trace' => true,
'encoding' => 'UTF-8',
'exceptions' => true,
'connection_timeout' => 30,
'stream_context' => $context,
'cache_wsdl' => WSDL_CACHE_NONE,
]);
}
return $this->tiSoapClient;
}
/**
* SOAP API 호출
*
* MNG EtaxController::callBarobillSOAP() 포팅
* 음수 반환값 = 에러 코드 (바로빌 규격)
*/
private function callSoap(string $method, array $params): array
{
$client = $this->getTiSoapClient();
if (! isset($params['CERTKEY'])) {
$setting = $this->getSetting();
if (! $setting) {
return [
'success' => false,
'error' => '바로빌 설정이 없습니다.',
];
}
$params['CERTKEY'] = $setting->cert_key;
}
try {
$result = $client->$method($params);
$resultProperty = $method.'Result';
if (isset($result->$resultProperty)) {
$resultData = $result->$resultProperty;
// 바로빌 규격: 음수 반환값은 에러 코드
if (is_numeric($resultData) && $resultData < 0) {
return [
'success' => false,
'error' => '바로빌 API 오류 코드: '.$resultData,
'error_code' => (int) $resultData,
];
}
return ['success' => true, 'data' => $resultData];
}
return ['success' => true, 'data' => $result];
} catch (\SoapFault $e) {
return [
'success' => false,
'error' => 'SOAP 오류: '.$e->getMessage(),
];
} catch (\Throwable $e) {
return [
'success' => false,
'error' => 'API 호출 오류: '.$e->getMessage(),
];
}
}
// =========================================================================
// 설정 관리
// =========================================================================
@@ -171,7 +78,7 @@ public function saveSetting(array $data): BarobillSetting
}
/**
* 연동 테스트 (SOAP)
* 연동 테스트
*/
public function testConnection(): array
{
@@ -182,31 +89,26 @@ public function testConnection(): array
}
try {
$response = $this->callSoap('GetAccessToken', [
// 바로빌 API 토큰 조회로 연동 테스트
$response = $this->callApi('GetAccessToken', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
]);
if ($response['success']) {
$resultData = $response['data'];
if (! empty($response['AccessToken'])) {
// 검증 성공 시 verified_at 업데이트
$setting->verified_at = now();
$setting->save();
// 양수 또는 문자열 토큰 = 성공
if (is_string($resultData) || (is_numeric($resultData) && $resultData > 0)) {
$setting->verified_at = now();
$setting->save();
return [
'success' => true,
'message' => __('message.barobill.connection_success'),
'verified_at' => $setting->verified_at->toDateTimeString(),
];
}
return [
'success' => true,
'message' => __('message.barobill.connection_success'),
'verified_at' => $setting->verified_at->toDateTimeString(),
];
}
throw new \Exception($response['error'] ?? __('error.barobill.connection_failed'));
} catch (BadRequestHttpException $e) {
throw $e;
throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed'));
} catch (\Exception $e) {
Log::error('바로빌 연동 테스트 실패', [
'tenant_id' => $this->tenantId(),
@@ -259,29 +161,52 @@ public function checkBusinessNumber(string $businessNumber): array
];
}
// 바로빌 SOAP API 조회 시도
// 바로빌 API 조회 시도
try {
$response = $this->callSoap('CheckCorpNum', [
$response = $this->callApi('CheckCorpNum', [
'CorpNum' => $businessNumber,
]);
if ($response['success']) {
$resultData = $response['data'];
// 바로빌 응답 해석
if (isset($response['CorpState'])) {
$state = $response['CorpState'];
$isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업
$statusLabel = match ($state) {
'01' => '사업중',
'02' => '휴업',
'03' => '폐업',
default => '조회 불가',
};
// 양수 결과 = 유효한 사업자
if (is_numeric($resultData) && $resultData >= 0) {
return [
'valid' => true,
'status' => 'active',
'status_label' => '유효함',
'corp_name' => null,
'ceo_name' => null,
'message' => __('message.company.business_number_valid'),
];
}
return [
'valid' => $isValid,
'status' => $state,
'status_label' => $statusLabel,
'corp_name' => $response['CorpName'] ?? null,
'ceo_name' => $response['CEOName'] ?? null,
'message' => $isValid
? __('message.company.business_number_valid')
: __('error.company.business_closed'),
];
}
// API 실패 시 형식 검증 결과만 반환
// 응답 형식이 다른 경우 (결과 코드 방식)
if (isset($response['Result'])) {
$isValid = $response['Result'] >= 0;
return [
'valid' => $isValid,
'status' => $isValid ? 'active' : 'unknown',
'status_label' => $isValid ? '유효함' : '조회 불가',
'corp_name' => $response['CorpName'] ?? null,
'ceo_name' => $response['CEOName'] ?? null,
'message' => $isValid
? __('message.company.business_number_valid')
: ($response['Message'] ?? __('error.company.check_failed')),
];
}
// 기본 응답 (체크섬만 통과한 경우)
return [
'valid' => true,
'status' => 'format_valid',
@@ -341,9 +266,7 @@ private function validateBusinessNumberChecksum(string $businessNumber): bool
// =========================================================================
/**
* 세금계산서 발행 (SOAP RegistAndIssueTaxInvoice)
*
* MNG EtaxController::issueTaxInvoice() 포팅
* 세금계산서 발행
*/
public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
{
@@ -354,13 +277,16 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
}
try {
// 바로빌 API 호출을 위한 데이터 구성
$apiData = $this->buildTaxInvoiceData($taxInvoice, $setting);
$response = $this->callSoap('RegistAndIssueTaxInvoice', $apiData);
if ($response['success']) {
$resultData = $response['data'];
// 바로빌 규격: 양수 반환값이 Invoice ID
$taxInvoice->barobill_invoice_id = is_numeric($resultData) ? (string) $resultData : null;
// 세금계산서 발행 API 호출
$response = $this->callApi('RegistAndIssueTaxInvoice', $apiData);
if (! empty($response['InvoiceID'])) {
// 발행 성공
$taxInvoice->barobill_invoice_id = $response['InvoiceID'];
$taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? null;
$taxInvoice->status = TaxInvoice::STATUS_ISSUED;
$taxInvoice->issued_at = now();
$taxInvoice->error_message = null;
@@ -369,15 +295,13 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
Log::info('세금계산서 발행 성공', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'barobill_invoice_id' => $taxInvoice->barobill_invoice_id,
'barobill_invoice_id' => $response['InvoiceID'],
]);
return $taxInvoice->fresh();
}
throw new \Exception($response['error'] ?? '발행 실패');
} catch (BadRequestHttpException $e) {
throw $e;
throw new \Exception($response['Message'] ?? '발행 실패');
} catch (\Exception $e) {
// 발행 실패
$taxInvoice->status = TaxInvoice::STATUS_FAILED;
@@ -395,7 +319,7 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
}
/**
* 세금계산서 취소 (SOAP CancelTaxInvoice)
* 세금계산서 취소
*/
public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInvoice
{
@@ -410,15 +334,16 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv
}
try {
$response = $this->callSoap('ProcTaxInvoice', [
// 세금계산서 취소 API 호출
$response = $this->callApi('CancelTaxInvoice', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'MgtNum' => $taxInvoice->barobill_invoice_id,
'ProcType' => 4, // 4: 발행취소
'ID' => $setting->barobill_id,
'InvoiceID' => $taxInvoice->barobill_invoice_id,
'Memo' => $reason,
]);
if ($response['success']) {
if ($response['Result'] === 0 || ! empty($response['Success'])) {
$taxInvoice->status = TaxInvoice::STATUS_CANCELLED;
$taxInvoice->cancelled_at = now();
$taxInvoice->description = ($taxInvoice->description ? $taxInvoice->description."\n" : '').'취소 사유: '.$reason;
@@ -433,9 +358,7 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv
return $taxInvoice->fresh();
}
throw new \Exception($response['error'] ?? '취소 실패');
} catch (BadRequestHttpException $e) {
throw $e;
throw new \Exception($response['Message'] ?? '취소 실패');
} catch (\Exception $e) {
Log::error('세금계산서 취소 실패', [
'tenant_id' => $this->tenantId(),
@@ -448,7 +371,7 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv
}
/**
* 국세청 전송 상태 조회 (SOAP GetTaxInvoiceState)
* 국세청 전송 상태 조회
*/
public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
{
@@ -463,38 +386,27 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
}
try {
$response = $this->callSoap('GetTaxInvoiceState', [
$response = $this->callApi('GetTaxInvoiceState', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'MgtNum' => $taxInvoice->barobill_invoice_id,
'ID' => $setting->barobill_id,
'InvoiceID' => $taxInvoice->barobill_invoice_id,
]);
if ($response['success'] && $response['data']) {
$stateData = $response['data'];
if (! empty($response['State'])) {
$taxInvoice->nts_send_status = $response['State'];
// SOAP 결과 객체에서 상태 추출
$state = is_object($stateData) ? ($stateData->NTSSendState ?? null) : null;
if ($state !== null) {
$taxInvoice->nts_send_status = (string) $state;
// 국세청 전송 완료 시 상태 업데이트
if (in_array($state, [3, '3', '전송완료']) && ! $taxInvoice->sent_at) {
$taxInvoice->status = TaxInvoice::STATUS_SENT;
$taxInvoice->sent_at = now();
if (is_object($stateData) && ! empty($stateData->NTSConfirmNum)) {
$taxInvoice->nts_confirm_num = $stateData->NTSConfirmNum;
}
}
$taxInvoice->save();
// 국세청 전송 완료 시 상태 업데이트
if ($response['State'] === '전송완료' && ! $taxInvoice->sent_at) {
$taxInvoice->status = TaxInvoice::STATUS_SENT;
$taxInvoice->sent_at = now();
$taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? $taxInvoice->nts_confirm_num;
}
$taxInvoice->save();
}
return $taxInvoice->fresh();
} catch (BadRequestHttpException $e) {
throw $e;
} catch (\Exception $e) {
Log::error('국세청 전송 상태 조회 실패', [
'tenant_id' => $this->tenantId(),
@@ -511,123 +423,126 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
// =========================================================================
/**
* 세금계산서 발행용 데이터 구성 (MNG SOAP 형식)
*
* MNG EtaxController::issueTaxInvoice() 의 Invoice 구조를 포팅
* InvoicerParty/InvoiceeParty/TaxInvoiceTradeLineItems 중첩 구조
* 바로빌 API 호출
*/
private function callApi(string $method, array $data): array
{
$baseUrl = $this->testMode ? self::API_TEST_URL : self::API_BASE_URL;
$url = $baseUrl.'/TI/'.$method;
$response = Http::timeout(30)
->withHeaders([
'Content-Type' => 'application/json',
])
->post($url, $data);
if ($response->failed()) {
throw new \Exception('API 호출 실패: '.$response->status());
}
return $response->json() ?? [];
}
/**
* 세금계산서 발행용 데이터 구성
*/
private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array
{
$supplyAmt = (int) $taxInvoice->supply_amount;
$taxAmt = (int) $taxInvoice->tax_amount;
$total = (int) $taxInvoice->total_amount;
$taxType = $taxAmt == 0 ? 2 : 1; // 1:과세, 2:영세, 3:면세
// 관리번호 (유니크)
$mgtNum = 'SAM'.date('YmdHis').$taxInvoice->id;
// 품목 구성
$tradeLineItems = [];
foreach ($taxInvoice->items ?? [] as $item) {
$tradeLineItems[] = [
'PurchaseExpiry' => $taxInvoice->issue_date->format('Ymd'),
'Name' => $item['name'] ?? '',
'Information' => $item['spec'] ?? '',
'ChargeableUnit' => (string) ($item['qty'] ?? 1),
'UnitPrice' => (string) ($item['unit_price'] ?? 0),
'Amount' => (string) ($item['supply_amt'] ?? 0),
'Tax' => (string) ($item['tax_amt'] ?? 0),
'Description' => $item['remark'] ?? '',
// 품목 데이터 구성
$items = [];
foreach ($taxInvoice->items ?? [] as $index => $item) {
$items[] = [
'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'),
'ItemName' => $item['name'] ?? '',
'Spec' => $item['spec'] ?? '',
'Qty' => $item['qty'] ?? 1,
'UnitCost' => $item['unit_price'] ?? 0,
'SupplyCost' => $item['supply_amt'] ?? 0,
'Tax' => $item['tax_amt'] ?? 0,
'Remark' => $item['remark'] ?? '',
];
}
// 품목이 없는 경우 기본 품목 추가
if (empty($tradeLineItems)) {
$tradeLineItems[] = [
'PurchaseExpiry' => $taxInvoice->issue_date->format('Ymd'),
'Name' => $taxInvoice->description ?? '품목',
'Information' => '',
'ChargeableUnit' => '1',
'UnitPrice' => (string) $supplyAmt,
'Amount' => (string) $supplyAmt,
'Tax' => (string) $taxAmt,
'Description' => '',
if (empty($items)) {
$items[] = [
'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'),
'ItemName' => $taxInvoice->description ?? '품목',
'Spec' => '',
'Qty' => 1,
'UnitCost' => (float) $taxInvoice->supply_amount,
'SupplyCost' => (float) $taxInvoice->supply_amount,
'Tax' => (float) $taxInvoice->tax_amount,
'Remark' => '',
];
}
return [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'Invoice' => [
'IssueDirection' => 1, // 1: 정발행
'TaxInvoiceType' => $this->mapInvoiceTypeToCode($taxInvoice->invoice_type),
'ModifyCode' => '',
'TaxType' => $taxType,
'TaxCalcType' => 1, // 1: 소계합계
'PurposeType' => 2, // 2: 청구
'ID' => $setting->barobill_id,
'TaxInvoice' => [
'InvoiceType' => $this->mapInvoiceType($taxInvoice->invoice_type),
'IssueType' => $this->mapIssueType($taxInvoice->issue_type),
'TaxType' => '과세',
'PurposeType' => '영수',
'WriteDate' => $taxInvoice->issue_date->format('Ymd'),
'AmountTotal' => (string) $supplyAmt,
'TaxTotal' => (string) $taxAmt,
'TotalAmount' => (string) $total,
'Cash' => '0',
'ChkBill' => '0',
'Note' => '0',
'Credit' => (string) $total,
// 공급자 정보
'InvoicerCorpNum' => $taxInvoice->supplier_corp_num,
'InvoicerCorpName' => $taxInvoice->supplier_corp_name,
'InvoicerCEOName' => $taxInvoice->supplier_ceo_name,
'InvoicerAddr' => $taxInvoice->supplier_addr,
'InvoicerBizType' => $taxInvoice->supplier_biz_type,
'InvoicerBizClass' => $taxInvoice->supplier_biz_class,
'InvoicerContactID' => $taxInvoice->supplier_contact_id,
// 공급받는자 정보
'InvoiceeCorpNum' => $taxInvoice->buyer_corp_num,
'InvoiceeCorpName' => $taxInvoice->buyer_corp_name,
'InvoiceeCEOName' => $taxInvoice->buyer_ceo_name,
'InvoiceeAddr' => $taxInvoice->buyer_addr,
'InvoiceeBizType' => $taxInvoice->buyer_biz_type,
'InvoiceeBizClass' => $taxInvoice->buyer_biz_class,
'InvoiceeContactID' => $taxInvoice->buyer_contact_id,
// 금액 정보
'SupplyCostTotal' => (int) $taxInvoice->supply_amount,
'TaxTotal' => (int) $taxInvoice->tax_amount,
'TotalAmount' => (int) $taxInvoice->total_amount,
// 품목 정보
'TaxInvoiceTradeLineItems' => $items,
// 비고
'Remark1' => $taxInvoice->description ?? '',
'Remark2' => '',
'Remark3' => '',
'InvoicerParty' => [
'MgtNum' => $mgtNum,
'CorpNum' => $taxInvoice->supplier_corp_num,
'TaxRegID' => '',
'CorpName' => $taxInvoice->supplier_corp_name,
'CEOName' => $taxInvoice->supplier_ceo_name ?? '',
'Addr' => $taxInvoice->supplier_addr ?? '',
'BizType' => $taxInvoice->supplier_biz_type ?? '',
'BizClass' => $taxInvoice->supplier_biz_class ?? '',
'ContactID' => $setting->barobill_id,
'ContactName' => $setting->contact_name ?? '',
'TEL' => $setting->contact_tel ?? '',
'HP' => '',
'Email' => $setting->contact_id ?? '',
],
'InvoiceeParty' => [
'MgtNum' => '',
'CorpNum' => str_replace('-', '', $taxInvoice->buyer_corp_num ?? ''),
'TaxRegID' => '',
'CorpName' => $taxInvoice->buyer_corp_name ?? '',
'CEOName' => $taxInvoice->buyer_ceo_name ?? '',
'Addr' => $taxInvoice->buyer_addr ?? '',
'BizType' => $taxInvoice->buyer_biz_type ?? '',
'BizClass' => $taxInvoice->buyer_biz_class ?? '',
'ContactID' => '',
'ContactName' => '',
'TEL' => '',
'HP' => '',
'Email' => $taxInvoice->buyer_contact_id ?? '',
],
'BrokerParty' => [],
'TaxInvoiceTradeLineItems' => [
'TaxInvoiceTradeLineItem' => $tradeLineItems,
],
],
'SendSMS' => false,
'ForceIssue' => false,
'MailTitle' => '',
];
}
/**
* 세금계산서 유형을 바로빌 코드로 매핑
*
* @return int 1: 세금계산서, 2: 계산서, 4: 수정세금계산서
* 세금계산서 유형 매핑
*/
private function mapInvoiceTypeToCode(string $type): int
private function mapInvoiceType(string $type): string
{
return match ($type) {
TaxInvoice::TYPE_TAX_INVOICE => 1,
TaxInvoice::TYPE_INVOICE => 2,
TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => 4,
default => 1,
TaxInvoice::TYPE_TAX_INVOICE => '세금계산서',
TaxInvoice::TYPE_INVOICE => '계산서',
TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서',
default => '세금계산서',
};
}
/**
* 발행 유형 매핑
*/
private function mapIssueType(string $type): string
{
return match ($type) {
TaxInvoice::ISSUE_TYPE_NORMAL => '정발행',
TaxInvoice::ISSUE_TYPE_REVERSE => '역발행',
TaxInvoice::ISSUE_TYPE_TRUSTEE => '위수탁',
default => '정발행',
};
}
}

View File

@@ -87,19 +87,11 @@ public function store(array $data): Card
$card = new Card;
$card->tenant_id = $tenantId;
$card->card_company = $data['card_company'];
$card->card_type = $data['card_type'] ?? null;
$card->setCardNumber($data['card_number']);
$card->expiry_date = $data['expiry_date'];
$card->card_name = $data['card_name'];
$card->alias = $data['alias'] ?? null;
$card->payment_day = $data['payment_day'] ?? null;
$card->total_limit = $data['total_limit'] ?? null;
$card->used_amount = $data['used_amount'] ?? null;
$card->remaining_limit = $data['remaining_limit'] ?? null;
$card->status = $data['status'] ?? 'active';
$card->is_manual = $data['is_manual'] ?? false;
$card->assigned_user_id = $data['assigned_user_id'] ?? null;
$card->memo = $data['memo'] ?? null;
$card->created_by = $userId;
$card->updated_by = $userId;
@@ -107,13 +99,9 @@ public function store(array $data): Card
$card->setCardPassword($data['card_password']);
}
if (array_key_exists('csv', $data)) {
$card->setCvc($data['csv']);
}
$card->save();
return $this->show($card->id);
return $card;
});
}
@@ -133,9 +121,6 @@ public function update(int $id, array $data): Card
if (isset($data['card_company'])) {
$card->card_company = $data['card_company'];
}
if (array_key_exists('card_type', $data)) {
$card->card_type = $data['card_type'];
}
if (isset($data['card_number'])) {
$card->setCardNumber($data['card_number']);
}
@@ -145,36 +130,12 @@ public function update(int $id, array $data): Card
if (isset($data['card_name'])) {
$card->card_name = $data['card_name'];
}
if (array_key_exists('alias', $data)) {
$card->alias = $data['alias'];
}
if (array_key_exists('csv', $data)) {
$card->setCvc($data['csv']);
}
if (array_key_exists('payment_day', $data)) {
$card->payment_day = $data['payment_day'];
}
if (array_key_exists('total_limit', $data)) {
$card->total_limit = $data['total_limit'];
}
if (array_key_exists('used_amount', $data)) {
$card->used_amount = $data['used_amount'];
}
if (array_key_exists('remaining_limit', $data)) {
$card->remaining_limit = $data['remaining_limit'];
}
if (isset($data['status'])) {
$card->status = $data['status'];
}
if (array_key_exists('is_manual', $data)) {
$card->is_manual = $data['is_manual'];
}
if (array_key_exists('assigned_user_id', $data)) {
$card->assigned_user_id = $data['assigned_user_id'];
}
if (array_key_exists('memo', $data)) {
$card->memo = $data['memo'];
}
if (isset($data['card_password'])) {
$card->setCardPassword($data['card_password']);
}
@@ -182,7 +143,7 @@ public function update(int $id, array $data): Card
$card->updated_by = $userId;
$card->save();
return $this->show($card->id);
return $card->fresh();
});
}

View File

@@ -1,171 +0,0 @@
<?php
namespace App\Services;
use App\Models\Tenants\CorporateCard;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class CorporateCardService extends Service
{
/**
* 법인카드 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = CorporateCard::query()
->where('tenant_id', $tenantId);
// 검색
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('card_name', 'like', "%{$search}%")
->orWhere('card_company', 'like', "%{$search}%")
->orWhere('card_number', 'like', "%{$search}%")
->orWhere('card_holder_name', 'like', "%{$search}%")
->orWhere('actual_user', 'like', "%{$search}%");
});
}
// 상태 필터
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
// 카드 유형 필터
if (! empty($params['card_type'])) {
$query->where('card_type', $params['card_type']);
}
// 정렬
$query->orderBy($params['sort_by'] ?? 'created_at', $params['sort_dir'] ?? 'desc');
return $query->paginate($params['per_page'] ?? 20);
}
/**
* 법인카드 상세 조회
*/
public function show(int $id): CorporateCard
{
return CorporateCard::query()
->where('tenant_id', $this->tenantId())
->findOrFail($id);
}
/**
* 법인카드 등록
*/
public function store(array $data): CorporateCard
{
$tenantId = $this->tenantId();
return DB::transaction(function () use ($data, $tenantId) {
return CorporateCard::create([
'tenant_id' => $tenantId,
'card_name' => $data['card_name'],
'card_company' => $data['card_company'],
'card_number' => $data['card_number'],
'card_type' => $data['card_type'],
'payment_day' => $data['payment_day'] ?? 15,
'credit_limit' => $data['credit_limit'] ?? 0,
'current_usage' => 0,
'card_holder_name' => $data['card_holder_name'],
'actual_user' => $data['actual_user'],
'expiry_date' => $data['expiry_date'] ?? null,
'cvc' => $data['cvc'] ?? null,
'status' => $data['status'] ?? 'active',
'memo' => $data['memo'] ?? null,
]);
});
}
/**
* 법인카드 수정
*/
public function update(int $id, array $data): CorporateCard
{
return DB::transaction(function () use ($id, $data) {
$card = CorporateCard::query()
->where('tenant_id', $this->tenantId())
->findOrFail($id);
$card->fill([
'card_name' => $data['card_name'] ?? $card->card_name,
'card_company' => $data['card_company'] ?? $card->card_company,
'card_number' => $data['card_number'] ?? $card->card_number,
'card_type' => $data['card_type'] ?? $card->card_type,
'payment_day' => $data['payment_day'] ?? $card->payment_day,
'credit_limit' => $data['credit_limit'] ?? $card->credit_limit,
'card_holder_name' => $data['card_holder_name'] ?? $card->card_holder_name,
'actual_user' => $data['actual_user'] ?? $card->actual_user,
'expiry_date' => $data['expiry_date'] ?? $card->expiry_date,
'cvc' => $data['cvc'] ?? $card->cvc,
'status' => $data['status'] ?? $card->status,
'memo' => $data['memo'] ?? $card->memo,
]);
$card->save();
return $card->fresh();
});
}
/**
* 법인카드 삭제
*/
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$card = CorporateCard::query()
->where('tenant_id', $this->tenantId())
->findOrFail($id);
$card->delete();
return true;
});
}
/**
* 법인카드 상태 토글 (사용/정지)
*/
public function toggleStatus(int $id): CorporateCard
{
return DB::transaction(function () use ($id) {
$card = CorporateCard::query()
->where('tenant_id', $this->tenantId())
->findOrFail($id);
$card->toggleStatus();
$card->save();
return $card;
});
}
/**
* 활성 법인카드 목록 (셀렉트박스용)
*/
public function getActiveCards(): array
{
return CorporateCard::query()
->where('tenant_id', $this->tenantId())
->where('status', 'active')
->orderBy('card_name')
->get(['id', 'card_name', 'card_company', 'card_number', 'card_type'])
->map(function ($card) {
return [
'id' => $card->id,
'card_name' => $card->card_name,
'card_company' => $card->card_company,
'display_number' => $card->getMaskedCardNumber(),
'card_type' => $card->card_type,
];
})
->toArray();
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Services;
use App\Models\Tenants\TaxInvoice;
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
@@ -272,105 +271,6 @@ public function checkStatus(int $id): TaxInvoice
return $this->barobillService->checkNtsSendStatus($taxInvoice);
}
// =========================================================================
// 공급자 설정
// =========================================================================
/**
* 공급자 설정 조회 (BarobillSetting 기반, 미설정 시 Tenant 정보 Fallback)
*/
public function getSupplierSettings(): array
{
$setting = $this->barobillService->getSetting();
if ($setting && $this->hasSupplierData($setting)) {
return [
'business_number' => $setting->corp_num,
'company_name' => $setting->corp_name,
'representative_name' => $setting->ceo_name,
'address' => $setting->addr,
'business_type' => $setting->biz_type,
'business_item' => $setting->biz_class,
'contact_name' => $setting->contact_name,
'contact_phone' => $setting->contact_tel,
'contact_email' => $setting->contact_id,
];
}
// BarobillSetting 미설정 시 Tenant 정보를 기본값으로 반환
return $this->getSupplierSettingsFromTenant();
}
/**
* BarobillSetting에 공급자 정보가 입력되어 있는지 확인
*/
private function hasSupplierData($setting): bool
{
return ! empty($setting->corp_num) || ! empty($setting->corp_name);
}
/**
* Tenant 정보에서 공급자 설정 기본값 조회
*/
private function getSupplierSettingsFromTenant(): array
{
$tenant = Tenant::find($this->tenantId());
if (! $tenant) {
return [];
}
$options = $tenant->options ?? [];
return [
'business_number' => $tenant->business_num ?? '',
'company_name' => $tenant->company_name ?? '',
'representative_name' => $tenant->ceo_name ?? '',
'address' => $tenant->address ?? '',
'business_type' => $options['business_type'] ?? '',
'business_item' => $options['business_category'] ?? '',
'contact_name' => $options['tax_invoice_contact'] ?? '',
'contact_phone' => $tenant->phone ?? '',
'contact_email' => $options['tax_invoice_email'] ?? '',
];
}
/**
* 공급자 설정 저장
*/
public function saveSupplierSettings(array $data): array
{
$this->barobillService->saveSetting([
'corp_num' => $data['business_number'] ?? null,
'corp_name' => $data['company_name'] ?? null,
'ceo_name' => $data['representative_name'] ?? null,
'addr' => $data['address'] ?? null,
'biz_type' => $data['business_type'] ?? null,
'biz_class' => $data['business_item'] ?? null,
'contact_name' => $data['contact_name'] ?? null,
'contact_tel' => $data['contact_phone'] ?? null,
'contact_id' => $data['contact_email'] ?? null,
]);
return $this->getSupplierSettings();
}
// =========================================================================
// 생성+발행 통합
// =========================================================================
/**
* 세금계산서 생성 후 즉시 발행
*/
public function createAndIssue(array $data): TaxInvoice
{
return DB::transaction(function () use ($data) {
$taxInvoice = $this->create($data);
return $this->barobillService->issueTaxInvoice($taxInvoice);
});
}
// =========================================================================
// 통계
// =========================================================================

View File

@@ -3,7 +3,7 @@
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Role", description="역할 관리(목록/조회/등록/수정/삭제/통계/활성)")
* @OA\Tag(name="Role", description="역할 관리(목록/조회/등록/수정/삭제)")
*/
/**
@@ -18,9 +18,6 @@
* @OA\Property(property="name", type="string", example="menu-manager"),
* @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할"),
* @OA\Property(property="guard_name", type="string", example="api"),
* @OA\Property(property="is_hidden", type="boolean", example=false),
* @OA\Property(property="permissions_count", type="integer", example=12),
* @OA\Property(property="users_count", type="integer", example=3),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-16 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-16 10:00:00")
* )
@@ -50,8 +47,7 @@
* required={"name"},
*
* @OA\Property(property="name", type="string", example="menu-manager", description="역할명(테넌트+가드 내 고유)"),
* @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할"),
* @OA\Property(property="is_hidden", type="boolean", example=false, description="숨김 여부")
* @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할")
* )
*
* @OA\Schema(
@@ -59,19 +55,7 @@
* type="object",
*
* @OA\Property(property="name", type="string", example="menu-admin"),
* @OA\Property(property="description", type="string", nullable=true, example="설명 변경"),
* @OA\Property(property="is_hidden", type="boolean", example=false)
* )
*
* @OA\Schema(
* schema="RoleStats",
* type="object",
* description="역할 통계",
*
* @OA\Property(property="total", type="integer", example=5),
* @OA\Property(property="visible", type="integer", example=3),
* @OA\Property(property="hidden", type="integer", example=2),
* @OA\Property(property="with_users", type="integer", example=4)
* @OA\Property(property="description", type="string", nullable=true, example="설명 변경")
* )
*/
class RoleApi
@@ -80,14 +64,13 @@ class RoleApi
* @OA\Get(
* path="/api/v1/roles",
* summary="역할 목록 조회",
* description="테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색, is_hidden으로 필터)",
* description="테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색)",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", example=1)),
* @OA\Parameter(name="size", in="query", required=false, @OA\Schema(type="integer", example=10)),
* @OA\Parameter(name="q", in="query", required=false, @OA\Schema(type="string", example="read")),
* @OA\Parameter(name="is_hidden", in="query", required=false, description="숨김 상태 필터", @OA\Schema(type="boolean", example=false)),
*
* @OA\Response(response=200, description="목록 조회 성공",
*
@@ -225,62 +208,4 @@ public function update() {}
* )
*/
public function destroy() {}
/**
* @OA\Get(
* path="/api/v1/roles/stats",
* summary="역할 통계 조회",
* description="테넌트 범위 내 역할 통계(전체/공개/숨김/사용자 보유)를 반환합니다.",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Response(response=200, description="통계 조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/RoleStats"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/roles/active",
* summary="활성 역할 목록 (드롭다운용)",
* description="숨겨지지 않은 활성 역할 목록을 이름순으로 반환합니다. (id, name, description만 포함)",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Response(response=200, description="목록 조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="admin"),
* @OA\Property(property="description", type="string", nullable=true, example="관리자")
* )
* ))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function active() {}
}

View File

@@ -5,7 +5,7 @@
/**
* @OA\Tag(
* name="RolePermission",
* description="역할-퍼미션 매핑(조회/부여/회수/동기화/매트릭스/토글)"
* description="역할-퍼미션 매핑(조회/부여/회수/동기화)"
* )
*/
@@ -96,64 +96,6 @@
* )
* }
* )
*
* @OA\Schema(
* schema="PermissionMenuTree",
* type="object",
* description="권한 매트릭스용 메뉴 트리",
*
* @OA\Property(property="menus", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="parent_id", type="integer", nullable=true, example=null),
* @OA\Property(property="name", type="string", example="대시보드"),
* @OA\Property(property="url", type="string", nullable=true, example="/dashboard"),
* @OA\Property(property="icon", type="string", nullable=true, example="dashboard"),
* @OA\Property(property="sort_order", type="integer", example=1),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="depth", type="integer", example=0),
* @OA\Property(property="has_children", type="boolean", example=true)
* )
* ),
* @OA\Property(property="permission_types", type="array", @OA\Items(type="string"), example={"view","create","update","delete","approve","export","manage"})
* )
*
* @OA\Schema(
* schema="RolePermissionMatrix",
* type="object",
* description="역할의 권한 매트릭스",
*
* @OA\Property(property="role", type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="admin"),
* @OA\Property(property="description", type="string", nullable=true, example="관리자")
* ),
* @OA\Property(property="permission_types", type="array", @OA\Items(type="string"), example={"view","create","update","delete","approve","export","manage"}),
* @OA\Property(property="permissions", type="object", description="메뉴ID를 키로 한 권한 맵",
* example={"101": {"view": true, "create": true}, "102": {"view": true}},
* additionalProperties=true
* )
* )
*
* @OA\Schema(
* schema="RolePermissionToggleRequest",
* type="object",
* required={"menu_id","permission_type"},
*
* @OA\Property(property="menu_id", type="integer", example=101, description="메뉴 ID"),
* @OA\Property(property="permission_type", type="string", example="view", description="권한 유형 (view, create, update, delete, approve, export, manage)")
* )
*
* @OA\Schema(
* schema="RolePermissionToggleResponse",
* type="object",
*
* @OA\Property(property="menu_id", type="integer", example=101),
* @OA\Property(property="permission_type", type="string", example="view"),
* @OA\Property(property="granted", type="boolean", example=true, description="토글 후 권한 부여 상태")
* )
*/
class RolePermissionApi
{
@@ -251,142 +193,4 @@ public function revoke() {}
* )
*/
public function sync() {}
/**
* @OA\Get(
* path="/api/v1/role-permissions/menus",
* summary="권한 매트릭스용 메뉴 트리 조회",
* description="활성 메뉴를 플랫 배열(depth 포함)로 반환하고, 사용 가능한 권한 유형 목록을 함께 반환합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PermissionMenuTree"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function menus() {}
/**
* @OA\Get(
* path="/api/v1/roles/{id}/permissions/matrix",
* summary="역할의 권한 매트릭스 조회",
* description="해당 역할에 부여된 메뉴별 권한 매트릭스를 반환합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/RolePermissionMatrix"))
* }
* )
* ),
*
* @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function matrix() {}
/**
* @OA\Post(
* path="/api/v1/roles/{id}/permissions/toggle",
* summary="특정 메뉴의 특정 권한 토글",
* description="지정한 메뉴+권한 유형의 부여 상태를 반전합니다. 하위 메뉴에 재귀적으로 전파합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/RolePermissionToggleRequest")),
*
* @OA\Response(response=200, description="토글 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/RolePermissionToggleResponse"))
* }
* )
* ),
*
* @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function toggle() {}
/**
* @OA\Post(
* path="/api/v1/roles/{id}/permissions/allow-all",
* summary="모든 권한 허용",
* description="해당 역할에 모든 활성 메뉴의 모든 권한 유형을 일괄 부여합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
*
* @OA\Response(response=200, description="성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function allowAll() {}
/**
* @OA\Post(
* path="/api/v1/roles/{id}/permissions/deny-all",
* summary="모든 권한 거부",
* description="해당 역할의 모든 메뉴 권한을 일괄 제거합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
*
* @OA\Response(response=200, description="성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function denyAll() {}
/**
* @OA\Post(
* path="/api/v1/roles/{id}/permissions/reset",
* summary="기본 권한으로 초기화 (view만 허용)",
* description="해당 역할의 모든 권한을 제거한 후, 모든 활성 메뉴에 view 권한만 부여합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
*
* @OA\Response(response=200, description="성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function reset() {}
}

View File

@@ -1,21 +0,0 @@
<?php
/**
* Google Cloud Storage 설정
*
* 우선순위:
* 1. DB 설정 (ai_configs 테이블) - UI에서 오버라이드 가능
* 2. 환경변수 (.env)
* 3. 레거시 파일 (/sales/apikey/)
*/
return [
// 버킷 이름
'bucket_name' => env('GCS_BUCKET_NAME'),
// 서비스 계정 파일 경로
'service_account_path' => env('GCS_SERVICE_ACCOUNT_PATH', '/var/www/mng/apikey/google_service_account.json'),
// DB 설정 사용 여부 (false면 .env만 사용)
'use_db_config' => env('GCS_USE_DB_CONFIG', true),
];

View File

@@ -24,7 +24,7 @@
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => App\Models\Permissions\Role::class,
'role' => Spatie\Permission\Models\Role::class,
],

View File

@@ -58,48 +58,4 @@
'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'),
],
/*
|--------------------------------------------------------------------------
| Claude AI
|--------------------------------------------------------------------------
*/
'claude' => [
'api_key' => env('CLAUDE_API_KEY'),
],
/*
|--------------------------------------------------------------------------
| Google Cloud (STT + GCS Storage)
|--------------------------------------------------------------------------
| Google Cloud 서비스 인증 및 음성인식(STT) 설정
*/
'google' => [
'credentials_path' => env('GOOGLE_APPLICATION_CREDENTIALS'),
'storage_bucket' => env('GOOGLE_STORAGE_BUCKET'),
'location' => env('GOOGLE_STT_LOCATION', 'asia-southeast1'),
],
/*
|--------------------------------------------------------------------------
| Vertex AI (Veo 영상 생성 등)
|--------------------------------------------------------------------------
*/
'vertex_ai' => [
'project_id' => env('VERTEX_AI_PROJECT_ID', 'codebridge-chatbot'),
'location' => env('VERTEX_AI_LOCATION', 'us-central1'),
],
/*
|--------------------------------------------------------------------------
| Barobill API (SOAP)
|--------------------------------------------------------------------------
| 바로빌 SOAP 연동 설정 (계좌/카드/인증서 등)
*/
'barobill' => [
'cert_key_test' => env('BAROBILL_CERT_KEY_TEST', ''),
'cert_key_prod' => env('BAROBILL_CERT_KEY_PROD', ''),
'corp_num' => env('BAROBILL_CORP_NUM', ''),
'test_mode' => env('BAROBILL_TEST_MODE', true),
],
];

View File

@@ -1,46 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cards', function (Blueprint $table) {
$table->string('card_type', 50)->nullable()->after('card_company')->comment('카드 종류 (corporate_1, personal 등)');
$table->string('alias', 100)->nullable()->after('card_name')->comment('카드 별칭');
$table->text('cvc_encrypted')->nullable()->after('card_password_encrypted')->comment('암호화된 CVC/CVV');
$table->unsignedTinyInteger('payment_day')->nullable()->after('cvc_encrypted')->comment('결제일 (1-31)');
$table->decimal('total_limit', 15, 2)->nullable()->after('payment_day')->comment('총 한도');
$table->decimal('used_amount', 15, 2)->nullable()->after('total_limit')->comment('사용 금액');
$table->decimal('remaining_limit', 15, 2)->nullable()->after('used_amount')->comment('잔여 한도');
$table->boolean('is_manual')->default(false)->after('status')->comment('수기 등록 여부');
$table->text('memo')->nullable()->after('assigned_user_id')->comment('메모');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cards', function (Blueprint $table) {
$table->dropColumn([
'card_type',
'alias',
'cvc_encrypted',
'payment_day',
'total_limit',
'used_amount',
'remaining_limit',
'is_manual',
'memo',
]);
});
}
};

View File

@@ -32,16 +32,6 @@
// Server errors
'server_error' => 'An internal server error occurred.', // 5xx
// Role/Permission related
'role' => [
'not_found' => 'Role not found.',
'permission_input_required' => 'Either permission_names or menus+actions is required.',
'no_valid_permissions' => 'No valid permissions found.',
'role_input_required' => 'Either role_names or role_ids is required.',
'no_valid_roles' => 'No valid roles found.',
'user_not_found' => 'User not found.',
],
// Estimate related errors
'estimate' => [
'cannot_delete_sent_or_approved' => 'Cannot delete estimates that have been sent or approved.',

View File

@@ -117,11 +117,4 @@
'cancelled' => 'Tax invoice has been cancelled.',
'bulk_issued' => 'Tax invoices have been issued in bulk.',
],
// Barobill Integration
'barobill' => [
'login_success' => 'Barobill login registered successfully.',
'signup_success' => 'Barobill signup completed successfully.',
'connection_success' => 'Barobill connection test succeeded.',
],
];

View File

@@ -39,16 +39,6 @@
// 서버 오류
'server_error' => '서버 처리 중 오류가 발생했습니다.', // 5xx 일반
// 역할/권한 관련
'role' => [
'not_found' => '역할을 찾을 수 없습니다.',
'permission_input_required' => 'permission_names 또는 menus+actions 중 하나는 필요합니다.',
'no_valid_permissions' => '유효한 퍼미션이 없습니다.',
'role_input_required' => 'role_names 또는 role_ids 중 하나는 필요합니다.',
'no_valid_roles' => '유효한 역할이 없습니다.',
'user_not_found' => '사용자를 찾을 수 없습니다.',
],
// 견적 관련 에러
'estimate' => [
'cannot_delete_sent_or_approved' => '발송되었거나 승인된 견적은 삭제할 수 없습니다.',
@@ -291,11 +281,6 @@
'no_base_salary' => '기본급이 설정되지 않았습니다.',
],
// 바로빌 연동 관련
'barobill' => [
'member_not_found' => '바로빌 회원 정보가 등록되어 있지 않습니다.',
],
// 세금계산서 관련
'tax_invoice' => [
'not_found' => '세금계산서 정보를 찾을 수 없습니다.',

View File

@@ -566,13 +566,6 @@
'downloaded' => '문서가 다운로드되었습니다.',
],
// 바로빌 연동
'barobill' => [
'login_success' => '바로빌 로그인 정보가 등록되었습니다.',
'signup_success' => '바로빌 회원가입이 완료되었습니다.',
'connection_success' => '바로빌 연동 테스트 성공',
],
// CEO 대시보드 부가세 현황
'vat' => [
'sales_tax' => '매출세액',

View File

@@ -57,7 +57,7 @@
});
// Role API
Route::middleware(['perm.map', 'permission'])->prefix('roles')->group(function () {
Route::prefix('roles')->group(function () {
Route::get('/', [RoleController::class, 'index'])->name('v1.roles.index');
Route::post('/', [RoleController::class, 'store'])->name('v1.roles.store');
Route::get('/stats', [RoleController::class, 'stats'])->name('v1.roles.stats');
@@ -68,10 +68,10 @@
});
// Role Permission API - 공통
Route::middleware(['perm.map', 'permission'])->get('/role-permissions/menus', [RolePermissionController::class, 'menus'])->name('v1.roles.perms.menus');
Route::get('/role-permissions/menus', [RolePermissionController::class, 'menus'])->name('v1.roles.perms.menus');
// Role Permission API - 역할별
Route::middleware(['perm.map', 'permission'])->prefix('roles/{id}/permissions')->group(function () {
Route::prefix('roles/{id}/permissions')->group(function () {
Route::get('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index');
Route::post('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant');
Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke');

View File

@@ -15,14 +15,12 @@
use App\Http\Controllers\Api\V1\BadDebtController;
use App\Http\Controllers\Api\V1\BankAccountController;
use App\Http\Controllers\Api\V1\BankTransactionController;
use App\Http\Controllers\Api\V1\BarobillController;
use App\Http\Controllers\Api\V1\BarobillSettingController;
use App\Http\Controllers\Api\V1\BillController;
use App\Http\Controllers\Api\V1\CalendarController;
use App\Http\Controllers\Api\V1\CardController;
use App\Http\Controllers\Api\V1\CardTransactionController;
use App\Http\Controllers\Api\V1\ComprehensiveAnalysisController;
use App\Http\Controllers\Api\V1\CorporateCardController;
use App\Http\Controllers\Api\V1\DailyReportController;
use App\Http\Controllers\Api\V1\DepositController;
use App\Http\Controllers\Api\V1\EntertainmentController;
@@ -66,17 +64,6 @@
Route::patch('/{id}/set-primary', [BankAccountController::class, 'setPrimary'])->whereNumber('id')->name('v1.bank-accounts.set-primary');
});
// CorporateCard API (법인카드 관리)
Route::prefix('corporate-cards')->group(function () {
Route::get('', [CorporateCardController::class, 'index'])->name('v1.corporate-cards.index');
Route::post('', [CorporateCardController::class, 'store'])->name('v1.corporate-cards.store');
Route::get('/active', [CorporateCardController::class, 'active'])->name('v1.corporate-cards.active');
Route::get('/{id}', [CorporateCardController::class, 'show'])->whereNumber('id')->name('v1.corporate-cards.show');
Route::put('/{id}', [CorporateCardController::class, 'update'])->whereNumber('id')->name('v1.corporate-cards.update');
Route::delete('/{id}', [CorporateCardController::class, 'destroy'])->whereNumber('id')->name('v1.corporate-cards.destroy');
Route::patch('/{id}/toggle', [CorporateCardController::class, 'toggle'])->whereNumber('id')->name('v1.corporate-cards.toggle');
});
// Deposit API (입금 관리)
Route::prefix('deposits')->group(function () {
Route::get('', [DepositController::class, 'index'])->name('v1.deposits.index');
@@ -267,32 +254,18 @@
Route::post('/test-connection', [BarobillSettingController::class, 'testConnection'])->name('v1.barobill-settings.test-connection');
});
// Barobill Integration API (바로빌 연동 관리)
Route::prefix('barobill')->group(function () {
Route::post('/login', [BarobillController::class, 'login'])->name('v1.barobill.login');
Route::post('/signup', [BarobillController::class, 'signup'])->name('v1.barobill.signup');
Route::get('/bank-service-url', [BarobillController::class, 'bankServiceUrl'])->name('v1.barobill.bank-service-url');
Route::get('/status', [BarobillController::class, 'status'])->name('v1.barobill.status');
Route::get('/account-link-url', [BarobillController::class, 'accountLinkUrl'])->name('v1.barobill.account-link-url');
Route::get('/card-link-url', [BarobillController::class, 'cardLinkUrl'])->name('v1.barobill.card-link-url');
Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url');
});
// Tax Invoice API (세금계산서)
Route::prefix('tax-invoices')->group(function () {
Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index');
Route::post('', [TaxInvoiceController::class, 'store'])->name('v1.tax-invoices.store');
Route::get('/summary', [TaxInvoiceController::class, 'summary'])->name('v1.tax-invoices.summary');
Route::get('/supplier-settings', [TaxInvoiceController::class, 'supplierSettings'])->name('v1.tax-invoices.supplier-settings');
Route::put('/supplier-settings', [TaxInvoiceController::class, 'saveSupplierSettings'])->name('v1.tax-invoices.save-supplier-settings');
Route::post('/issue-direct', [TaxInvoiceController::class, 'storeAndIssue'])->name('v1.tax-invoices.issue-direct');
Route::post('/bulk-issue', [TaxInvoiceController::class, 'bulkIssue'])->name('v1.tax-invoices.bulk-issue');
Route::get('/{id}', [TaxInvoiceController::class, 'show'])->whereNumber('id')->name('v1.tax-invoices.show');
Route::put('/{id}', [TaxInvoiceController::class, 'update'])->whereNumber('id')->name('v1.tax-invoices.update');
Route::delete('/{id}', [TaxInvoiceController::class, 'destroy'])->whereNumber('id')->name('v1.tax-invoices.destroy');
Route::post('/{id}/issue', [TaxInvoiceController::class, 'issue'])->whereNumber('id')->name('v1.tax-invoices.issue');
Route::post('/{id}/cancel', [TaxInvoiceController::class, 'cancel'])->whereNumber('id')->name('v1.tax-invoices.cancel');
Route::get('/{id}/check-status', [TaxInvoiceController::class, 'checkStatus'])->whereNumber('id')->name('v1.tax-invoices.check-status');
Route::post('/bulk-issue', [TaxInvoiceController::class, 'bulkIssue'])->name('v1.tax-invoices.bulk-issue');
});
// Bad Debt API (악성채권 추심관리)