diff --git a/app/Models/Tenants/Schedule.php b/app/Models/Tenants/Schedule.php new file mode 100644 index 0000000..f955de5 --- /dev/null +++ b/app/Models/Tenants/Schedule.php @@ -0,0 +1,224 @@ + 'date', + 'end_date' => 'date', + 'is_all_day' => 'boolean', + 'is_recurring' => 'boolean', + 'is_active' => 'boolean', + ]; + + protected $attributes = [ + 'is_all_day' => true, + 'type' => 'event', + 'is_recurring' => false, + 'is_active' => true, + ]; + + // ========================================================================= + // 일정 유형 상수 + // ========================================================================= + + public const TYPE_TAX = 'tax'; // 세금 신고 + + public const TYPE_HOLIDAY = 'holiday'; // 공휴일 + + public const TYPE_EVENT = 'event'; // 일반 이벤트 + + public const TYPE_NOTICE = 'notice'; // 공지/알림 + + public const TYPE_MEETING = 'meeting'; // 회의 + + public const TYPE_OTHER = 'other'; // 기타 + + public const TYPES = [ + self::TYPE_TAX => '세금 신고', + self::TYPE_HOLIDAY => '공휴일', + self::TYPE_EVENT => '이벤트', + self::TYPE_NOTICE => '공지', + self::TYPE_MEETING => '회의', + self::TYPE_OTHER => '기타', + ]; + + // ========================================================================= + // 반복 규칙 상수 + // ========================================================================= + + public const RECURRENCE_YEARLY = 'yearly'; // 매년 + + public const RECURRENCE_QUARTERLY = 'quarterly'; // 매분기 + + public const RECURRENCE_MONTHLY = 'monthly'; // 매월 + + public const RECURRENCE_WEEKLY = 'weekly'; // 매주 + + public const RECURRENCE_RULES = [ + self::RECURRENCE_YEARLY => '매년', + self::RECURRENCE_QUARTERLY => '매분기', + self::RECURRENCE_MONTHLY => '매월', + self::RECURRENCE_WEEKLY => '매주', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 특정 테넌트 + 글로벌 일정 조회 + */ + public function scopeForTenant(Builder $query, int $tenantId): Builder + { + return $query->where(function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->orWhereNull('tenant_id'); // 글로벌 일정 포함 + }); + } + + /** + * 글로벌 일정만 조회 (본사 등록) + */ + public function scopeGlobal(Builder $query): Builder + { + return $query->whereNull('tenant_id'); + } + + /** + * 활성 일정만 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * 특정 유형 + */ + public function scopeOfType(Builder $query, string $type): Builder + { + return $query->where('type', $type); + } + + /** + * 세금 관련 일정 + */ + public function scopeTax(Builder $query): Builder + { + return $query->where('type', self::TYPE_TAX); + } + + /** + * 기간 내 일정 (겹치는 일정 포함) + */ + public function scopeBetweenDates(Builder $query, string $startDate, string $endDate): Builder + { + return $query->where(function ($q) use ($startDate, $endDate) { + $q->where('start_date', '<=', $endDate) + ->where(function ($inner) use ($startDate) { + $inner->where('end_date', '>=', $startDate) + ->orWhereNull('end_date'); + }); + }); + } + + /** + * 특정 날짜 이후 가장 가까운 일정 + */ + public function scopeUpcoming(Builder $query, ?string $fromDate = null): Builder + { + $fromDate = $fromDate ?? now()->format('Y-m-d'); + + return $query->where('start_date', '>=', $fromDate) + ->orderBy('start_date'); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 글로벌 일정인지 확인 + */ + public function isGlobal(): bool + { + return $this->tenant_id === null; + } + + /** + * 유형 라벨 + */ + public function getTypeLabelAttribute(): string + { + return self::TYPES[$this->type] ?? $this->type; + } + + /** + * 반복 규칙 라벨 + */ + public function getRecurrenceRuleLabelAttribute(): ?string + { + if (! $this->recurrence_rule) { + return null; + } + + return self::RECURRENCE_RULES[$this->recurrence_rule] ?? $this->recurrence_rule; + } +} \ No newline at end of file diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index dfa9a66..1447525 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -5,6 +5,7 @@ use App\Models\Construction\Contract; use App\Models\Production\WorkOrder; use App\Models\Tenants\Leave; +use App\Models\Tenants\Schedule; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -15,6 +16,7 @@ * - 작업지시(WorkOrder): 생산 일정 * - 계약(Contract): 시공 일정 * - 휴가(Leave): 직원 휴가 일정 + * - 일정(Schedule): 본사 공통 일정 + 테넌트 일정 (세금 신고, 공휴일 등) */ class CalendarService extends Service { @@ -56,6 +58,13 @@ public function getSchedules( ); } + // 범용 일정 (본사 공통 + 테넌트 일정): 항상 포함 또는 'other' 필터 시 + if ($type === null || $type === 'other') { + $schedules = $schedules->merge( + $this->getGeneralSchedules($tenantId, $startDate, $endDate) + ); + } + // startDate 기준 정렬 $sortedSchedules = $schedules ->sortBy('startDate') @@ -217,4 +226,38 @@ private function getLeaveSchedules( ]; }); } + + /** + * 범용 일정 조회 (본사 공통 + 테넌트 일정) + */ + private function getGeneralSchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $schedules = Schedule::query() + ->forTenant($tenantId) + ->active() + ->betweenDates($startDate, $endDate) + ->with(['creator:id,name']) + ->orderBy('start_date') + ->limit(100) + ->get(); + + return $schedules->map(function ($schedule) { + return [ + 'id' => 'schedule_'.$schedule->id, + 'title' => $schedule->title, + 'startDate' => $schedule->start_date?->format('Y-m-d'), + 'endDate' => $schedule->end_date?->format('Y-m-d') ?? $schedule->start_date?->format('Y-m-d'), + 'startTime' => $schedule->start_time, + 'endTime' => $schedule->end_time, + 'isAllDay' => $schedule->is_all_day, + 'type' => 'other', + 'department' => null, + 'personName' => $schedule->creator?->name, + 'color' => $schedule->color, + ]; + }); + } } \ No newline at end of file diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index 4a9a74a..a924e34 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -5,10 +5,10 @@ use App\Models\BadDebts\BadDebt; use App\Models\Orders\Client; use App\Models\Orders\Order; -use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\Leave; use App\Models\Tenants\Purchase; +use App\Models\Tenants\Schedule; use App\Models\Tenants\Stock; use Carbon\Carbon; @@ -50,7 +50,7 @@ private function getOrdersStatus(int $tenantId, Carbon $today): array $count = Order::query() ->where('tenant_id', $tenantId) ->whereDate('created_at', $today) - ->where('status_code', 'confirmed') // 확정된 수주만 + ->where('status_code', Order::STATUS_CONFIRMED) // 확정된 수주만 ->count(); return [ @@ -69,7 +69,7 @@ private function getBadDebtStatus(int $tenantId): array { $count = BadDebt::query() ->where('tenant_id', $tenantId) - ->where('status', 'in_progress') // 추심 진행 중 + ->where('status', BadDebt::STATUS_COLLECTING) // 추심 진행 중 ->count(); return [ @@ -105,33 +105,32 @@ private function getSafetyStockStatus(int $tenantId): array /** * 세금 신고 현황 (부가세 신고 D-day) + * + * Schedule 테이블에서 type='tax'인 가장 가까운 일정 조회 + * - 본사(tenant_id=NULL) 등록 글로벌 일정 + 테넌트 전용 일정 모두 포함 */ private function getTaxDeadlineStatus(int $tenantId, Carbon $today): array { - // 부가세 신고 마감일 계산 (분기별: 1/25, 4/25, 7/25, 10/25) - $quarter = $today->quarter; - $deadlineMonth = match ($quarter) { - 1 => 1, // 1분기 → 1월 25일 - 2 => 4, // 2분기 → 4월 25일 - 3 => 7, // 3분기 → 7월 25일 - 4 => 10, // 4분기 → 10월 25일 - }; + // Schedule 테이블에서 가장 가까운 세금 신고 일정 조회 + $nextTaxSchedule = Schedule::query() + ->forTenant($tenantId) + ->active() + ->tax() + ->upcoming($today->format('Y-m-d')) + ->first(); - $deadlineYear = $today->year; - // 1분기 마감일이 지났으면 다음 분기 마감일 - if ($today->month > $deadlineMonth || ($today->month == $deadlineMonth && $today->day > 25)) { - $deadlineMonth = match ($quarter) { - 1 => 4, - 2 => 7, - 3 => 10, - 4 => 1, // 다음 해 - }; - if ($deadlineMonth == 1) { - $deadlineYear++; - } + if (! $nextTaxSchedule) { + // 등록된 세금 일정이 없는 경우 + return [ + 'id' => 'tax_deadline', + 'label' => __('message.status_board.tax_deadline'), + 'count' => __('message.status_board.tax_no_schedule'), + 'path' => '/accounting/tax', + 'isHighlighted' => false, + ]; } - $deadline = Carbon::create($deadlineYear, $deadlineMonth, 25); + $deadline = $nextTaxSchedule->start_date; $daysUntil = $today->diffInDays($deadline, false); $countText = $daysUntil >= 0 @@ -194,7 +193,7 @@ private function getPurchaseStatus(int $tenantId): array { $count = Purchase::query() ->where('tenant_id', $tenantId) - ->where('status', 'pending') // 대기 중인 발주 + ->where('status', 'draft') // 대기 중인 발주 (임시저장 상태) ->count(); return [ @@ -228,4 +227,4 @@ private function getApprovalStatus(int $tenantId, int $userId): array 'isHighlighted' => $count > 0, ]; } -} \ No newline at end of file +} diff --git a/database/migrations/2026_01_21_100000_create_schedules_table.php b/database/migrations/2026_01_21_100000_create_schedules_table.php new file mode 100644 index 0000000..1ecd44b --- /dev/null +++ b/database/migrations/2026_01_21_100000_create_schedules_table.php @@ -0,0 +1,50 @@ +id(); + $table->unsignedBigInteger('tenant_id')->nullable()->comment('NULL이면 전체 테넌트 공통 (본사 등록)'); + $table->string('title', 200)->comment('일정 제목'); + $table->text('description')->nullable()->comment('일정 설명'); + $table->date('start_date')->comment('시작일'); + $table->date('end_date')->nullable()->comment('종료일 (NULL이면 당일)'); + $table->time('start_time')->nullable()->comment('시작 시간'); + $table->time('end_time')->nullable()->comment('종료 시간'); + $table->boolean('is_all_day')->default(true)->comment('종일 여부'); + $table->string('type', 50)->default('event')->comment('일정 유형: tax, holiday, event, notice, meeting, other'); + $table->boolean('is_recurring')->default(false)->comment('반복 일정 여부'); + $table->string('recurrence_rule', 50)->nullable()->comment('반복 규칙: yearly, quarterly, monthly, weekly'); + $table->string('color', 20)->nullable()->comment('캘린더 표시 색상'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id'); + $table->index('type'); + $table->index('start_date'); + $table->index(['tenant_id', 'type', 'start_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('schedules'); + } +}; \ No newline at end of file diff --git a/database/seeders/TaxScheduleSeeder.php b/database/seeders/TaxScheduleSeeder.php new file mode 100644 index 0000000..185e643 --- /dev/null +++ b/database/seeders/TaxScheduleSeeder.php @@ -0,0 +1,89 @@ +year; + + $taxDeadlines = [ + [ + 'title' => "{$year}년 1분기 부가세 신고", + 'description' => '전년도 4분기분 부가세 예정신고 마감일', + 'start_date' => "{$year}-01-25", + 'type' => Schedule::TYPE_TAX, + 'is_recurring' => true, + 'recurrence_rule' => Schedule::RECURRENCE_YEARLY, + 'color' => '#EF4444', // red-500 + ], + [ + 'title' => "{$year}년 2분기 부가세 신고", + 'description' => '1분기분 부가세 예정신고 마감일', + 'start_date' => "{$year}-04-25", + 'type' => Schedule::TYPE_TAX, + 'is_recurring' => true, + 'recurrence_rule' => Schedule::RECURRENCE_YEARLY, + 'color' => '#EF4444', // red-500 + ], + [ + 'title' => "{$year}년 3분기 부가세 신고", + 'description' => '2분기분 부가세 예정신고 마감일', + 'start_date' => "{$year}-07-25", + 'type' => Schedule::TYPE_TAX, + 'is_recurring' => true, + 'recurrence_rule' => Schedule::RECURRENCE_YEARLY, + 'color' => '#EF4444', // red-500 + ], + [ + 'title' => "{$year}년 4분기 부가세 신고", + 'description' => '3분기분 부가세 예정신고 마감일', + 'start_date' => "{$year}-10-25", + 'type' => Schedule::TYPE_TAX, + 'is_recurring' => true, + 'recurrence_rule' => Schedule::RECURRENCE_YEARLY, + 'color' => '#EF4444', // red-500 + ], + ]; + + foreach ($taxDeadlines as $deadline) { + Schedule::firstOrCreate( + [ + 'tenant_id' => null, // 글로벌 일정 + 'title' => $deadline['title'], + 'start_date' => $deadline['start_date'], + ], + [ + 'description' => $deadline['description'], + 'type' => $deadline['type'], + 'is_all_day' => true, + 'is_recurring' => $deadline['is_recurring'], + 'recurrence_rule' => $deadline['recurrence_rule'], + 'color' => $deadline['color'], + 'is_active' => true, + ] + ); + } + + $this->command->info("부가세 신고 마감일 {$year}년 일정이 등록되었습니다."); + } +} \ No newline at end of file diff --git a/lang/ko/message.php b/lang/ko/message.php index 84666eb..d92cc40 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -490,6 +490,7 @@ 'tax_deadline' => '세금 신고', 'tax_d_day' => '부가세 신고 D-:days', 'tax_overdue' => '부가세 신고 :days일 초과', + 'tax_no_schedule' => '일정 없음', 'new_clients' => '신규 업체 등록', 'leaves' => '연차', 'purchases' => '발주', @@ -505,20 +506,30 @@ 'approval_request' => '결재 요청', // 이슈 내용 메시지 + 'order_register' => ':client 신규 수주 :amount원 등록', + 'collection_issue' => ':client 미수금 :amount원 연체 :days일 추심중', + 'safety_stock_alert' => ':item 안전재고 미달', + 'expense_pending' => ':title (:amount원) 승인대기', + 'tax_vat_deadline' => ':quarter분기 부가세 신고 D-:days', + 'approval_pending' => ':title 승인 요청 (:drafter)', + 'new_client' => '신규 거래처 :name 등록 완료', + + // 하위 호환성 (deprecated) 'order_success' => ':client 신규 수주 :amount원 확정', 'receivable_overdue' => ':client 미수금 :amount원 연체 :days일', 'stock_below_safety' => ':item 재고 부족 경고', 'expense_pending_multiple' => ':title 외 :count건 (:amount원)', 'expense_pending_single' => ':title (:amount원)', - 'tax_vat_deadline' => ':quarter분기 부가세 신고 D-:days', - 'approval_pending' => ':title 승인 요청 (:drafter)', - 'new_client' => '신규 거래처 :name 등록 완료', // 상대 시간 'time_minutes_ago' => ':minutes분 전', 'time_hours_ago' => ':hours시간 전', 'time_yesterday' => '어제', 'time_days_ago' => ':days일 전', + + // 읽음 처리 + 'marked_as_read' => '알림을 읽음 처리했습니다.', + 'all_marked_as_read' => '모든 알림을 읽음 처리했습니다.', ], // CEO 대시보드 캘린더