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:
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
*/
|
||||
|
||||
62
app/Http/Requests/V1/Sale/BulkIssueStatementRequest.php
Normal file
62
app/Http/Requests/V1/Sale/BulkIssueStatementRequest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
62
app/Http/Requests/V1/TaxInvoice/BulkIssueRequest.php
Normal file
62
app/Http/Requests/V1/TaxInvoice/BulkIssueRequest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래명세서 이메일 발송
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
*/
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user