- completed_amount가 null/0인 경우 amount로 fallback 처리 - 수입/지출 모두 동일하게 적용 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
11 KiB
PHP
362 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Finance\FundSchedule;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class FundScheduleService
|
|
{
|
|
// =========================================================================
|
|
// 일정 목록 조회
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 일정 목록 조회 (페이지네이션)
|
|
*/
|
|
public function getSchedules(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
|
{
|
|
$query = FundSchedule::query();
|
|
|
|
// Soft Delete 필터
|
|
if (isset($filters['trashed'])) {
|
|
if ($filters['trashed'] === 'only') {
|
|
$query->onlyTrashed();
|
|
} elseif ($filters['trashed'] === 'with') {
|
|
$query->withTrashed();
|
|
}
|
|
}
|
|
|
|
// 검색 필터
|
|
if (! empty($filters['search'])) {
|
|
$search = $filters['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('title', 'like', "%{$search}%")
|
|
->orWhere('counterparty', 'like', "%{$search}%")
|
|
->orWhere('description', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 일정 유형 필터
|
|
if (! empty($filters['schedule_type'])) {
|
|
$query->where('schedule_type', $filters['schedule_type']);
|
|
}
|
|
|
|
// 상태 필터
|
|
if (! empty($filters['status'])) {
|
|
$query->where('status', $filters['status']);
|
|
}
|
|
|
|
// 날짜 범위 필터
|
|
if (! empty($filters['start_date']) && ! empty($filters['end_date'])) {
|
|
$query->dateBetween($filters['start_date'], $filters['end_date']);
|
|
} elseif (! empty($filters['start_date'])) {
|
|
$query->where('scheduled_date', '>=', $filters['start_date']);
|
|
} elseif (! empty($filters['end_date'])) {
|
|
$query->where('scheduled_date', '<=', $filters['end_date']);
|
|
}
|
|
|
|
// 월별 필터
|
|
if (! empty($filters['year']) && ! empty($filters['month'])) {
|
|
$query->forMonth((int) $filters['year'], (int) $filters['month']);
|
|
}
|
|
|
|
// 카테고리 필터
|
|
if (! empty($filters['category'])) {
|
|
$query->where('category', $filters['category']);
|
|
}
|
|
|
|
return $query
|
|
->with('bankAccount:id,bank_name,account_number')
|
|
->orderBy('scheduled_date')
|
|
->orderBy('id')
|
|
->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 월별 일정 조회 (캘린더용)
|
|
*/
|
|
public function getSchedulesForMonth(int $year, int $month): Collection
|
|
{
|
|
return FundSchedule::forMonth($year, $month)
|
|
->with('bankAccount:id,bank_name,account_number')
|
|
->ordered()
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 특정 날짜의 일정 조회
|
|
*/
|
|
public function getSchedulesForDate(string $date): Collection
|
|
{
|
|
return FundSchedule::where('scheduled_date', $date)
|
|
->with('bankAccount:id,bank_name,account_number')
|
|
->ordered()
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 예정된 일정 조회 (향후 N일)
|
|
*/
|
|
public function getUpcomingSchedules(int $days = 30): Collection
|
|
{
|
|
$startDate = now()->toDateString();
|
|
$endDate = now()->addDays($days)->toDateString();
|
|
|
|
return FundSchedule::pending()
|
|
->dateBetween($startDate, $endDate)
|
|
->with('bankAccount:id,bank_name,account_number')
|
|
->ordered()
|
|
->get();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 일정 CRUD
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 일정 상세 조회
|
|
*/
|
|
public function getScheduleById(int $id, bool $withTrashed = false): ?FundSchedule
|
|
{
|
|
$query = FundSchedule::query();
|
|
|
|
if ($withTrashed) {
|
|
$query->withTrashed();
|
|
}
|
|
|
|
return $query->with('bankAccount')->find($id);
|
|
}
|
|
|
|
/**
|
|
* 일정 생성
|
|
*/
|
|
public function createSchedule(array $data): FundSchedule
|
|
{
|
|
$data['created_by'] = auth()->id();
|
|
$data['tenant_id'] = $data['tenant_id'] ?? session('selected_tenant_id') ?? auth()->user()?->tenant_id;
|
|
|
|
return FundSchedule::create($data);
|
|
}
|
|
|
|
/**
|
|
* 일정 수정
|
|
*/
|
|
public function updateSchedule(FundSchedule $schedule, array $data): FundSchedule
|
|
{
|
|
$data['updated_by'] = auth()->id();
|
|
|
|
$schedule->update($data);
|
|
|
|
return $schedule->fresh();
|
|
}
|
|
|
|
/**
|
|
* 일정 삭제 (Soft Delete)
|
|
*/
|
|
public function deleteSchedule(FundSchedule $schedule): bool
|
|
{
|
|
$schedule->deleted_by = auth()->id();
|
|
$schedule->save();
|
|
|
|
return $schedule->delete();
|
|
}
|
|
|
|
/**
|
|
* 일정 복원
|
|
*/
|
|
public function restoreSchedule(FundSchedule $schedule): bool
|
|
{
|
|
$schedule->deleted_by = null;
|
|
|
|
return $schedule->restore();
|
|
}
|
|
|
|
/**
|
|
* 일정 영구 삭제
|
|
*/
|
|
public function forceDeleteSchedule(FundSchedule $schedule): bool
|
|
{
|
|
return $schedule->forceDelete();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 상태 변경
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 완료 처리
|
|
*/
|
|
public function markAsCompleted(FundSchedule $schedule, ?float $actualAmount = null, ?string $completedDate = null): FundSchedule
|
|
{
|
|
$schedule->markAsCompleted($actualAmount, $completedDate);
|
|
|
|
return $schedule->fresh();
|
|
}
|
|
|
|
/**
|
|
* 취소 처리
|
|
*/
|
|
public function markAsCancelled(FundSchedule $schedule): FundSchedule
|
|
{
|
|
$schedule->markAsCancelled();
|
|
|
|
return $schedule->fresh();
|
|
}
|
|
|
|
/**
|
|
* 상태 변경
|
|
*/
|
|
public function updateStatus(FundSchedule $schedule, string $status): FundSchedule
|
|
{
|
|
$schedule->update([
|
|
'status' => $status,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
return $schedule->fresh();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 월별 복사
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 원본 월의 일정을 대상 월로 복사
|
|
*/
|
|
public function copySchedulesToMonth(int $sourceYear, int $sourceMonth, int $targetYear, int $targetMonth): int
|
|
{
|
|
$schedules = $this->getSchedulesForMonth($sourceYear, $sourceMonth);
|
|
|
|
if ($schedules->isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
$targetLastDay = cal_days_in_month(CAL_GREGORIAN, $targetMonth, $targetYear);
|
|
$copiedCount = 0;
|
|
|
|
foreach ($schedules as $schedule) {
|
|
$sourceDay = $schedule->scheduled_date->day;
|
|
$targetDay = min($sourceDay, $targetLastDay);
|
|
|
|
$targetDate = sprintf('%04d-%02d-%02d', $targetYear, $targetMonth, $targetDay);
|
|
|
|
$this->createSchedule([
|
|
'title' => $schedule->title,
|
|
'description' => $schedule->description,
|
|
'schedule_type' => $schedule->schedule_type,
|
|
'scheduled_date' => $targetDate,
|
|
'amount' => $schedule->amount,
|
|
'currency' => $schedule->currency,
|
|
'related_bank_account_id' => $schedule->related_bank_account_id,
|
|
'counterparty' => $schedule->counterparty,
|
|
'category' => $schedule->category,
|
|
'status' => FundSchedule::STATUS_PENDING,
|
|
'is_recurring' => $schedule->is_recurring,
|
|
'recurrence_rule' => $schedule->recurrence_rule,
|
|
'recurrence_end_date' => $schedule->recurrence_end_date,
|
|
'memo' => $schedule->memo,
|
|
]);
|
|
|
|
$copiedCount++;
|
|
}
|
|
|
|
return $copiedCount;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 일괄 작업
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 일괄 삭제
|
|
*/
|
|
public function bulkDelete(array $ids): int
|
|
{
|
|
return FundSchedule::whereIn('id', $ids)
|
|
->update([
|
|
'deleted_by' => auth()->id(),
|
|
'deleted_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 일괄 상태 변경
|
|
*/
|
|
public function bulkUpdateStatus(array $ids, string $status): int
|
|
{
|
|
return FundSchedule::whereIn('id', $ids)
|
|
->update([
|
|
'status' => $status,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 요약 및 통계
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 월별 요약 통계
|
|
*/
|
|
public function getMonthlySummary(int $year, int $month): array
|
|
{
|
|
$schedules = FundSchedule::forMonth($year, $month)->get();
|
|
|
|
$incomeSchedules = $schedules->where('schedule_type', FundSchedule::TYPE_INCOME);
|
|
$expenseSchedules = $schedules->where('schedule_type', FundSchedule::TYPE_EXPENSE);
|
|
|
|
return [
|
|
'year' => $year,
|
|
'month' => $month,
|
|
'total_count' => $schedules->count(),
|
|
'income' => [
|
|
'count' => $incomeSchedules->count(),
|
|
'total' => $incomeSchedules->sum('amount'),
|
|
'pending' => $incomeSchedules->where('status', FundSchedule::STATUS_PENDING)->sum('amount'),
|
|
'completed' => $incomeSchedules->where('status', FundSchedule::STATUS_COMPLETED)->sum(fn($s) => $s->completed_amount ?: $s->amount),
|
|
],
|
|
'expense' => [
|
|
'count' => $expenseSchedules->count(),
|
|
'total' => $expenseSchedules->sum('amount'),
|
|
'pending' => $expenseSchedules->where('status', FundSchedule::STATUS_PENDING)->sum('amount'),
|
|
'completed' => $expenseSchedules->where('status', FundSchedule::STATUS_COMPLETED)->sum(fn($s) => $s->completed_amount ?: $s->amount),
|
|
],
|
|
'net' => $incomeSchedules->sum('amount') - $expenseSchedules->sum('amount'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 캘린더용 데이터 구조화
|
|
*/
|
|
public function getCalendarData(int $year, int $month): array
|
|
{
|
|
$schedules = $this->getSchedulesForMonth($year, $month);
|
|
|
|
// 날짜별로 그룹화
|
|
$groupedByDate = $schedules->groupBy(function ($schedule) {
|
|
return $schedule->scheduled_date->format('Y-m-d');
|
|
});
|
|
|
|
return $groupedByDate->toArray();
|
|
}
|
|
|
|
/**
|
|
* 전체 요약 통계
|
|
*/
|
|
public function getSummary(): array
|
|
{
|
|
$pending = FundSchedule::pending()->get();
|
|
|
|
return [
|
|
'pending_count' => $pending->count(),
|
|
'pending_income' => $pending->where('schedule_type', FundSchedule::TYPE_INCOME)->sum('amount'),
|
|
'pending_expense' => $pending->where('schedule_type', FundSchedule::TYPE_EXPENSE)->sum('amount'),
|
|
'upcoming_7days' => FundSchedule::pending()
|
|
->dateBetween(now()->toDateString(), now()->addDays(7)->toDateString())
|
|
->count(),
|
|
];
|
|
}
|
|
}
|