feat: Phase 3.8 바로빌 세금계산서 연동 API 구현

- 마이그레이션: barobill_settings, tax_invoices 테이블 생성
- 모델: BarobillSetting (인증서 암호화), TaxInvoice (상태/유형 상수)
- 서비스: BarobillService (API 연동), TaxInvoiceService (CRUD, 발행/취소)
- 컨트롤러: BarobillSettingController, TaxInvoiceController
- FormRequest: 6개 요청 검증 클래스
- Swagger: API 문서 완성 (BarobillSettingApi, TaxInvoiceApi)
This commit is contained in:
2025-12-18 15:31:59 +09:00
parent 9b3dd2f4b8
commit 8ad4d7c0ce
17 changed files with 2425 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\BarobillSetting\SaveBarobillSettingRequest;
use App\Services\BarobillService;
class BarobillSettingController extends Controller
{
public function __construct(
private BarobillService $barobillService
) {}
/**
* 바로빌 설정 조회
*/
public function show()
{
$setting = $this->barobillService->getSetting();
return ApiResponse::handle(
data: $setting,
message: __('message.fetched')
);
}
/**
* 바로빌 설정 저장
*/
public function save(SaveBarobillSettingRequest $request)
{
$setting = $this->barobillService->saveSetting($request->validated());
return ApiResponse::handle(
data: $setting,
message: __('message.saved')
);
}
/**
* 연동 테스트
*/
public function testConnection()
{
$result = $this->barobillService->testConnection();
return ApiResponse::handle(
data: $result,
message: __('message.barobill.connection_success')
);
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\TaxInvoice\CancelTaxInvoiceRequest;
use App\Http\Requests\TaxInvoice\CreateTaxInvoiceRequest;
use App\Http\Requests\TaxInvoice\TaxInvoiceListRequest;
use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest;
use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest;
use App\Services\TaxInvoiceService;
class TaxInvoiceController extends Controller
{
public function __construct(
private TaxInvoiceService $taxInvoiceService
) {}
/**
* 세금계산서 목록 조회
*/
public function index(TaxInvoiceListRequest $request)
{
$taxInvoices = $this->taxInvoiceService->list($request->validated());
return ApiResponse::handle(
data: $taxInvoices,
message: __('message.fetched')
);
}
/**
* 세금계산서 상세 조회
*/
public function show(int $id)
{
$taxInvoice = $this->taxInvoiceService->show($id);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.fetched')
);
}
/**
* 세금계산서 생성
*/
public function store(CreateTaxInvoiceRequest $request)
{
$taxInvoice = $this->taxInvoiceService->create($request->validated());
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.created'),
status: 201
);
}
/**
* 세금계산서 수정
*/
public function update(UpdateTaxInvoiceRequest $request, int $id)
{
$taxInvoice = $this->taxInvoiceService->update($id, $request->validated());
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.updated')
);
}
/**
* 세금계산서 삭제
*/
public function destroy(int $id)
{
$this->taxInvoiceService->delete($id);
return ApiResponse::handle(
data: null,
message: __('message.deleted')
);
}
/**
* 세금계산서 발행
*/
public function issue(int $id)
{
$taxInvoice = $this->taxInvoiceService->issue($id);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.tax_invoice.issued')
);
}
/**
* 세금계산서 취소
*/
public function cancel(CancelTaxInvoiceRequest $request, int $id)
{
$taxInvoice = $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.tax_invoice.cancelled')
);
}
/**
* 국세청 전송 상태 조회
*/
public function checkStatus(int $id)
{
$taxInvoice = $this->taxInvoiceService->checkStatus($id);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.fetched')
);
}
/**
* 세금계산서 요약 통계
*/
public function summary(TaxInvoiceSummaryRequest $request)
{
$summary = $this->taxInvoiceService->summary($request->validated());
return ApiResponse::handle(
data: $summary,
message: __('message.fetched')
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\BarobillSetting;
use Illuminate\Foundation\Http\FormRequest;
class SaveBarobillSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'corp_num' => ['required', 'string', 'size:10'],
'cert_key' => ['nullable', 'string', 'max:500'],
'barobill_id' => ['nullable', 'string', 'max:100'],
'corp_name' => ['required', 'string', 'max:100'],
'ceo_name' => ['required', 'string', 'max:50'],
'addr' => ['nullable', 'string', 'max:200'],
'biz_type' => ['nullable', 'string', 'max:100'],
'biz_class' => ['nullable', 'string', 'max:100'],
'contact_id' => ['nullable', 'string', 'email', 'max:100'],
'contact_name' => ['nullable', 'string', 'max:50'],
'contact_tel' => ['nullable', 'string', 'max:20'],
'is_active' => ['boolean'],
'auto_issue' => ['boolean'],
];
}
public function attributes(): array
{
return [
'corp_num' => __('validation.attributes.corp_num'),
'cert_key' => __('validation.attributes.cert_key'),
'barobill_id' => __('validation.attributes.barobill_id'),
'corp_name' => __('validation.attributes.corp_name'),
'ceo_name' => __('validation.attributes.ceo_name'),
'addr' => __('validation.attributes.addr'),
'biz_type' => __('validation.attributes.biz_type'),
'biz_class' => __('validation.attributes.biz_class'),
'contact_id' => __('validation.attributes.contact_id'),
'contact_name' => __('validation.attributes.contact_name'),
'contact_tel' => __('validation.attributes.contact_tel'),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\TaxInvoice;
use Illuminate\Foundation\Http\FormRequest;
class CancelTaxInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:500'],
];
}
public function attributes(): array
{
return [
'reason' => __('validation.attributes.cancel_reason'),
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Requests\TaxInvoice;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CreateTaxInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'invoice_type' => ['required', 'string', Rule::in(TaxInvoice::INVOICE_TYPES)],
'issue_type' => ['required', 'string', Rule::in(TaxInvoice::ISSUE_TYPES)],
'direction' => ['required', 'string', Rule::in(TaxInvoice::DIRECTIONS)],
// 공급자 정보
'supplier_corp_num' => ['required', 'string', 'max:20'],
'supplier_corp_name' => ['required', 'string', 'max:100'],
'supplier_ceo_name' => ['nullable', 'string', 'max:50'],
'supplier_addr' => ['nullable', 'string', 'max:200'],
'supplier_biz_type' => ['nullable', 'string', 'max:100'],
'supplier_biz_class' => ['nullable', 'string', 'max:100'],
'supplier_contact_id' => ['nullable', 'string', 'email', 'max:100'],
// 공급받는자 정보
'buyer_corp_num' => ['required', 'string', 'max:20'],
'buyer_corp_name' => ['required', 'string', 'max:100'],
'buyer_ceo_name' => ['nullable', 'string', 'max:50'],
'buyer_addr' => ['nullable', 'string', 'max:200'],
'buyer_biz_type' => ['nullable', 'string', 'max:100'],
'buyer_biz_class' => ['nullable', 'string', 'max:100'],
'buyer_contact_id' => ['nullable', 'string', 'email', 'max:100'],
// 금액 정보
'issue_date' => ['required', 'date'],
'supply_amount' => ['required', 'numeric', 'min:0'],
'tax_amount' => ['required', 'numeric', 'min:0'],
// 품목 정보
'items' => ['nullable', 'array'],
'items.*.name' => ['required_with:items', 'string', 'max:100'],
'items.*.spec' => ['nullable', 'string', 'max:100'],
'items.*.qty' => ['nullable', 'numeric', 'min:0'],
'items.*.unit_price' => ['nullable', 'numeric', 'min:0'],
'items.*.supply_amt' => ['nullable', 'numeric', 'min:0'],
'items.*.tax_amt' => ['nullable', 'numeric', 'min:0'],
'items.*.remark' => ['nullable', 'string', 'max:200'],
// 참조 정보
'reference_type' => ['nullable', 'string', 'max:50'],
'reference_id' => ['nullable', 'integer'],
'description' => ['nullable', 'string', 'max:1000'],
];
}
public function attributes(): array
{
return [
'invoice_type' => __('validation.attributes.invoice_type'),
'issue_type' => __('validation.attributes.issue_type'),
'direction' => __('validation.attributes.direction'),
'supplier_corp_num' => __('validation.attributes.supplier_corp_num'),
'supplier_corp_name' => __('validation.attributes.supplier_corp_name'),
'buyer_corp_num' => __('validation.attributes.buyer_corp_num'),
'buyer_corp_name' => __('validation.attributes.buyer_corp_name'),
'issue_date' => __('validation.attributes.issue_date'),
'supply_amount' => __('validation.attributes.supply_amount'),
'tax_amount' => __('validation.attributes.tax_amount'),
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\TaxInvoice;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TaxInvoiceListRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'direction' => ['nullable', 'string', Rule::in(TaxInvoice::DIRECTIONS)],
'status' => ['nullable', 'string', Rule::in(TaxInvoice::STATUSES)],
'invoice_type' => ['nullable', 'string', Rule::in(TaxInvoice::INVOICE_TYPES)],
'issue_type' => ['nullable', 'string', Rule::in(TaxInvoice::ISSUE_TYPES)],
'issue_date_from' => ['nullable', 'date'],
'issue_date_to' => ['nullable', 'date', 'after_or_equal:issue_date_from'],
'corp_num' => ['nullable', 'string', 'max:20'],
'corp_name' => ['nullable', 'string', 'max:100'],
'nts_confirm_num' => ['nullable', 'string', 'max:24'],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\TaxInvoice;
use Illuminate\Foundation\Http\FormRequest;
class TaxInvoiceSummaryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'issue_date_from' => ['nullable', 'date'],
'issue_date_to' => ['nullable', 'date', 'after_or_equal:issue_date_from'],
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests\TaxInvoice;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTaxInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'invoice_type' => ['sometimes', 'string', Rule::in(TaxInvoice::INVOICE_TYPES)],
'issue_type' => ['sometimes', 'string', Rule::in(TaxInvoice::ISSUE_TYPES)],
'direction' => ['sometimes', 'string', Rule::in(TaxInvoice::DIRECTIONS)],
// 공급자 정보
'supplier_corp_num' => ['sometimes', 'string', 'max:20'],
'supplier_corp_name' => ['sometimes', 'string', 'max:100'],
'supplier_ceo_name' => ['nullable', 'string', 'max:50'],
'supplier_addr' => ['nullable', 'string', 'max:200'],
'supplier_biz_type' => ['nullable', 'string', 'max:100'],
'supplier_biz_class' => ['nullable', 'string', 'max:100'],
'supplier_contact_id' => ['nullable', 'string', 'email', 'max:100'],
// 공급받는자 정보
'buyer_corp_num' => ['sometimes', 'string', 'max:20'],
'buyer_corp_name' => ['sometimes', 'string', 'max:100'],
'buyer_ceo_name' => ['nullable', 'string', 'max:50'],
'buyer_addr' => ['nullable', 'string', 'max:200'],
'buyer_biz_type' => ['nullable', 'string', 'max:100'],
'buyer_biz_class' => ['nullable', 'string', 'max:100'],
'buyer_contact_id' => ['nullable', 'string', 'email', 'max:100'],
// 금액 정보
'issue_date' => ['sometimes', 'date'],
'supply_amount' => ['sometimes', 'numeric', 'min:0'],
'tax_amount' => ['sometimes', 'numeric', 'min:0'],
// 품목 정보
'items' => ['nullable', 'array'],
'items.*.name' => ['required_with:items', 'string', 'max:100'],
'items.*.spec' => ['nullable', 'string', 'max:100'],
'items.*.qty' => ['nullable', 'numeric', 'min:0'],
'items.*.unit_price' => ['nullable', 'numeric', 'min:0'],
'items.*.supply_amt' => ['nullable', 'numeric', 'min:0'],
'items.*.tax_amt' => ['nullable', 'numeric', 'min:0'],
'items.*.remark' => ['nullable', 'string', 'max:200'],
// 참조 정보
'reference_type' => ['nullable', 'string', 'max:50'],
'reference_id' => ['nullable', 'integer'],
'description' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class BarobillSetting extends Model
{
protected $fillable = [
'tenant_id',
'corp_num',
'cert_key',
'barobill_id',
'corp_name',
'ceo_name',
'addr',
'biz_type',
'biz_class',
'contact_id',
'contact_name',
'contact_tel',
'is_active',
'auto_issue',
'verified_at',
'created_by',
'updated_by',
];
protected $casts = [
'is_active' => 'boolean',
'auto_issue' => 'boolean',
'verified_at' => 'datetime',
];
protected $hidden = [
'cert_key',
];
// =========================================================================
// 암호화 처리 (cert_key)
// =========================================================================
/**
* cert_key 암호화 저장
*/
public function setCertKeyAttribute(?string $value): void
{
$this->attributes['cert_key'] = $value ? Crypt::encryptString($value) : null;
}
/**
* cert_key 복호화 조회
*/
public function getCertKeyAttribute(?string $value): ?string
{
if (! $value) {
return null;
}
try {
return Crypt::decryptString($value);
} catch (\Exception $e) {
return null;
}
}
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 테넌트 관계
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* 생성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
/**
* 수정자 관계
*/
public function updater(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'updated_by');
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 연동 가능 여부
*/
public function canConnect(): bool
{
return $this->is_active
&& ! empty($this->corp_num)
&& ! empty($this->attributes['cert_key'])
&& ! empty($this->barobill_id);
}
/**
* 검증 완료 여부
*/
public function isVerified(): bool
{
return $this->verified_at !== null;
}
/**
* 사업자번호 포맷 (하이픈 포함)
*/
public function getFormattedCorpNumAttribute(): string
{
$num = $this->corp_num;
if (strlen($num) === 10) {
return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5);
}
return $num;
}
}

View File

@@ -0,0 +1,281 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class TaxInvoice extends Model
{
use BelongsToTenant, SoftDeletes;
// =========================================================================
// 상수 정의
// =========================================================================
/**
* 세금계산서 유형
*/
public const TYPE_TAX_INVOICE = 'tax_invoice'; // 세금계산서
public const TYPE_INVOICE = 'invoice'; // 계산서 (면세)
public const TYPE_MODIFIED_TAX_INVOICE = 'modified'; // 수정세금계산서
public const INVOICE_TYPES = [
self::TYPE_TAX_INVOICE,
self::TYPE_INVOICE,
self::TYPE_MODIFIED_TAX_INVOICE,
];
/**
* 발행 유형
*/
public const ISSUE_TYPE_NORMAL = 'normal'; // 정발행
public const ISSUE_TYPE_REVERSE = 'reverse'; // 역발행
public const ISSUE_TYPE_TRUSTEE = 'trustee'; // 위수탁
public const ISSUE_TYPES = [
self::ISSUE_TYPE_NORMAL,
self::ISSUE_TYPE_REVERSE,
self::ISSUE_TYPE_TRUSTEE,
];
/**
* 방향
*/
public const DIRECTION_SALES = 'sales'; // 매출
public const DIRECTION_PURCHASES = 'purchases'; // 매입
public const DIRECTIONS = [
self::DIRECTION_SALES,
self::DIRECTION_PURCHASES,
];
/**
* 상태
*/
public const STATUS_DRAFT = 'draft'; // 임시저장
public const STATUS_ISSUED = 'issued'; // 발행완료
public const STATUS_SENT = 'sent'; // 국세청 전송완료
public const STATUS_CANCELLED = 'cancelled'; // 취소
public const STATUS_FAILED = 'failed'; // 발행실패
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_ISSUED,
self::STATUS_SENT,
self::STATUS_CANCELLED,
self::STATUS_FAILED,
];
/**
* 상태 라벨
*/
public const STATUS_LABELS = [
self::STATUS_DRAFT => '임시저장',
self::STATUS_ISSUED => '발행완료',
self::STATUS_SENT => '국세청 전송',
self::STATUS_CANCELLED => '취소',
self::STATUS_FAILED => '발행실패',
];
// =========================================================================
// 모델 설정
// =========================================================================
protected $fillable = [
'tenant_id',
'nts_confirm_num',
'invoice_type',
'issue_type',
'direction',
'supplier_corp_num',
'supplier_corp_name',
'supplier_ceo_name',
'supplier_addr',
'supplier_biz_type',
'supplier_biz_class',
'supplier_contact_id',
'buyer_corp_num',
'buyer_corp_name',
'buyer_ceo_name',
'buyer_addr',
'buyer_biz_type',
'buyer_biz_class',
'buyer_contact_id',
'issue_date',
'supply_amount',
'tax_amount',
'total_amount',
'items',
'status',
'nts_send_status',
'issued_at',
'sent_at',
'cancelled_at',
'barobill_invoice_id',
'description',
'error_message',
'reference_type',
'reference_id',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'issue_date' => 'date',
'supply_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'items' => 'array',
'issued_at' => 'datetime',
'sent_at' => 'datetime',
'cancelled_at' => 'datetime',
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 참조 관계 (Sale 또는 Purchase)
*/
public function reference(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'reference_type', 'reference_id');
}
/**
* 생성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
/**
* 수정자 관계
*/
public function updater(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'updated_by');
}
// =========================================================================
// 접근자 (Accessors)
// =========================================================================
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
}
/**
* 세금계산서 유형 라벨
*/
public function getInvoiceTypeLabelAttribute(): string
{
return match ($this->invoice_type) {
self::TYPE_TAX_INVOICE => '세금계산서',
self::TYPE_INVOICE => '계산서',
self::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서',
default => $this->invoice_type,
};
}
/**
* 발행 유형 라벨
*/
public function getIssueTypeLabelAttribute(): string
{
return match ($this->issue_type) {
self::ISSUE_TYPE_NORMAL => '정발행',
self::ISSUE_TYPE_REVERSE => '역발행',
self::ISSUE_TYPE_TRUSTEE => '위수탁',
default => $this->issue_type,
};
}
/**
* 방향 라벨
*/
public function getDirectionLabelAttribute(): string
{
return $this->direction === self::DIRECTION_SALES ? '매출' : '매입';
}
/**
* 공급자 사업자번호 포맷
*/
public function getFormattedSupplierCorpNumAttribute(): string
{
return $this->formatCorpNum($this->supplier_corp_num);
}
/**
* 공급받는자 사업자번호 포맷
*/
public function getFormattedBuyerCorpNumAttribute(): string
{
return $this->formatCorpNum($this->buyer_corp_num);
}
// =========================================================================
// 상태 체크 메서드
// =========================================================================
/**
* 취소 가능 여부
*/
public function canCancel(): bool
{
return in_array($this->status, [self::STATUS_ISSUED, self::STATUS_SENT]);
}
/**
* 수정 가능 여부
*/
public function canEdit(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 발행 완료 여부
*/
public function isIssued(): bool
{
return in_array($this->status, [self::STATUS_ISSUED, self::STATUS_SENT]);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 사업자번호 포맷팅
*/
private function formatCorpNum(?string $num): string
{
if (! $num || strlen($num) !== 10) {
return $num ?? '';
}
return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5);
}
}

