feat: Phase 8.3 회사 추가 API 구현

- 사업자등록번호 유효성 검사 API (바로빌 연동)
- 회사 추가 신청/승인/반려 워크플로우
- 신청 승인 시 테넌트 자동 생성 및 사용자 연결
- 관리자용 신청 목록/상세 조회
- 사용자용 내 신청 목록 조회
- Swagger 문서 및 i18n 메시지 추가
This commit is contained in:
2025-12-22 15:30:38 +09:00
parent 8ab65e18d0
commit 7781253491
13 changed files with 1332 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Company\CheckBusinessNumberRequest;
use App\Http\Requests\V1\Company\CompanyRequestActionRequest;
use App\Http\Requests\V1\Company\CompanyRequestIndexRequest;
use App\Http\Requests\V1\Company\CompanyRequestStoreRequest;
use App\Http\Response\ApiResponse;
use App\Services\CompanyService;
use Illuminate\Http\JsonResponse;
class CompanyController extends Controller
{
public function __construct(
private readonly CompanyService $companyService
) {}
/**
* 사업자등록번호 유효성 검사
*/
public function check(CheckBusinessNumberRequest $request): JsonResponse
{
$result = $this->companyService->checkBusinessNumber(
$request->validated()['business_number']
);
return ApiResponse::handle('message.company.checked', $result);
}
/**
* 회사 추가 신청
*/
public function request(CompanyRequestStoreRequest $request): JsonResponse
{
$result = $this->companyService->createRequest($request->validated());
return ApiResponse::handle('message.company.request_created', $result, 201);
}
/**
* 회사 추가 신청 목록 (관리자용)
*/
public function requests(CompanyRequestIndexRequest $request): JsonResponse
{
$result = $this->companyService->getRequests($request->validated());
return ApiResponse::handle('message.fetched', $result);
}
/**
* 회사 추가 신청 상세
*/
public function showRequest(int $id): JsonResponse
{
$result = $this->companyService->getRequest($id);
return ApiResponse::handle('message.fetched', $result);
}
/**
* 회사 추가 신청 승인
*/
public function approve(int $id): JsonResponse
{
$result = $this->companyService->approveRequest($id);
return ApiResponse::handle('message.company.request_approved', $result);
}
/**
* 회사 추가 신청 반려
*/
public function reject(CompanyRequestActionRequest $request, int $id): JsonResponse
{
$result = $this->companyService->rejectRequest($id, $request->validated()['reason'] ?? null);
return ApiResponse::handle('message.company.request_rejected', $result);
}
/**
* 내 회사 추가 신청 목록
*/
public function myRequests(CompanyRequestIndexRequest $request): JsonResponse
{
$result = $this->companyService->getMyRequests($request->validated());
return ApiResponse::handle('message.fetched', $result);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\V1\Company;
use Illuminate\Foundation\Http\FormRequest;
class CheckBusinessNumberRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'business_number' => 'required|string|min:10|max:13',
];
}
public function messages(): array
{
return [
'business_number.required' => __('validation.required', ['attribute' => '사업자등록번호']),
'business_number.min' => __('validation.min.string', ['attribute' => '사업자등록번호', 'min' => 10]),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\V1\Company;
use Illuminate\Foundation\Http\FormRequest;
class CompanyRequestActionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => 'nullable|string|max:500',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\V1\Company;
use App\Models\CompanyRequest;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CompanyRequestIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['nullable', Rule::in(CompanyRequest::STATUSES)],
'search' => 'nullable|string|max:100',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'sort_by' => ['nullable', Rule::in(['created_at', 'company_name', 'status', 'processed_at'])],
'sort_dir' => ['nullable', Rule::in(['asc', 'desc'])],
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\V1\Company;
use Illuminate\Foundation\Http\FormRequest;
class CompanyRequestStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'business_number' => 'required|string|min:10|max:13',
'company_name' => 'required|string|max:200',
'ceo_name' => 'nullable|string|max:50',
'address' => 'nullable|string|max:300',
'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:100',
'message' => 'nullable|string|max:1000',
];
}
public function messages(): array
{
return [
'business_number.required' => __('validation.required', ['attribute' => '사업자등록번호']),
'company_name.required' => __('validation.required', ['attribute' => '회사명']),
];
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Models;
use App\Models\Members\User;
use App\Models\Tenants\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 회사 추가 신청 모델
*
* @property int $id
* @property int $user_id 신청자 ID
* @property string $business_number 사업자등록번호
* @property string $company_name 회사명
* @property string|null $ceo_name 대표자명
* @property string|null $address 주소
* @property string|null $phone 전화번호
* @property string|null $email 이메일
* @property string $status 상태 (pending, approved, rejected)
* @property string|null $message 신청 메시지
* @property string|null $reject_reason 반려 사유
* @property array|null $barobill_response 바로빌 검증 응답
* @property int|null $approved_by 승인/반려 처리자
* @property int|null $created_tenant_id 생성된 테넌트 ID
* @property \Carbon\Carbon|null $processed_at 처리일시
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class CompanyRequest extends Model
{
// =========================================================================
// 상수 정의
// =========================================================================
/** 상태 */
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
];
/** 상태 라벨 */
public const STATUS_LABELS = [
self::STATUS_PENDING => '대기중',
self::STATUS_APPROVED => '승인',
self::STATUS_REJECTED => '반려',
];
// =========================================================================
// 모델 설정
// =========================================================================
protected $fillable = [
'user_id',
'business_number',
'company_name',
'ceo_name',
'address',
'phone',
'email',
'status',
'message',
'reject_reason',
'barobill_response',
'approved_by',
'created_tenant_id',
'processed_at',
];
protected $casts = [
'barobill_response' => 'array',
'processed_at' => 'datetime',
];
protected $attributes = [
'status' => self::STATUS_PENDING,
];
// =========================================================================
// 관계
// =========================================================================
/**
* 신청자
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 처리자 (승인/반려)
*/
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
/**
* 생성된 테넌트
*/
public function createdTenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'created_tenant_id');
}
// =========================================================================
// 스코프
// =========================================================================
/**
* 대기중인 신청만
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* 승인된 신청만
*/
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
/**
* 반려된 신청만
*/
public function scopeRejected($query)
{
return $query->where('status', self::STATUS_REJECTED);
}
/**
* 상태 필터
*/
public function scopeOfStatus($query, string $status)
{
return $query->where('status', $status);
}
// =========================================================================
// 접근자
// =========================================================================
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
}
/**
* 대기중 여부
*/
public function getIsPendingAttribute(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 승인됨 여부
*/
public function getIsApprovedAttribute(): bool
{
return $this->status === self::STATUS_APPROVED;
}
/**
* 반려됨 여부
*/
public function getIsRejectedAttribute(): bool
{
return $this->status === self::STATUS_REJECTED;
}
/**
* 사업자등록번호 포맷 (하이픈 포함)
*/
public function getFormattedBusinessNumberAttribute(): string
{
$num = preg_replace('/[^0-9]/', '', $this->business_number);
if (strlen($num) === 10) {
return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5, 5);
}
return $this->business_number;
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 승인 처리
*/
public function approve(int $approverId, int $tenantId): bool
{
if (! $this->is_pending) {
return false;
}
$this->status = self::STATUS_APPROVED;
$this->approved_by = $approverId;
$this->created_tenant_id = $tenantId;
$this->processed_at = now();
return $this->save();
}
/**
* 반려 처리
*/
public function reject(int $approverId, ?string $reason = null): bool
{
if (! $this->is_pending) {
return false;
}
$this->status = self::STATUS_REJECTED;
$this->approved_by = $approverId;
$this->reject_reason = $reason;
$this->processed_at = now();
return $this->save();
}
}

View File

@@ -119,6 +119,148 @@ public function testConnection(): array
}
}
// =========================================================================
// 사업자등록번호 검증
// =========================================================================
/**
* 사업자등록번호 유효성 검사 (휴폐업 조회)
*
* 바로빌 API를 통해 사업자등록번호의 유효성을 검증합니다.
* 바로빌 설정이 없는 경우 기본 형식 검증만 수행합니다.
*
* @param string $businessNumber 사업자등록번호 (10자리, 하이픈 제거)
* @return array{valid: bool, status: string, status_label: string, corp_name: ?string, ceo_name: ?string, message: string}
*/
public function checkBusinessNumber(string $businessNumber): array
{
// 하이픈 제거 및 숫자만 추출
$businessNumber = preg_replace('/[^0-9]/', '', $businessNumber);
// 기본 형식 검증 (10자리)
if (strlen($businessNumber) !== 10) {
return [
'valid' => false,
'status' => 'invalid_format',
'status_label' => '형식 오류',
'corp_name' => null,
'ceo_name' => null,
'message' => __('error.company.invalid_business_number_format'),
];
}
// 체크섬 검증 (사업자등록번호 자체 유효성)
if (! $this->validateBusinessNumberChecksum($businessNumber)) {
return [
'valid' => false,
'status' => 'invalid_checksum',
'status_label' => '유효하지 않음',
'corp_name' => null,
'ceo_name' => null,
'message' => __('error.company.invalid_business_number'),
];
}
// 바로빌 API 조회 시도
try {
$response = $this->callApi('CheckCorpNum', [
'CorpNum' => $businessNumber,
]);
// 바로빌 응답 해석
if (isset($response['CorpState'])) {
$state = $response['CorpState'];
$isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업
$statusLabel = match ($state) {
'01' => '사업중',
'02' => '휴업',
'03' => '폐업',
default => '조회 불가',
};
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'),
];
}
// 응답 형식이 다른 경우 (결과 코드 방식)
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',
'status_label' => '형식 유효',
'corp_name' => null,
'ceo_name' => null,
'message' => __('message.company.business_number_format_valid'),
];
} catch (\Exception $e) {
// API 호출 실패 시 형식 검증 결과만 반환
Log::warning('바로빌 사업자번호 조회 실패', [
'business_number' => $businessNumber,
'error' => $e->getMessage(),
]);
return [
'valid' => true,
'status' => 'format_valid',
'status_label' => '형식 유효 (API 조회 불가)',
'corp_name' => null,
'ceo_name' => null,
'message' => __('message.company.business_number_format_valid'),
];
}
}
/**
* 사업자등록번호 체크섬 검증
*
* @param string $businessNumber 10자리 사업자등록번호
*/
private function validateBusinessNumberChecksum(string $businessNumber): bool
{
if (strlen($businessNumber) !== 10) {
return false;
}
$digits = str_split($businessNumber);
$multipliers = [1, 3, 7, 1, 3, 7, 1, 3, 5];
$sum = 0;
for ($i = 0; $i < 9; $i++) {
$sum += intval($digits[$i]) * $multipliers[$i];
}
// 8번째 자리 (인덱스 8)에 대한 추가 처리
$sum += intval(floor(intval($digits[8]) * 5 / 10));
$remainder = $sum % 10;
$checkDigit = (10 - $remainder) % 10;
return $checkDigit === intval($digits[9]);
}
// =========================================================================
// 세금계산서 발행
// =========================================================================

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Services;
use App\Models\CompanyRequest;
use App\Models\Members\UserTenant;
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class CompanyService extends Service
{
public function __construct(
private readonly BarobillService $barobillService,
private readonly TenantService $tenantService
) {}
// =========================================================================
// 사업자등록번호 검증
// =========================================================================
/**
* 사업자등록번호 유효성 검사
*/
public function checkBusinessNumber(string $businessNumber): array
{
// 바로빌 서비스를 통한 사업자등록번호 검증
$result = $this->barobillService->checkBusinessNumber($businessNumber);
// 이미 등록된 회사인지 확인
$normalizedNumber = preg_replace('/[^0-9]/', '', $businessNumber);
$existingTenant = Tenant::query()
->where('business_num', $normalizedNumber)
->orWhere('business_num', $this->formatBusinessNumber($normalizedNumber))
->first();
if ($existingTenant) {
$result['already_exists'] = true;
$result['existing_company_name'] = $existingTenant->company_name;
} else {
$result['already_exists'] = false;
$result['existing_company_name'] = null;
}
return $result;
}
/**
* 사업자등록번호 포맷 (하이픈 추가)
*/
private function formatBusinessNumber(string $number): string
{
$number = preg_replace('/[^0-9]/', '', $number);
if (strlen($number) === 10) {
return substr($number, 0, 3).'-'.substr($number, 3, 2).'-'.substr($number, 5, 5);
}
return $number;
}
// =========================================================================
// 회사 추가 신청
// =========================================================================
/**
* 회사 추가 신청 생성
*/
public function createRequest(array $data): CompanyRequest
{
$userId = $this->apiUserId();
$businessNumber = preg_replace('/[^0-9]/', '', $data['business_number']);
// 중복 신청 확인 (대기중인 신청이 있는지)
$existingRequest = CompanyRequest::query()
->where('user_id', $userId)
->where('business_number', $businessNumber)
->where('status', CompanyRequest::STATUS_PENDING)
->first();
if ($existingRequest) {
throw new BadRequestHttpException(__('error.company.request_already_exists'));
}
// 이미 등록된 회사인지 확인
$existingTenant = Tenant::query()
->where('business_num', $businessNumber)
->orWhere('business_num', $this->formatBusinessNumber($businessNumber))
->first();
if ($existingTenant) {
// 이미 존재하는 회사에 합류 신청
// 해당 회사의 관리자에게 알림을 보내거나 별도 처리 가능
throw new BadRequestHttpException(__('error.company.already_registered'));
}
// 사업자등록번호 유효성 검사
$validationResult = $this->barobillService->checkBusinessNumber($businessNumber);
return CompanyRequest::create([
'user_id' => $userId,
'business_number' => $businessNumber,
'company_name' => $data['company_name'],
'ceo_name' => $data['ceo_name'] ?? $validationResult['ceo_name'] ?? null,
'address' => $data['address'] ?? null,
'phone' => $data['phone'] ?? null,
'email' => $data['email'] ?? null,
'message' => $data['message'] ?? null,
'status' => CompanyRequest::STATUS_PENDING,
'barobill_response' => $validationResult,
]);
}
/**
* 회사 추가 신청 목록 (관리자용)
*/
public function getRequests(array $params): LengthAwarePaginator
{
$query = CompanyRequest::query()
->with(['user:id,name,email', 'approver:id,name,email', 'createdTenant:id,company_name,code']);
// 상태 필터
if (! empty($params['status'])) {
$query->ofStatus($params['status']);
}
// 검색 (사업자번호, 회사명, 신청자명)
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('business_number', 'like', "%{$search}%")
->orWhere('company_name', 'like', "%{$search}%")
->orWhereHas('user', function ($uq) use ($search) {
$uq->where('name', 'like', "%{$search}%");
});
});
}
// 날짜 범위 필터
if (! empty($params['start_date'])) {
$query->whereDate('created_at', '>=', $params['start_date']);
}
if (! empty($params['end_date'])) {
$query->whereDate('created_at', '<=', $params['end_date']);
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 회사 추가 신청 상세
*/
public function getRequest(int $id): CompanyRequest
{
return CompanyRequest::query()
->with(['user:id,name,email', 'approver:id,name,email', 'createdTenant:id,company_name,code'])
->findOrFail($id);
}
// =========================================================================
// 신청 처리 (관리자)
// =========================================================================
/**
* 회사 추가 신청 승인
*/
public function approveRequest(int $id): CompanyRequest
{
$userId = $this->apiUserId();
$request = CompanyRequest::findOrFail($id);
if (! $request->is_pending) {
throw new BadRequestHttpException(__('error.company.request_not_pending'));
}
return DB::transaction(function () use ($request, $userId) {
// 테넌트 생성
$tenantCode = $this->tenantService->generateTenantCode($request->company_name);
$tenant = Tenant::create([
'code' => $tenantCode,
'company_name' => $request->company_name,
'business_num' => $request->business_number,
'ceo_name' => $request->ceo_name,
'address' => $request->address,
'phone' => $request->phone,
'email' => $request->email,
]);
// 신청자를 테넌트에 연결
UserTenant::create([
'user_id' => $request->user_id,
'tenant_id' => $tenant->id,
'is_active' => true,
'is_default' => true,
'joined_at' => now(),
]);
// 신청 승인 처리
$request->approve($userId, $tenant->id);
return $request->fresh(['user', 'approver', 'createdTenant']);
});
}
/**
* 회사 추가 신청 반려
*/
public function rejectRequest(int $id, ?string $reason = null): CompanyRequest
{
$userId = $this->apiUserId();
$request = CompanyRequest::findOrFail($id);
if (! $request->is_pending) {
throw new BadRequestHttpException(__('error.company.request_not_pending'));
}
$request->reject($userId, $reason);
return $request->fresh(['user', 'approver']);
}
// =========================================================================
// 내 신청 목록 (사용자용)
// =========================================================================
/**
* 내 회사 추가 신청 목록
*/
public function getMyRequests(array $params): LengthAwarePaginator
{
$userId = $this->apiUserId();
$query = CompanyRequest::query()
->where('user_id', $userId)
->with(['approver:id,name,email', 'createdTenant:id,company_name,code']);
// 상태 필터
if (! empty($params['status'])) {
$query->ofStatus($params['status']);
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
}

View File

@@ -0,0 +1,419 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Companies", description="회사 관리")
*
* @OA\Schema(
* schema="CompanyRequest",
* type="object",
* description="회사 추가 신청 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="신청 ID"),
* @OA\Property(property="user_id", type="integer", example=1, description="신청자 ID"),
* @OA\Property(property="business_number", type="string", example="1234567890", description="사업자등록번호"),
* @OA\Property(property="formatted_business_number", type="string", example="123-45-67890", description="사업자등록번호 (포맷)"),
* @OA\Property(property="company_name", type="string", example="주식회사 테스트", description="회사명"),
* @OA\Property(property="ceo_name", type="string", nullable=true, example="홍길동", description="대표자명"),
* @OA\Property(property="address", type="string", nullable=true, example="서울시 강남구 테헤란로 123", description="주소"),
* @OA\Property(property="phone", type="string", nullable=true, example="02-1234-5678", description="전화번호"),
* @OA\Property(property="email", type="string", nullable=true, example="contact@test.com", description="이메일"),
* @OA\Property(property="status", type="string", enum={"pending","approved","rejected"}, example="pending", description="상태"),
* @OA\Property(property="status_label", type="string", example="대기중", description="상태 라벨"),
* @OA\Property(property="message", type="string", nullable=true, description="신청 메시지"),
* @OA\Property(property="reject_reason", type="string", nullable=true, description="반려 사유"),
* @OA\Property(property="barobill_response", type="object", nullable=true, description="바로빌 검증 응답"),
* @OA\Property(property="approved_by", type="integer", nullable=true, description="승인/반려 처리자 ID"),
* @OA\Property(property="created_tenant_id", type="integer", nullable=true, description="생성된 테넌트 ID"),
* @OA\Property(property="processed_at", type="string", format="date-time", nullable=true, description="처리일시"),
* @OA\Property(property="is_pending", type="boolean", example=true, description="대기중 여부"),
* @OA\Property(property="is_approved", type="boolean", example=false, description="승인됨 여부"),
* @OA\Property(property="is_rejected", type="boolean", example=false, description="반려됨 여부"),
* @OA\Property(property="user", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="홍길동"),
* @OA\Property(property="email", type="string", example="user@example.com"),
* description="신청자 정보"
* ),
* @OA\Property(property="approver", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="관리자"),
* @OA\Property(property="email", type="string", example="admin@example.com"),
* description="처리자 정보"
* ),
* @OA\Property(property="created_tenant", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="company_name", type="string", example="주식회사 테스트"),
* @OA\Property(property="code", type="string", example="TEST0001"),
* description="생성된 테넌트 정보"
* ),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="BusinessNumberCheckResponse",
* type="object",
* description="사업자등록번호 검증 결과",
*
* @OA\Property(property="valid", type="boolean", example=true, description="유효 여부"),
* @OA\Property(property="status", type="string", example="01", description="상태 코드"),
* @OA\Property(property="status_label", type="string", example="사업중", description="상태 라벨"),
* @OA\Property(property="corp_name", type="string", nullable=true, example="주식회사 테스트", description="회사명 (바로빌 조회)"),
* @OA\Property(property="ceo_name", type="string", nullable=true, example="홍길동", description="대표자명 (바로빌 조회)"),
* @OA\Property(property="message", type="string", example="유효한 사업자등록번호입니다.", description="결과 메시지"),
* @OA\Property(property="already_exists", type="boolean", example=false, description="이미 등록된 회사 여부"),
* @OA\Property(property="existing_company_name", type="string", nullable=true, description="이미 등록된 회사명")
* )
*
* @OA\Schema(
* schema="CompanyRequestCreateRequest",
* type="object",
* required={"business_number", "company_name"},
* description="회사 추가 신청 요청",
*
* @OA\Property(property="business_number", type="string", example="1234567890", minLength=10, maxLength=13, description="사업자등록번호 (하이픈 포함/미포함 모두 가능)"),
* @OA\Property(property="company_name", type="string", example="주식회사 테스트", maxLength=200, description="회사명"),
* @OA\Property(property="ceo_name", type="string", example="홍길동", maxLength=50, nullable=true, description="대표자명"),
* @OA\Property(property="address", type="string", example="서울시 강남구 테헤란로 123", maxLength=300, nullable=true, description="주소"),
* @OA\Property(property="phone", type="string", example="02-1234-5678", maxLength=30, nullable=true, description="전화번호"),
* @OA\Property(property="email", type="string", format="email", example="contact@test.com", maxLength=100, nullable=true, description="이메일"),
* @OA\Property(property="message", type="string", example="신규 법인 등록 요청드립니다.", maxLength=1000, nullable=true, description="신청 메시지")
* )
*
* @OA\Schema(
* schema="CompanyRequestActionRequest",
* type="object",
* description="회사 추가 신청 처리 요청",
*
* @OA\Property(property="reason", type="string", example="서류 미비로 반려합니다.", maxLength=500, nullable=true, description="처리 사유 (반려 시 필수)")
* )
*
* @OA\Schema(
* schema="CompanyRequestPagination",
* type="object",
* description="회사 추가 신청 목록 (페이지네이션)",
*
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CompanyRequest")),
* @OA\Property(property="links", type="object",
* @OA\Property(property="first", type="string"),
* @OA\Property(property="last", type="string"),
* @OA\Property(property="prev", type="string", nullable=true),
* @OA\Property(property="next", type="string", nullable=true)
* ),
* @OA\Property(property="meta", type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=1),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="to", type="integer", example=5),
* @OA\Property(property="total", type="integer", example=5)
* )
* )
*/
class CompanyApi
{
/**
* @OA\Post(
* path="/api/v1/companies/check",
* operationId="checkBusinessNumber",
* tags={"Companies"},
* summary="사업자등록번호 유효성 검사",
* description="사업자등록번호의 유효성을 검사합니다. 바로빌 API를 통해 휴폐업 여부를 확인하고, 이미 등록된 회사인지 확인합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
* description="사업자등록번호",
*
* @OA\JsonContent(
* required={"business_number"},
*
* @OA\Property(property="business_number", type="string", example="1234567890", description="사업자등록번호 (10자리, 하이픈 제거)")
* )
* ),
*
* @OA\Response(
* response=200,
* description="검증 결과",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="검증이 완료되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/BusinessNumberCheckResponse")
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청"),
* @OA\Response(response=401, description="인증 실패")
* )
*/
public function check() {}
/**
* @OA\Post(
* path="/api/v1/companies/request",
* operationId="createCompanyRequest",
* tags={"Companies"},
* summary="회사 추가 신청",
* description="새로운 회사 추가를 신청합니다. 관리자 승인 후 테넌트가 생성됩니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
* description="회사 정보",
*
* @OA\JsonContent(ref="#/components/schemas/CompanyRequestCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="신청 완료",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="회사 추가 신청이 완료되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/CompanyRequest")
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청 (중복 신청, 이미 등록된 회사 등)"),
* @OA\Response(response=401, description="인증 실패")
* )
*/
public function request() {}
/**
* @OA\Get(
* path="/api/v1/companies/requests",
* operationId="getCompanyRequests",
* tags={"Companies"},
* summary="회사 추가 신청 목록 (관리자용)",
* description="회사 추가 신청 목록을 조회합니다. 관리자 권한이 필요합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="status", in="query", description="상태 필터",
*
* @OA\Schema(type="string", enum={"pending","approved","rejected"})
* ),
*
* @OA\Parameter(name="search", in="query", description="검색어 (사업자번호, 회사명, 신청자명)",
*
* @OA\Schema(type="string")
* ),
*
* @OA\Parameter(name="start_date", in="query", description="시작일",
*
* @OA\Schema(type="string", format="date")
* ),
*
* @OA\Parameter(name="end_date", in="query", description="종료일",
*
* @OA\Schema(type="string", format="date")
* ),
*
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준",
*
* @OA\Schema(type="string", enum={"created_at","company_name","status","processed_at"})
* ),
*
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향",
*
* @OA\Schema(type="string", enum={"asc","desc"})
* ),
*
* @OA\Parameter(name="per_page", in="query", description="페이지당 항목 수",
*
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
* ),
*
* @OA\Parameter(name="page", in="query", description="페이지 번호",
*
* @OA\Schema(type="integer", minimum=1)
* ),
*
* @OA\Response(
* response=200,
* description="신청 목록",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/CompanyRequestPagination")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음")
* )
*/
public function requests() {}
/**
* @OA\Get(
* path="/api/v1/companies/requests/{id}",
* operationId="getCompanyRequest",
* tags={"Companies"},
* summary="회사 추가 신청 상세",
* description="회사 추가 신청 상세 정보를 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="신청 ID",
*
* @OA\Schema(type="integer")
* ),
*
* @OA\Response(
* response=200,
* description="신청 상세",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/CompanyRequest")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=404, description="신청 없음")
* )
*/
public function showRequest() {}
/**
* @OA\Post(
* path="/api/v1/companies/requests/{id}/approve",
* operationId="approveCompanyRequest",
* tags={"Companies"},
* summary="회사 추가 신청 승인",
* description="회사 추가 신청을 승인합니다. 승인 시 새로운 테넌트가 생성되고, 신청자가 해당 테넌트에 연결됩니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="신청 ID",
*
* @OA\Schema(type="integer")
* ),
*
* @OA\Response(
* response=200,
* description="승인 완료",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="승인되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/CompanyRequest")
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청 (이미 처리됨 등)"),
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음"),
* @OA\Response(response=404, description="신청 없음")
* )
*/
public function approve() {}
/**
* @OA\Post(
* path="/api/v1/companies/requests/{id}/reject",
* operationId="rejectCompanyRequest",
* tags={"Companies"},
* summary="회사 추가 신청 반려",
* description="회사 추가 신청을 반려합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="신청 ID",
*
* @OA\Schema(type="integer")
* ),
*
* @OA\RequestBody(
* description="반려 사유",
*
* @OA\JsonContent(ref="#/components/schemas/CompanyRequestActionRequest")
* ),
*
* @OA\Response(
* response=200,
* description="반려 완료",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="반려되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/CompanyRequest")
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청 (이미 처리됨 등)"),
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음"),
* @OA\Response(response=404, description="신청 없음")
* )
*/
public function reject() {}
/**
* @OA\Get(
* path="/api/v1/companies/my-requests",
* operationId="getMyCompanyRequests",
* tags={"Companies"},
* summary="내 회사 추가 신청 목록",
* description="현재 사용자의 회사 추가 신청 목록을 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="status", in="query", description="상태 필터",
*
* @OA\Schema(type="string", enum={"pending","approved","rejected"})
* ),
*
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준",
*
* @OA\Schema(type="string", enum={"created_at","company_name","status","processed_at"})
* ),
*
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향",
*
* @OA\Schema(type="string", enum={"asc","desc"})
* ),
*
* @OA\Parameter(name="per_page", in="query", description="페이지당 항목 수",
*
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
* ),
*
* @OA\Parameter(name="page", in="query", description="페이지 번호",
*
* @OA\Schema(type="integer", minimum=1)
* ),
*
* @OA\Response(
* response=200,
* description="내 신청 목록",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/CompanyRequestPagination")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패")
* )
*/
public function myRequests() {}
}

View File

@@ -0,0 +1,45 @@
<?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::create('company_requests', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->comment('신청자 ID');
$table->string('business_number', 20)->comment('사업자등록번호');
$table->string('company_name', 200)->comment('회사명');
$table->string('ceo_name', 50)->nullable()->comment('대표자명');
$table->string('address', 300)->nullable()->comment('주소');
$table->string('phone', 30)->nullable()->comment('전화번호');
$table->string('email', 100)->nullable()->comment('이메일');
$table->string('status', 20)->default('pending')->comment('상태 (pending, approved, rejected)');
$table->text('message')->nullable()->comment('신청 메시지');
$table->text('reject_reason')->nullable()->comment('반려 사유');
$table->json('barobill_response')->nullable()->comment('바로빌 검증 응답');
$table->unsignedBigInteger('approved_by')->nullable()->comment('승인/반려 처리자');
$table->unsignedBigInteger('created_tenant_id')->nullable()->comment('생성된 테넌트 ID');
$table->timestamp('processed_at')->nullable()->comment('처리일시');
$table->timestamps();
$table->index('user_id', 'idx_company_requests_user_id');
$table->index('business_number', 'idx_company_requests_business_number');
$table->index('status', 'idx_company_requests_status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('company_requests');
}
};

View File

@@ -312,4 +312,13 @@
'not_found' => '내보내기 요청을 찾을 수 없습니다.',
'failed' => '내보내기 처리 중 오류가 발생했습니다.',
],
// 회사 추가 신청 관련
'company' => [
'not_found' => '회사 추가 신청을 찾을 수 없습니다.',
'already_pending' => '이미 대기 중인 동일 사업자등록번호 신청이 있습니다.',
'already_processed' => '이미 처리된 신청입니다.',
'tenant_creation_failed' => '테넌트 생성에 실패했습니다.',
'invalid_business_number' => '유효하지 않은 사업자등록번호입니다.',
],
];

View File

@@ -367,4 +367,12 @@
'cancelled' => '결제가 취소되었습니다.',
'refunded' => '환불이 완료되었습니다.',
],
// 회사 추가 신청 관리
'company' => [
'checked' => '사업자등록번호 확인이 완료되었습니다.',
'request_created' => '회사 추가 신청이 접수되었습니다.',
'request_approved' => '회사 추가 신청이 승인되었습니다.',
'request_rejected' => '회사 추가 신청이 반려되었습니다.',
],
];

View File

@@ -21,6 +21,7 @@
use App\Http\Controllers\Api\V1\ClassificationController;
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\ClientGroupController;
use App\Http\Controllers\Api\V1\CompanyController;
use App\Http\Controllers\Api\V1\CommonController;
use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\DepartmentController;
@@ -476,6 +477,17 @@
Route::get('/{id}/statement', [PaymentController::class, 'statement'])->whereNumber('id')->name('v1.payments.statement');
});
// Company API (회사 추가 관리)
Route::prefix('companies')->group(function () {
Route::post('/check', [CompanyController::class, 'check'])->name('v1.companies.check'); // 사업자등록번호 검증
Route::post('/request', [CompanyController::class, 'request'])->name('v1.companies.request'); // 회사 추가 신청
Route::get('/requests', [CompanyController::class, 'requests'])->name('v1.companies.requests.index'); // 신청 목록 (관리자)
Route::get('/requests/{id}', [CompanyController::class, 'showRequest'])->whereNumber('id')->name('v1.companies.requests.show'); // 신청 상세
Route::post('/requests/{id}/approve', [CompanyController::class, 'approve'])->whereNumber('id')->name('v1.companies.requests.approve'); // 승인
Route::post('/requests/{id}/reject', [CompanyController::class, 'reject'])->whereNumber('id')->name('v1.companies.requests.reject'); // 반려
Route::get('/my-requests', [CompanyController::class, 'myRequests'])->name('v1.companies.my-requests'); // 내 신청 목록
});
// Sale API (매출 관리)
Route::prefix('sales')->group(function () {
Route::get('', [SaleController::class, 'index'])->name('v1.sales.index');