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

View File

@@ -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.',
],
];

View File

@@ -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' => '유효하지 않은 계약 상태입니다.',
],
];

View File

@@ -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' => '견적이 입찰로 변환되었습니다.',
],
];

View File

@@ -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 (악성채권 추심관리)