diff --git a/app/Http/Controllers/Api/V1/CondolenceExpenseController.php b/app/Http/Controllers/Api/V1/CondolenceExpenseController.php new file mode 100644 index 00000000..977e4f4d --- /dev/null +++ b/app/Http/Controllers/Api/V1/CondolenceExpenseController.php @@ -0,0 +1,88 @@ +only([ + 'year', + 'category', + 'search', + 'sort_by', + 'sort_order', + 'per_page', + 'page', + ]); + + $expenses = $this->service->index($params); + + return ApiResponse::success($expenses, __('message.fetched')); + } + + /** + * 경조사비 통계 + */ + public function summary(Request $request) + { + $params = $request->only(['year', 'category']); + $summary = $this->service->summary($params); + + return ApiResponse::success($summary, __('message.fetched')); + } + + /** + * 경조사비 상세 + */ + public function show(int $id) + { + $expense = $this->service->show($id); + + return ApiResponse::success($expense, __('message.fetched')); + } + + /** + * 경조사비 등록 + */ + public function store(StoreCondolenceExpenseRequest $request) + { + $expense = $this->service->store($request->validated()); + + return ApiResponse::success($expense, __('message.created'), [], 201); + } + + /** + * 경조사비 수정 + */ + public function update(int $id, UpdateCondolenceExpenseRequest $request) + { + $expense = $this->service->update($id, $request->validated()); + + return ApiResponse::success($expense, __('message.updated')); + } + + /** + * 경조사비 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::success(null, __('message.deleted')); + } +} diff --git a/app/Http/Requests/V1/CondolenceExpense/StoreCondolenceExpenseRequest.php b/app/Http/Requests/V1/CondolenceExpense/StoreCondolenceExpenseRequest.php new file mode 100644 index 00000000..34ee84be --- /dev/null +++ b/app/Http/Requests/V1/CondolenceExpense/StoreCondolenceExpenseRequest.php @@ -0,0 +1,49 @@ + ['nullable', 'date'], + 'expense_date' => ['nullable', 'date'], + 'partner_name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:200'], + 'category' => ['required', 'string', 'in:congratulation,condolence'], + 'has_cash' => ['nullable', 'boolean'], + 'cash_method' => ['required_if:has_cash,true', 'nullable', 'string', 'in:cash,transfer,card'], + 'cash_amount' => ['required_if:has_cash,true', 'nullable', 'integer', 'min:0'], + 'has_gift' => ['nullable', 'boolean'], + 'gift_type' => ['nullable', 'string', 'max:50'], + 'gift_amount' => ['required_if:has_gift,true', 'nullable', 'integer', 'min:0'], + 'memo' => ['nullable', 'string'], + ]; + } + + public function attributes(): array + { + return [ + 'event_date' => '경조사일자', + 'expense_date' => '지출일자', + 'partner_name' => '거래처명', + 'description' => '내역', + 'category' => '구분', + 'has_cash' => '부조금 여부', + 'cash_method' => '지출방법', + 'cash_amount' => '부조금액', + 'has_gift' => '선물 여부', + 'gift_type' => '선물종류', + 'gift_amount' => '선물금액', + 'memo' => '비고', + ]; + } +} diff --git a/app/Http/Requests/V1/CondolenceExpense/UpdateCondolenceExpenseRequest.php b/app/Http/Requests/V1/CondolenceExpense/UpdateCondolenceExpenseRequest.php new file mode 100644 index 00000000..df39da57 --- /dev/null +++ b/app/Http/Requests/V1/CondolenceExpense/UpdateCondolenceExpenseRequest.php @@ -0,0 +1,49 @@ + ['nullable', 'date'], + 'expense_date' => ['nullable', 'date'], + 'partner_name' => ['sometimes', 'required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:200'], + 'category' => ['sometimes', 'required', 'string', 'in:congratulation,condolence'], + 'has_cash' => ['nullable', 'boolean'], + 'cash_method' => ['required_if:has_cash,true', 'nullable', 'string', 'in:cash,transfer,card'], + 'cash_amount' => ['required_if:has_cash,true', 'nullable', 'integer', 'min:0'], + 'has_gift' => ['nullable', 'boolean'], + 'gift_type' => ['nullable', 'string', 'max:50'], + 'gift_amount' => ['required_if:has_gift,true', 'nullable', 'integer', 'min:0'], + 'memo' => ['nullable', 'string'], + ]; + } + + public function attributes(): array + { + return [ + 'event_date' => '경조사일자', + 'expense_date' => '지출일자', + 'partner_name' => '거래처명', + 'description' => '내역', + 'category' => '구분', + 'has_cash' => '부조금 여부', + 'cash_method' => '지출방법', + 'cash_amount' => '부조금액', + 'has_gift' => '선물 여부', + 'gift_type' => '선물종류', + 'gift_amount' => '선물금액', + 'memo' => '비고', + ]; + } +} diff --git a/app/Models/Tenants/CondolenceExpense.php b/app/Models/Tenants/CondolenceExpense.php new file mode 100644 index 00000000..a30fb810 --- /dev/null +++ b/app/Models/Tenants/CondolenceExpense.php @@ -0,0 +1,138 @@ + '축의', + self::CATEGORY_CONDOLENCE => '부조', + ]; + + // 지출방법 상수 + public const CASH_METHOD_CASH = 'cash'; + + public const CASH_METHOD_TRANSFER = 'transfer'; + + public const CASH_METHOD_CARD = 'card'; + + public const CASH_METHOD_LABELS = [ + self::CASH_METHOD_CASH => '현금', + self::CASH_METHOD_TRANSFER => '계좌이체', + self::CASH_METHOD_CARD => '카드', + ]; + + protected $fillable = [ + 'tenant_id', + 'event_date', + 'expense_date', + 'partner_name', + 'description', + 'category', + 'has_cash', + 'cash_method', + 'cash_amount', + 'has_gift', + 'gift_type', + 'gift_amount', + 'total_amount', + 'options', + 'memo', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $casts = [ + 'event_date' => 'date', + 'expense_date' => 'date', + 'has_cash' => 'boolean', + 'has_gift' => 'boolean', + 'cash_amount' => 'integer', + 'gift_amount' => 'integer', + 'total_amount' => 'integer', + 'options' => 'array', + ]; + + protected $appends = [ + 'category_label', + 'cash_method_label', + ]; + + /** + * 카테고리 라벨 + */ + public function getCategoryLabelAttribute(): string + { + return self::CATEGORY_LABELS[$this->category] ?? $this->category; + } + + /** + * 지출방법 라벨 + */ + public function getCashMethodLabelAttribute(): ?string + { + if (! $this->cash_method) { + return null; + } + + return self::CASH_METHOD_LABELS[$this->cash_method] ?? $this->cash_method; + } + + /** + * 등록자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + /** + * 카테고리 필터 스코프 + */ + public function scopeByCategory($query, string $category) + { + return $query->where('category', $category); + } + + /** + * 연도 필터 스코프 + */ + public function scopeInYear($query, int $year) + { + return $query->whereYear('event_date', $year); + } + + /** + * options 헬퍼 + */ + public function getOption(string $key, $default = null) + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } +} diff --git a/app/Services/CondolenceExpenseService.php b/app/Services/CondolenceExpenseService.php new file mode 100644 index 00000000..13f55478 --- /dev/null +++ b/app/Services/CondolenceExpenseService.php @@ -0,0 +1,189 @@ +inYear((int) $params['year']); + } + + // 카테고리 필터 + if (! empty($params['category']) && $params['category'] !== 'all') { + $query->byCategory($params['category']); + } + + // 통합 검색 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('partner_name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhere('memo', 'like', "%{$search}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'event_date'; + $sortOrder = $params['sort_order'] ?? 'desc'; + $query->orderBy($sortBy, $sortOrder)->orderByDesc('id'); + + $perPage = $params['per_page'] ?? 50; + + return $query->paginate($perPage); + } + + /** + * 상세 조회 + */ + public function show(int $id): CondolenceExpense + { + return CondolenceExpense::with('creator:id,name')->findOrFail($id); + } + + /** + * 등록 + */ + public function store(array $data): CondolenceExpense + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + return CondolenceExpense::create([ + 'tenant_id' => $tenantId, + 'event_date' => $data['event_date'] ?? null, + 'expense_date' => $data['expense_date'] ?? null, + 'partner_name' => $data['partner_name'], + 'description' => $data['description'] ?? null, + 'category' => $data['category'], + 'has_cash' => $data['has_cash'] ?? false, + 'cash_method' => ($data['has_cash'] ?? false) ? ($data['cash_method'] ?? null) : null, + 'cash_amount' => ($data['has_cash'] ?? false) ? ($data['cash_amount'] ?? 0) : 0, + 'has_gift' => $data['has_gift'] ?? false, + 'gift_type' => ($data['has_gift'] ?? false) ? ($data['gift_type'] ?? null) : null, + 'gift_amount' => ($data['has_gift'] ?? false) ? ($data['gift_amount'] ?? 0) : 0, + 'total_amount' => $this->calculateTotal($data), + 'memo' => $data['memo'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + }); + } + + /** + * 수정 + */ + public function update(int $id, array $data): CondolenceExpense + { + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $userId) { + $expense = CondolenceExpense::findOrFail($id); + + $expense->fill([ + 'event_date' => $data['event_date'] ?? $expense->event_date, + 'expense_date' => $data['expense_date'] ?? $expense->expense_date, + 'partner_name' => $data['partner_name'] ?? $expense->partner_name, + 'description' => $data['description'] ?? $expense->description, + 'category' => $data['category'] ?? $expense->category, + 'has_cash' => $data['has_cash'] ?? $expense->has_cash, + 'cash_method' => ($data['has_cash'] ?? $expense->has_cash) ? ($data['cash_method'] ?? $expense->cash_method) : null, + 'cash_amount' => ($data['has_cash'] ?? $expense->has_cash) ? ($data['cash_amount'] ?? $expense->cash_amount) : 0, + 'has_gift' => $data['has_gift'] ?? $expense->has_gift, + 'gift_type' => ($data['has_gift'] ?? $expense->has_gift) ? ($data['gift_type'] ?? $expense->gift_type) : null, + 'gift_amount' => ($data['has_gift'] ?? $expense->has_gift) ? ($data['gift_amount'] ?? $expense->gift_amount) : 0, + 'total_amount' => $this->calculateTotal(array_merge($expense->toArray(), $data)), + 'memo' => $data['memo'] ?? $expense->memo, + 'updated_by' => $userId, + ]); + + $expense->save(); + + return $expense->fresh(); + }); + } + + /** + * 삭제 (소프트) + */ + public function destroy(int $id): bool + { + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $userId) { + $expense = CondolenceExpense::findOrFail($id); + $expense->deleted_by = $userId; + $expense->save(); + $expense->delete(); + + return true; + }); + } + + /** + * 통계 조회 + */ + public function summary(array $params): array + { + $query = CondolenceExpense::query(); + + if (! empty($params['year'])) { + $query->inYear((int) $params['year']); + } + + if (! empty($params['category']) && $params['category'] !== 'all') { + $query->byCategory($params['category']); + } + + $stats = $query->selectRaw(' + COUNT(*) as total_count, + COALESCE(SUM(total_amount), 0) as total_amount, + COALESCE(SUM(cash_amount), 0) as cash_total, + COALESCE(SUM(gift_amount), 0) as gift_total, + COALESCE(SUM(CASE WHEN category = ? THEN 1 ELSE 0 END), 0) as congratulation_count, + COALESCE(SUM(CASE WHEN category = ? THEN 1 ELSE 0 END), 0) as condolence_count, + COALESCE(SUM(CASE WHEN category = ? THEN total_amount ELSE 0 END), 0) as congratulation_amount, + COALESCE(SUM(CASE WHEN category = ? THEN total_amount ELSE 0 END), 0) as condolence_amount + ', [ + CondolenceExpense::CATEGORY_CONGRATULATION, + CondolenceExpense::CATEGORY_CONDOLENCE, + CondolenceExpense::CATEGORY_CONGRATULATION, + CondolenceExpense::CATEGORY_CONDOLENCE, + ])->first(); + + return [ + 'total_count' => (int) $stats->total_count, + 'total_amount' => (int) $stats->total_amount, + 'cash_total' => (int) $stats->cash_total, + 'gift_total' => (int) $stats->gift_total, + 'congratulation_count' => (int) $stats->congratulation_count, + 'condolence_count' => (int) $stats->condolence_count, + 'congratulation_amount' => (int) $stats->congratulation_amount, + 'condolence_amount' => (int) $stats->condolence_amount, + ]; + } + + /** + * 총금액 자동 계산 + */ + private function calculateTotal(array $data): int + { + $cash = ($data['has_cash'] ?? false) ? (int) ($data['cash_amount'] ?? 0) : 0; + $gift = ($data['has_gift'] ?? false) ? (int) ($data['gift_amount'] ?? 0) : 0; + + return $cash + $gift; + } +} diff --git a/database/migrations/2026_03_19_100000_add_audit_columns_to_condolence_expenses_table.php b/database/migrations/2026_03_19_100000_add_audit_columns_to_condolence_expenses_table.php new file mode 100644 index 00000000..5ba01268 --- /dev/null +++ b/database/migrations/2026_03_19_100000_add_audit_columns_to_condolence_expenses_table.php @@ -0,0 +1,69 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->date('event_date')->nullable()->comment('경조사일자'); + $table->date('expense_date')->nullable()->comment('지출일자'); + $table->string('partner_name', 100)->comment('거래처명/대상자'); + $table->string('description', 200)->nullable()->comment('내역'); + $table->string('category', 20)->default('congratulation')->comment('구분: congratulation(축의), condolence(부조)'); + $table->boolean('has_cash')->default(false)->comment('부조금 여부'); + $table->string('cash_method', 30)->nullable()->comment('지출방법: cash, transfer, card'); + $table->integer('cash_amount')->default(0)->comment('부조금 금액'); + $table->boolean('has_gift')->default(false)->comment('선물 여부'); + $table->string('gift_type', 50)->nullable()->comment('선물 종류'); + $table->integer('gift_amount')->default(0)->comment('선물 금액'); + $table->integer('total_amount')->default(0)->comment('총금액'); + $table->json('options')->nullable(); + $table->text('memo')->nullable()->comment('비고'); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'event_date']); + $table->index(['tenant_id', 'category']); + }); + + return; + } + + // 테이블이 있으면 컬럼만 추가 + Schema::table('condolence_expenses', function (Blueprint $table) { + if (! Schema::hasColumn('condolence_expenses', 'updated_by')) { + $table->unsignedBigInteger('updated_by')->nullable()->after('created_by'); + } + if (! Schema::hasColumn('condolence_expenses', 'deleted_by')) { + $table->unsignedBigInteger('deleted_by')->nullable()->after('updated_by'); + } + }); + } + + public function down(): void + { + // 이 마이그레이션이 테이블 전체를 생성한 경우 삭제하지 않음 + // (원본 create 마이그레이션의 down에서 처리) + if (Schema::hasTable('condolence_expenses')) { + Schema::table('condolence_expenses', function (Blueprint $table) { + if (Schema::hasColumn('condolence_expenses', 'updated_by')) { + $table->dropColumn('updated_by'); + } + if (Schema::hasColumn('condolence_expenses', 'deleted_by')) { + $table->dropColumn('deleted_by'); + } + }); + } + } +}; diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 05f3d4fe..65d17a73 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -14,7 +14,6 @@ use App\Http\Controllers\Api\V1\AccountLedgerController; use App\Http\Controllers\Api\V1\AccountSubjectController; -use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\BadDebtController; use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankTransactionController; @@ -27,7 +26,9 @@ use App\Http\Controllers\Api\V1\CalendarController; use App\Http\Controllers\Api\V1\CardController; use App\Http\Controllers\Api\V1\CardTransactionController; +use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ComprehensiveAnalysisController; +use App\Http\Controllers\Api\V1\CondolenceExpenseController; use App\Http\Controllers\Api\V1\DailyReportController; use App\Http\Controllers\Api\V1\DepositController; use App\Http\Controllers\Api\V1\EntertainmentController; @@ -239,6 +240,16 @@ Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary'); Route::get('/welfare/detail', [WelfareController::class, 'detail'])->name('v1.welfare.detail'); +// Condolence Expense API (경조사비 관리) +Route::prefix('condolence-expenses')->group(function () { + Route::get('', [CondolenceExpenseController::class, 'index'])->name('v1.condolence-expenses.index'); + Route::post('', [CondolenceExpenseController::class, 'store'])->name('v1.condolence-expenses.store'); + Route::get('/summary', [CondolenceExpenseController::class, 'summary'])->name('v1.condolence-expenses.summary'); + Route::get('/{id}', [CondolenceExpenseController::class, 'show'])->whereNumber('id')->name('v1.condolence-expenses.show'); + Route::put('/{id}', [CondolenceExpenseController::class, 'update'])->whereNumber('id')->name('v1.condolence-expenses.update'); + Route::delete('/{id}', [CondolenceExpenseController::class, 'destroy'])->whereNumber('id')->name('v1.condolence-expenses.destroy'); +}); + // Plan API (요금제 관리) Route::prefix('plans')->group(function () { Route::get('', [PlanController::class, 'index'])->name('v1.plans.index');