feat: 급여 관리 API 및 더미 시더 확장

- 급여 관리 API 추가 (SalaryController, SalaryService, Salary 모델)
  - 급여 목록/상세/등록/수정/삭제
  - 상태 변경 (scheduled/completed)
  - 일괄 상태 변경
  - 통계 조회
- 더미 시더 확장
  - 근태, 휴가, 부서, 사용자 시더 추가
  - 기존 시더 tenant_id/created_by/updated_by 필드 추가
- 부서 서비스 개선 (tree 조회 기능 추가)
- 카드 서비스 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-25 03:48:32 +09:00
parent 0508282e58
commit 638e87b05d
35 changed files with 2057 additions and 24 deletions

View File

@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('salaries', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('employee_id')->comment('직원 ID');
$table->unsignedSmallInteger('year')->comment('년도');
$table->unsignedTinyInteger('month')->comment('월');
// 급여 금액
$table->decimal('base_salary', 15, 2)->default(0)->comment('기본급');
$table->decimal('total_allowance', 15, 2)->default(0)->comment('총 수당');
$table->decimal('total_overtime', 15, 2)->default(0)->comment('초과근무수당');
$table->decimal('total_bonus', 15, 2)->default(0)->comment('상여');
$table->decimal('total_deduction', 15, 2)->default(0)->comment('총 공제');
$table->decimal('net_payment', 15, 2)->default(0)->comment('실지급액');
// 상세 내역 (JSON)
$table->json('allowance_details')->nullable()->comment('수당 상세 내역');
$table->json('deduction_details')->nullable()->comment('공제 상세 내역');
// 지급 정보
$table->date('payment_date')->nullable()->comment('지급일');
$table->enum('status', ['scheduled', 'completed'])->default('scheduled')->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('employee_id');
$table->index(['year', 'month']);
$table->index('status');
$table->unique(['tenant_id', 'employee_id', 'year', 'month'], 'unique_salary_per_employee_month');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('salaries');
}
};

View File

@@ -0,0 +1,312 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ApprovalTestDataSeeder extends Seeder
{
/**
* 결재 시스템 테스트 데이터 시더
* - 기안함: 15건 (draft 5건, pending 10건)
* - 결재함: 15건 (결재 대기 상태)
* - 참조함: 10건 (열람 대기 상태)
*/
public function run(): void
{
$tenantId = 1;
$now = Carbon::now();
// 사용자 ID 가져오기
$users = DB::table('users')->pluck('id')->toArray();
if (count($users) < 3) {
$this->command->error('최소 3명의 사용자가 필요합니다.');
return;
}
$mainUser = $users[0]; // 기안자 겸 참조 대상
$approver1 = $users[1] ?? $users[0];
$approver2 = $users[2] ?? $users[0];
// 1. 결재 양식 생성
$this->command->info('결재 양식 생성 중...');
$forms = $this->createApprovalForms($tenantId, $mainUser, $now);
// 2. 결재 문서 생성
$this->command->info('결재 문서 생성 중...');
$this->createApprovals($tenantId, $forms, $mainUser, $approver1, $approver2, $now);
$this->command->info('✅ 결재 테스트 데이터 생성 완료!');
$this->command->info(' - 기안함: 15건');
$this->command->info(' - 결재함: 15건');
$this->command->info(' - 참조함: 10건');
}
private function createApprovalForms(int $tenantId, int $userId, Carbon $now): array
{
$forms = [
[
'tenant_id' => $tenantId,
'name' => '품의서',
'code' => 'proposal',
'category' => '일반',
'template' => json_encode([
'fields' => [
['name' => 'title', 'type' => 'text', 'label' => '제목', 'required' => true],
['name' => 'vendor', 'type' => 'text', 'label' => '거래처', 'required' => false],
['name' => 'description', 'type' => 'textarea', 'label' => '내용', 'required' => true],
['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true],
['name' => 'estimatedCost', 'type' => 'number', 'label' => '예상비용', 'required' => false],
]
]),
'is_active' => true,
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
],
[
'tenant_id' => $tenantId,
'name' => '지출결의서',
'code' => 'expenseReport',
'category' => '경비',
'template' => json_encode([
'fields' => [
['name' => 'requestDate', 'type' => 'date', 'label' => '신청일', 'required' => true],
['name' => 'paymentDate', 'type' => 'date', 'label' => '지급일', 'required' => true],
['name' => 'items', 'type' => 'array', 'label' => '지출항목', 'required' => true],
['name' => 'totalAmount', 'type' => 'number', 'label' => '총액', 'required' => true],
]
]),
'is_active' => true,
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
],
[
'tenant_id' => $tenantId,
'name' => '비용견적서',
'code' => 'expenseEstimate',
'category' => '경비',
'template' => json_encode([
'fields' => [
['name' => 'items', 'type' => 'array', 'label' => '비용항목', 'required' => true],
['name' => 'totalExpense', 'type' => 'number', 'label' => '총지출', 'required' => true],
['name' => 'accountBalance', 'type' => 'number', 'label' => '계좌잔액', 'required' => true],
]
]),
'is_active' => true,
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
],
];
$formIds = [];
foreach ($forms as $form) {
// 기존 양식 확인
$existing = DB::table('approval_forms')
->where('tenant_id', $tenantId)
->where('code', $form['code'])
->first();
if ($existing) {
$formIds[$form['code']] = $existing->id;
} else {
$formIds[$form['code']] = DB::table('approval_forms')->insertGetId($form);
}
}
return $formIds;
}
private function createApprovals(
int $tenantId,
array $forms,
int $mainUser,
int $approver1,
int $approver2,
Carbon $now
): void {
$proposalTitles = [
'신규 장비 구매 품의', '사무용품 구매 요청', '소프트웨어 라이선스 갱신',
'출장 경비 지원 요청', '교육 프로그램 신청', '복지시설 개선 제안',
'마케팅 예산 증액 품의', '시스템 업그레이드 제안', '인력 충원 요청',
'사무실 이전 품의', '연구개발 예산 신청', '고객 세미나 개최 품의',
'협력업체 계약 갱신', '보안 시스템 도입 품의', '업무 차량 구매 요청',
];
$expenseItems = [
'교통비', '식비', '숙박비', '소모품비', '통신비', '유류비', '접대비', '회의비'
];
$vendors = [
'삼성전자', 'LG전자', 'SK하이닉스', '현대자동차', '네이버', '카카오',
'쿠팡', '배달의민족', '토스', '당근마켓'
];
$docNumber = 1;
// 기안함용 문서 15건 (mainUser가 기안자)
for ($i = 0; $i < 15; $i++) {
$status = $i < 5 ? 'draft' : 'pending';
$formCode = ['proposal', 'expenseReport', 'expenseEstimate'][$i % 3];
$formId = $forms[$formCode];
$content = $this->generateContent($formCode, $proposalTitles[$i], $vendors, $expenseItems);
$approvalId = DB::table('approvals')->insertGetId([
'tenant_id' => $tenantId,
'document_number' => sprintf('DOC-%s-%04d', $now->format('Ymd'), $docNumber++),
'form_id' => $formId,
'title' => $proposalTitles[$i],
'content' => json_encode($content),
'status' => $status,
'drafter_id' => $mainUser,
'drafted_at' => $status === 'pending' ? $now->copy()->subDays(rand(1, 10)) : null,
'current_step' => $status === 'pending' ? 1 : 0,
'created_by' => $mainUser,
'created_at' => $now->copy()->subDays(rand(1, 15)),
'updated_at' => $now,
]);
// 결재선 추가 (pending 상태인 경우)
if ($status === 'pending') {
// 결재자 1 (approver1이 결재 대기)
DB::table('approval_steps')->insert([
'approval_id' => $approvalId,
'step_order' => 1,
'step_type' => 'approval',
'approver_id' => $approver1,
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
]);
// 결재자 2
DB::table('approval_steps')->insert([
'approval_id' => $approvalId,
'step_order' => 2,
'step_type' => 'approval',
'approver_id' => $approver2,
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
]);
// 참조 (mainUser에게 참조)
if ($i < 10) {
DB::table('approval_steps')->insert([
'approval_id' => $approvalId,
'step_order' => 3,
'step_type' => 'reference',
'approver_id' => $mainUser,
'status' => 'pending',
'is_read' => false,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
// 결재함용 추가 문서 (approver1/approver2가 기안, mainUser가 결재자)
for ($i = 0; $i < 5; $i++) {
$formCode = ['proposal', 'expenseReport'][$i % 2];
$formId = $forms[$formCode];
$drafter = $i < 3 ? $approver1 : $approver2;
$title = '추가 결재 요청 문서 ' . ($i + 1);
$content = $this->generateContent($formCode, $title, $vendors, $expenseItems);
$approvalId = DB::table('approvals')->insertGetId([
'tenant_id' => $tenantId,
'document_number' => sprintf('DOC-%s-%04d', $now->format('Ymd'), $docNumber++),
'form_id' => $formId,
'title' => $title,
'content' => json_encode($content),
'status' => 'pending',
'drafter_id' => $drafter,
'drafted_at' => $now->copy()->subDays(rand(1, 5)),
'current_step' => 1,
'created_by' => $drafter,
'created_at' => $now->copy()->subDays(rand(1, 10)),
'updated_at' => $now,
]);
// mainUser가 결재자
DB::table('approval_steps')->insert([
'approval_id' => $approvalId,
'step_order' => 1,
'step_type' => 'approval',
'approver_id' => $mainUser,
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
]);
}
}
private function generateContent(string $formCode, string $title, array $vendors, array $expenseItems): array
{
switch ($formCode) {
case 'proposal':
return [
'title' => $title,
'vendor' => $vendors[array_rand($vendors)],
'vendorPaymentDate' => Carbon::now()->addDays(rand(7, 30))->format('Y-m-d'),
'description' => $title . '에 대한 상세 설명입니다. 업무 효율성 향상과 비용 절감을 위해 필요합니다.',
'reason' => '업무 효율성 향상 및 경쟁력 강화를 위해 필수적으로 진행해야 합니다.',
'estimatedCost' => rand(100, 5000) * 10000,
];
case 'expenseReport':
$items = [];
$total = 0;
for ($j = 0; $j < rand(2, 5); $j++) {
$amount = rand(10, 200) * 1000;
$total += $amount;
$items[] = [
'id' => (string) ($j + 1),
'description' => $expenseItems[array_rand($expenseItems)],
'amount' => $amount,
'note' => '업무 관련 지출',
];
}
return [
'requestDate' => Carbon::now()->subDays(rand(1, 7))->format('Y-m-d'),
'paymentDate' => Carbon::now()->addDays(rand(1, 14))->format('Y-m-d'),
'items' => $items,
'cardId' => 'CARD-' . rand(1000, 9999),
'totalAmount' => $total,
];
case 'expenseEstimate':
$items = [];
$total = 0;
for ($j = 0; $j < rand(3, 8); $j++) {
$amount = rand(50, 500) * 10000;
$total += $amount;
$items[] = [
'id' => (string) ($j + 1),
'expectedPaymentDate' => Carbon::now()->addDays(rand(1, 60))->format('Y-m-d'),
'category' => $expenseItems[array_rand($expenseItems)],
'amount' => $amount,
'vendor' => $vendors[array_rand($vendors)],
'memo' => '예정 지출',
'checked' => false,
];
}
return [
'items' => $items,
'totalExpense' => $total,
'accountBalance' => rand(5000, 20000) * 10000,
'finalDifference' => rand(5000, 20000) * 10000 - $total,
];
default:
return [];
}
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace Database\Seeders\Dummy;
use App\Models\Tenants\Attendance;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DummyAttendanceSeeder extends Seeder
{
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 테넌트 소속 사용자 조회
$userIds = DB::table('user_tenants')
->where('tenant_id', $tenantId)
->pluck('user_id')
->toArray();
if (empty($userIds)) {
$this->command->warn(' ⚠ attendances: 사용자가 없습니다');
return;
}
// 최근 30일간의 근태 데이터 생성
$startDate = now()->subDays(30);
$endDate = now();
$count = 0;
// 상태 분포 (enum: onTime, late, absent, vacation, businessTrip, fieldWork, overtime, remote)
$statuses = [
'onTime' => 60, // 정시 출근 60%
'late' => 10, // 지각 10%
'vacation' => 10, // 휴가 10%
'absent' => 5, // 결근 5%
'businessTrip' => 5, // 출장 5%
'remote' => 5, // 재택 5%
'overtime' => 5, // 초과근무 5%
];
foreach ($userIds as $uId) {
$currentDate = clone $startDate;
while ($currentDate <= $endDate) {
// 주말 제외
if ($currentDate->isWeekend()) {
$currentDate->addDay();
continue;
}
$baseDate = $currentDate->format('Y-m-d');
// 이미 존재하는지 확인
$exists = Attendance::where('tenant_id', $tenantId)
->where('user_id', $uId)
->where('base_date', $baseDate)
->exists();
if ($exists) {
$currentDate->addDay();
continue;
}
// 상태 결정 (가중치 기반 랜덤)
$status = $this->getRandomStatus($statuses);
// 출퇴근 시간 생성
$jsonDetails = $this->generateTimeDetails($status);
Attendance::create([
'tenant_id' => $tenantId,
'user_id' => $uId,
'base_date' => $baseDate,
'status' => $status,
'json_details' => $jsonDetails,
'remarks' => $this->getRemarks($status),
'created_by' => $userId,
]);
$count++;
$currentDate->addDay();
}
}
$this->command->info(' ✓ attendances: ' . $count . '건 생성');
}
private function getRandomStatus(array $weights): string
{
$total = array_sum($weights);
$rand = rand(1, $total);
$cumulative = 0;
foreach ($weights as $status => $weight) {
$cumulative += $weight;
if ($rand <= $cumulative) {
return $status;
}
}
return 'normal';
}
private function generateTimeDetails(string $status): array
{
$checkIn = null;
$checkOut = null;
$workMinutes = 0;
$lateMinutes = 0;
$earlyLeaveMinutes = 0;
$overtimeMinutes = 0;
$standardStart = 9 * 60; // 09:00 in minutes
$standardEnd = 18 * 60; // 18:00 in minutes
$breakMinutes = 60;
switch ($status) {
case 'onTime':
$startVariance = rand(-10, 10); // ±10분
$endVariance = rand(-5, 30); // -5분 ~ +30분
$checkIn = sprintf('%02d:%02d:00', 9, max(0, $startVariance));
$checkOut = sprintf('%02d:%02d:00', 18 + intdiv($endVariance, 60), abs($endVariance) % 60);
$workMinutes = ($standardEnd - $standardStart - $breakMinutes) + $endVariance;
break;
case 'late':
$lateMinutes = rand(10, 60); // 10분 ~ 1시간 지각
$checkIn = sprintf('%02d:%02d:00', 9 + intdiv($lateMinutes, 60), $lateMinutes % 60);
$checkOut = '18:00:00';
$workMinutes = ($standardEnd - $standardStart - $breakMinutes) - $lateMinutes;
break;
case 'overtime':
$checkIn = '09:00:00';
$overtimeMinutes = rand(60, 180); // 1시간 ~ 3시간 초과근무
$endTime = $standardEnd + $overtimeMinutes;
$checkOut = sprintf('%02d:%02d:00', intdiv($endTime, 60), $endTime % 60);
$workMinutes = ($standardEnd - $standardStart - $breakMinutes) + $overtimeMinutes;
break;
case 'remote':
$checkIn = sprintf('%02d:%02d:00', 9, rand(0, 10));
$checkOut = sprintf('%02d:%02d:00', 18, rand(0, 30));
$workMinutes = ($standardEnd - $standardStart - $breakMinutes);
break;
case 'businessTrip':
case 'fieldWork':
$checkIn = sprintf('%02d:%02d:00', 8, rand(0, 30)); // 출장은 일찍 시작
$checkOut = sprintf('%02d:%02d:00', 19, rand(0, 60)); // 늦게 종료
$workMinutes = 10 * 60 - $breakMinutes; // 10시간 근무
break;
case 'vacation':
case 'absent':
// 출퇴근 기록 없음
break;
}
return [
'check_in' => $checkIn,
'check_out' => $checkOut,
'work_minutes' => max(0, $workMinutes),
'late_minutes' => $lateMinutes,
'early_leave_minutes' => $earlyLeaveMinutes,
'overtime_minutes' => $overtimeMinutes,
'vacation_type' => $status === 'vacation' ? 'annual' : null,
'gps_data' => $checkIn ? [
'check_in' => ['lat' => 37.5012 + (rand(-10, 10) / 10000), 'lng' => 127.0396 + (rand(-10, 10) / 10000)],
'check_out' => $checkOut ? ['lat' => 37.5012 + (rand(-10, 10) / 10000), 'lng' => 127.0396 + (rand(-10, 10) / 10000)] : null,
] : null,
];
}
private function getRemarks(string $status): ?string
{
return match ($status) {
'late' => '지각 - 교통 체증',
'vacation' => '연차 휴가',
'absent' => '결근',
'businessTrip' => '출장 - 외부 미팅',
'fieldWork' => '외근 - 현장 방문',
'overtime' => '초과근무 - 프로젝트 마감',
'remote' => '재택근무',
default => null,
};
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Database\Seeders\Dummy;
use App\Models\Tenants\AttendanceSetting;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
class DummyAttendanceSettingSeeder extends Seeder
{
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 설정이 있으면 스킵
$existing = AttendanceSetting::where('tenant_id', $tenantId)->first();
if ($existing) {
$this->command->info(' ⚠ attendance_settings: 이미 존재 (스킵)');
return;
}
AttendanceSetting::create([
'tenant_id' => $tenantId,
'use_gps' => true,
'allowed_radius' => 500, // 500m
'hq_address' => '서울시 강남구 테헤란로 123',
'hq_latitude' => 37.5012,
'hq_longitude' => 127.0396,
]);
$this->command->info(' ✓ attendance_settings: 1건 생성');
}
}

View File

@@ -14,6 +14,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = BadDebt::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ bad_debts: 이미 ' . $existing . '건 존재 (스킵)');
return;
}
// SALES 또는 BOTH 타입의 거래처 조회
$clients = Client::where('tenant_id', $tenantId)
->whereIn('client_type', ['SALES', 'BOTH'])

View File

@@ -13,6 +13,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = BankAccount::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ bank_accounts: 이미 ' . $existing . '개 존재 (스킵)');
return;
}
$accounts = [
['bank_code' => '004', 'bank_name' => 'KB국민은행', 'account_number' => '123-45-6789012', 'account_holder' => '프론트테스트', 'account_name' => '운영계좌', 'is_primary' => true],
['bank_code' => '088', 'bank_name' => '신한은행', 'account_number' => '110-123-456789', 'account_holder' => '프론트테스트', 'account_name' => '급여계좌', 'is_primary' => false],

View File

@@ -16,6 +16,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Bill::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ bills: 이미 ' . $existing . '건 존재 (스킵)');
return;
}
// 거래처 매핑
$clients = Client::where('tenant_id', $tenantId)->get()->keyBy('name');

View File

@@ -13,6 +13,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = ClientGroup::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ client_groups: 이미 ' . $existing . '개 존재 (스킵)');
return;
}
$groups = [
['group_code' => 'VIP', 'group_name' => 'VIP 고객', 'price_rate' => 0.95],
['group_code' => 'GOLD', 'group_name' => '골드 고객', 'price_rate' => 0.97],

View File

@@ -14,6 +14,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Client::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ clients: 이미 ' . $existing . '개 존재 (스킵)');
return;
}
// 그룹 ID 조회
$groups = ClientGroup::where('tenant_id', $tenantId)
->pluck('id', 'group_code')

View File

@@ -0,0 +1,133 @@
<?php
namespace Database\Seeders\Dummy;
use App\Models\Tenants\Department;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DummyDepartmentSeeder extends Seeder
{
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 부서가 있으면 스킵
$existing = Department::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ departments: 이미 ' . $existing . '개 존재 (스킵)');
return;
}
// 1레벨: 본부 (3개)
$divisions = [
['code' => 'DIV01', 'name' => '경영본부', 'description' => '경영 및 기획 업무'],
['code' => 'DIV02', 'name' => '기술본부', 'description' => '기술 개발 및 연구'],
['code' => 'DIV03', 'name' => '영업본부', 'description' => '영업 및 마케팅'],
];
// 2레벨: 부서 (본부별 2~3개씩)
$departments = [
'DIV01' => [
['code' => 'HR', 'name' => '인사팀', 'description' => '인사 및 채용 관리'],
['code' => 'FIN', 'name' => '재무팀', 'description' => '재무 및 회계 관리'],
['code' => 'ADM', 'name' => '총무팀', 'description' => '총무 및 시설 관리'],
],
'DIV02' => [
['code' => 'DEV', 'name' => '개발팀', 'description' => '소프트웨어 개발'],
['code' => 'QA', 'name' => 'QA팀', 'description' => '품질 보증 및 테스트'],
['code' => 'INFRA', 'name' => '인프라팀', 'description' => '서버 및 인프라 관리'],
],
'DIV03' => [
['code' => 'SALES', 'name' => '영업팀', 'description' => '영업 및 고객 관리'],
['code' => 'MKT', 'name' => '마케팅팀', 'description' => '마케팅 및 홍보'],
],
];
$count = 0;
$divisionIds = [];
// 본부 생성
foreach ($divisions as $index => $division) {
$dept = Department::create([
'tenant_id' => $tenantId,
'parent_id' => null,
'code' => $division['code'],
'name' => $division['name'],
'description' => $division['description'],
'is_active' => true,
'sort_order' => $index + 1,
'created_by' => $userId,
]);
$divisionIds[$division['code']] = $dept->id;
$count++;
}
// 부서 생성
foreach ($departments as $divCode => $depts) {
$parentId = $divisionIds[$divCode] ?? null;
foreach ($depts as $index => $dept) {
Department::create([
'tenant_id' => $tenantId,
'parent_id' => $parentId,
'code' => $dept['code'],
'name' => $dept['name'],
'description' => $dept['description'],
'is_active' => true,
'sort_order' => $index + 1,
'created_by' => $userId,
]);
$count++;
}
}
// 사용자-부서 연결
$this->assignUsersToDepartments($tenantId);
$this->command->info(' ✓ departments: ' . $count . '개 생성 (본부 3개, 부서 8개)');
}
private function assignUsersToDepartments(int $tenantId): void
{
// 테넌트 소속 사용자 조회
$userIds = DB::table('user_tenants')
->where('tenant_id', $tenantId)
->pluck('user_id')
->toArray();
// 부서 조회 (2레벨만)
$departments = Department::where('tenant_id', $tenantId)
->whereNotNull('parent_id')
->get();
if ($departments->isEmpty() || empty($userIds)) {
return;
}
// 사용자를 부서에 분배
foreach ($userIds as $index => $userId) {
$dept = $departments[$index % $departments->count()];
$isPrimary = ($index % $departments->count() === 0); // 첫 번째만 primary
// 이미 연결되어 있는지 확인
$exists = DB::table('department_user')
->where('user_id', $userId)
->where('department_id', $dept->id)
->exists();
if (!$exists) {
DB::table('department_user')->insert([
'tenant_id' => $tenantId,
'user_id' => $userId,
'department_id' => $dept->id,
'is_primary' => $isPrimary,
'joined_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
}

View File

@@ -15,6 +15,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Deposit::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ deposits: 이미 ' . $existing . '건 존재 (스킵)');
return;
}
// 거래처 매핑 (SALES, BOTH만)
$clients = Client::where('tenant_id', $tenantId)
->whereIn('client_type', ['SALES', 'BOTH'])

View File

@@ -0,0 +1,95 @@
<?php
namespace Database\Seeders\Dummy;
use App\Models\Tenants\LeaveGrant;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DummyLeaveGrantSeeder extends Seeder
{
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 테넌트 소속 사용자 조회
$userIds = DB::table('user_tenants')
->where('tenant_id', $tenantId)
->pluck('user_id')
->toArray();
if (empty($userIds)) {
$this->command->warn(' ⚠ leave_grants: 사용자가 없습니다');
return;
}
$year = 2025;
$count = 0;
// 각 사용자별 연차/월차 부여
foreach ($userIds as $uId) {
// 연차 부여 (1월 1일)
$existing = LeaveGrant::where('tenant_id', $tenantId)
->where('user_id', $uId)
->where('grant_type', 'annual')
->whereYear('grant_date', $year)
->exists();
if (!$existing) {
LeaveGrant::create([
'tenant_id' => $tenantId,
'user_id' => $uId,
'grant_type' => 'annual',
'grant_date' => sprintf('%04d-01-01', $year),
'grant_days' => rand(12, 20), // 12~20일
'reason' => sprintf('%d년 연차 부여', $year),
'created_by' => $userId,
]);
$count++;
}
// 월차 부여 (월별, 12건)
for ($month = 1; $month <= 12; $month++) {
$grantDate = sprintf('%04d-%02d-01', $year, $month);
$monthlyExists = LeaveGrant::where('tenant_id', $tenantId)
->where('user_id', $uId)
->where('grant_type', 'monthly')
->where('grant_date', $grantDate)
->exists();
if (!$monthlyExists) {
LeaveGrant::create([
'tenant_id' => $tenantId,
'user_id' => $uId,
'grant_type' => 'monthly',
'grant_date' => $grantDate,
'grant_days' => 1,
'reason' => sprintf('%d년 %d월 월차', $year, $month),
'created_by' => $userId,
]);
$count++;
}
}
// 포상 휴가 (일부 사용자에게 랜덤)
if (rand(1, 5) === 1) { // 20% 확률
$rewardMonth = rand(3, 11);
LeaveGrant::create([
'tenant_id' => $tenantId,
'user_id' => $uId,
'grant_type' => 'reward',
'grant_date' => sprintf('%04d-%02d-15', $year, $rewardMonth),
'grant_days' => rand(1, 3),
'reason' => '우수 사원 포상 휴가',
'created_by' => $userId,
]);
$count++;
}
}
$this->command->info(' ✓ leave_grants: ' . $count . '건 생성');
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Database\Seeders\Dummy;
use App\Models\Tenants\Leave;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DummyLeaveSeeder extends Seeder
{
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 테넌트 소속 사용자 조회
$userIds = DB::table('user_tenants')
->where('tenant_id', $tenantId)
->pluck('user_id')
->toArray();
if (empty($userIds)) {
$this->command->warn(' ⚠ leaves: 사용자가 없습니다');
return;
}
// 휴가 유형 (가중치)
$leaveTypes = [
'annual' => 50, // 연차 50%
'half_am' => 15, // 오전 반차 15%
'half_pm' => 15, // 오후 반차 15%
'sick' => 10, // 병가 10%
'family' => 5, // 경조사 5%
'other' => 5, // 기타 5%
];
// 상태 (가중치)
$statuses = [
'approved' => 60, // 승인 60%
'pending' => 25, // 대기 25%
'rejected' => 10, // 반려 10%
'cancelled' => 5, // 취소 5%
];
$count = 0;
$year = 2025;
// 사용자별로 1~4건의 휴가 생성
foreach ($userIds as $uId) {
$leaveCount = rand(1, 4);
for ($i = 0; $i < $leaveCount; $i++) {
$month = rand(1, 12);
$day = rand(1, 28);
$startDate = sprintf('%04d-%02d-%02d', $year, $month, $day);
$leaveType = $this->getRandomWeighted($leaveTypes);
$status = $this->getRandomWeighted($statuses);
// 휴가 일수 결정
if (in_array($leaveType, ['half_am', 'half_pm'])) {
$days = 0.5;
$endDate = $startDate;
} else {
$days = rand(1, 3);
$endDate = date('Y-m-d', strtotime($startDate . ' + ' . ($days - 1) . ' days'));
}
// 승인자 정보
$approvedBy = null;
$approvedAt = null;
$rejectReason = null;
if ($status === 'approved') {
$approvedBy = $userId;
$approvedAt = date('Y-m-d H:i:s', strtotime($startDate . ' - 2 days'));
} elseif ($status === 'rejected') {
$approvedBy = $userId;
$approvedAt = date('Y-m-d H:i:s', strtotime($startDate . ' - 2 days'));
$rejectReason = '업무 일정 상 불가';
}
Leave::create([
'tenant_id' => $tenantId,
'user_id' => $uId,
'leave_type' => $leaveType,
'start_date' => $startDate,
'end_date' => $endDate,
'days' => $days,
'reason' => $this->getLeaveReason($leaveType),
'status' => $status,
'approved_by' => $approvedBy,
'approved_at' => $approvedAt,
'reject_reason' => $rejectReason,
'created_by' => $uId,
]);
$count++;
}
}
$this->command->info(' ✓ leaves: ' . $count . '건 생성');
}
private function getRandomWeighted(array $weights): string
{
$total = array_sum($weights);
$rand = rand(1, $total);
$cumulative = 0;
foreach ($weights as $key => $weight) {
$cumulative += $weight;
if ($rand <= $cumulative) {
return $key;
}
}
return array_key_first($weights);
}
private function getLeaveReason(string $type): string
{
return match ($type) {
'annual' => '개인 휴가',
'half_am' => '오전 병원 방문',
'half_pm' => '오후 개인 일정',
'sick' => '건강 사유',
'family' => '가족 행사',
'maternity' => '출산 휴가',
'parental' => '육아 휴직',
default => '개인 사유',
};
}
}

View File

@@ -15,6 +15,15 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Payment::whereHas('subscription', function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId);
})->count();
if ($existing > 0) {
$this->command->info(' ⚠ payments: 이미 ' . $existing . '건 존재 (스킵)');
return;
}
// 1. 요금제 생성 (없으면)
$plans = $this->createPlans($userId);

View File

@@ -13,6 +13,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Popup::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ popups: 이미 ' . $existing . '개 존재 (스킵)');
return;
}
$popups = [
[
'target_type' => 'all',

View File

@@ -14,6 +14,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Purchase::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ purchases: 이미 ' . $existing . '건 존재 (스킵)');
return;
}
// 거래처 매핑 (PURCHASE, BOTH만)
$clients = Client::where('tenant_id', $tenantId)
->whereIn('client_type', ['PURCHASE', 'BOTH'])

View File

@@ -14,6 +14,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Sale::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ sales: 이미 ' . $existing . '건 존재 (스킵)');
return;
}
// 거래처 매핑 (SALES, BOTH만)
$clients = Client::where('tenant_id', $tenantId)
->whereIn('client_type', ['SALES', 'BOTH'])

View File

@@ -0,0 +1,89 @@
<?php
namespace Database\Seeders\Dummy;
use App\Models\Members\User;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class DummyUserSeeder extends Seeder
{
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 더미 직원 데이터 (15명)
$employees = [
['user_id' => 'EMP001', 'name' => '김철수', 'email' => 'chulsoo.kim@test.com', 'phone' => '010-1234-5678'],
['user_id' => 'EMP002', 'name' => '이영희', 'email' => 'younghee.lee@test.com', 'phone' => '010-2345-6789'],
['user_id' => 'EMP003', 'name' => '박민수', 'email' => 'minsoo.park@test.com', 'phone' => '010-3456-7890'],
['user_id' => 'EMP004', 'name' => '정은지', 'email' => 'eunji.jung@test.com', 'phone' => '010-4567-8901'],
['user_id' => 'EMP005', 'name' => '최준호', 'email' => 'junho.choi@test.com', 'phone' => '010-5678-9012'],
['user_id' => 'EMP006', 'name' => '강미래', 'email' => 'mirae.kang@test.com', 'phone' => '010-6789-0123'],
['user_id' => 'EMP007', 'name' => '임도현', 'email' => 'dohyun.lim@test.com', 'phone' => '010-7890-1234'],
['user_id' => 'EMP008', 'name' => '윤서연', 'email' => 'seoyeon.yoon@test.com', 'phone' => '010-8901-2345'],
['user_id' => 'EMP009', 'name' => '한지민', 'email' => 'jimin.han@test.com', 'phone' => '010-9012-3456'],
['user_id' => 'EMP010', 'name' => '오태양', 'email' => 'taeyang.oh@test.com', 'phone' => '010-0123-4567'],
['user_id' => 'EMP011', 'name' => '신동욱', 'email' => 'dongwook.shin@test.com', 'phone' => '010-1111-2222'],
['user_id' => 'EMP012', 'name' => '권나래', 'email' => 'narae.kwon@test.com', 'phone' => '010-2222-3333'],
['user_id' => 'EMP013', 'name' => '조성민', 'email' => 'sungmin.cho@test.com', 'phone' => '010-3333-4444'],
['user_id' => 'EMP014', 'name' => '백지훈', 'email' => 'jihun.baek@test.com', 'phone' => '010-4444-5555'],
['user_id' => 'EMP015', 'name' => '송하늘', 'email' => 'haneul.song@test.com', 'phone' => '010-5555-6666'],
];
$count = 0;
foreach ($employees as $employee) {
// 이미 존재하는지 확인
$existing = User::where('email', $employee['email'])->first();
if ($existing) {
// 이미 tenant에 연결되어 있는지 확인
$linked = DB::table('user_tenants')
->where('user_id', $existing->id)
->where('tenant_id', $tenantId)
->exists();
if (!$linked) {
DB::table('user_tenants')->insert([
'user_id' => $existing->id,
'tenant_id' => $tenantId,
'is_active' => true,
'is_default' => false,
'joined_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
continue;
}
$user = User::create([
'user_id' => $employee['user_id'],
'name' => $employee['name'],
'email' => $employee['email'],
'phone' => $employee['phone'],
'password' => Hash::make('password123'),
'is_active' => true,
'created_by' => $userId,
]);
// 테넌트에 연결
DB::table('user_tenants')->insert([
'user_id' => $user->id,
'tenant_id' => $tenantId,
'is_active' => true,
'is_default' => true,
'joined_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$count++;
}
$this->command->info(' ✓ users: ' . $count . '명 생성 (테넌트 연결 완료)');
}
}

View File

@@ -15,6 +15,13 @@ public function run(): void
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 데이터 있으면 스킵
$existing = Withdrawal::where('tenant_id', $tenantId)->count();
if ($existing > 0) {
$this->command->info(' ⚠ withdrawals: 이미 ' . $existing . '건 존재 (스킵)');
return;
}
// 거래처 매핑 (PURCHASE, BOTH만)
$clients = Client::where('tenant_id', $tenantId)
->whereIn('client_type', ['PURCHASE', 'BOTH'])

View File

@@ -0,0 +1,39 @@
<?php
namespace Database\Seeders\Dummy;
use App\Models\Tenants\WorkSetting;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
class DummyWorkSettingSeeder extends Seeder
{
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
// 기존 설정이 있으면 스킵
$existing = WorkSetting::where('tenant_id', $tenantId)->first();
if ($existing) {
$this->command->info(' ⚠ work_settings: 이미 존재 (스킵)');
return;
}
WorkSetting::create([
'tenant_id' => $tenantId,
'work_type' => 'fixed',
'standard_hours' => 8,
'overtime_hours' => 4,
'overtime_limit' => 52,
'work_days' => [1, 2, 3, 4, 5], // 월~금
'start_time' => '09:00:00',
'end_time' => '18:00:00',
'break_minutes' => 60,
'break_start' => '12:00:00',
'break_end' => '13:00:00',
]);
$this->command->info(' ✓ work_settings: 1건 생성');
}
}

View File

@@ -19,14 +19,44 @@ public function run(): void
$this->command->info('🌱 더미 데이터 시딩 시작...');
$this->command->info(' 대상 테넌트: ID '.self::TENANT_ID);
// 1. 기본 데이터 (순서 중요)
$this->command->info('');
$this->command->info('📦 기본 데이터 생성...');
$this->call([
Dummy\DummyUserSeeder::class, // HR용 사용자
Dummy\DummyDepartmentSeeder::class, // 부서
Dummy\DummyClientGroupSeeder::class,
Dummy\DummyBankAccountSeeder::class,
Dummy\DummyClientSeeder::class,
]);
// 2. 회계 데이터
$this->command->info('');
$this->command->info('💰 회계 데이터 생성...');
$this->call([
Dummy\DummyDepositSeeder::class,
Dummy\DummyWithdrawalSeeder::class,
Dummy\DummySaleSeeder::class,
Dummy\DummyPurchaseSeeder::class,
Dummy\DummyBadDebtSeeder::class, // 악성채권
Dummy\DummyBillSeeder::class, // 어음
]);
// 3. HR 데이터
$this->command->info('');
$this->command->info('👥 HR 데이터 생성...');
$this->call([
Dummy\DummyWorkSettingSeeder::class, // 근무 설정
Dummy\DummyAttendanceSettingSeeder::class, // 근태 설정
Dummy\DummyAttendanceSeeder::class, // 근태
Dummy\DummyLeaveGrantSeeder::class, // 휴가 부여
Dummy\DummyLeaveSeeder::class, // 휴가
]);
// 4. 기타 데이터
$this->command->info('');
$this->command->info('📋 기타 데이터 생성...');
$this->call([
Dummy\DummyPopupSeeder::class,
Dummy\DummyPaymentSeeder::class,
]);
@@ -34,20 +64,27 @@ public function run(): void
$this->command->info('');
$this->command->info('✅ 더미 데이터 시딩 완료!');
$this->command->table(
['테이블', '생성 수량'],
['카테고리', '테이블', '생성 수량'],
[
['client_groups', '5'],
['bank_accounts', '5'],
['clients', '20'],
['deposits', '60'],
['withdrawals', '60'],
['sales', '80'],
['purchases', '70'],
['popups', '8'],
['plans', '6'],
['subscriptions', '1'],
['payments', '13'],
['총계', '~328'],
['기본', 'users', '15'],
['기본', 'departments', '11'],
['기본', 'client_groups', '5'],
['기본', 'bank_accounts', '5'],
['기본', 'clients', '20'],
['회계', 'deposits', '60'],
['회계', 'withdrawals', '60'],
['회계', 'sales', '80'],
['회계', 'purchases', '70'],
['회계', 'bad_debts', '18'],
['회계', 'bills', '30'],
['HR', 'work_settings', '1'],
['HR', 'attendance_settings', '1'],
['HR', 'attendances', '~300'],
['HR', 'leave_grants', '~200'],
['HR', 'leaves', '~50'],
['기타', 'popups', '8'],
['기타', 'payments', '13'],
['', '총계', '~950'],
]
);
}