From 0b94da0741d9a4ab0d731152e4236a0f87dae96a Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 19 Jan 2026 20:53:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C/=EA=B1=B0=EB=9E=98=EB=AA=85=EC=84=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EB=B0=9C=ED=96=89=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/tax-invoices/bulk-issue: 세금계산서 일괄 발행 - POST /api/v1/sales/bulk-issue-statement: 거래명세서 일괄 발행 - FormRequest 검증 (최대 100건) - Service 일괄 처리 로직 (개별 오류 처리) - Swagger 문서 추가 - i18n 메시지 키 추가 (ko/en) --- .../Controllers/Api/V1/SaleController.php | 11 ++++ .../Api/V1/TaxInvoiceController.php | 14 +++++ .../V1/Sale/BulkIssueStatementRequest.php | 62 +++++++++++++++++++ .../V1/TaxInvoice/BulkIssueRequest.php | 62 +++++++++++++++++++ app/Services/SaleService.php | 62 +++++++++++++++++++ app/Services/TaxInvoiceService.php | 48 ++++++++++++++ app/Swagger/v1/SaleApi.php | 60 ++++++++++++++++++ app/Swagger/v1/TaxInvoiceApi.php | 60 ++++++++++++++++++ lang/en/message.php | 15 +++++ lang/ko/error.php | 28 +++++++++ lang/ko/message.php | 19 ++++++ routes/api.php | 3 + 12 files changed, 444 insertions(+) create mode 100644 app/Http/Requests/V1/Sale/BulkIssueStatementRequest.php create mode 100644 app/Http/Requests/V1/TaxInvoice/BulkIssueRequest.php diff --git a/app/Http/Controllers/Api/V1/SaleController.php b/app/Http/Controllers/Api/V1/SaleController.php index 3828353..77abb1c 100644 --- a/app/Http/Controllers/Api/V1/SaleController.php +++ b/app/Http/Controllers/Api/V1/SaleController.php @@ -5,6 +5,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Common\BulkUpdateAccountCodeRequest; +use App\Http\Requests\V1\Sale\BulkIssueStatementRequest; use App\Http\Requests\V1\Sale\SendStatementRequest; use App\Http\Requests\V1\Sale\StoreSaleRequest; use App\Http\Requests\V1\Sale\UpdateSaleRequest; @@ -151,4 +152,14 @@ public function sendStatement(int $id, SendStatementRequest $request) return ApiResponse::success($result, __('message.sale.statement_sent')); } + + /** + * 거래명세서 일괄 발행 + */ + public function bulkIssueStatement(BulkIssueStatementRequest $request) + { + $result = $this->service->bulkIssueStatement($request->getIds()); + + return ApiResponse::success($result, __('message.sale.bulk_statement_issued')); + } } diff --git a/app/Http/Controllers/Api/V1/TaxInvoiceController.php b/app/Http/Controllers/Api/V1/TaxInvoiceController.php index cb8193c..2fe8dcf 100644 --- a/app/Http/Controllers/Api/V1/TaxInvoiceController.php +++ b/app/Http/Controllers/Api/V1/TaxInvoiceController.php @@ -9,6 +9,7 @@ use App\Http\Requests\TaxInvoice\TaxInvoiceListRequest; use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest; use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest; +use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest; use App\Services\TaxInvoiceService; class TaxInvoiceController extends Controller @@ -96,6 +97,19 @@ public function issue(int $id) ); } + /** + * 세금계산서 일괄 발행 + */ + public function bulkIssue(BulkIssueRequest $request) + { + $result = $this->taxInvoiceService->bulkIssue($request->getIds()); + + return ApiResponse::handle( + data: $result, + message: __('message.tax_invoice.bulk_issued') + ); + } + /** * 세금계산서 취소 */ diff --git a/app/Http/Requests/V1/Sale/BulkIssueStatementRequest.php b/app/Http/Requests/V1/Sale/BulkIssueStatementRequest.php new file mode 100644 index 0000000..8b9dc1b --- /dev/null +++ b/app/Http/Requests/V1/Sale/BulkIssueStatementRequest.php @@ -0,0 +1,62 @@ +> + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1', 'max:100'], + 'ids.*' => ['required', 'integer', 'min:1'], + ]; + } + + /** + * 유효성 검사 메시지 + * + * @return array + */ + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => 'ID 목록']), + 'ids.array' => __('validation.array', ['attribute' => 'ID 목록']), + 'ids.min' => __('validation.min.array', ['attribute' => 'ID 목록', 'min' => 1]), + 'ids.max' => __('validation.max.array', ['attribute' => 'ID 목록', 'max' => 100]), + 'ids.*.required' => __('validation.required', ['attribute' => 'ID']), + 'ids.*.integer' => __('validation.integer', ['attribute' => 'ID']), + 'ids.*.min' => __('validation.min.numeric', ['attribute' => 'ID', 'min' => 1]), + ]; + } + + /** + * 검증된 ID 배열 반환 + * + * @return array + */ + public function getIds(): array + { + return $this->validated('ids'); + } +} diff --git a/app/Http/Requests/V1/TaxInvoice/BulkIssueRequest.php b/app/Http/Requests/V1/TaxInvoice/BulkIssueRequest.php new file mode 100644 index 0000000..3090c65 --- /dev/null +++ b/app/Http/Requests/V1/TaxInvoice/BulkIssueRequest.php @@ -0,0 +1,62 @@ +> + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1', 'max:100'], + 'ids.*' => ['required', 'integer', 'min:1'], + ]; + } + + /** + * 유효성 검사 메시지 + * + * @return array + */ + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => 'ID 목록']), + 'ids.array' => __('validation.array', ['attribute' => 'ID 목록']), + 'ids.min' => __('validation.min.array', ['attribute' => 'ID 목록', 'min' => 1]), + 'ids.max' => __('validation.max.array', ['attribute' => 'ID 목록', 'max' => 100]), + 'ids.*.required' => __('validation.required', ['attribute' => 'ID']), + 'ids.*.integer' => __('validation.integer', ['attribute' => 'ID']), + 'ids.*.min' => __('validation.min.numeric', ['attribute' => 'ID', 'min' => 1]), + ]; + } + + /** + * 검증된 ID 배열 반환 + * + * @return array + */ + public function getIds(): array + { + return $this->validated('ids'); + } +} diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 238d2db..31fa262 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -365,6 +365,68 @@ public function issueStatement(int $id): array }); } + /** + * 거래명세서 일괄 발행 + * + * @param array $ids 발행할 매출 ID 배열 + * @return array{issued: int, failed: int, errors: array} + */ + public function bulkIssueStatement(array $ids): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $results = [ + 'issued' => 0, + 'failed' => 0, + 'errors' => [], + ]; + + $sales = Sale::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + foreach ($sales as $sale) { + try { + // 확정된 매출만 거래명세서 발행 가능 + if ($sale->status !== 'confirmed') { + $results['errors'][$sale->id] = __('error.sale.statement_requires_confirmed'); + $results['failed']++; + + continue; + } + + // 이미 발행된 경우 + if ($sale->statement_issued_at !== null) { + $results['errors'][$sale->id] = __('error.sale.statement_already_issued'); + $results['failed']++; + + continue; + } + + // 발행 시간 기록 + $sale->statement_issued_at = now(); + $sale->statement_issued_by = $userId; + $sale->save(); + + $results['issued']++; + } catch (\Throwable $e) { + $results['errors'][$sale->id] = $e->getMessage(); + $results['failed']++; + } + } + + // 요청된 ID 중 찾지 못한 것들도 실패 처리 + $foundIds = $sales->pluck('id')->toArray(); + $notFoundIds = array_diff($ids, $foundIds); + foreach ($notFoundIds as $notFoundId) { + $results['errors'][$notFoundId] = __('error.sale.not_found'); + $results['failed']++; + } + + return $results; + } + /** * 거래명세서 이메일 발송 */ diff --git a/app/Services/TaxInvoiceService.php b/app/Services/TaxInvoiceService.php index d1f76fa..19cd6c8 100644 --- a/app/Services/TaxInvoiceService.php +++ b/app/Services/TaxInvoiceService.php @@ -195,6 +195,54 @@ public function issue(int $id): TaxInvoice return $this->barobillService->issueTaxInvoice($taxInvoice); } + /** + * 세금계산서 일괄 발행 + * + * @param array $ids 발행할 세금계산서 ID 배열 + * @return array{issued: int, failed: int, errors: array} + */ + public function bulkIssue(array $ids): array + { + $tenantId = $this->tenantId(); + $results = [ + 'issued' => 0, + 'failed' => 0, + 'errors' => [], + ]; + + $taxInvoices = TaxInvoice::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + foreach ($taxInvoices as $taxInvoice) { + try { + if (! $taxInvoice->canEdit()) { + $results['errors'][$taxInvoice->id] = __('error.tax_invoice.already_issued'); + $results['failed']++; + + continue; + } + + $this->barobillService->issueTaxInvoice($taxInvoice); + $results['issued']++; + } catch (\Throwable $e) { + $results['errors'][$taxInvoice->id] = $e->getMessage(); + $results['failed']++; + } + } + + // 요청된 ID 중 찾지 못한 것들도 실패 처리 + $foundIds = $taxInvoices->pluck('id')->toArray(); + $notFoundIds = array_diff($ids, $foundIds); + foreach ($notFoundIds as $notFoundId) { + $results['errors'][$notFoundId] = __('error.tax_invoice.not_found'); + $results['failed']++; + } + + return $results; + } + /** * 세금계산서 취소 */ diff --git a/app/Swagger/v1/SaleApi.php b/app/Swagger/v1/SaleApi.php index 103e4b3..1f5f995 100644 --- a/app/Swagger/v1/SaleApi.php +++ b/app/Swagger/v1/SaleApi.php @@ -154,6 +154,29 @@ * @OA\Property(property="sent_at", type="string", format="date-time", example="2025-01-15T10:30:00+09:00", description="발송일시"), * @OA\Property(property="statement_number", type="string", example="STSL202501150001", description="거래명세서 번호") * ) + * + * @OA\Schema( + * schema="SaleBulkIssueStatementRequest", + * type="object", + * required={"ids"}, + * description="거래명세서 일괄 발행 요청", + * + * @OA\Property(property="ids", type="array", minItems=1, maxItems=100, description="발행할 매출 ID 목록 (최대 100개)", + * @OA\Items(type="integer", example=1) + * ) + * ) + * + * @OA\Schema( + * schema="SaleBulkIssueStatementResponse", + * type="object", + * description="거래명세서 일괄 발행 응답", + * + * @OA\Property(property="issued", type="integer", example=8, description="발행 성공 건수"), + * @OA\Property(property="failed", type="integer", example=2, description="발행 실패 건수"), + * @OA\Property(property="errors", type="object", description="실패 상세 (ID: 에러메시지)", + * @OA\AdditionalProperties(type="string", example="확정 상태가 아닌 매출입니다.") + * ) + * ) */ class SaleApi { @@ -514,4 +537,41 @@ public function issueStatement() {} * ) */ public function sendStatement() {} + + /** + * @OA\Post( + * path="/api/v1/sales/bulk-issue-statement", + * tags={"Sales"}, + * summary="거래명세서 일괄 발행", + * description="여러 매출에 대한 거래명세서를 일괄 발행합니다. 확정(confirmed) 상태이면서 아직 발행되지 않은 건만 발행됩니다. 각 건별로 성공/실패가 처리됩니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent(ref="#/components/schemas/SaleBulkIssueStatementRequest") + * ), + * + * @OA\Response( + * response=200, + * description="일괄 발행 처리 완료", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * + * @OA\Property(property="data", ref="#/components/schemas/SaleBulkIssueStatementResponse") + * ) + * } + * ) + * ), + * + * @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 bulkIssueStatement() {} } diff --git a/app/Swagger/v1/TaxInvoiceApi.php b/app/Swagger/v1/TaxInvoiceApi.php index e10a4b2..27d6b67 100644 --- a/app/Swagger/v1/TaxInvoiceApi.php +++ b/app/Swagger/v1/TaxInvoiceApi.php @@ -171,6 +171,29 @@ * ) * * @OA\Schema( + * schema="TaxInvoiceBulkIssueRequest", + * type="object", + * required={"ids"}, + * description="세금계산서 일괄 발행 요청", + * + * @OA\Property(property="ids", type="array", minItems=1, maxItems=100, description="발행할 세금계산서 ID 목록 (최대 100개)", + * @OA\Items(type="integer", example=1) + * ) + * ) + * + * @OA\Schema( + * schema="TaxInvoiceBulkIssueResponse", + * type="object", + * description="세금계산서 일괄 발행 응답", + * + * @OA\Property(property="issued", type="integer", example=8, description="발행 성공 건수"), + * @OA\Property(property="failed", type="integer", example=2, description="발행 실패 건수"), + * @OA\Property(property="errors", type="object", description="실패 상세 (ID: 에러메시지)", + * @OA\AdditionalProperties(type="string", example="이미 발행된 세금계산서입니다.") + * ) + * ) + * + * @OA\Schema( * schema="TaxInvoiceSummary", * type="object", * description="세금계산서 요약 통계", @@ -525,4 +548,41 @@ public function cancel() {} * ) */ public function checkStatus() {} + + /** + * @OA\Post( + * path="/api/v1/tax-invoices/bulk-issue", + * tags={"TaxInvoices"}, + * summary="세금계산서 일괄 발행", + * description="여러 세금계산서를 일괄 발행합니다. 바로빌 API를 통해 전자세금계산서가 발행됩니다. 임시저장(draft) 상태인 건만 발행되며, 각 건별로 성공/실패가 처리됩니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent(ref="#/components/schemas/TaxInvoiceBulkIssueRequest") + * ), + * + * @OA\Response( + * response=200, + * description="일괄 발행 처리 완료", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * + * @OA\Property(property="data", ref="#/components/schemas/TaxInvoiceBulkIssueResponse") + * ) + * } + * ) + * ), + * + * @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 bulkIssue() {} } diff --git a/lang/en/message.php b/lang/en/message.php index fea4fa5..f2036b5 100644 --- a/lang/en/message.php +++ b/lang/en/message.php @@ -102,4 +102,19 @@ 'deleted' => 'Order has been deleted.', 'status_updated' => 'Order status has been updated.', ], + + // Sale Management + 'sale' => [ + 'confirmed' => 'Sale has been confirmed.', + 'statement_issued' => 'Statement has been issued.', + 'statement_sent' => 'Statement has been sent.', + 'bulk_statement_issued' => 'Statements have been issued in bulk.', + ], + + // Tax Invoice Management + 'tax_invoice' => [ + 'issued' => 'Tax invoice has been issued.', + 'cancelled' => 'Tax invoice has been cancelled.', + 'bulk_issued' => 'Tax invoices have been issued in bulk.', + ], ]; diff --git a/lang/ko/error.php b/lang/ko/error.php index 02dac5d..8d7b6b5 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -254,6 +254,25 @@ 'no_base_salary' => '기본급이 설정되지 않았습니다.', ], + // 세금계산서 관련 + 'tax_invoice' => [ + 'not_found' => '세금계산서 정보를 찾을 수 없습니다.', + 'cannot_edit' => '발행된 세금계산서는 수정할 수 없습니다.', + 'cannot_delete' => '발행된 세금계산서는 삭제할 수 없습니다.', + 'already_issued' => '이미 발행된 세금계산서입니다.', + ], + + // 매출 관련 + 'sale' => [ + 'not_found' => '매출 정보를 찾을 수 없습니다.', + 'cannot_edit' => '확정된 매출은 수정할 수 없습니다.', + 'cannot_delete' => '확정된 매출은 삭제할 수 없습니다.', + 'cannot_confirm' => '이미 확정된 매출입니다.', + 'statement_requires_confirmed' => '확정된 매출만 거래명세서를 발행할 수 있습니다.', + 'statement_already_issued' => '이미 발행된 거래명세서입니다.', + 'recipient_email_required' => '수신자 이메일이 필요합니다.', + ], + // 대시보드 관련 'dashboard' => [ 'invalid_period' => '기간은 week, month, quarter 중 하나여야 합니다.', @@ -399,4 +418,13 @@ 'cannot_delete' => '해당 입찰은 삭제할 수 없습니다.', 'invalid_status' => '유효하지 않은 입찰 상태입니다.', ], + + // 계약 관련 + 'contract' => [ + 'not_found' => '계약을 찾을 수 없습니다.', + 'already_registered' => '이미 계약이 등록된 입찰입니다. (계약번호: :code)', + 'bidding_not_awarded' => '낙찰 상태인 입찰만 계약으로 전환할 수 있습니다.', + 'cannot_delete' => '해당 계약은 삭제할 수 없습니다.', + 'invalid_status' => '유효하지 않은 계약 상태입니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 329d939..afd6563 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -298,6 +298,14 @@ 'confirmed' => '매출이 확정되었습니다.', 'statement_issued' => '거래명세서가 발행되었습니다.', 'statement_sent' => '거래명세서가 발송되었습니다.', + 'bulk_statement_issued' => '거래명세서가 일괄 발행되었습니다.', + ], + + // 세금계산서 관리 + 'tax_invoice' => [ + 'issued' => '세금계산서가 발행되었습니다.', + 'cancelled' => '세금계산서가 취소되었습니다.', + 'bulk_issued' => '세금계산서가 일괄 발행되었습니다.', ], // 급여 관리 @@ -459,4 +467,15 @@ 'production_order_reverted' => '생산지시가 되돌려졌습니다.', 'order_confirmation_reverted' => '수주확정이 취소되었습니다.', ], + + // 입찰관리 + 'bidding' => [ + 'fetched' => '입찰을 조회했습니다.', + 'created' => '입찰이 등록되었습니다.', + 'updated' => '입찰이 수정되었습니다.', + 'deleted' => '입찰이 삭제되었습니다.', + 'bulk_deleted' => '입찰이 일괄 삭제되었습니다.', + 'status_updated' => '입찰 상태가 변경되었습니다.', + 'converted' => '견적이 입찰로 변환되었습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index 85ee1a9..87e732c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -450,6 +450,7 @@ Route::get('/stats', [ContractController::class, 'stats'])->name('v1.construction.contracts.stats'); Route::get('/stage-counts', [ContractController::class, 'stageCounts'])->name('v1.construction.contracts.stage-counts'); Route::delete('/bulk', [ContractController::class, 'bulkDestroy'])->name('v1.construction.contracts.bulk-destroy'); + Route::post('/from-bidding/{biddingId}', [ContractController::class, 'storeFromBidding'])->whereNumber('biddingId')->name('v1.construction.contracts.store-from-bidding'); Route::get('/{id}', [ContractController::class, 'show'])->whereNumber('id')->name('v1.construction.contracts.show'); Route::put('/{id}', [ContractController::class, 'update'])->whereNumber('id')->name('v1.construction.contracts.update'); Route::delete('/{id}', [ContractController::class, 'destroy'])->whereNumber('id')->name('v1.construction.contracts.destroy'); @@ -679,6 +680,7 @@ Route::get('/{id}/statement', [SaleController::class, 'getStatement'])->whereNumber('id')->name('v1.sales.statement.show'); Route::post('/{id}/statement/issue', [SaleController::class, 'issueStatement'])->whereNumber('id')->name('v1.sales.statement.issue'); Route::post('/{id}/statement/send', [SaleController::class, 'sendStatement'])->whereNumber('id')->name('v1.sales.statement.send'); + Route::post('/bulk-issue-statement', [SaleController::class, 'bulkIssueStatement'])->name('v1.sales.bulk-issue-statement'); }); // Purchase API (매입 관리) @@ -746,6 +748,7 @@ Route::post('/{id}/issue', [TaxInvoiceController::class, 'issue'])->whereNumber('id')->name('v1.tax-invoices.issue'); Route::post('/{id}/cancel', [TaxInvoiceController::class, 'cancel'])->whereNumber('id')->name('v1.tax-invoices.cancel'); Route::get('/{id}/check-status', [TaxInvoiceController::class, 'checkStatus'])->whereNumber('id')->name('v1.tax-invoices.check-status'); + Route::post('/bulk-issue', [TaxInvoiceController::class, 'bulkIssue'])->name('v1.tax-invoices.bulk-issue'); }); // Bad Debt API (악성채권 추심관리)