View File

@@ -0,0 +1,406 @@
<?php
namespace App\Services;
use App\Models\Tenants\BarobillSetting;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* 바로빌 API 연동 서비스
*
* 바로빌 개발자센터: https://dev.barobill.co.kr/
*/
class BarobillService extends Service
{
/**
* 바로빌 API 기본 URL
*/
private const API_BASE_URL = 'https://ws.barobill.co.kr';
/**
* 바로빌 API 테스트 URL
*/
private const API_TEST_URL = 'https://testws.barobill.co.kr';
/**
* 테스트 모드 여부
*/
private bool $testMode;
public function __construct()
{
$this->testMode = config('services.barobill.test_mode', true);
}
// =========================================================================
// 설정 관리
// =========================================================================
/**
* 바로빌 설정 조회
*/
public function getSetting(): ?BarobillSetting
{
$tenantId = $this->tenantId();
return BarobillSetting::query()
->where('tenant_id', $tenantId)
->first();
}
/**
* 바로빌 설정 저장
*/
public function saveSetting(array $data): BarobillSetting
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$setting = BarobillSetting::query()
->where('tenant_id', $tenantId)
->first();
if ($setting) {
$setting->fill(array_merge($data, ['updated_by' => $userId]));
$setting->save();
} else {
$setting = BarobillSetting::create(array_merge($data, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]));
}
return $setting->fresh();
}
/**
* 연동 테스트
*/
public function testConnection(): array
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
try {
// 바로빌 API 토큰 조회로 연동 테스트
$response = $this->callApi('GetAccessToken', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
]);
if (! empty($response['AccessToken'])) {
// 검증 성공 시 verified_at 업데이트
$setting->verified_at = now();
$setting->save();
return [
'success' => true,
'message' => __('message.barobill.connection_success'),
'verified_at' => $setting->verified_at->toDateTimeString(),
];
}
throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed'));
} catch (\Exception $e) {
Log::error('바로빌 연동 테스트 실패', [
'tenant_id' => $this->tenantId(),
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.connection_failed').': '.$e->getMessage());
}
}
// =========================================================================
// 세금계산서 발행
// =========================================================================
/**
* 세금계산서 발행
*/
public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
try {
// 바로빌 API 호출을 위한 데이터 구성
$apiData = $this->buildTaxInvoiceData($taxInvoice, $setting);
// 세금계산서 발행 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;
$taxInvoice->save();
Log::info('세금계산서 발행 성공', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'barobill_invoice_id' => $response['InvoiceID'],
]);
return $taxInvoice->fresh();
}
throw new \Exception($response['Message'] ?? '발행 실패');
} catch (\Exception $e) {
// 발행 실패
$taxInvoice->status = TaxInvoice::STATUS_FAILED;
$taxInvoice->error_message = $e->getMessage();
$taxInvoice->save();
Log::error('세금계산서 발행 실패', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.issue_failed').': '.$e->getMessage());
}
}
/**
* 세금계산서 취소
*/
public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInvoice
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
if (! $taxInvoice->canCancel()) {
throw new BadRequestHttpException(__('error.barobill.cannot_cancel'));
}
try {
// 세금계산서 취소 API 호출
$response = $this->callApi('CancelTaxInvoice', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
'InvoiceID' => $taxInvoice->barobill_invoice_id,
'Memo' => $reason,
]);
if ($response['Result'] === 0 || ! empty($response['Success'])) {
$taxInvoice->status = TaxInvoice::STATUS_CANCELLED;
$taxInvoice->cancelled_at = now();
$taxInvoice->description = ($taxInvoice->description ? $taxInvoice->description."\n" : '').'취소 사유: '.$reason;
$taxInvoice->save();
Log::info('세금계산서 취소 성공', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'reason' => $reason,
]);
return $taxInvoice->fresh();
}
throw new \Exception($response['Message'] ?? '취소 실패');
} catch (\Exception $e) {
Log::error('세금계산서 취소 실패', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.cancel_failed').': '.$e->getMessage());
}
}
/**
* 국세청 전송 상태 조회
*/
public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
if (empty($taxInvoice->barobill_invoice_id)) {
throw new BadRequestHttpException(__('error.barobill.not_issued'));
}
try {
$response = $this->callApi('GetTaxInvoiceState', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
'InvoiceID' => $taxInvoice->barobill_invoice_id,
]);
if (! empty($response['State'])) {
$taxInvoice->nts_send_status = $response['State'];
// 국세청 전송 완료 시 상태 업데이트
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 (\Exception $e) {
Log::error('국세청 전송 상태 조회 실패', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.status_check_failed').': '.$e->getMessage());
}
}
// =========================================================================
// Private 메서드
// =========================================================================
/**
* 바로빌 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
{
// 품목 데이터 구성
$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($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,
'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'),
// 공급자 정보
'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 ?? '',
],
];
}
/**
* 세금계산서 유형 매핑
*/
private function mapInvoiceType(string $type): string
{
return match ($type) {
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

@@ -0,0 +1,297 @@
<?php
namespace App\Services;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* 세금계산서 관리 서비스
*/
class TaxInvoiceService extends Service
{
public function __construct(
private BarobillService $barobillService
) {}
// =========================================================================
// 목록 조회
// =========================================================================
/**
* 세금계산서 목록 조회
*/
public function list(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$perPage = $params['per_page'] ?? 20;
$query = TaxInvoice::query()
->where('tenant_id', $tenantId)
->orderBy('issue_date', 'desc')
->orderBy('id', 'desc');
// 방향 (매출/매입)
if (! empty($params['direction'])) {
$query->where('direction', $params['direction']);
}
// 상태
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
// 세금계산서 유형
if (! empty($params['invoice_type'])) {
$query->where('invoice_type', $params['invoice_type']);
}
// 발행 유형
if (! empty($params['issue_type'])) {
$query->where('issue_type', $params['issue_type']);
}
// 기간 검색
if (! empty($params['issue_date_from'])) {
$query->whereDate('issue_date', '>=', $params['issue_date_from']);
}
if (! empty($params['issue_date_to'])) {
$query->whereDate('issue_date', '<=', $params['issue_date_to']);
}
// 거래처 검색 (공급자 또는 공급받는자)
if (! empty($params['corp_num'])) {
$query->where(function ($q) use ($params) {
$q->where('supplier_corp_num', $params['corp_num'])
->orWhere('buyer_corp_num', $params['corp_num']);
});
}
// 거래처명 검색
if (! empty($params['corp_name'])) {
$query->where(function ($q) use ($params) {
$q->where('supplier_corp_name', 'like', '%'.$params['corp_name'].'%')
->orWhere('buyer_corp_name', 'like', '%'.$params['corp_name'].'%');
});
}
// 국세청 승인번호 검색
if (! empty($params['nts_confirm_num'])) {
$query->where('nts_confirm_num', 'like', '%'.$params['nts_confirm_num'].'%');
}
return $query->paginate($perPage);
}
/**
* 세금계산서 상세 조회
*/
public function show(int $id): TaxInvoice
{
$tenantId = $this->tenantId();
return TaxInvoice::query()
->where('tenant_id', $tenantId)
->with(['creator', 'updater'])
->findOrFail($id);
}
// =========================================================================
// 세금계산서 생성/수정
// =========================================================================
/**
* 세금계산서 생성
*/
public function create(array $data): TaxInvoice
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 합계금액 계산
$data['total_amount'] = ($data['supply_amount'] ?? 0) + ($data['tax_amount'] ?? 0);
$taxInvoice = TaxInvoice::create(array_merge($data, [
'tenant_id' => $tenantId,
'status' => TaxInvoice::STATUS_DRAFT,
'created_by' => $userId,
'updated_by' => $userId,
]));
return $taxInvoice->fresh();
}
/**
* 세금계산서 수정
*/
public function update(int $id, array $data): TaxInvoice
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$taxInvoice = TaxInvoice::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $taxInvoice->canEdit()) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(__('error.tax_invoice.cannot_edit'));
}
// 합계금액 계산
if (isset($data['supply_amount']) || isset($data['tax_amount'])) {
$supplyAmount = $data['supply_amount'] ?? $taxInvoice->supply_amount;
$taxAmount = $data['tax_amount'] ?? $taxInvoice->tax_amount;
$data['total_amount'] = $supplyAmount + $taxAmount;
}
$taxInvoice->fill(array_merge($data, ['updated_by' => $userId]));
$taxInvoice->save();
return $taxInvoice->fresh();
}
/**
* 세금계산서 삭제
*/
public function delete(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$taxInvoice = TaxInvoice::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $taxInvoice->canEdit()) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(__('error.tax_invoice.cannot_delete'));
}
$taxInvoice->deleted_by = $userId;
$taxInvoice->save();
return $taxInvoice->delete();
}
// =========================================================================
// 발행/취소
// =========================================================================
/**
* 세금계산서 발행
*/
public function issue(int $id): TaxInvoice
{
$tenantId = $this->tenantId();
$taxInvoice = TaxInvoice::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $taxInvoice->canEdit()) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(__('error.tax_invoice.already_issued'));
}
return $this->barobillService->issueTaxInvoice($taxInvoice);
}
/**
* 세금계산서 취소
*/
public function cancel(int $id, string $reason): TaxInvoice
{
$tenantId = $this->tenantId();
$taxInvoice = TaxInvoice::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
return $this->barobillService->cancelTaxInvoice($taxInvoice, $reason);
}
/**
* 국세청 전송 상태 조회
*/
public function checkStatus(int $id): TaxInvoice
{
$tenantId = $this->tenantId();
$taxInvoice = TaxInvoice::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
return $this->barobillService->checkNtsSendStatus($taxInvoice);
}
// =========================================================================
// 통계
// =========================================================================
/**
* 세금계산서 요약 통계
*/
public function summary(array $params): array
{
$tenantId = $this->tenantId();
$query = TaxInvoice::query()
->where('tenant_id', $tenantId);
// 기간 필터
if (! empty($params['issue_date_from'])) {
$query->whereDate('issue_date', '>=', $params['issue_date_from']);
}
if (! empty($params['issue_date_to'])) {
$query->whereDate('issue_date', '<=', $params['issue_date_to']);
}
// 방향별 통계
$summary = $query->clone()
->select([
'direction',
DB::raw('COUNT(*) as count'),
DB::raw('SUM(supply_amount) as supply_amount'),
DB::raw('SUM(tax_amount) as tax_amount'),
DB::raw('SUM(total_amount) as total_amount'),
])
->groupBy('direction')
->get()
->keyBy('direction')
->toArray();
// 상태별 통계
$byStatus = $query->clone()
->select([
'status',
DB::raw('COUNT(*) as count'),
])
->groupBy('status')
->get()
->keyBy('status')
->toArray();
return [
'by_direction' => [
'sales' => $summary[TaxInvoice::DIRECTION_SALES] ?? [
'count' => 0,
'supply_amount' => 0,
'tax_amount' => 0,
'total_amount' => 0,
],
'purchases' => $summary[TaxInvoice::DIRECTION_PURCHASES] ?? [
'count' => 0,
'supply_amount' => 0,
'tax_amount' => 0,
'total_amount' => 0,
],
],
'by_status' => [
TaxInvoice::STATUS_DRAFT => $byStatus[TaxInvoice::STATUS_DRAFT]['count'] ?? 0,
TaxInvoice::STATUS_ISSUED => $byStatus[TaxInvoice::STATUS_ISSUED]['count'] ?? 0,
TaxInvoice::STATUS_SENT => $byStatus[TaxInvoice::STATUS_SENT]['count'] ?? 0,
TaxInvoice::STATUS_CANCELLED => $byStatus[TaxInvoice::STATUS_CANCELLED]['count'] ?? 0,
TaxInvoice::STATUS_FAILED => $byStatus[TaxInvoice::STATUS_FAILED]['count'] ?? 0,
],
];
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="BarobillSettings", description="바로빌 설정 관리")
*
* @OA\Schema(
* schema="BarobillSetting",
* type="object",
* description="바로빌 설정 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="설정 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="corp_num", type="string", example="1234567890", description="사업자번호 (10자리)"),
* @OA\Property(property="barobill_id", type="string", example="testuser", description="바로빌 아이디"),
* @OA\Property(property="corp_name", type="string", example="(주)테스트회사", description="회사명"),
* @OA\Property(property="ceo_name", type="string", example="홍길동", description="대표자명"),
* @OA\Property(property="addr", type="string", example="서울시 강남구 테헤란로 123", nullable=true, description="주소"),
* @OA\Property(property="biz_type", type="string", example="서비스", nullable=true, description="업태"),
* @OA\Property(property="biz_class", type="string", example="소프트웨어개발", nullable=true, description="종목"),
* @OA\Property(property="contact_id", type="string", example="manager@test.com", nullable=true, description="담당자 이메일"),
* @OA\Property(property="contact_name", type="string", example="김담당", nullable=true, description="담당자명"),
* @OA\Property(property="contact_tel", type="string", example="02-1234-5678", nullable=true, description="담당자 연락처"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부"),
* @OA\Property(property="auto_issue", type="boolean", example=false, description="자동발행 여부"),
* @OA\Property(property="verified_at", type="string", format="date-time", nullable=true, description="검증 완료 일시"),
* @OA\Property(property="formatted_corp_num", type="string", example="123-45-67890", description="포맷팅된 사업자번호"),
* @OA\Property(property="created_by", type="integer", example=1, nullable=true, description="생성자 ID"),
* @OA\Property(property="updated_by", type="integer", example=1, nullable=true, description="수정자 ID"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="BarobillSettingSaveRequest",
* type="object",
* required={"corp_num","cert_key","barobill_id","corp_name","ceo_name"},
* description="바로빌 설정 저장 요청",
*
* @OA\Property(property="corp_num", type="string", example="1234567890", minLength=10, maxLength=10, description="사업자번호 (10자리, 하이픈 제외)"),
* @OA\Property(property="cert_key", type="string", example="xxxxxx", description="공인인증서 키 (암호화되어 저장)"),
* @OA\Property(property="barobill_id", type="string", example="testuser", maxLength=50, description="바로빌 아이디"),
* @OA\Property(property="corp_name", type="string", example="(주)테스트회사", maxLength=200, description="회사명"),
* @OA\Property(property="ceo_name", type="string", example="홍길동", maxLength=100, description="대표자명"),
* @OA\Property(property="addr", type="string", example="서울시 강남구 테헤란로 123", maxLength=500, nullable=true, description="주소"),
* @OA\Property(property="biz_type", type="string", example="서비스", maxLength=100, nullable=true, description="업태"),
* @OA\Property(property="biz_class", type="string", example="소프트웨어개발", maxLength=100, nullable=true, description="종목"),
* @OA\Property(property="contact_id", type="string", example="manager@test.com", maxLength=100, nullable=true, description="담당자 이메일"),
* @OA\Property(property="contact_name", type="string", example="김담당", maxLength=100, nullable=true, description="담당자명"),
* @OA\Property(property="contact_tel", type="string", example="02-1234-5678", maxLength=50, nullable=true, description="담당자 연락처"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부 (기본값: true)"),
* @OA\Property(property="auto_issue", type="boolean", example=false, description="자동발행 여부 (기본값: false)")
* )
*
* @OA\Schema(
* schema="BarobillConnectionTestResult",
* type="object",
* description="바로빌 연동 테스트 결과",
*
* @OA\Property(property="success", type="boolean", example=true, description="연동 성공 여부"),
* @OA\Property(property="remaining_point", type="integer", example=10000, description="잔여 포인트"),
* @OA\Property(property="tested_at", type="string", format="date-time", description="테스트 일시")
* )
*/
class BarobillSettingApi
{
/**
* @OA\Get(
* path="/api/v1/barobill-settings",
* tags={"BarobillSettings"},
* summary="바로빌 설정 조회",
* description="현재 테넌트의 바로빌 설정을 조회합니다. 설정이 없는 경우 null을 반환합니다.",
* 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/BarobillSetting", nullable=true)
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/barobill-settings",
* tags={"BarobillSettings"},
* summary="바로빌 설정 저장",
* description="바로빌 설정을 저장합니다. 기존 설정이 있으면 수정하고, 없으면 새로 생성합니다. cert_key는 암호화되어 저장됩니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/BarobillSettingSaveRequest")
* ),
*
* @OA\Response(
* response=200,
* description="저장 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/BarobillSetting")
* )
* }
* )
* ),
*
* @OA\Response(response=400, 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 save() {}
/**
* @OA\Post(
* path="/api/v1/barobill-settings/test-connection",
* tags={"BarobillSettings"},
* summary="바로빌 연동 테스트",
* description="저장된 바로빌 설정으로 연동 테스트를 수행합니다. 설정이 없거나 필수 값이 누락되면 에러를 반환합니다.",
* 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/BarobillConnectionTestResult")
* )
* }
* )
* ),
*
* @OA\Response(response=400, 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 testConnection() {}
}

View File

@@ -0,0 +1,528 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="TaxInvoices", description="세금계산서 관리")
*
* @OA\Schema(
* schema="TaxInvoice",
* type="object",
* description="세금계산서 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="세금계산서 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="nts_confirm_num", type="string", example="20250115-12345678-12345678", nullable=true, description="국세청 승인번호"),
* @OA\Property(property="invoice_type", type="string", enum={"tax_invoice","invoice","modified"}, example="tax_invoice", description="세금계산서 유형"),
* @OA\Property(property="issue_type", type="string", enum={"normal","reverse","trustee"}, example="normal", description="발행 유형"),
* @OA\Property(property="direction", type="string", enum={"sales","purchases"}, example="sales", description="방향 (매출/매입)"),
* @OA\Property(property="supplier_corp_num", type="string", example="1234567890", description="공급자 사업자번호"),
* @OA\Property(property="supplier_corp_name", type="string", example="(주)공급사", description="공급자 회사명"),
* @OA\Property(property="supplier_ceo_name", type="string", example="홍길동", description="공급자 대표자명"),
* @OA\Property(property="supplier_addr", type="string", example="서울시 강남구", nullable=true, description="공급자 주소"),
* @OA\Property(property="supplier_biz_type", type="string", example="서비스", nullable=true, description="공급자 업태"),
* @OA\Property(property="supplier_biz_class", type="string", example="소프트웨어", nullable=true, description="공급자 종목"),
* @OA\Property(property="supplier_contact_id", type="string", example="supplier@test.com", nullable=true, description="공급자 담당자 이메일"),
* @OA\Property(property="buyer_corp_num", type="string", example="0987654321", description="공급받는자 사업자번호"),
* @OA\Property(property="buyer_corp_name", type="string", example="(주)구매사", description="공급받는자 회사명"),
* @OA\Property(property="buyer_ceo_name", type="string", example="김대표", description="공급받는자 대표자명"),
* @OA\Property(property="buyer_addr", type="string", example="서울시 서초구", nullable=true, description="공급받는자 주소"),
* @OA\Property(property="buyer_biz_type", type="string", example="제조", nullable=true, description="공급받는자 업태"),
* @OA\Property(property="buyer_biz_class", type="string", example="전자제품", nullable=true, description="공급받는자 종목"),
* @OA\Property(property="buyer_contact_id", type="string", example="buyer@test.com", nullable=true, description="공급받는자 담당자 이메일"),
* @OA\Property(property="issue_date", type="string", format="date", example="2025-01-15", description="작성일자"),
* @OA\Property(property="supply_amount", type="number", format="float", example=1000000, description="공급가액"),
* @OA\Property(property="tax_amount", type="number", format="float", example=100000, description="세액"),
* @OA\Property(property="total_amount", type="number", format="float", example=1100000, description="합계금액"),
* @OA\Property(property="items", type="array", description="품목 목록",
*
* @OA\Items(
*
* @OA\Property(property="date", type="string", format="date", example="2025-01-15"),
* @OA\Property(property="name", type="string", example="컨설팅 서비스"),
* @OA\Property(property="spec", type="string", example="월간"),
* @OA\Property(property="quantity", type="number", example=1),
* @OA\Property(property="unit_price", type="number", example=1000000),
* @OA\Property(property="supply_amount", type="number", example=1000000),
* @OA\Property(property="tax_amount", type="number", example=100000),
* @OA\Property(property="remark", type="string", example="1월분", nullable=true)
* )
* ),
* @OA\Property(property="status", type="string", enum={"draft","issued","sent","cancelled","failed"}, example="draft", description="상태"),
* @OA\Property(property="status_label", type="string", example="임시저장", description="상태 라벨"),
* @OA\Property(property="invoice_type_label", type="string", example="세금계산서", description="세금계산서 유형 라벨"),
* @OA\Property(property="issue_type_label", type="string", example="정발행", description="발행 유형 라벨"),
* @OA\Property(property="direction_label", type="string", example="매출", description="방향 라벨"),
* @OA\Property(property="formatted_supplier_corp_num", type="string", example="123-45-67890", description="포맷팅된 공급자 사업자번호"),
* @OA\Property(property="formatted_buyer_corp_num", type="string", example="098-76-54321", description="포맷팅된 공급받는자 사업자번호"),
* @OA\Property(property="nts_send_status", type="string", example="success", nullable=true, description="국세청 전송 상태"),
* @OA\Property(property="issued_at", type="string", format="date-time", nullable=true, description="발행 일시"),
* @OA\Property(property="sent_at", type="string", format="date-time", nullable=true, description="국세청 전송 일시"),
* @OA\Property(property="cancelled_at", type="string", format="date-time", nullable=true, description="취소 일시"),
* @OA\Property(property="barobill_invoice_id", type="string", nullable=true, description="바로빌 발행 ID"),
* @OA\Property(property="description", type="string", nullable=true, description="비고"),
* @OA\Property(property="error_message", type="string", nullable=true, description="에러 메시지"),
* @OA\Property(property="reference_type", type="string", example="App\\Models\\Tenants\\Sale", nullable=true, description="참조 모델 타입"),
* @OA\Property(property="reference_id", type="integer", example=1, nullable=true, description="참조 ID"),
* @OA\Property(property="creator", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="관리자"),
* description="생성자 정보"
* ),
* @OA\Property(property="created_by", type="integer", example=1, nullable=true, description="생성자 ID"),
* @OA\Property(property="updated_by", type="integer", example=1, nullable=true, description="수정자 ID"),
* @OA\Property(property="deleted_by", type="integer", example=1, nullable=true, description="삭제자 ID"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="TaxInvoiceCreateRequest",
* type="object",
* required={"invoice_type","issue_type","direction","supplier_corp_num","supplier_corp_name","supplier_ceo_name","buyer_corp_num","buyer_corp_name","buyer_ceo_name","issue_date","supply_amount","tax_amount"},
* description="세금계산서 생성 요청",
*
* @OA\Property(property="invoice_type", type="string", enum={"tax_invoice","invoice","modified"}, example="tax_invoice", description="세금계산서 유형"),
* @OA\Property(property="issue_type", type="string", enum={"normal","reverse","trustee"}, example="normal", description="발행 유형"),
* @OA\Property(property="direction", type="string", enum={"sales","purchases"}, example="sales", description="방향 (매출/매입)"),
* @OA\Property(property="supplier_corp_num", type="string", example="1234567890", minLength=10, maxLength=10, description="공급자 사업자번호"),
* @OA\Property(property="supplier_corp_name", type="string", example="(주)공급사", maxLength=200, description="공급자 회사명"),
* @OA\Property(property="supplier_ceo_name", type="string", example="홍길동", maxLength=100, description="공급자 대표자명"),
* @OA\Property(property="supplier_addr", type="string", example="서울시 강남구", maxLength=500, nullable=true, description="공급자 주소"),
* @OA\Property(property="supplier_biz_type", type="string", example="서비스", maxLength=100, nullable=true, description="공급자 업태"),
* @OA\Property(property="supplier_biz_class", type="string", example="소프트웨어", maxLength=100, nullable=true, description="공급자 종목"),
* @OA\Property(property="supplier_contact_id", type="string", example="supplier@test.com", maxLength=100, nullable=true, description="공급자 담당자 이메일"),
* @OA\Property(property="buyer_corp_num", type="string", example="0987654321", minLength=10, maxLength=10, description="공급받는자 사업자번호"),
* @OA\Property(property="buyer_corp_name", type="string", example="(주)구매사", maxLength=200, description="공급받는자 회사명"),
* @OA\Property(property="buyer_ceo_name", type="string", example="김대표", maxLength=100, description="공급받는자 대표자명"),
* @OA\Property(property="buyer_addr", type="string", example="서울시 서초구", maxLength=500, nullable=true, description="공급받는자 주소"),
* @OA\Property(property="buyer_biz_type", type="string", example="제조", maxLength=100, nullable=true, description="공급받는자 업태"),
* @OA\Property(property="buyer_biz_class", type="string", example="전자제품", maxLength=100, nullable=true, description="공급받는자 종목"),
* @OA\Property(property="buyer_contact_id", type="string", example="buyer@test.com", maxLength=100, nullable=true, description="공급받는자 담당자 이메일"),
* @OA\Property(property="issue_date", type="string", format="date", example="2025-01-15", description="작성일자"),
* @OA\Property(property="supply_amount", type="number", format="float", example=1000000, minimum=0, description="공급가액"),
* @OA\Property(property="tax_amount", type="number", format="float", example=100000, minimum=0, description="세액"),
* @OA\Property(property="items", type="array", description="품목 목록 (최대 99개)",
*
* @OA\Items(
*
* @OA\Property(property="date", type="string", format="date", example="2025-01-15", description="거래일자"),
* @OA\Property(property="name", type="string", example="컨설팅 서비스", maxLength=200, description="품목명"),
* @OA\Property(property="spec", type="string", example="월간", maxLength=100, nullable=true, description="규격"),
* @OA\Property(property="quantity", type="number", example=1, minimum=0, description="수량"),
* @OA\Property(property="unit_price", type="number", example=1000000, minimum=0, description="단가"),
* @OA\Property(property="supply_amount", type="number", example=1000000, minimum=0, description="공급가액"),
* @OA\Property(property="tax_amount", type="number", example=100000, minimum=0, description="세액"),
* @OA\Property(property="remark", type="string", example="1월분", maxLength=200, nullable=true, description="비고")
* )
* ),
* @OA\Property(property="description", type="string", example="월간 컨설팅 비용", maxLength=500, nullable=true, description="비고")
* )
*
* @OA\Schema(
* schema="TaxInvoiceUpdateRequest",
* type="object",
* description="세금계산서 수정 요청 (임시저장 상태에서만 수정 가능)",
*
* @OA\Property(property="invoice_type", type="string", enum={"tax_invoice","invoice","modified"}, example="tax_invoice", description="세금계산서 유형"),
* @OA\Property(property="issue_type", type="string", enum={"normal","reverse","trustee"}, example="normal", description="발행 유형"),
* @OA\Property(property="direction", type="string", enum={"sales","purchases"}, example="sales", description="방향 (매출/매입)"),
* @OA\Property(property="supplier_corp_num", type="string", example="1234567890", minLength=10, maxLength=10, description="공급자 사업자번호"),
* @OA\Property(property="supplier_corp_name", type="string", example="(주)공급사", maxLength=200, description="공급자 회사명"),
* @OA\Property(property="supplier_ceo_name", type="string", example="홍길동", maxLength=100, description="공급자 대표자명"),
* @OA\Property(property="supplier_addr", type="string", example="서울시 강남구", maxLength=500, nullable=true, description="공급자 주소"),
* @OA\Property(property="supplier_biz_type", type="string", example="서비스", maxLength=100, nullable=true, description="공급자 업태"),
* @OA\Property(property="supplier_biz_class", type="string", example="소프트웨어", maxLength=100, nullable=true, description="공급자 종목"),
* @OA\Property(property="supplier_contact_id", type="string", example="supplier@test.com", maxLength=100, nullable=true, description="공급자 담당자 이메일"),
* @OA\Property(property="buyer_corp_num", type="string", example="0987654321", minLength=10, maxLength=10, description="공급받는자 사업자번호"),
* @OA\Property(property="buyer_corp_name", type="string", example="(주)구매사", maxLength=200, description="공급받는자 회사명"),
* @OA\Property(property="buyer_ceo_name", type="string", example="김대표", maxLength=100, description="공급받는자 대표자명"),
* @OA\Property(property="buyer_addr", type="string", example="서울시 서초구", maxLength=500, nullable=true, description="공급받는자 주소"),
* @OA\Property(property="buyer_biz_type", type="string", example="제조", maxLength=100, nullable=true, description="공급받는자 업태"),
* @OA\Property(property="buyer_biz_class", type="string", example="전자제품", maxLength=100, nullable=true, description="공급받는자 종목"),
* @OA\Property(property="buyer_contact_id", type="string", example="buyer@test.com", maxLength=100, nullable=true, description="공급받는자 담당자 이메일"),
* @OA\Property(property="issue_date", type="string", format="date", example="2025-01-15", description="작성일자"),
* @OA\Property(property="supply_amount", type="number", format="float", example=1000000, minimum=0, description="공급가액"),
* @OA\Property(property="tax_amount", type="number", format="float", example=100000, minimum=0, description="세액"),
* @OA\Property(property="items", type="array", description="품목 목록 (최대 99개)",
*
* @OA\Items(
*
* @OA\Property(property="date", type="string", format="date", example="2025-01-15", description="거래일자"),
* @OA\Property(property="name", type="string", example="컨설팅 서비스", maxLength=200, description="품목명"),
* @OA\Property(property="spec", type="string", example="월간", maxLength=100, nullable=true, description="규격"),
* @OA\Property(property="quantity", type="number", example=1, minimum=0, description="수량"),
* @OA\Property(property="unit_price", type="number", example=1000000, minimum=0, description="단가"),
* @OA\Property(property="supply_amount", type="number", example=1000000, minimum=0, description="공급가액"),
* @OA\Property(property="tax_amount", type="number", example=100000, minimum=0, description="세액"),
* @OA\Property(property="remark", type="string", example="1월분", maxLength=200, nullable=true, description="비고")
* )
* ),
* @OA\Property(property="description", type="string", example="월간 컨설팅 비용", maxLength=500, nullable=true, description="비고")
* )
*
* @OA\Schema(
* schema="TaxInvoiceCancelRequest",
* type="object",
* required={"reason"},
* description="세금계산서 취소 요청",
*
* @OA\Property(property="reason", type="string", example="거래 취소로 인한 세금계산서 취소", maxLength=500, description="취소 사유")
* )
*
* @OA\Schema(
* schema="TaxInvoiceSummary",
* type="object",
* description="세금계산서 요약 통계",
*
* @OA\Property(property="by_direction", type="object",
* @OA\Property(property="sales", type="object",
* @OA\Property(property="count", type="integer", example=50, description="매출 건수"),
* @OA\Property(property="supply_amount", type="number", format="float", example=50000000, description="공급가액 합계"),
* @OA\Property(property="tax_amount", type="number", format="float", example=5000000, description="세액 합계"),
* @OA\Property(property="total_amount", type="number", format="float", example=55000000, description="합계금액")
* ),
* @OA\Property(property="purchases", type="object",
* @OA\Property(property="count", type="integer", example=30, description="매입 건수"),
* @OA\Property(property="supply_amount", type="number", format="float", example=30000000, description="공급가액 합계"),
* @OA\Property(property="tax_amount", type="number", format="float", example=3000000, description="세액 합계"),
* @OA\Property(property="total_amount", type="number", format="float", example=33000000, description="합계금액")
* ),
* description="방향별 통계"
* ),
* @OA\Property(property="by_status", type="object",
* @OA\Property(property="draft", type="integer", example=5, description="임시저장 건수"),
* @OA\Property(property="issued", type="integer", example=30, description="발행완료 건수"),
* @OA\Property(property="sent", type="integer", example=40, description="국세청 전송 건수"),
* @OA\Property(property="cancelled", type="integer", example=3, description="취소 건수"),
* @OA\Property(property="failed", type="integer", example=2, description="발행실패 건수"),
* description="상태별 건수"
* )
* )
*/
class TaxInvoiceApi
{
/**
* @OA\Get(
* path="/api/v1/tax-invoices",
* tags={"TaxInvoices"},
* summary="세금계산서 목록 조회",
* description="세금계산서 목록을 조회합니다. 다양한 필터 조건으로 검색할 수 있습니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="direction", in="query", description="방향 (매출/매입)", @OA\Schema(type="string", enum={"sales","purchases"})),
* @OA\Parameter(name="status", in="query", description="상태", @OA\Schema(type="string", enum={"draft","issued","sent","cancelled","failed"})),
* @OA\Parameter(name="invoice_type", in="query", description="세금계산서 유형", @OA\Schema(type="string", enum={"tax_invoice","invoice","modified"})),
* @OA\Parameter(name="issue_type", in="query", description="발행 유형", @OA\Schema(type="string", enum={"normal","reverse","trustee"})),
* @OA\Parameter(name="issue_date_from", in="query", description="작성일 시작", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="issue_date_to", in="query", description="작성일 종료", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="corp_num", in="query", description="거래처 사업자번호 (공급자 또는 공급받는자)", @OA\Schema(type="string")),
* @OA\Parameter(name="corp_name", in="query", description="거래처명 검색 (공급자 또는 공급받는자)", @OA\Schema(type="string", maxLength=100)),
* @OA\Parameter(name="nts_confirm_num", in="query", description="국세청 승인번호 검색", @OA\Schema(type="string")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/TaxInvoice")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=100)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/tax-invoices/summary",
* tags={"TaxInvoices"},
* summary="세금계산서 요약 통계",
* description="세금계산서 요약 통계를 조회합니다. 방향별(매출/매입), 상태별 집계 정보를 제공합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="issue_date_from", in="query", description="작성일 시작", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="issue_date_to", in="query", description="작성일 종료", @OA\Schema(type="string", format="date")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/TaxInvoiceSummary")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function summary() {}
/**
* @OA\Post(
* path="/api/v1/tax-invoices",
* tags={"TaxInvoices"},
* summary="세금계산서 생성",
* description="새로운 세금계산서를 생성합니다. 생성 시 임시저장(draft) 상태로 저장됩니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/TaxInvoiceCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="생성 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/TaxInvoice")
* )
* }
* )
* ),
*
* @OA\Response(response=400, 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 store() {}
/**
* @OA\Get(
* path="/api/v1/tax-invoices/{id}",
* tags={"TaxInvoices"},
* 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(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/TaxInvoice")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="세금계산서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/tax-invoices/{id}",
* tags={"TaxInvoices"},
* summary="세금계산서 수정",
* description="세금계산서 정보를 수정합니다. 임시저장(draft) 상태에서만 수정 가능합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="세금계산서 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/TaxInvoiceUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/TaxInvoice")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청 또는 수정 불가 상태", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="세금계산서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/tax-invoices/{id}",
* tags={"TaxInvoices"},
* summary="세금계산서 삭제",
* description="세금계산서를 삭제합니다. 임시저장(draft) 상태에서만 삭제 가능합니다. (Soft Delete)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="세금계산서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
*
* @OA\Response(response=400, description="삭제 불가 상태", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="세금계산서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Post(
* path="/api/v1/tax-invoices/{id}/issue",
* tags={"TaxInvoices"},
* summary="세금계산서 발행",
* description="세금계산서를 발행합니다. 바로빌 API를 통해 전자세금계산서가 발행됩니다. 임시저장(draft) 상태에서만 발행 가능합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="세금계산서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="발행 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/TaxInvoice")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="발행 불가 상태 또는 바로빌 설정 미완료", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="세금계산서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러 또는 바로빌 발행 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function issue() {}
/**
* @OA\Post(
* path="/api/v1/tax-invoices/{id}/cancel",
* tags={"TaxInvoices"},
* summary="세금계산서 취소",
* description="세금계산서를 취소합니다. 바로빌 API를 통해 전자세금계산서가 취소됩니다. 발행완료(issued) 또는 국세청 전송(sent) 상태에서만 취소 가능합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="세금계산서 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/TaxInvoiceCancelRequest")
* ),
*
* @OA\Response(
* response=200,
* description="취소 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/TaxInvoice")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="취소 불가 상태 또는 바로빌 취소 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="세금계산서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function cancel() {}
/**
* @OA\Get(
* path="/api/v1/tax-invoices/{id}/check-status",
* tags={"TaxInvoices"},
* summary="국세청 전송 상태 조회",
* description="세금계산서의 국세청 전송 상태를 조회합니다. 바로빌 API를 통해 최신 상태를 확인하고 업데이트합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="세금계산서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/TaxInvoice")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="세금계산서 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function checkStatus() {}
}

View File

@@ -0,0 +1,46 @@
<?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('barobill_settings', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->unique()->comment('테넌트 ID');
$table->string('corp_num', 20)->comment('사업자번호 (하이픈 제외)');
$table->string('cert_key')->nullable()->comment('바로빌 인증키 (암호화)');
$table->string('barobill_id', 100)->nullable()->comment('바로빌 아이디');
$table->string('corp_name', 100)->comment('상호');
$table->string('ceo_name', 50)->comment('대표자명');
$table->string('addr', 200)->nullable()->comment('사업장 주소');
$table->string('biz_type', 100)->nullable()->comment('업태');
$table->string('biz_class', 100)->nullable()->comment('종목');
$table->string('contact_id', 100)->nullable()->comment('담당자 이메일');
$table->string('contact_name', 50)->nullable()->comment('담당자명');
$table->string('contact_tel', 20)->nullable()->comment('담당자 전화번호');
$table->boolean('is_active')->default(false)->comment('활성화 여부');
$table->boolean('auto_issue')->default(false)->comment('자동 발행 여부');
$table->timestamp('verified_at')->nullable()->comment('연동 검증일시');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->timestamps();
$table->index('corp_num', 'idx_corp_num');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('barobill_settings');
}
};

View File

@@ -0,0 +1,89 @@
<?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('tax_invoices', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('nts_confirm_num', 24)->nullable()->comment('국세청 승인번호');
$table->string('invoice_type', 20)->comment('세금계산서/계산서/수정세금계산서');
$table->string('issue_type', 20)->comment('정발행/역발행/위수탁');
$table->string('direction', 10)->comment('매출(sales)/매입(purchases)');
// 공급자 정보
$table->string('supplier_corp_num', 20)->comment('공급자 사업자번호');
$table->string('supplier_corp_name', 100)->comment('공급자 상호');
$table->string('supplier_ceo_name', 50)->nullable()->comment('공급자 대표자명');
$table->string('supplier_addr', 200)->nullable()->comment('공급자 주소');
$table->string('supplier_biz_type', 100)->nullable()->comment('공급자 업태');
$table->string('supplier_biz_class', 100)->nullable()->comment('공급자 종목');
$table->string('supplier_contact_id', 100)->nullable()->comment('공급자 담당자 이메일');
// 공급받는자 정보
$table->string('buyer_corp_num', 20)->comment('공급받는자 사업자번호');
$table->string('buyer_corp_name', 100)->comment('공급받는자 상호');
$table->string('buyer_ceo_name', 50)->nullable()->comment('공급받는자 대표자명');
$table->string('buyer_addr', 200)->nullable()->comment('공급받는자 주소');
$table->string('buyer_biz_type', 100)->nullable()->comment('공급받는자 업태');
$table->string('buyer_biz_class', 100)->nullable()->comment('공급받는자 종목');
$table->string('buyer_contact_id', 100)->nullable()->comment('공급받는자 담당자 이메일');
// 금액 정보
$table->date('issue_date')->comment('발행일자');
$table->decimal('supply_amount', 15, 2)->comment('공급가액');
$table->decimal('tax_amount', 15, 2)->comment('세액');
$table->decimal('total_amount', 15, 2)->comment('합계금액');
// 품목 정보 (JSON)
$table->json('items')->nullable()->comment('품목 목록 [{name, spec, qty, unit_price, supply_amt, tax_amt, remark}]');
// 상태 정보
$table->string('status', 20)->default('draft')->comment('상태: draft/issued/sent/cancelled/failed');
$table->string('nts_send_status', 20)->nullable()->comment('국세청 전송상태');
$table->timestamp('issued_at')->nullable()->comment('발행일시');
$table->timestamp('sent_at')->nullable()->comment('전송일시');
$table->timestamp('cancelled_at')->nullable()->comment('취소일시');
// 연동 정보
$table->string('barobill_invoice_id', 50)->nullable()->comment('바로빌 세금계산서 ID');
$table->text('description')->nullable()->comment('비고');
$table->text('error_message')->nullable()->comment('오류 메시지');
// 참조 정보
$table->string('reference_type', 50)->nullable()->comment('참조 유형 (sale/purchase)');
$table->unsignedBigInteger('reference_id')->nullable()->comment('참조 ID');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
$table->softDeletes();
$table->timestamps();
// 인덱스
$table->index(['tenant_id', 'issue_date'], 'idx_tenant_issue_date');
$table->index(['tenant_id', 'direction'], 'idx_tenant_direction');
$table->index(['tenant_id', 'status'], 'idx_tenant_status');
$table->index('nts_confirm_num', 'idx_nts_confirm_num');
$table->index(['reference_type', 'reference_id'], 'idx_reference');
$table->index('supplier_corp_num', 'idx_supplier');
$table->index('buyer_corp_num', 'idx_buyer');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tax_invoices');
}
};

View File

@@ -65,6 +65,8 @@
use App\Http\Controllers\Api\V1\RolePermissionController; use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\SaleController; use App\Http\Controllers\Api\V1\SaleController;
use App\Http\Controllers\Api\V1\SiteController; use App\Http\Controllers\Api\V1\SiteController;
use App\Http\Controllers\Api\V1\TaxInvoiceController;
use App\Http\Controllers\Api\V1\BarobillSettingController;
// 설계 전용 (디자인 네임스페이스) // 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\TenantController; use App\Http\Controllers\Api\V1\TenantController;
use App\Http\Controllers\Api\V1\TenantFieldSettingController; use App\Http\Controllers\Api\V1\TenantFieldSettingController;
@@ -427,6 +429,26 @@
Route::post('/{id}/confirm', [PurchaseController::class, 'confirm'])->whereNumber('id')->name('v1.purchases.confirm'); Route::post('/{id}/confirm', [PurchaseController::class, 'confirm'])->whereNumber('id')->name('v1.purchases.confirm');
}); });
// Barobill Setting API (바로빌 설정)
Route::prefix('barobill-settings')->group(function () {
Route::get('', [BarobillSettingController::class, 'show'])->name('v1.barobill-settings.show');
Route::put('', [BarobillSettingController::class, 'save'])->name('v1.barobill-settings.save');
Route::post('/test-connection', [BarobillSettingController::class, 'testConnection'])->name('v1.barobill-settings.test-connection');
});
// 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('/{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');
});
// Report API (보고서) // Report API (보고서)
Route::prefix('reports')->group(function () { Route::prefix('reports')->group(function () {
Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily'); Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily');