feat: 세금계산서/거래명세서 일괄 발행 API 추가

- POST /api/v1/tax-invoices/bulk-issue: 세금계산서 일괄 발행
- POST /api/v1/sales/bulk-issue-statement: 거래명세서 일괄 발행
- FormRequest 검증 (최대 100건)
- Service 일괄 처리 로직 (개별 오류 처리)
- Swagger 문서 추가
- i18n 메시지 키 추가 (ko/en)
This commit is contained in:
2026-01-19 20:53:36 +09:00
parent 7dd683ace8
commit 0b94da0741
12 changed files with 444 additions and 0 deletions

View File

@@ -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'));
}
}

View File

@@ -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')
);
}
/**
* 세금계산서 취소
*/

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Sale;
use Illuminate\Foundation\Http\FormRequest;
/**
* 거래명세서 일괄 발행 요청 검증
*/
class BulkIssueStatementRequest extends FormRequest
{
/**
* 권한 확인
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검사 규칙
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1', 'max:100'],
'ids.*' => ['required', 'integer', 'min:1'],
];
}
/**
* 유효성 검사 메시지
*
* @return array<string, string>
*/
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<int, int>
*/
public function getIds(): array
{
return $this->validated('ids');
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TaxInvoice;
use Illuminate\Foundation\Http\FormRequest;
/**
* 세금계산서 일괄 발행 요청 검증
*/
class BulkIssueRequest extends FormRequest
{
/**
* 권한 확인
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검사 규칙
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1', 'max:100'],
'ids.*' => ['required', 'integer', 'min:1'],
];
}
/**
* 유효성 검사 메시지
*
* @return array<string, string>
*/
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<int, int>
*/
public function getIds(): array
{
return $this->validated('ids');
}
}

View File

@@ -365,6 +365,68 @@ public function issueStatement(int $id): array
});
}
/**
* 거래명세서 일괄 발행
*
* @param array<int> $ids 발행할 매출 ID 배열
* @return array{issued: int, failed: int, errors: array<int, string>}
*/
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;
}
/**
* 거래명세서 이메일 발송
*/

View File

@@ -195,6 +195,54 @@ public function issue(int $id): TaxInvoice
return $this->barobillService->issueTaxInvoice($taxInvoice);
}
/**
* 세금계산서 일괄 발행
*
* @param array<int> $ids 발행할 세금계산서 ID 배열
* @return array{issued: int, failed: int, errors: array<int, string>}
*/
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;
}
/**
* 세금계산서 취소
*/

View File

@@ -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() {}
}

View File

@@ -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() {}
}