From 8ad4d7c0ce95dea64a3a4f23892f44de10fa5a97 Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 18 Dec 2025 15:31:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203.8=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EB=B9=8C=20=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0=EC=84=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마이그레이션: barobill_settings, tax_invoices 테이블 생성 - 모델: BarobillSetting (인증서 암호화), TaxInvoice (상태/유형 상수) - 서비스: BarobillService (API 연동), TaxInvoiceService (CRUD, 발행/취소) - 컨트롤러: BarobillSettingController, TaxInvoiceController - FormRequest: 6개 요청 검증 클래스 - Swagger: API 문서 완성 (BarobillSettingApi, TaxInvoiceApi) --- .../Api/V1/BarobillSettingController.php | 54 ++ .../Api/V1/TaxInvoiceController.php | 137 +++++ .../SaveBarobillSettingRequest.php | 49 ++ .../TaxInvoice/CancelTaxInvoiceRequest.php | 27 + .../TaxInvoice/CreateTaxInvoiceRequest.php | 78 +++ .../TaxInvoice/TaxInvoiceListRequest.php | 31 + .../TaxInvoice/TaxInvoiceSummaryRequest.php | 21 + .../TaxInvoice/UpdateTaxInvoiceRequest.php | 62 ++ app/Models/Tenants/BarobillSetting.php | 132 +++++ app/Models/Tenants/TaxInvoice.php | 281 ++++++++++ app/Services/BarobillService.php | 406 ++++++++++++++ app/Services/TaxInvoiceService.php | 297 ++++++++++ app/Swagger/v1/BarobillSettingApi.php | 165 ++++++ app/Swagger/v1/TaxInvoiceApi.php | 528 ++++++++++++++++++ ..._200001_create_barobill_settings_table.php | 46 ++ ...12_18_200002_create_tax_invoices_table.php | 89 +++ routes/api.php | 22 + 17 files changed, 2425 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/BarobillSettingController.php create mode 100644 app/Http/Controllers/Api/V1/TaxInvoiceController.php create mode 100644 app/Http/Requests/BarobillSetting/SaveBarobillSettingRequest.php create mode 100644 app/Http/Requests/TaxInvoice/CancelTaxInvoiceRequest.php create mode 100644 app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php create mode 100644 app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php create mode 100644 app/Http/Requests/TaxInvoice/TaxInvoiceSummaryRequest.php create mode 100644 app/Http/Requests/TaxInvoice/UpdateTaxInvoiceRequest.php create mode 100644 app/Models/Tenants/BarobillSetting.php create mode 100644 app/Models/Tenants/TaxInvoice.php create mode 100644 app/Services/BarobillService.php create mode 100644 app/Services/TaxInvoiceService.php create mode 100644 app/Swagger/v1/BarobillSettingApi.php create mode 100644 app/Swagger/v1/TaxInvoiceApi.php create mode 100644 database/migrations/2025_12_18_200001_create_barobill_settings_table.php create mode 100644 database/migrations/2025_12_18_200002_create_tax_invoices_table.php diff --git a/app/Http/Controllers/Api/V1/BarobillSettingController.php b/app/Http/Controllers/Api/V1/BarobillSettingController.php new file mode 100644 index 0000000..9980397 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillSettingController.php @@ -0,0 +1,54 @@ +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') + ); + } +} diff --git a/app/Http/Controllers/Api/V1/TaxInvoiceController.php b/app/Http/Controllers/Api/V1/TaxInvoiceController.php new file mode 100644 index 0000000..cb8193c --- /dev/null +++ b/app/Http/Controllers/Api/V1/TaxInvoiceController.php @@ -0,0 +1,137 @@ +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') + ); + } +} diff --git a/app/Http/Requests/BarobillSetting/SaveBarobillSettingRequest.php b/app/Http/Requests/BarobillSetting/SaveBarobillSettingRequest.php new file mode 100644 index 0000000..181e8e9 --- /dev/null +++ b/app/Http/Requests/BarobillSetting/SaveBarobillSettingRequest.php @@ -0,0 +1,49 @@ + ['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'), + ]; + } +} diff --git a/app/Http/Requests/TaxInvoice/CancelTaxInvoiceRequest.php b/app/Http/Requests/TaxInvoice/CancelTaxInvoiceRequest.php new file mode 100644 index 0000000..0340fa2 --- /dev/null +++ b/app/Http/Requests/TaxInvoice/CancelTaxInvoiceRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:500'], + ]; + } + + public function attributes(): array + { + return [ + 'reason' => __('validation.attributes.cancel_reason'), + ]; + } +} diff --git a/app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php b/app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php new file mode 100644 index 0000000..d65e989 --- /dev/null +++ b/app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php @@ -0,0 +1,78 @@ + ['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'), + ]; + } +} diff --git a/app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php b/app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php new file mode 100644 index 0000000..f1679ac --- /dev/null +++ b/app/Http/Requests/TaxInvoice/TaxInvoiceListRequest.php @@ -0,0 +1,31 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/TaxInvoice/TaxInvoiceSummaryRequest.php b/app/Http/Requests/TaxInvoice/TaxInvoiceSummaryRequest.php new file mode 100644 index 0000000..056d5a3 --- /dev/null +++ b/app/Http/Requests/TaxInvoice/TaxInvoiceSummaryRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'date'], + 'issue_date_to' => ['nullable', 'date', 'after_or_equal:issue_date_from'], + ]; + } +} diff --git a/app/Http/Requests/TaxInvoice/UpdateTaxInvoiceRequest.php b/app/Http/Requests/TaxInvoice/UpdateTaxInvoiceRequest.php new file mode 100644 index 0000000..6004086 --- /dev/null +++ b/app/Http/Requests/TaxInvoice/UpdateTaxInvoiceRequest.php @@ -0,0 +1,62 @@ + ['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'], + ]; + } +} diff --git a/app/Models/Tenants/BarobillSetting.php b/app/Models/Tenants/BarobillSetting.php new file mode 100644 index 0000000..8dbd0cc --- /dev/null +++ b/app/Models/Tenants/BarobillSetting.php @@ -0,0 +1,132 @@ + '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; + } +} diff --git a/app/Models/Tenants/TaxInvoice.php b/app/Models/Tenants/TaxInvoice.php new file mode 100644 index 0000000..c3c7f8f --- /dev/null +++ b/app/Models/Tenants/TaxInvoice.php @@ -0,0 +1,281 @@ + '임시저장', + 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); + } +} diff --git a/app/Services/BarobillService.php b/app/Services/BarobillService.php new file mode 100644 index 0000000..970bbf4 --- /dev/null +++ b/app/Services/BarobillService.php @@ -0,0 +1,406 @@ +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 => '정발행', + }; + } +} diff --git a/app/Services/TaxInvoiceService.php b/app/Services/TaxInvoiceService.php new file mode 100644 index 0000000..d1f76fa --- /dev/null +++ b/app/Services/TaxInvoiceService.php @@ -0,0 +1,297 @@ +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, + ], + ]; + } +} diff --git a/app/Swagger/v1/BarobillSettingApi.php b/app/Swagger/v1/BarobillSettingApi.php new file mode 100644 index 0000000..627e6bd --- /dev/null +++ b/app/Swagger/v1/BarobillSettingApi.php @@ -0,0 +1,165 @@ +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'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_18_200002_create_tax_invoices_table.php b/database/migrations/2025_12_18_200002_create_tax_invoices_table.php new file mode 100644 index 0000000..e060a95 --- /dev/null +++ b/database/migrations/2025_12_18_200002_create_tax_invoices_table.php @@ -0,0 +1,89 @@ +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'); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index f981d57..3b426a9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -65,6 +65,8 @@ use App\Http\Controllers\Api\V1\RolePermissionController; use App\Http\Controllers\Api\V1\SaleController; 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\TenantFieldSettingController; @@ -427,6 +429,26 @@ 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 (보고서) Route::prefix('reports')->group(function () { Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily');