From 74a60e06bc50bb209d9870edde8fa947d236179f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 20:33:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[calendar,vat]=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20CRUD=20=EB=B0=8F=20=EB=B6=80=EA=B0=80=EC=84=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=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 - CalendarController/Service: 일정 등록/수정/삭제 API 추가 - VatController/Service: getDetail() 상세 조회 (요약, 참조테이블, 미발행 목록, 신고기간 옵션) - 라우트: POST/PUT/DELETE /calendar/schedules, GET /vat/detail 추가 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/V1/CalendarController.php | 52 +++++++ app/Http/Controllers/Api/V1/VatController.php | 14 ++ app/Services/CalendarService.php | 72 ++++++++++ app/Services/VatService.php | 133 ++++++++++++++++++ routes/api/v1/finance.php | 4 + 5 files changed, 275 insertions(+) diff --git a/app/Http/Controllers/Api/V1/CalendarController.php b/app/Http/Controllers/Api/V1/CalendarController.php index e7bd95e..fb4dfaa 100644 --- a/app/Http/Controllers/Api/V1/CalendarController.php +++ b/app/Http/Controllers/Api/V1/CalendarController.php @@ -51,4 +51,56 @@ public function summary(Request $request) ); }, __('message.fetched')); } + + /** + * 일정 등록 + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'title' => 'required|string|max:200', + 'description' => 'nullable|string|max:1000', + 'start_date' => 'required|date_format:Y-m-d', + 'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_all_day' => 'boolean', + 'color' => 'nullable|string|max:20', + ]); + + return ApiResponse::handle(function () use ($validated) { + return $this->calendarService->createSchedule($validated); + }, __('message.created')); + } + + /** + * 일정 수정 + */ + public function update(Request $request, int $id) + { + $validated = $request->validate([ + 'title' => 'required|string|max:200', + 'description' => 'nullable|string|max:1000', + 'start_date' => 'required|date_format:Y-m-d', + 'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_all_day' => 'boolean', + 'color' => 'nullable|string|max:20', + ]); + + return ApiResponse::handle(function () use ($id, $validated) { + return $this->calendarService->updateSchedule($id, $validated); + }, __('message.updated')); + } + + /** + * 일정 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->calendarService->deleteSchedule($id); + }, __('message.deleted')); + } } diff --git a/app/Http/Controllers/Api/V1/VatController.php b/app/Http/Controllers/Api/V1/VatController.php index 765692b..1f02954 100644 --- a/app/Http/Controllers/Api/V1/VatController.php +++ b/app/Http/Controllers/Api/V1/VatController.php @@ -32,4 +32,18 @@ public function summary(Request $request): JsonResponse return $this->vatService->getSummary($periodType, $year, $period); }, __('message.fetched')); } + + /** + * 부가세 상세 조회 (모달용) + */ + public function detail(Request $request): JsonResponse + { + $periodType = $request->query('period_type', 'quarter'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $period = $request->query('period') ? (int) $request->query('period') : null; + + return ApiResponse::handle(function () use ($periodType, $year, $period) { + return $this->vatService->getDetail($periodType, $year, $period); + }, __('message.fetched')); + } } diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index 61d4150..bdd87ae 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -226,6 +226,78 @@ private function getLeaveSchedules( }); } + /** + * 일정 등록 + */ + public function createSchedule(array $data): array + { + $schedule = Schedule::create([ + 'tenant_id' => $this->tenantId(), + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'start_time' => $data['start_time'] ?? null, + 'end_time' => $data['end_time'] ?? null, + 'is_all_day' => $data['is_all_day'] ?? true, + 'type' => Schedule::TYPE_EVENT, + 'color' => $data['color'] ?? null, + 'is_active' => true, + 'created_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $schedule->id, + 'title' => $schedule->title, + 'start_date' => $schedule->start_date?->format('Y-m-d'), + 'end_date' => $schedule->end_date?->format('Y-m-d'), + ]; + } + + /** + * 일정 수정 + */ + public function updateSchedule(int $id, array $data): array + { + $schedule = Schedule::where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $schedule->update([ + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'start_time' => $data['start_time'] ?? null, + 'end_time' => $data['end_time'] ?? null, + 'is_all_day' => $data['is_all_day'] ?? true, + 'color' => $data['color'] ?? null, + 'updated_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $schedule->id, + 'title' => $schedule->title, + 'start_date' => $schedule->start_date?->format('Y-m-d'), + 'end_date' => $schedule->end_date?->format('Y-m-d'), + ]; + } + + /** + * 일정 삭제 (소프트 삭제) + */ + public function deleteSchedule(int $id): array + { + $schedule = Schedule::where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $schedule->update(['deleted_by' => $this->apiUserId()]); + $schedule->delete(); + + return [ + 'id' => $schedule->id, + ]; + } + /** * 범용 일정 조회 (본사 공통 + 테넌트 일정) */ diff --git a/app/Services/VatService.php b/app/Services/VatService.php index 9f9b0b2..9e937ef 100644 --- a/app/Services/VatService.php +++ b/app/Services/VatService.php @@ -237,6 +237,139 @@ private function getPeriodLabel(int $year, string $periodType, int $period): str }; } + /** + * 부가세 상세 조회 (모달용) + * + * @param string|null $periodType 기간 타입 (quarter|half|year) + * @param int|null $year 연도 + * @param int|null $period 기간 번호 + * @return array + */ + public function getDetail(?string $periodType = 'quarter', ?int $year = null, ?int $period = null): array + { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + + $year = $year ?? $now->year; + $periodType = $periodType ?? 'quarter'; + $period = $period ?? $this->getCurrentPeriod($periodType, $now); + + [$startDate, $endDate] = $this->getPeriodDateRange($year, $periodType, $period); + $periodLabel = $this->getPeriodLabel($year, $periodType, $period); + + $validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT]; + + // 매출 공급가액 + 세액 + $salesData = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_SALES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount') + ->first(); + + // 매입 공급가액 + 세액 + $purchasesData = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_PURCHASES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount') + ->first(); + + $salesSupplyAmount = (int) ($salesData->supply_amount ?? 0); + $salesTaxAmount = (int) ($salesData->tax_amount ?? 0); + $purchasesSupplyAmount = (int) ($purchasesData->supply_amount ?? 0); + $purchasesTaxAmount = (int) ($purchasesData->tax_amount ?? 0); + $estimatedPayment = $salesTaxAmount - $purchasesTaxAmount; + + // 신고기간 옵션 생성 + $periodOptions = $this->generatePeriodOptions($year, $periodType, $period); + + // 부가세 요약 테이블 (direction + invoice_type 별 GROUP BY) + $referenceTable = TaxInvoice::where('tenant_id', $tenantId) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw(" + direction, + invoice_type, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount + ") + ->groupBy('direction', 'invoice_type') + ->get() + ->map(fn ($row) => [ + 'direction' => $row->direction, + 'direction_label' => $row->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입', + 'invoice_type' => $row->invoice_type, + 'invoice_type_label' => match ($row->invoice_type) { + TaxInvoice::TYPE_TAX_INVOICE => '전자세금계산서', + TaxInvoice::TYPE_INVOICE => '계산서', + TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서', + default => $row->invoice_type, + }, + 'supply_amount' => (int) $row->supply_amount, + 'tax_amount' => (int) $row->tax_amount, + ]) + ->toArray(); + + // 미발행/미수취 세금계산서 목록 (status=draft) + $unissuedInvoices = TaxInvoice::where('tenant_id', $tenantId) + ->where('status', TaxInvoice::STATUS_DRAFT) + ->orderBy('issue_date', 'desc') + ->limit(100) + ->get() + ->map(fn ($invoice) => [ + 'id' => $invoice->id, + 'direction' => $invoice->direction, + 'direction_label' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입', + 'issue_date' => $invoice->issue_date, + 'vendor_name' => $invoice->direction === TaxInvoice::DIRECTION_SALES + ? ($invoice->buyer_corp_name ?? '-') + : ($invoice->supplier_corp_name ?? '-'), + 'tax_amount' => (int) $invoice->tax_amount, + 'status' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '미발행' : '미수취', + ]) + ->toArray(); + + return [ + 'period_label' => $periodLabel, + 'period_options' => $periodOptions, + 'summary' => [ + 'sales_supply_amount' => $salesSupplyAmount, + 'sales_tax_amount' => $salesTaxAmount, + 'purchases_supply_amount' => $purchasesSupplyAmount, + 'purchases_tax_amount' => $purchasesTaxAmount, + 'estimated_payment' => (int) abs($estimatedPayment), + 'is_refund' => $estimatedPayment < 0, + ], + 'reference_table' => $referenceTable, + 'unissued_invoices' => $unissuedInvoices, + ]; + } + + /** + * 신고기간 드롭다운 옵션 생성 + * 현재 기간 포함 최근 8개 기간 + */ + private function generatePeriodOptions(int $currentYear, string $periodType, int $currentPeriod): array + { + $options = []; + $year = $currentYear; + $period = $currentPeriod; + + for ($i = 0; $i < 8; $i++) { + $label = $this->getPeriodLabel($year, $periodType, $period); + $value = "{$year}-{$periodType}-{$period}"; + $options[] = ['value' => $value, 'label' => $label]; + + // 이전 기간으로 이동 + $prev = $this->getPreviousPeriod($year, $periodType, $period); + $year = $prev['year']; + $period = $prev['period']; + } + + return $options; + } + /** * 이전 기간 계산 * diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index d9627a6..aef25d2 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -200,9 +200,13 @@ // Calendar API (CEO 대시보드 캘린더) Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules'); +Route::post('/calendar/schedules', [CalendarController::class, 'store'])->name('v1.calendar.schedules.store'); +Route::put('/calendar/schedules/{id}', [CalendarController::class, 'update'])->whereNumber('id')->name('v1.calendar.schedules.update'); +Route::delete('/calendar/schedules/{id}', [CalendarController::class, 'destroy'])->whereNumber('id')->name('v1.calendar.schedules.destroy'); // Vat API (CEO 대시보드 부가세 현황) Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary'); +Route::get('/vat/detail', [VatController::class, 'detail'])->name('v1.vat.detail'); // Entertainment API (CEO 대시보드 접대비 현황) Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary');